[
  {
    "path": ".dockerignore",
    "content": "/target\n/var\n"
  },
  {
    "path": ".editorconfig",
    "content": "# This file is the top-most EditorConfig file\nroot = true\n\n# All Files\n[*]\ncharset = utf-8\nend_of_line = lf\nindent_style = tab\nindent_size = 4\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n#########################\n# File Extension Settings\n#########################\n\n[*.{yml,yaml,yml.dist}]\nindent_style = space\nindent_size = 2\n\n[*.rs]\nindent_style = space\nindent_size = 4\n\n# Markdown Files\n#\n# Two spaces at the end of a line in Markdown mean \"new line\",\n# so trimming trailing whitespace for such files can cause breakage.\n[*.md]\ntrim_trailing_whitespace = false\nindent_style = space\nindent_size = 2\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\non:\n  workflow_dispatch:\n  pull_request:\n    branches: [ \"main\" ]\n  push:\n    branches:\n      - \"**\"\n    tags: [ \"v*\" ]\npermissions:\n  contents: read\n  pull-requests: read\nconcurrency:\n  group: ci-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\njobs:\n  test-and-clippy:\n    name: Unit testing and linting\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: dtolnay/rust-toolchain@1.93.0\n      - name: Install SQLite3\n        run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev\n      - run: cargo test --all-features\n      - run: cargo clippy\n"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "name: Publish\non:\n  workflow_run:\n    workflows: [ \"CI\" ]\n    types: [ \"completed\" ]\npermissions:\n  contents: read\nconcurrency:\n  group: publish-${{ github.event.workflow_run.id || github.ref }}\n  cancel-in-progress: false\njobs:\n  docker-clean-metadata:\n    if: |\n      github.event.workflow_run.conclusion == 'success' &&\n      github.event.workflow_run.event == 'push' &&\n      (\n        github.event.workflow_run.head_branch == 'main' ||\n        startsWith(github.event.workflow_run.head_branch || '', 'v')\n      )\n    runs-on: ubuntu-latest\n    outputs:\n      json: ${{ steps.meta.outputs.json }}\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          ref: ${{ github.event.workflow_run.head_sha }}\n          fetch-depth: 0\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@v6\n        with:\n          images: |\n            ghcr.io/${{ github.repository }}\n          tags: |\n            type=raw,value=latest,enable=${{ github.event.workflow_run.head_branch == 'main' }}\n            type=semver,pattern={{raw}},value=${{ github.event.workflow_run.head_branch }},enable=${{ startsWith(github.event.workflow_run.head_branch || '', 'v') }}\n\n  docker-build:\n    if: |\n      github.event.workflow_run.conclusion == 'success' &&\n      github.event.workflow_run.event == 'push' &&\n      (\n        github.event.workflow_run.head_branch == 'main' ||\n        startsWith(github.event.workflow_run.head_branch || '', 'v')\n      )\n    permissions:\n      contents: read\n      packages: write\n      attestations: write\n      id-token: write\n    strategy:\n      matrix:\n        include:\n          - os: self-hosted\n            arch: amd64\n          - os: ubuntu-24.04-arm\n            arch: arm64\n\n    runs-on: ${{ matrix.os }}\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          ref: ${{ github.event.workflow_run.head_sha }}\n          fetch-depth: 0\n      - name: Log in to the GitHub Container registry\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@v6\n        with:\n          tags: |\n            type=raw,value=latest,enable=${{ github.event.workflow_run.head_branch == 'main' }}\n            type=semver,pattern={{raw}},value=${{ github.event.workflow_run.head_branch }},enable=${{ startsWith(github.event.workflow_run.head_branch || '', 'v') }}\n          flavor: |\n            latest=auto\n            suffix=-${{ matrix.arch }},onlatest=true\n          images: |\n            ghcr.io/${{ github.repository }}\n\n      - name: Build and push Docker images\n        uses: docker/build-push-action@v7\n        with:\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n\n  docker-manifest:\n    if: |\n      github.event.workflow_run.conclusion == 'success' &&\n      github.event.workflow_run.event == 'push' &&\n      (\n        github.event.workflow_run.head_branch == 'main' ||\n        startsWith(github.event.workflow_run.head_branch || '', 'v')\n      )\n    permissions:\n      contents: read\n      packages: write\n    needs:\n      - docker-build\n      - docker-clean-metadata\n    runs-on: ubuntu-latest\n\n    strategy:\n      matrix:\n        image: ${{ fromJson(needs.docker-clean-metadata.outputs.json).tags }}\n\n    steps:\n      - name: Log in to the GitHub Container registry\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Create and push manifest\n        run: |\n          docker buildx imagetools create -t ${{ matrix.image }} ${{ matrix.image }}-amd64 ${{ matrix.image }}-arm64\n"
  },
  {
    "path": ".gitignore",
    "content": "/target\n/var\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n  # Fast built-in hooks (Rust-native, no dependencies)\n  - repo: builtin\n    hooks:\n      - id: trailing-whitespace\n      - id: end-of-file-fixer\n      - id: check-yaml\n      - id: check-merge-conflict\n      - id: check-added-large-files\n        args: ['--maxkb=1024']\n\n  # Local hooks that run project-specific tools\n  - repo: local\n    hooks:\n      - id: cargo-fmt-check\n        name: Cargo Format Check\n        entry: cargo fmt --all -- --check\n        language: system\n        files: '\\.rs$'\n        pass_filenames: false\n\n      - id: cargo-clippy\n        name: Cargo Clippy\n        entry: cargo clippy -- -D warnings\n        language: system\n        files: '\\.rs$'\n        pass_filenames: false\n        priority: 100\n\n      - id: test-unit\n        name: Unit Tests\n        entry: just test\n        language: system\n        files: '\\.rs$'\n        pass_filenames: false\n        priority: 100\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# (2026-05-21) Version 1.19.2\n\n- (**Internal Improvement**) Update [async-openai](https://crates.io/crates/async-openai) to 0.40.0.\n\n- (**Internal Improvement**) Dependency updates.\n\n\n# (2026-05-09) Version 1.19.1\n\n- (**Internal Improvement**) Update [async-openai](https://crates.io/crates/async-openai) to 0.38.0.\n\n- (**Internal Improvement**) Dependency updates.\n\n\n# (2026-05-09) Version 1.19.0\n\n- (**Internal Improvement**) Update [matrix-sdk](https://crates.io/crates/matrix-sdk) from 0.16 to 0.17 and [mxlink](https://crates.io/crates/mxlink) to 1.14.0. matrix-sdk 0.17 dropped its `native-tls` feature and now uses [rustls](https://github.com/rustls/rustls) exclusively as its TLS backend.\n\n- (**Internal Improvement**) Bump the pinned Rust toolchain from 1.93.0 to 1.95.0 (in `rust-toolchain.toml` and the Docker build images).\n\n- (**Internal Improvement**) Dependency updates.\n\n\n# (2026-04-11) Version 1.18.0\n\n- (**Bugfix**) Fix the bot not sending a welcome message when joining a room on homeservers (like [Continuwuity](https://continuwuity.org/)) that place the join membership event in the sync response's `state` block rather than the `timeline` block, via [mxlink](https://crates.io/crates/mxlink) 1.13.1\n\n- (**Improvement**) Update [tiktoken-rs](https://crates.io/crates/tiktoken-rs) to 0.11, adding tokenization support for newer GPT models (gpt-5.x, codex, etc.) and fixing context sizes for o1-mini/chatgpt-4o/gpt-4.5\n\n- (**Internal Improvement**) Dependency updates\n\n\n# (2026-03-25) Version 1.17.0\n\n- (**Feature**) Add `text-generation sender-context-mode` for attaching sender metadata to conversation messages. See the [💬 Text Generation](./docs/configuration/text-generation.md#-sender-context-mode) documentation for details. Thanks to [kschwank](https://github.com/kschwank) for the contribution in [#104](https://github.com/etkecc/baibot/pull/104)!\n\n\n# (2026-03-24) Version 1.16.1\n\n- (**Bugfix**) Fix compatibility with [async-openai](https://crates.io/crates/async-openai) 0.34.0 by populating the new `phase` field required for OpenAI Responses API message inputs. baibot does not currently distinguish between assistant `commentary` and `final_answer` turns, so using `None` preserves the previous behavior while remaining compatible with the updated crate.\n\n- (**Internal Improvement**) Dependency updates.\n\n\n# (2026-03-20) Version 1.16.0\n\n- (**Feature**) Add support for file attachments (`m.file` Matrix messages) in conversations. Files like PDFs, text documents, spreadsheets, code files, etc. are now downloaded and forwarded to the LLM alongside the conversation context, similar to how images (`m.image`) are already handled. See the [💬 Text Generation](./docs/features.md#-text-generation) documentation for details and known limitations.\n\n- (**Improvement**) Use the [mime_guess](https://crates.io/crates/mime_guess) crate for MIME type detection from file extensions, replacing a hand-maintained mapping. This covers hundreds of file extensions out of the box.\n\n\n\n# (2026-03-07) Version 1.15.0\n\n- (**Feature**) Add support for authentication via access tokens (for [Matrix Authentication Service](https://github.com/element-hq/matrix-authentication-service)/OIDC-enabled homeservers) as an alternative to password authentication. See [🔐 Authentication](./docs/configuration/authentication.md) for setup details. Thanks to [Taylor Southwick](https://github.com/twsouthwick) for the contribution in [#83](https://github.com/etkecc/baibot/pull/83)!\n\n- (**Internal Improvement**) Pin the Rust toolchain to `1.93.0` in both CI and local development to avoid `matrix-sdk` build failures on newer stable toolchains.\n\n- (**Internal Improvement**) Documentation updates.\n\n- (**Internal Improvement**) Dependency updates.\n\n\n# (2026-02-18) Version 1.14.3\n\n- (**Internal Improvement**) Add [Renovate](https://docs.renovatebot.com/) configuration for automated dependency updates\n\n- (**Internal Improvement**) Dependency updates\n\n\n# (2026-02-18) Version 1.14.2\n\n- (**Internal Improvement**) Dependency updates\n\n- (**Internal Improvement**) Reorganize the development environment to support [Continuwuity](https://continuwuity.org/) as a homeserver choice (in addition to [Synapse](https://github.com/element-hq/synapse)). Continuwuity is now the default for its lighter footprint (no external database required). See [development docs](./docs/development.md) for details.\n\n\n# (2026-02-10) Version 1.14.1\n\n- (**Security**) Dependency updates to fix security vulnerabilities ([time](https://crates.io/crates/time) stack exhaustion DoS, [bytes](https://crates.io/crates/bytes) integer overflow), via [mxlink](https://crates.io/crates/mxlink) 1.12.0\n\n- (**Internal Improvement**) Switch from deprecated [serde_yaml](https://crates.io/crates/serde_yaml) to its maintained fork [serde_yaml_ng](https://crates.io/crates/serde_yaml_ng)\n\n- (**Internal Improvement**) Add [prek](https://github.com/nicholasgasior/prek) pre-commit hooks via [mise](https://mise.jdx.dev/) for automated code quality checks (formatting, clippy, tests)\n\n- (**Internal Improvement**) Fix clippy warnings and formatting issues\n\n\n# (2026-02-04) Version 1.14.0\n\n- (**Feature**) The `openai` provider now uses OpenAI's [Responses API](https://platform.openai.com/docs/api-reference/responses) (instead of the older Chat Completions API), adding support for [🛠️ built-in tools](./docs/features.md#️-built-in-tools-openai-only) (`web_search` and `code_interpreter`). These tools are **disabled by default** and can be enabled via the `text_generation.tools` configuration (see the [sample configuration](https://github.com/etkecc/baibot/blob/c70387b0c38d8d0f30bba2179a2a21a3710dbeaf/docs/sample-provider-configs/openai.yml#L12-L15)). To enable tools on an existing agent, you need to [update the agent](./docs/agents.md#updating-agents) to re-create it with the `text_generation.tools` section added and enable the tools you need. Thanks to [Layla Manley](https://github.com/yeslayla) for the contribution in [#62](https://github.com/etkecc/baibot/pull/62)!\n\n- (**Bugfix**) Fix sticker generation for newer GPT image models (`gpt-image-1`, `gpt-image-1-mini`, `gpt-image-1.5`) which don't support the previously hardcoded `256x256` size (minimum is `1024x1024`)\n\n- (**Internal Improvement**) Dependency updates\n\n\n# (2026-01-23) Version 1.13.0\n\n- (**Improvement**) Extend auto-switching to support cheaper models (`gpt-image-1-mini`) for `gpt-image-1` and `gpt-image-1.5` when generating stickers ([e0b4a40](https://github.com/etkecc/baibot/commit/e0b4a40))\n\n- (**Internal Improvement**) Upgrade Rust compiler (1.92.0 -> 1.93.0) ([691aeeb](https://github.com/etkecc/baibot/commit/691aeeb))\n\n- (**Internal Improvement**) Dependency updates\n\n\n# (2025-12-21) Version 1.12.0\n\n- (**Improvement**) Upgrade [async-openai](https://crates.io/crates/async-openai) (0.31.1 -> 0.32.2) and add support for OpenAI's `gpt-image-1.5` model ([08c689a](https://github.com/etkecc/baibot/commit/08c689a), [f7bf3d7](https://github.com/etkecc/baibot/commit/f7bf3d7))\n\n- (**Internal Improvement**) Dependency updates\n\n\n# (2025-12-15) Version 1.11.0\n\n- (**Feature**) Add support for custom avatars via file path and for keeping the already-set avatar (for those who wish to manage it by themselves via other means). See the [sample config](./etc/app/config.yml.dist) for details. ([062fbbb](https://github.com/etkecc/baibot/commit/062fbbb8ef9ad600db483a431c5c782402191023))\n\n- (**Internal Improvement**) Dependency updates ([99bde53](https://github.com/etkecc/baibot/commit/99bde53ef648a5a9086a96778fde4a9dbc1ede58))\n\n- (**Internal Improvement**) Documentation updates ([b3fd8e5](https://github.com/etkecc/baibot/commit/b3fd8e548f83fe46398ced4760d7e2bb7588c24d))\n\n- (**Internal Improvement**) Upgrade Rust compiler (1.91.1 -> 1.92.0) ([22906aa](https://github.com/etkecc/baibot/commit/22906aa2d3cae51815fad2560a545eaa69c247b6))\n\n\n# (2025-12-06) Version 1.10.0\n\n- (**Internal Improvement**) Dependency updates. This version is based on [mxlink](https://crates.io/crates/mxlink)@1.11.0 (which is based on the newly released [matrix-sdk](https://crates.io/crates/matrix-sdk)@[0.16.0](https://github.com/matrix-org/matrix-rust-sdk/releases/tag/matrix-sdk-0.16.0).\n\n# (2025-11-30) Version 1.9.0\n\n- (**Internal Improvement**) Upgrade [async-openai](https://crates.io/crates/async-openai) from our own etkecc fork (0.28.1-patched) to the official upstream version 0.31.1. This upgrade required some code adaptations to the new module structure, etc. While tested, regressions are possible.\n\n# (2025-11-28) Version 1.8.3\n\n- (**Improvement**) Add support for the `BAIBOT_PERSISTENCE_SESSION_ENCRYPTION_KEY` environment variable for configuring `persistence.session_encryption_key`\n\n- (**Improvement**) Add support for the `BAIBOT_USER_ENCRYPTION_RECOVERY_RESET_ALLOWED` environment variable for configuring `user.encryption.recovery_reset_allowed`\n\n- (**Internal Improvement**) Dependency updates.\n\n# (2025-11-20) Version 1.8.2\n\n- (**Internal Improvement**) Dependency and compiler updates (Rust 1.89.0 -> 1.91.1).\n\n# (2025-09-12) Version 1.8.1\n\n- (**Internal Improvement**) Dependency updates.\n\n# (2025-09-08) Version 1.8.0\n\n- (**Internal Improvement**) Upgrade [mxlink](https://crates.io/crates/mxlink) (1.9.0 -> 1.10.0) and [matrix-sdk](https://crates.io/crates/matrix-sdk) (0.13.0 -> 0.14.0)\n\n- (**Internal Improvement**) Upgrade [Rust](https://www.rust-lang.org/) (1.88.0 -> 1.89.0)\n\n- (**Internal Improvement**) Upgrade Debian base for container images (12/bookworm -> 13/trixie)\n\n# (2025-07-11) Version 1.7.6\n\n- (**Internal Improvement**) Dependency updates. This version is based on [mxlink](https://crates.io/crates/mxlink)@1.9.0 (which is based on the newly released [matrix-sdk](https://crates.io/crates/matrix-sdk)@[0.13.0](https://github.com/matrix-org/matrix-rust-sdk/releases/tag/matrix-sdk-0.13.0), which contains fixes for some security vulnerabilities)\n\n# (2025-06-10) Version 1.7.5\n\n- (**Internal Improvement**) Dependency and compiler updates (Rust 1.86 -> 1.86).\n\n# (2025-06-10) Version 1.7.4\n\n- (**Internal Improvement**) Dependency updates.\n\n# (2025-06-10) Version 1.7.3\n\n- (**Internal Improvement**) Dependency updates. This version is based on [mxlink](https://crates.io/crates/mxlink)@1.8.0 (which is based on the newly released [matrix-sdk](https://crates.io/crates/matrix-sdk)@[0.12.0](https://github.com/matrix-org/matrix-rust-sdk/releases/tag/matrix-sdk-0.12.0), which contains fixes for important security vulnerabilities)\n\n# (2025-05-11) Version 1.7.2\n\n- (**Bugfix**) Allow `image_generation.size` configuration value for OpenAI to be `null` to allow the model to choose the size automatically and default to that\n\n# (2025-05-11) Version 1.7.1\n\n- (**Bugfix**) Fix lack of documentation for the new [image-editing](./docs/features.md#-image-editing) feature in the `!bai usage` command's output\n\n# (2025-05-10) Version 1.7.0\n\n- (**Feature**) Add vision support to the OpenAI and Anthropic providers. You can now mix text and images in your conversations - fixes [issue #5](https://github.com/etkecc/baibot/issues/5)\n\n- (**Feature**) Add [image-editing](./docs/features.md#-image-editing) support to the OpenAI provider\n\n- (**Improvement**) Add compatibility with OpenAI's `gpt-image-1` model - fixes [issue #40](https://github.com/etkecc/baibot/issues/40)\n\n- (**Change**) Rework [image-creation](./docs/features.md#-image-creation) to avoid command conflicts with [image-editing](./docs/features.md#-image-editing). The image-creation command syntax is now `!bai image create <prompt>` (previously: `!bai image <prompt>`).\n\n- (**Internal Improvement**) Dependency and compiler updates\n\n> [!WARNING]\n> Unlike other releases, this release is not published to [crates.io](https://crates.io), because it relies on multiple library forks (`async-openai` and `anthropic-rs`) sourced from Github.\n\n\n# (2025-04-12) Version 1.6.0\n\n- (**Internal Improvement**) Dependency updates. This version is based on [mxlink](https://crates.io/crates/mxlink)@1.7.0 (which is based on the newly released [matrix-sdk](https://crates.io/crates/matrix-sdk)@[0.11.0](https://github.com/matrix-org/matrix-rust-sdk/releases/tag/matrix-sdk-0.11.0))\n\n\n# (2025-03-31) Version 1.5.1\n\n- (**Internal Improvement**) Dependency updates\n\n# (2025-02-27) Version 1.5.0\n\n- (**Feature**) Add support for sending Speech-to-Text replies for [Transcribe-only mode](./docs/features.md#transcribe-only-mode) as regular text messages instead of notices and doing it so by default ([a1bd292752](https://github.com/etkecc/baibot/commit/a1bd292752bdd37a196788c73d00b5619e843a78)) - improvement for [issue #14](https://github.com/etkecc/baibot/issues/14). See [🦻 Speech-to-Text / 🪄 Message Type for non-threaded only-transcribed messages](./docs/configuration/speech-to-text.md#-message-type-for-non-threaded-only-transcribed-messages) for details.\n\n- (**Feature**) Add config setting controlling if a self-introduction message is posted after joining a room ([c051da2f4a](https://github.com/etkecc/baibot/commit/c051da2f4a161de0974ebb917f7a52d01f5a001f)) - fixes [issue #32](https://github.com/etkecc/baibot/issues/32). You may wish to add a `room.post_join_self_introduction_enabled` property to your configuration. See the [sample config](./etc/app/config.yml.dist) for details. If unspecified, it defaults to `true` anyway which preserves the old behavior.\n\n- (**Feature**) Add support for configuring `max_completion_tokens` for OpenAI ([47d8edea70](https://github.com/etkecc/baibot/commit/47d8edea705a44aa25a9bfaec4888c0f9ea8700e))\n\n- (**Improvement**) Dependency updates. This version is based on [mxlink](https://crates.io/crates/mxlink)@1.6.1 (which is based on the newly released [matrix-sdk](https://crates.io/crates/matrix-sdk)@[0.10.0](https://github.com/matrix-org/matrix-rust-sdk/releases/tag/matrix-sdk-0.10.0))\n\n- (**Improvement**) Populate image/audio attachment `body` with a filename, not with text to avoid incorrect rendering in Element Web, etc. ([ec1879d212](https://github.com/etkecc/baibot/commit/ec1879d212fa8d6e5f8590486e94c72abfcb75a5))\n\n- (**Improvement**) Replace Anthropic library ([anthropic-rs](https://crates.io/crates/anthropic-rs) -> [anthropic](https://crates.io/crates/anthropic)) and switch default recommended model (`claude-3-5-sonnet-20240620` -> `claude-3-7-sonnet-20250219`) ([692d61b239](https://github.com/etkecc/baibot/commit/692d61b2398f073b81d32d4cbe8145ab3929e48c)) - fixes [issue #22](https://github.com/etkecc/baibot/issues/22)\n\n- (**Internal Improvement**) Switch to native building of `arm64` container images to decrease total build times from ~40 minutes to ~8 minutes ([6719538530b](https://github.com/etkecc/baibot/commit/6719538530bf76b3ff2d24077b2a7fa868276b79))\n\n- (**Internal Improvement**) Various other internal changes, including upgrading [Rust from 1.82 to 1.85 and switching to Rust edition 2024](https://blog.rust-lang.org/2025/02/20/Rust-1.85.0.html)\n\n\n# (2024-12-12) Version 1.4.1\n\n- (**Bugfix**) Fix detection for whether the bot is the last member in a room, to avoid incorrectly leaving multi-user rooms that have had at least one person `leave` ([3c47d40781](https://github.com/etkecc/baibot/commit/3c47d407819aa9c0121117a411858238724f06da))\n\n\n# (2024-11-19) Version 1.4.0\n\n- (**Improvement**) Dependency updates. This version is based on [mxlink](https://crates.io/crates/mxlink)@1.4.0 (which is based on the newly released [matrix-sdk](https://crates.io/crates/matrix-sdk)@[0.8.0](https://github.com/matrix-org/matrix-rust-sdk/releases/tag/matrix-sdk-0.8.0)). Once you run this version at least once and your matrix-sdk datastore gets upgraded to the new schema, **you will not be able to downgrade to older baibot versions** (based on the older matrix-sdk), unless you start with an empty datastore.\n\n- (**Bugfix**) Add missing typing notices sending functionality while generating images ([9d166e35ba](https://github.com/etkecc/baibot/commit/9d166e35ba6fc0daaf69318870e92436f3302056))\n\n- (**Feature**) Support for [Matrix authenticated media](https://matrix.org/docs/spec-guides/authed-media-servers/), thanks to upgrading [mxlink](https://crates.io/crates/mxlink) / [matrix-sdk](https://crates.io/crates/matrix-sdk) - fixes [issue #12](https://github.com/etkecc/baibot/issues/12)\n\n\n# (2024-11-12) Version 1.3.2\n\nDependency updates.\n\n\n# (2024-10-03) Version 1.3.1\n\n- (**Improvement**) Improves fallback user mentions support for old clients (like Element iOS) which use the bot's display name (not its full Matrix User ID). ([d9a045a5e4](https://github.com/etkecc/baibot/commit/d9a045a5e41d2b99694f92ec9e90f47529546d89))\n\n\n# (2024-10-03) Version 1.3.0\n\n**TLDR**: you can now use OpenAI's [o1](https://platform.openai.com/docs/models/o1) models, benefit from [prompt caching](https://platform.openai.com/docs/guides/prompt-caching) and mention the bot again from old clients lacking proper [user mentions support](https://spec.matrix.org/latest/client-server-api/#user-and-room-mentions) (like Element iOS).\n\n- (**Feature**) Introduces a new `baibot_conversation_start_time_utc` [prompt variable](./docs/configuration/text-generation.md#️-prompt-override) which is not a moving target (like the `baibot_now_utc` variable) and allows [prompt caching](https://platform.openai.com/docs/guides/prompt-caching) to work. All default/sample configs have been adjusted to make use of this new variable, but users need to adjust your existing dynamically-created agents to start using it. ([85e66406dc](https://github.com/etkecc/baibot/commit/85e66406dc6f430741c7819f420e2df4ae6e8d3b))\n\n- (**Improvement**) Allows for the `max_response_tokens` configuration value for the [OpenAI provider](./docs/providers.md#openai) to be set to `null` to allow [o1](https://platform.openai.com/docs/models/o1) models (which do not support `max_response_tokens`) to be used. See the new o1 sample config [here](./docs/sample-provider-configs/openai-o1.yml). ([db9422740c](https://github.com/etkecc/baibot/commit/db9422740ceca32956d9628b6326b8be206344e2))\n\n- (**Improvement**) Switches the sample configs for the [OpenAI provider](./docs/providers.md#openai) to point to the `gpt-4o` model, which since 2024-10-02 is the same as the `gpt-4o-2024-08-06` model. We previously explicitly pointed the bot to the `gpt-4o-2024-08-06` model, because it was much better (longer context window). Now that `gpt-4o` points to the same powerful model, we don't need to pin its version anymore. Existing users may wish to adjust their configuration to match. ([90fbad5b64](https://github.com/etkecc/baibot/commit/90fbad5b643cd06c23179f055a309ec6a7cba161))\n\n- (**Bugfix**) Restores fallback user mentions support (via regular text, not via the [user mentions spec](https://spec.matrix.org/latest/client-server-api/#user-and-room-mentions)) to allow certain old clients (like Element iOS) to be able to mention the bot again. Support for this was intentionally removed recently (in [v1.2.0](#2024-10-01-version-120)), but it turned out to be too early to do this. ([b40226826f](https://github.com/etkecc/baibot/commit/b40226826fe914d0d5d265230ebc5bac8058b6f7))\n\n\n# (2024-10-01) Version 1.2.0\n\n- (**Feature**) Adds support for [on-demand involvement](./docs/features.md#on-demand-involvement) of the bot (via mention) in arbitrary threads and reply chains ([9908512968](https://github.com/etkecc/baibot/commit/990851296828168c2106eb3f4668833e9e5a7463)) - fixes [issue #15](https://github.com/etkecc/baibot/issues/15)\n\n- (**Improvement**) Simplifies [Transcribe-only mode](./docs/features.md#transcribe-only-mode) reply format (removing `> 🦻` prefixing) to allow easier forwarding, etc. ([e6aa956423](https://github.com/etkecc/baibot/commit/e6aa95642376ee7d87932d0e66dcfedf261b188b)) - fixes [issue #14](https://github.com/etkecc/baibot/issues/14)\n\n- (**Bugfix**) Fixes speech-to-text replies rendering incorrectly in certain clients, due to them confusing our old reply format with [fallback for rich replies](https://spec.matrix.org/v1.11/client-server-api/#fallbacks-for-rich-replies) ([e6aa956423](https://github.com/etkecc/baibot/commit/e6aa95642376ee7d87932d0e66dcfedf261b188b)) - fixes [issue #17](https://github.com/etkecc/baibot/issues/17)\n\n\n# (2024-09-22) Version 1.1.1\n\n- (**Bugfix**) Fix thread messages being lost due to lack of pagination support ([d4ddd29660](https://github.com/etkecc/baibot/commit/d4ddd29660d9f51d248119dd6032e68ab29e7d35)) - fixes [issue #13](https://github.com/etkecc/baibot/issues/13)\n\n- (**Bugfix**) Fix Anthropic conversations getting stuck when being impatient and sending multiple consecutive messages ([8b12bdf2b3](https://github.com/etkecc/baibot/commit/8b12bdf2b3196abea0e8db33d7c50fff48341cb9)) - fixes [issue #13](https://github.com/etkecc/baibot/issues/13)\n\n\n# (2024-09-21) Version 1.1.0\n\n- (**Feature**) Adds support for [prompt variables](./docs/configuration/text-generation.md#️-prompt-override) (date/time, bot name, model id) ([2a5a2d6a4d](https://github.com/etkecc/baibot/commit/2a5a2d6a4dbf5fd7cb504ac07d4187fdc32ae395)) - fixes [issue #10](https://github.com/etkecc/baibot/issues/10)\n\n- (**Improvement**) [Dockerfile](./Dockerfile) changes to produce ~20MB smaller container images ([354063abb7](https://github.com/etkecc/baibot/commit/354063abb79035069bd3b26c53214874e9cdd95d))\n\n- (**Improvement**) [Dockerfile](./Dockerfile) changes to optimize local (debug) runs in a container ([c8c5e0e540](https://github.com/etkecc/baibot/commit/c8c5e0e540ab981e849452eb3ddb0378105e1fc6))\n\n- (**Improvement**) CI changes to try and work around multi-arch image issues like [this one](https://github.com/etkecc/baibot/issues/2) ([5de7559ed6](https://github.com/etkecc/baibot/commit/5de7559ed685a41c22dfc12283681f02f4c2ee00))\n\n\n# (2024-09-19) Version 1.0.6\n\nImprovements to:\n\n- messages sent by the bot - better onboarding flow, especially when no agents have been created yet\n- documentation pages\n\n\n# (2024-09-14) Version 1.0.5\n\nFurther [improves](https://github.com/etkecc/baibot/commit/3b25b92a81a05ebaf1c6dbabf675fbfbe6c9f418) the typing notification logic, so that it tolerates edge cases better.\n\n\n# (2024-09-14) Version 1.0.4\n\n[Improves](https://github.com/etkecc/baibot/commit/dd1dd78312e3db7f92b37fb3b4750fbe35de7115) the typing notification logic.\n\n\n# (2024-09-13) Version 1.0.3\n\nContains [fixes](https://github.com/etkecc/rust-mxlink/commit/f339fc85e69aa7f614394ad303d1614cd307319c) for [some](https://github.com/etkecc/baibot/issues/1) startup failures caused by partial initialization (errors during startup).\n\n\n# (2024-09-12) Version 1.0.0\n\nInitial release. 🎉\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"baibot\"\ndescription = \"A Matrix bot for using diffent capabilities (text-generation, text-to-speech, speech-to-text, image-generation, etc.) of AI / Large Language Models\"\nauthors = [\"Slavi Pantaleev <slavi@devture.com>\"]\nrepository = \"https://github.com/etkecc/baibot\"\nlicense = \"AGPL-3.0-or-later\"\nreadme = \"README.md\"\nkeywords = [\"matrix\", \"chat\", \"bot\", \"AI\", \"LLM\"]\ninclude = [\"/etc/assets/baibot-torso-768.png\", \"/src\", \"/README.md\", \"/CHANGELOG.md\", \"/LICENSE\"]\nversion = \"1.19.2\"\nedition = \"2024\"\n\n[lib]\nname = \"baibot\"\npath = \"src/lib.rs\"\n\n[dependencies]\nanthropic = { git = \"https://github.com/etkecc/anthropic-rs.git\", branch = \"fix-content-block-image\" }\nanyhow = \"1.0.*\"\nasync-openai = { version = \"0.40.0\", features = [\"audio\", \"chat-completion\", \"image\", \"responses\"] }\nbase64 = \"0.22.*\"\nchrono = { version = \"0.4.*\", default-features = false, features = [\"std\", \"now\"] }\n# We'd rather not depend on this, but we cannot use the ruma-events EventContent macro without it.\nmatrix-sdk = { version = \"0.17.0\", default-features = false }\nmime_guess = \"2.0.*\"\nmxidwc = \"1.0.*\"\nmxlink = \">=1.14.0\"\netke_openai_api_rust = \"0.1.*\"\nquick_cache = \"0.6.*\"\nregex = \"1.12.*\"\nserde = { version = \"1.0.*\", features = [\"derive\"], default-features = false }\nserde_json = \"1.0.*\"\nserde_yaml_ng = \"0.10.*\"\ntempfile = \"3.27.*\"\ntiktoken-rs = { version = \"0.11.*\", default-features = false }\ntokio = { version = \"1.52.*\", features = [\"rt\", \"rt-multi-thread\", \"macros\"] }\ntracing = \"0.1.*\"\ntracing-subscriber = { version = \"0.3.*\", features = [\"env-filter\"] }\nurl = \"2.5.*\"\n\n[profile.release]\nstrip = true\nopt-level = \"z\"\nlto = \"thin\"\n"
  },
  {
    "path": "Dockerfile",
    "content": "#######################################\n#                                     #\n# Stage 1: building                   #\n#                                     #\n#######################################\n\nFROM docker.io/rust:1.95.0-slim-trixie AS build\n\nRUN apt-get update && apt-get install -y build-essential pkg-config libssl-dev libsqlite3-dev\n\nENV CARGO_HOME=/cargo\nENV CARGO_TARGET_DIR=/target\n\nWORKDIR /app\n\nCOPY . /app\n\nARG RELEASE_BUILD=true\n\nRUN --mount=type=cache,target=/cargo,sharing=locked \\\n\t--mount=type=cache,target=/target,sharing=locked \\\n\tif [ \"$RELEASE_BUILD\" = \"true\" ]; then \\\n\t\tcargo build --release; \\\n\telse \\\n\t\tcargo build; \\\n\tfi\n\n# Move it out of the mounted cache, so we can copy it in the next stage.\nRUN --mount=type=cache,target=/target,sharing=locked \\\n\tif [ \"$RELEASE_BUILD\" = \"true\" ]; then \\\n\t\tcp /target/release/baibot /baibot; \\\n\telse \\\n\t\tcp /target/debug/baibot /baibot; \\\n\tfi\n\n#######################################\n#                                     #\n# Stage 2: packaging                  #\n#                                     #\n#######################################\n\nFROM docker.io/debian:trixie-slim\n\nRUN apt-get update && apt-get install -y ca-certificates sqlite3 && \\\n    apt-get clean && \\\n    rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\n\nCOPY --from=build /baibot .\n\nENTRYPOINT [\"/bin/sh\", \"-c\"]\n\nCMD [\"/app/baibot\"]\n"
  },
  {
    "path": "Dockerfile.ci",
    "content": "#######################################\n#                                     #\n# Stage 1: building                   #\n#                                     #\n#######################################\n\nFROM docker.io/rust:1.95.0-slim-trixie AS build\n\nRUN apt-get update && apt-get install -y build-essential pkg-config libssl-dev libsqlite3-dev\n\nWORKDIR /app\n\nCOPY . /app\n\nRUN cargo build --release\n\n#######################################\n#                                     #\n# Stage 2: packaging                  #\n#                                     #\n#######################################\n\nFROM docker.io/debian:trixie-slim\n\nRUN apt-get update && apt-get install -y ca-certificates sqlite3 && \\\n    apt-get clean && \\\n    rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\n\nCOPY --from=build /app/target/release/baibot .\n\nENTRYPOINT [\"/bin/sh\", \"-c\"]\n\nCMD [\"/app/baibot\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n\t<img src=\"./etc/assets/baibot.svg\" alt=\"baibot logo\" width=\"150\" />\n\t<h1 align=\"center\">baibot</h1>\n</p>\n\n🤖 baibot is an [AI](https://en.wikipedia.org/wiki/Artificial_intelligence) ([Large Language Model](https://en.wikipedia.org/wiki/Large_language_model)) bot for [Matrix](https://matrix.org/) built by [etke.cc](https://etke.cc/) (managed Matrix servers).\n\nThe name is pronounced 'bye'-bot and is a play on [AI](https://en.wikipedia.org/wiki/Artificial_intelligence), referencing the fictional character [🇧🇬 Bai Ganyo](https://en.wikipedia.org/wiki/Bay_Ganyo).\n\nIt's designed as a more private and [featureful](#-features) alternative to [matrix-chatgpt-bot](https://github.com/matrixgpt/matrix-chatgpt-bot).\nIt's influenced by [chaz](https://github.com/arcuru/chaz), but does **not** use the [AIChat](https://github.com/sigoden/aichat) CLI tool and instead does everything in-process, without forking.\n\n\n## 🌟 Features\n\n- 🎨 Encourages **[provider](./docs/providers.md) choice** ([Anthropic](./docs/providers.md#anthropic), [Groq](./docs/providers.md#groq), [LocalAI](./docs/providers.md#localai), [OpenAI](./docs/providers.md#openai) and [☁️ many more](./docs/providers.md#️-providers)) as well as **[mixing & matching models](./docs/features.md#-mixing--matching-models)**:\n\n- Supports **different use purposes** (depending on the [☁️ provider](./docs/providers.md) & model):\n\n  - [💬 text-generation](./docs/features.md#-text-generation): communicating with you via text (though certain models may \"see\" images as well). The [OpenAI provider](./docs/providers.md#openai) also supports [🛠️ built-in tools](./docs/features.md#️-built-in-tools-openai-only) (web search, code interpreter)\n  - [🦻 speech-to-text](./docs/features.md#-speech-to-text): turning your voice messages into text\n  - [🗣️ text-to-speech](./docs/features.md#%EF%B8%8F-text-to-speech): turning bot or users text messages into voice messages\n  - [🖌️ image-generation](./docs/features.md#image-generation): creating and editing images based on instructions\n\n- 🪄 Supports [seamless voice interaction](./docs/features.md#seamless-voice-interaction) (turning user voice messages into text, answering in text, then turning that text back into voice)\n\n- 🦻 Supports [transcribe-only mode](./docs/features.md#transcribe-only-mode) (turning user voice messages into text, without doing text-generation)\n\n- 🗣️ Supports [text-to-speech-only mode](./docs/features.md#text-to-speech-only-mode) (turning user text messages into voice, without doing text-generation)\n\n- 🔒 Supports [encryption](./docs/features.md#-encryption) for Matrix communication and Account-Data-stored configuration\n\n- ♻️ Supports [context-management](./docs/configuration/text-generation.md#️-context-management) handling on some models (automatically adjusting the message history length, etc.)\n\n- 🛠️ Allows **customizing much of the bot's [configuration](./docs/configuration/README.md)** at runtime (using commands sent via chat)\n\n- 👥 **Actively maintained** by the team at [etke.cc](https://etke.cc/)\n\n\n## 🖼️ Screenshots\n\n![Introduction and general usage](./docs/screenshots/introduction-and-general-usage.webp)\n\nYou can find more screenshots on the [🌟 Features](./docs/features.md) and other [📚 Documentation](./docs/README.md) pages, as well as in the [docs/screenshots](./docs/screenshots) directory.\n\n\n## 🚀 Getting Started\n\n🗲 For a quick experiment, you can refer to the [🧑‍💻 development documentation](./docs/development.md) which contains information on how to build and run the bot (and its various dependency services) locally.\n\nFor a real installation, see the [🚀 Installation](./docs/installation.md) documentation which contains information on [🐋 Running in a container](./docs/installation.md#-running-in-a-container) and [🖥️️️️️ Running a binary](./docs/installation.md#-running-a-binary).\n\n\n## 📚 Documentation\n\nSee the bot's [📚 documentation](./docs/README.md) for more information on how to use and configure the bot.\n\n\n## 💻 Development\n\nSee the bot's [🧑‍💻 development documentation](./docs/development.md) for more information on how to develop on the bot.\n\n\n## 📜 Changes\n\nThis bot evolves over time, sometimes with backward-incompatible changes.\n\nWhen updating the bot, refer to [the changelog](CHANGELOG.md) to catch up with what's new.\n\n\n## 🆘 Support\n\n- Matrix room: [#baibot:etke.cc](https://matrix.to/#/#baibot:etke.cc)\n\n- GitHub issues: [etkecc/baibot/issues](https://github.com/etkecc/baibot/issues)\n\n- (for [etke.cc](https://etke.cc/) customers): etke.cc [support](https://etke.cc/contacts/)\n"
  },
  {
    "path": "docs/README.md",
    "content": "# Table of Contents\n\n- [🔒 Access](./access.md)\n- [🤖 Agents](./agents.md)\n- [🛠️ Configuration](./configuration/README.md)\n- [🌟 Features](./features.md)\n- [🤝 Handlers](./configuration/handlers.md)\n- [☁️ Providers](./providers.md)\n- [📖 Usage](./usage.md)\n- [🚀 Installation](./installation.md)\n- [💻 Development](./development.md)\n"
  },
  {
    "path": "docs/access.md",
    "content": "## 🔒 Access\n\nThis bot employs access control to decide who can use its services and manage its configuration.\n\n\n### 👋 Joining rooms\n\nThe bot automatically joins rooms only when invited by someone considered a bot [👥 user](#-users).\n\n\n### 👥 Users\n\nThe bot can be used by users that match some [dynamically](./configuration/README.md#dynamic-configuration) configured [Matrix user id](https://spec.matrix.org/v1.11/#users) patterns.\n\nUsers:\n\n- ✅ can **invite the bot to rooms**\n- ✅ can **use all the bot's [features](./features.md)** ([💬 Text Generation](./features.md#-text-generation), [🦻 Speech-to-Text](./features.md#-speech-to-text), etc.) by sending room messages\n- ✅ can **mention the bot** in threads and reply chains to provoke it to respond to non-user messages (see [🌟 Features / 💬 Text Generation / On-demand involvement](./features.md#on-demand-involvement))\n- ✅ can **change the bot's configuration in a room** (e.g. `!bai config room ...` commands)\n- ❌ cannot **change the bot's global configuration** (e.g. `!bai config global ...` commands)\n- ❌ cannot **create new [🤖 Agents](./agents.md)** (neither in rooms, nor globally). See [💼 Room-local agent managers](#-room-local-agent-managers) for controlling which users can create agents.\n\nThe following commands are available:\n- **Show** the currently allowed users: `!bai access users`\n- **Set** the list of allowed users: `!bai access set-users SPACE_SEPARATED_PATTERNS`\n\nExample patterns: `@*:example.com @*:another.com @someone:company.org`\n\n\n### 👮‍♂️ Administrators\n\nAdministrators can **manage the bot's configuration and access control**.\n\nAdministrators are [👥 Users](#-users) and [💼 Room-local agent managers](#-room-local-agent-managers) implicitly, so they inherit all their permissions.\n\nThe bot can be administrated by users that match some [statically](./configuration/README.md#static-configuration) configured [Matrix user id](https://spec.matrix.org/v1.11/#users) patterns.\n\nAdministrators cannot be changed without adjusting the bot's configuration on the server.\n\n\n### 💼 Room-local agent managers\n\nRoom-local agent managers are users privileged to **create their own [agents](./agents.md)** (see `!bai agent`) in rooms.\n\n> [!WARNING]\n> Letting regular users create agents which contact arbitrary network services **may be a security issue**.\n\nThe following commands are available:\n- **Show** the currently allowed users: `!bai access room-local-agent-managers`\n- **Set** the list of allowed users: `!bai access set-room-local-agent-managers SPACE_SEPARATED_PATTERNS`\n\nExample patterns: `@*:example.com @*:another.com @someone:company.org`\n"
  },
  {
    "path": "docs/agents.md",
    "content": "## 🤖 Agents\n\nAn agent is an instantiation and configuration of some [☁️ provider](./providers.md).\nIt can support different capabilities (text-generation, speech-to-text, etc.) depending on the provider used and on the configuration of the agent.\n\nAgents can be set as **[🤝 handlers](./configuration/handlers.md) for various purposes** (text-generation, speech-to-text, etc.) globally or in specific rooms. Send a `!bai config status` command to see the current configuration.\n\nAgents can be **defined [statically](./configuration/README.md#static-configuration)** (in the server configuration) **or dynamically** (via commands sent to the bot).\n\nWhen [creating agents](#creating-agents) dynamically, you can do it **per-room or globally**.\nGlobally-defined agents can be used by any authorized bot user in any room, while room-local agents can only be used in the room where they were defined.\n\nAgent configuration (like all other configuration) is stored in the Matrix Account Data of the bot user and is **potentially encrypted** (if enabled in the configuration), so that your configuration data is safe even on untrusted homeservers.\n\n\n### Listing agents\n\nTo **list** all available agents: `!bai agent list`\n\n\n#### Creating agents\n\nSee a [🖼️ Screenshot of the agent creation process](./screenshots/agent-creation.webp).\n\nTo **create** a new agent, you need to specify the [provider](./providers.md) and an agent id of your choosing.\n\n- **Create** a new agent:\n    - (Accessible in **this room only**) `!bai agent create-room-local PROVIDER_ID AGENT_ID`\n    - (Accessible in **all rooms**) `!bai agent create-global PROVIDER AGENT_ID`\n    - Example: `!bai agent create-room-local openai my-openai-agent`\n\nThe `AGENT_ID` is a unique identifier for the agent. It can be any string which **doesn't contain spaces and `/`**.\n\nDepending on where the agent is defined (within a room, globally, or [statically](./configuration/README.md#static-configuration)), this id will get a prefix (e.g. `room-local/`, `global/` or `static/`). The combined id (prefix + agent id) makes the **full agent identifier** (refered to as `FULL_AGENT_IDENTIFIER` in commands below).\n\nWhen creating an agent, you will be given some sample [YAML](https://en.wikipedia.org/wiki/YAML) configuration which you can use to customize the agent's behavior.\n\nThis configuration varies depending on the [☁️ provider](./providers.md) used and the capabilities of the agent. Based on the configuration keys you pass, certain features will be enabled or disabled. For example, if you skip the `image_generation` key for an [OpenAI](./providers.md#openai) agent, it won't be able to generate images (see [🖌️ Image Creation](./features.md#-image-creation), [🎨 Image Editing](./features.md#-image-editing), [🫵 Sticker Creation](./features.md#-sticker-creation)).\n\nAfter making your modifications to the sample YAML, you submit it back to the bot and the new agent will be created.\n\n**To make use of the agent**, you need to [🤝 configure it as a handler for a given purpose](./configuration/handlers.md).\n\n\n### Showing agent details\n\nTo **show** full details for a given agent: `!bai agent details FULL_AGENT_IDENTIFIER`\n\nThis command requires a full agent identifier (e.g. `room-local/agent-id`).\n\n\n### Deleting agents\n\nTo **delete** an agent: `!bai agent delete FULL_AGENT_IDENTIFIER`\n\nThis command requires a full agent identifier (e.g. `room-local/agent-id`).\n\n\n### Updating agents\n\nTo **update** a given agent's configuration: show the agent's [details](#showing-agent-details) (current configuration), then [delete](#deleting-agents) it and finally [re-create](#creating-agents) it.\n"
  },
  {
    "path": "docs/configuration/README.md",
    "content": "## 🛠️ Configuration\n\nThe bot's behavior is controlled by a combination of [static](#static-configuration) and [dynamic](#dynamic-configuration) configuration.\n\n\n### Static configuration\n\nThe bot can be configured using a [YAML](https://en.wikipedia.org/wiki/YAML) configuration file as well as [environment variables](https://en.wikipedia.org/wiki/Environment_variable).\n\nWhen running the bot locally (during [🧑‍💻 development](../development.md)), the bot's configuration is read from the `var/app/config.yml` file.\nThis file is created from the template found in [etc/app/config.yml.dist](../../etc/app/config.yml.dist).\n\nCertain keys can be left unset, in which case [📝 hardcoded defaults](../../src/entity/cfg/defaults.rs) would be used.\n\nSome configuration keys found in the YAML configuration can be overridden by setting an environment variable (dots should be replaced with `_`). Example:\n\n- to override `command_prefix`, set an environment variable `BAIBOT_COMMAND_PREFIX`\n- to override `homeserver.server_name`, set an environment variable `BAIBOT_HOMESERVER_SERVER_NAME`\n\nYou can see the list of supported environment variables in the [🦀 src/entity/cfg/env.rs](../../src/entity/cfg/env.rs) file.\n\n> [!WARNING]\n> The static configuration contains an `initial_global_config` key, which is used to populate the bot's global configuration (stored as [dynamic configuration](#dynamic-configuration)) the first time the bot starts. Modifying this subsequently will not have any effect. After initial global configuration creation, it's expected to be managed dynamically via chat commands.\n\nFor Matrix-account authentication setup, see [🔐 Authentication](./authentication.md).\n\n\n### Dynamic configuration\n\nBesides the bot's [static configuration](#static-configuration), **the bot can also be configured dynamically at runtime (via chat messages)**.\n\nThis includes changes to [🔒 Access](../access.md), [🤖 Agents](../agents.md) and [🛠️ Room Settings](#room-settings).\n\n\n#### Room Settings\n\nRoom Settings come from 3 different levels with priority in the following order (higher to lower):\n\n- 📍 per-room (`!bai config room ..` commands)\n- 🌐 globally (`!bai config global ..` commands)\n- 📝 as [hardcoded defaults](../../src/entity/cfg/defaults.rs)\n\nYou can adjust the following settings per room and/or globally:\n\n- [💬 Text Generation](text-generation.md)\n- [🦻 Speech-to-Text](speech-to-text.md)\n- [🗣️ Text-to-Speech](text-to-speech.md)\n- [🖌️ Image Creation](image-generation.md)\n- [🤝 Handlers](handlers.md)\n\nRefer to the bot's help messages (as a response to a `!bai config` help command) for the most up-to-date information on what Room Settings can be configured.\n\nYou can **get an overview of the configuration affecting the current room** (a mix of hardcoded defaults, agent defaults, global and room-level settings) by sending a `!bai config status` command to the room.\n"
  },
  {
    "path": "docs/configuration/authentication.md",
    "content": "## 🔐 Authentication\n\nbaibot supports 2 authentication modes for the Matrix account (`user.*` keys in config).\n\nSet **exactly one** mode. If both are set (or neither is set), startup validation fails.\n\n### Password authentication\n\n- Config key: `user.password`\n- Environment variable: `BAIBOT_USER_PASSWORD`\n\n### Access token authentication\n\n- Config keys: `user.access_token` + `user.device_id`\n- Environment variables: `BAIBOT_USER_ACCESS_TOKEN` + `BAIBOT_USER_DEVICE_ID`\n\nAccess-token authentication is useful for OIDC-enabled homeservers (e.g. those using [Matrix Authentication Service](https://github.com/element-hq/matrix-authentication-service)).\n\nExample token-generation command:\n\n```sh\nmas-cli manage issue-compatibility-token <username> [device_id]\n```\n"
  },
  {
    "path": "docs/configuration/handlers.md",
    "content": "## 🤝 Handlers\n\n### Introduction\n\nYou can use **different models in different rooms** (e.g. [OpenAI](../providers.md#openai) GPT-4o alongside [Llama](https://en.wikipedia.org/wiki/Llama_(language_model)) running on [Groq](../providers.md#groq), etc.)\n\nYou can also use **different models within the same room** (e.g. [💬 text-generation](#-text-generation) handled by one [agent](./agents.md), [🦻 speech-to-text](#-speech-to-text) handled by another, [🗣️ text-to-speech](#️-text-to-speech) by a 3rd, etc.)\n\nThe bot supports the following use-purposes:\n\n- [💬 text-generation](../features.md#-text-generation): communicating with you via text (though certain models may also process images and files)\n- [🦻 speech-to-text](../features.md#-speech-to-text): turning your voice messages into text\n- [🗣️ text-to-speech](../features.md#️-text-to-speech): turning bot or users text messages into voice messages\n- [🖌️ image-generation](../features.md#image-generation): generating images based on instructions\n\nIn a given room, each different purpose can be served by a different [provider](../providers.md) and model. This combination of provider and model configuration is called an [🤖 agent](../agents.md). Each purpose can be served by a different **handler** agent.\n\nSee a [🖼️ Screenshot of an example room configuration](./screenshots/config-status-handlers.webp).\n\n\n### Configuring\n\nHandlers can be configured [dynamically](./README.md#dynamic-configuration):\n\n- either per-room (e.g. `!bai config room set-handler text-generation room-local/openai-gpt-4o`)\n- or globally (e.g. `!bai config global set-handler text-generation global/openai-gpt-4o`)\n\nThe per-room configuration takes priority over the global configuration.\n\nThere's also a `catch-all` purpose that can be used as a fallback handler for messages that don't match any other handler.\n\n💡 It's a good idea to globally-configure a powerful agent as a catch-all handler, so that the bot can always handle messages of any kind. You can then override individual handlers per room or globally.\n"
  },
  {
    "path": "docs/configuration/image-generation.md",
    "content": "\n## Image Generation\n\nThe Image Creation and Image Editing features are not configurable at this moment.\n\nYou may also wish to see:\n\n- [🌟 Features / Image Generation / 🖌️ Image Creation](../features.md#-image-creation) for a higher-level introduction to the Image Creation features\n- [🌟 Features / Image Generation / 🎨 Image Editing](../features.md#-image-editing) for a higher-level introduction to the Image Editing features\n- [📖 Usage / Image Generation / 🖌️ Creating Images](../usage.md#-creating-images) section for more details on how to use the bot for Image Creation in a room\n- [📖 Usage / Image Generation / 🎨 Editing images](../usage.md#-editing-images) section for more details on how to use the bot for Image Editing in a room\n"
  },
  {
    "path": "docs/configuration/speech-to-text.md",
    "content": "## 🦻 Speech-to-Text\n\nBelow are some configuration settings related to Speech-to-Text.\n\nYou may also wish to see:\n\n- [🌟 Features / 🦻 Speech-to-Text](../features.md#-speech-to-text) for a higher-level introduction to the Speech-to-Text features\n- [📖 Usage / 🦻 Speech-to-Text](../usage.md#-speech-to-text) section for more details on how to use the bot for Speech-to-Text in a room\n\n\n### 🪄 Flow Type\n\nControls how voice messages sent by [👥 user](../access.md#-users) are handled.\n\nThe following configuration values are recognized:\n\n- (default) `transcribe_and_generate_text`: the bot will turn [👥 user](../access.md#-users) voice messages into text and then generate text messages via [💬 Text Generation](../features.md#-text-generation). This is the default setting to allow for [Seamless voice interaction](../features.md#seamless-voice-interaction).\n\n- `ignore`: the bot will ignore all audio messages\n\n- `only_transcribe`: the bot will turn [👥 user](../access.md#-users) voice messages into text, but will **not** proceed with [💬 Text Generation](../features.md#-text-generation). Switching to this may be useful in some cases, as in [Transcribe-only mode](../features.md#transcribe-only-mode).\n\nExample: `!bai config room speech-to-text set-flow-type ignore` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings))\n\n\n### 🪄 Message Type for non-threaded only-transcribed messages\n\nControls how the transcribed text of voice messages is sent to the chat when Flow Type = `only_transcribe`.\n\nThe following configuration values are recognized:\n\n- (default) `text`: the transcribed text is sent as a regular message. This is more convenient if you'd like to forward the transcribed message to other rooms.\n\n- `notice`: the transcribed text is sent as a notice message. This provides better compatibility with other bots in the room, as they are less likely to interact with messages of type notice.\n\nExample: `!bai config room speech-to-text set-msg-type-for-non-threaded-only-transcribed-messages notice` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings))\n\n\n### 🔤 Language\n\nLets you specify the language of the input voice messages, to avoid using auto-detection.\nSupplying the input language using a 2-letter code (e.g. `ja`) as per [ISO-639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) may improve accuracy & latency.\n\n![Speech-to-Text Language setting usage example](../screenshots/speech-to-text-language.webp)\n\nIn the above example screenshot, even without a language specified, the voice was understood correctly as [Bulgarian](https://en.wikipedia.org/wiki/Bulgarian_language), but was produced in latin, not [Cyrillic](https://en.wikipedia.org/wiki/Cyrillic_script), which is wrong.\n\nIf different [👥 user](../access.md#-users) are using different languages, do not specify a language.\n\n💡 Certain models (like [OpenAI](../providers.md#openai)'s Whisper) may perform auto-translation if you specify a language, but you're speaking another one. You may abuse this side-effect for performing voice-to-text translation, but be aware that not all models behave this way.\n\nExample (setting it to Japanese): `!bai config room speech-to-text set-language ja` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings))\n"
  },
  {
    "path": "docs/configuration/text-generation.md",
    "content": "\n## 💬 Text Generation\n\nBelow are some [🛠️ dynamic configuration settings](./README.md#dynamic-configuration) related to Text Generation.\n\nYou may also wish to see:\n\n- [🌟 Features / 💬 Text Generation](../features.md#-text-generation) for a higher-level introduction to the Text Generation features\n- [📖 Usage / 💬 Text Generation](../usage.md#-text-generation) section for more details on how to use the bot for Text Generation in a room\n\n\n### 🗟 Prefix Requirement Type\n\nIn Direct Message rooms with the bot (1:1 rooms), it most usually makes sense for the bot to respond to **all** of your messages, as shown on this [🖼️ screenshot](../screenshots/text-generation.webp).\n\nIn group rooms (with multiple users), it may be more appropriate for the bot to only respond to messages that are **prefixed** with the command prefix (e.g. `!bai`) or which are [mentioning](https://spec.matrix.org/latest/client-server-api/#user-and-room-mentions) the bot (e.g. `@baibot`), so that other chat exchange in the room will not trigger it. Such a setup is shown on the [🖼️ On-demand involvement in the room](../screenshots/text-generation-prefix-requirement.webp) screenshot.\n\nThere are exceptions to these rules, and you can configure the bot to respond only to prefixed messages in a 1:1 room, or to respond to all messages even in a multi-user group room.\n\nTo support such use-cases, the bot has a `text-generation prefix-requirement-type` setting, which can be set to:\n\n- (default) `no`: indicates that the bot would not require a prefix and would respond to all messages\n\n- `command_prefix`: indicates that the bot would require that messages be prefixed with the command prefix (e.g. `!bai`) and would ignore all messages that are not prefixed\n\nBy default, the bot is **auto-configured (upon joining a new room)** to use the `no` setting in rooms that only include 2 users (you and the bot), and `command_prefix` in rooms with more than 2 users. To prevent surprises, the bot will **not** adjust this setting subsequently. You can manually adjust it via `!bai config room text-generation set-prefix-requirement-type VALUE`.\n\nExample: `!bai config room text-generation set-prefix-requirement-type command_prefix` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings))\n\nRegardless of this configuration, **the bot will also respond to messages by allowed [👥 Users](../access.md#-users) which directly [mention](https://spec.matrix.org/latest/client-server-api/#user-and-room-mentions) the bot** (e.g. `@baibot`), even if they are not prefixed. An example of this can be seen on these screenshots:\n\n- [🖼️ On-demand involvement in a thread](../screenshots/text-generation-on-demand-thread-involvement.webp)\n- [🖼️ On-demand involvement in a reply chain](../screenshots/text-generation-on-demand-reply-involvement.webp)\n\n\n### 🪄 Auto Usage\n\nText generation is enabled by default (the `text-generation auto-usage` setting being set to `always`), but can be set to:\n\n- (default) `always`: generate text for all messages (also see [🗟 Prefix Requirement Type](#-prefix-requirement-type))\n\n- `never`: never generate text for messages\n\n- `only_for_voice`: only generate text when the original user message was a voice message, later transcribed via [🦻 Speech-to-Text](../features.md#-speech-to-text)\n\n- `only_for_text`: only generate text when original user message was a text message\n\nExample: `!bai config room text-generation set-auto-usage only_for_voice` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings))\n\n\n### ♻️ Context Management\n\nThe bot also supports ♻️ **context management**, which automatically adjusts the message history length, etc.\n\nThis feature relies on [tokenization](https://en.wikipedia.org/wiki/Large_language_model#Tokenization) performed by the [tiktoken-rs](https://github.com/zurawiki/tiktoken-rs) library which is [poorly well-maintained](https://github.com/zurawiki/tiktoken-rs/issues/50) and only works well for [OpenAI](../providers.md#openai) models.\n\nThis setting is **disabled by default**, but can be enabled via `!bai config room text-generation set-context-management-enabled true` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings)).\n\n\n### 👤 Sender Context Mode\n\nIn multi-user rooms, it may be useful for the model to know which participant sent each message in the conversation context.\n\nTo support this, the bot has a `text-generation sender-context-mode` setting, which can be set to:\n\n- (default) `disabled`: do not attach sender metadata to messages before sending them to the model\n\n- `matrix_user_id`: prefix text messages with the sender's Matrix user ID, for example: `[sender=@alice:example.com] Hello bot`\n\n- `matrix_user_id_and_timestamp`: prefix text messages with the sender's Matrix user ID and the message timestamp, for example: `[sender=@alice:example.com sent_at=2026-03-23T14:30:00Z] Hello bot`\n\nThis sender metadata is attached to conversation messages before they are sent to the model provider. It applies to user and assistant text messages, but not to system prompts or non-text content.\n\n⚠️ Enabling this sends Matrix user IDs, and optionally timestamps, to the model provider.\n\nExample: `!bai config room text-generation set-sender-context-mode matrix_user_id` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings))\n\n\n### ⌨️ Prompt Override\n\nYou can override the [system prompt](https://huggingface.co/docs/transformers/en/tasks/prompting) configured at the [🤖 agent](../agents.md) level.\n\nExample (multi-line is supported):\n\n```\n!bai config room text-generation set-prompt-override You're a UI/UX expert. Everything you say needs to consider design and usability.\n\nWhere appropriate, you'll mention best practices and common pitfalls.\n```\n\nA prompt override can also be set globally, see [🛠️ Room Settings](./README.md#room-settings).\n\nPrompts may contain the following **placeholder variables** which will be replaced *every time* the bot is interacted with:\n\n| Placeholder               | Description | Example |\n|---------------------------|-------------|---------|\n| `{{ baibot_name }}`       | Name of the bot as configured in the `user.name` field in the [Static configuration](./README.md#static-configuration) | `Baibot` |\n| `{{ baibot_model_id }}`   | Text-Generation model ID as configured in the [🤖 agent](../agents.md)'s configuration | `gpt-4o` |\n| `{{ baibot_now_utc }}`    | Current date and time in UTC (⚠️ usage may break prompt caching - see below) | `2024-09-20 (Friday), 14:26:42 UTC` |\n| `{{ baibot_conversation_start_time_utc }}` | The date and time in UTC that the conversation started | `2024-09-20 (Friday), 14:26:42 UTC` |\n\n💡 `{{ baibot_now_utc }}` changes as time goes on, which prevents [prompt caching](https://platform.openai.com/docs/guides/prompt-caching) from working. It's better to use `{{ baibot_conversation_start_time_utc }}` in prompts, as its value doesn't change yet still orients the bot to the current date/time.\n\nHere's a prompt that combines some of the above variables:\n\n> You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}.\"\n\n\n### 🌡️ Temperature Override\n\nYou can override the [temperature](https://blogs.novita.ai/what-are-large-language-model-settings-temperature-top-p-and-max-tokens/#what-is-llm-temperature) (randomness / creativity) parameter configured at the [🤖 agent](../agents.md) level.\n\nExample: `!bai config room text-generation set-temperature-override 3.5` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings))\n"
  },
  {
    "path": "docs/configuration/text-to-speech.md",
    "content": "\n## 🗣️ Text-to-Speech\n\nBelow are some configuration settings related to Text-to-Speech.\n\nYou may also wish to see:\n\n- [🌟 Features / 🗣️ Text-to-Speech](../features.md#-text-generation) for a higher-level introduction to the Text-to-Speech features\n- [📖 Usage / 🗣️ Text-to-Speech](../usage.md#-text-generation) section for more details on how to use the bot for Text-to-Speech in a room\n\n\n### 🪄 Bot Messages Flow Type\n\nControls how automatic text-to-speech functions for **messages sent by the bot**.\n\nThe following configuration values are recognized:\n\n- (default) `on_demand_for_voice`: the bot will turn its own text messages into audio (voice) messages only after an allowed [👥 user](../access.md#-users) **reacts** to a bot's message with 🗣️. To make it easier for users to react without having to hunt for this emoji, the bot will automatically add a 🗣️ reaction to its own messages which are in response to a user audio (voice) message.\n\n- `on_demand_always`: the bot will turn its own text messages into audio (voice) messages only after an allowed [👥 user](../access.md#-users) **reacts** to a bot's message with 🗣️. To make it easier for users to react without having to hunt for this emoji, the bot will automatically add a 🗣️ reaction to **all of its own messages**.\n\n- `only_for_voice`: the bot will turn its own text messages into audio (voice) messages only if the original user message was a voice message. This is to allow for [Seamless voice interaction](../features.md#seamless-voice-interaction), where you can speak to the bot and then hear its responses\n\n- `never`: the bot will never turn its own text messags into audio (voice) messages\n\n- `always`: the bot will turn all its text messages into audio (voice) messages. This also allows for [Seamless voice interaction](../features.md#seamless-voice-interaction).\n\nExample: `!bai config room text-to-speech set-bot-msgs-flow-type never` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings))\n\n\n### 🪄 User Messages Flow Type\n\nControls how automatic text-to-speech functions for **messages sent by [👥 users](../access.md#-users)**.\n\n**Only works when automatic text-generation is disabled** (see [💬 Text Generation / 🪄 Auto Usage](./text-generation.md#-auto-usage)).\n\nThe following configuration values are recognized:\n\n- (default) `never`: the bot will never turn [👥 user](../access.md#-users) text messages into audio (voice) messages\n\n- `on_demand`: the bot will turn [👥 user](../access.md#-users) text messages into audio (voice) messages if the text message receives a 🗣️ reaction\n\n- `always`: the bot will turn all [👥 user](../access.md#-users) text messages into audio (voice) messages. This is to allow for [Text-to-Speech-only mode](../features.md/#text-to-speech-only-mode).\n\nExample: `!bai config room text-to-speech set-user-msgs-flow-type always` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings))\n\n\n### 🗲 Speed override\n\nThe speed override setting lets you speed up/down speech relative to the default speed configured at the [🤖 agent](../agents.md) level (usually `1.0`).\n\nValues typically range from `0.25` to `4.0`, but may vary depending on the selected model.\n\nExample: `!bai config room text-to-speech set-speed-override 1.5` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings))\n\n\n### 👫 Voice override\n\nThe voice override setting lets you change the voice being used by the text-to-speech model configured at the [🤖 agent](../agents.md) level (usually `onyx` when using [OpenAI](../providers.md#openai)).\n\nPossible values (e.g. `onyx`) depend on the model you're using. For example, for [OpenAI](../providers.md#openai)'s Whisper model, [these voices](https://platform.openai.com/docs/guides/text-to-speech/voice-options) are available.\n\nExample: `!bai config room text-to-speech set-voice-override nova` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings))\n"
  },
  {
    "path": "docs/development.md",
    "content": "## 🧑‍💻 Development\n\nThis documentation page contains information about **running the bot locally for development purposes**.\nThis can also **helpful for quickly testing the bot in a containerized environment, with all dependency services included**.\n\nFor running the bot against your Matrix server, see the [🚀 Installation](./installation.md) documentation.\n\nThis bot is built in [🦀 Rust](https://www.rust-lang.org/) and uses the [mxlink](https://github.com/etkecc/rust-mxlink) library (built on top of [matrix-rust-sdk](https://github.com/matrix-org/matrix-rust-sdk)).\n\nFor local development, we run all dependency services in [🐋 Docker](https://www.docker.com/) containers via [docker-compose](https://docs.docker.com/compose/).\n\n\n### Prerequisites\n\n- [🐋 Docker](https://www.docker.com/) and [docker-compose](https://docs.docker.com/compose/)\n- [Just](https://github.com/casey/just)\n- (Optional) [🦀 Rust](https://www.rust-lang.org/) - for compiling and running outside of a container\n- (Optional) an API key for some Large Language Model [☁️ provider](./providers.md) (e.g. [OpenAI](./providers.md#openai)), though we recommend using [LocalAI](#localai) or [Ollama](#ollama) for local development\n\n\n### Choosing a homeserver\n\nThe development environment supports two homeserver implementations:\n\n- **[Continuwuity](https://continuwuity.org/)** (default) — lightweight, no external database required. Good for most development needs.\n- **[Synapse](https://github.com/element-hq/synapse)** — the reference implementation, bundled with Postgres. Use this if you need Synapse-specific behavior.\n\nTo choose a homeserver (optional — defaults to Continuwuity if skipped):\n\n```sh\njust homeserver-init continuwuity  # or: just homeserver-init synapse\n```\n\nThe choice is stored in `var/homeserver` and affects all subsequent commands.\n\n> **Note:** If you switch homeservers after initial setup, you will need to:\n> - Delete `var/app/local/` and/or `var/app/container/` (app config and data)\n> - Delete `var/services/element-web/` (to regenerate its config)\n> - Re-run the prepare and user registration steps\n\n\n### Getting started guide\n\nDeveloping [locally](#running-locally) is possible, but requires a [Rust](https://www.rust-lang.org/) toolchain.\nIf this dependency is problematic for you, consider [🐋 running in a container](#running-in-a-container).\n\nIn any case, you will need [🐋 Docker](https://www.docker.com/) as [dependency services](../etc/services/) run there.\n\n\n#### Running locally\n\n1. (Optional) Choose a homeserver: `just homeserver-init continuwuity` (or `synapse`). Default is `continuwuity`.\n2. Start the homeserver and Element Web: `just services-start`\n3. (Only the first time around) Prepare initial app configuration in `var/app/local/config.yml`: `just app-local-prepare`\n4. (Only the first time around) [Prepare your configuration file](#prepare-your-configuration-file)\n5. (Only the first time around) Prepare initial default Matrix user accounts (`admin` and `baibot`): `just users-prepare`\n6. (Optional) Start additional services depending on which [agent provider you've chosen](#choosing-an-agent-provider):\n  - for [LocalAI](#localai):\n    - Start services: `just localai-start`\n\t- Wait a while for LocalAI to start up. It has a lot of models to download. Monitor progress using `just localai-tail-logs`\n\t- When ready, you'll be able to reach LocalAI's web interface at http://localai.127.0.0.1.nip.io:42027/ (not that you really need it)\n  - for [Ollama](#ollama):\n    - Start services: `just ollama-start`\n    - (Only the first time around) Pull the model configured in `agents.static_definitions` in the configuration file: `just ollama-pull-model gemma2:2b`\n7. Start the bot: `just run-locally`\n8. Go to http://element.127.0.0.1.nip.io:42025/ and login with `admin` / `admin`\n9. Create a new room and invite `@baibot:continuwuity.127.0.0.1.nip.io` (or `@baibot:synapse.127.0.0.1.nip.io` if using Synapse)\n10. When done, stop the bot (`Ctrl` + `C`)\n11. Stop the services: `just services-stop`\n12. (Optional) Stop additional services:\n  - for [LocalAI](#localai): `just localai-stop`\n  - for [Ollama](#ollama): `just ollama-stop`\n\n\n#### Running in a container\n\nYou can avoid having a [Rust](https://www.rust-lang.org/) toolchain installed locally and build/run this in a container.\n\n1. (Optional) Choose a homeserver: `just homeserver-init continuwuity` (or `synapse`). Default is `continuwuity`.\n2. Start the homeserver and Element Web: `just services-start`\n3. (Only the first time around) Prepare initial app configuration in `var/app/container/config.yml`: `just app-container-prepare`\n4. (Only the first time around) [Prepare your configuration file](#prepare-your-configuration-file)\n5. (Only the first time around) Prepare initial default Matrix user accounts (`admin` and `baibot`): `just users-prepare`\n6. (Optional) Start additional services depending on which [agent provider you've chosen](#choosing-an-agent-provider):\n  - for [LocalAI](#localai):\n    - Start services: `just localai-start`\n\t- Wait a while for LocalAI to start up. It has a lot of models to download. Monitor progress using `just localai-tail-logs`\n\t- When ready, you'll be able to reach LocalAI's web interface at http://localai.127.0.0.1.nip.io:42027/ (not that you really need it)\n  - for [Ollama](#ollama):\n    - Start services: `just ollama-start`\n    - (Only the first time around) Pull the model configured in `agents.static_definitions` in the configuration file: `just ollama-pull-model gemma2:2b`\n7. Start the bot: `just run-in-container`\n8. Go to http://element.127.0.0.1.nip.io:42025/ and login with `admin` / `admin`\n9. Create a new room and invite `@baibot:continuwuity.127.0.0.1.nip.io` (or `@baibot:synapse.127.0.0.1.nip.io` if using Synapse)\n10. When done, stop the bot (`Ctrl` + `C`)\n11. Stop the services: `just services-stop`\n12. (Optional) Stop additional services:\n  - for [LocalAI](#localai): `just localai-stop`\n  - for [Ollama](#ollama): `just ollama-stop`\n\n\n#### Prepare your configuration file\n\nThis is about editing your configuration. The initial configuration is created based on `etc/app/config.yml.dist` when you run `just app-local-prepare` or `just app-container-prepare`.\n\nDepending on whether you run locally or in a container, your configuration lives in a different file (`var/app/local/config.yml` and `var/app/container/config.yml`, respectively).\n\nBefore starting the bot, you may wish to adjust this configuration.\n\n\n##### Choosing an agent provider\n\nYou can create [🤖 agents](./agents.md) either [statically](./configuration/README.md#static-configuration) or [dynamically](./configuration/README.md#dynamic-configuration) using any of the supported [☁️ providers](./providers.md).\n\nFor getting started most quickly (and locally), we recommend using [LocalAI](#localai) or [Ollama](#ollama). These services are already configured to run as [local services via docker-compose](../etc/services/).\n\n**Ollama is most lightweight** (~2GB for the container image + ~1.6GB for the model), but supports only [💬 text-generation](./features.md#-text-generation).\n\n**LocalAI requires 4x more disk space** (~6GB for the container image + ~12GB for the models), but supports [💬 text-generation](./features.md#-text-generation), [🗣️ text-to-speech](./features.md#️-text-to-speech), [🦻 speech-to-text](./features.md#-speech-to-text) and [🖼️ image-generation](./features.md#️-image-creation).\n\n**OpenAI supports all of these capabilities** as well and does not require powerful hardware or lots of disk space. However, it requires signup and an API key.\n\nFor local testing, **we recommend LocalAI**, because it runs fully locally and supports more features than Ollama.\n\n###### LocalAI\n\n[LocalAI](./providers.md#localai) supports all [🌟 features](./features.md) of the bot.\n\nIf you decided to go with [LocalAI](./providers.md#localai):\n\n- enable the `localai` entry in the `agents.static_definitions` list in the configuration file\n- adjust the `initial_global_config.handler.catch_all` setting in the configuration file (`null` -> `static/localai`)\n\nBy default, we configure LocalAI to use the [All-In-One images](https://localai.io/basics/container/#all-in-one-images) running on the CPU.\nPerformance is not great, but it should work reasonably well on good hardware.\n\nIf you'd like to use GPU acceleration, you may adjust the `SERVICE_LOCALAI_IMAGE_NAME` variable in [var/services/env](../var/services/env) (this file is automatically prepared for you based on [etc/services/env.dist](../etc/services/env.dist)) to use [other available LocalAI All-In-One images](https://localai.io/basics/container/#available-aio-images).\n\n###### Ollama\n\n[Ollama](./providers.md#ollama) only supports [💬 text-generation](./features.md#-text-generation).\n\nIf you decided to go with [Ollama](./providers.md#ollama):\n\n- enable the `ollama` entry in the `agents.static_definitions` list in the configuration file\n- adjust the `initial_global_config.handler.catch_all` setting in the configuration file (`null` -> `static/ollama`)\n\nThe [gemma2:2b](https://ollama.com/library/gemma2:2b) model was chosen as a default, because it's smallest/lightest and should run well under [Ollama](./providers.md#ollama) on most machines.\n\n###### OpenAI\n\n[OpenAI](./providers.md#openai) supports all [🌟 features](./features.md) of the bot.\n\nIf you decided to go with [OpenAI](./providers.md#openai):\n\n- enable the `openai` entry in the `agents.static_definitions` list in the configuration file\n- adjust the `initial_global_config.handler.catch_all` setting in the configuration file (`null` -> `static/openai`)\n"
  },
  {
    "path": "docs/features.md",
    "content": "## 🌟 Features\n\n### 🎨 Mixing & matching models\n\nYou can use **different models in different rooms** (e.g. [OpenAI](./providers.md#openai) GPT-4o alongside [Llama](https://en.wikipedia.org/wiki/Llama_(language_model)) running on [Groq](./providers.md#groq), etc.)\n\nYou can also use **different models within the same room** (e.g. [💬 text-generation](#-text-generation) handled by one [🤖 agent](./agents.md), [🦻 speech-to-text](#-speech-to-text) handled by another, [🗣️ text-to-speech](#️-text-to-speech) by a 3rd, etc.)\n\nThe bot supports the following use-purposes:\n\n- [💬 text-generation](#-text-generation): communicating with you via text (though certain models may also process images and files)\n- [🦻 speech-to-text](#-speech-to-text): turning your voice messages into text\n- [🗣️ text-to-speech](#%EF%B8%8F-text-to-speech): turning bot or users text messages into voice messages\n- [🖌️ image-generation](#%EF%B8%8F-image-generation): generating images based on instructions\n\nIn a given room, each different purpose can be served by a different [☁️ provider](./providers.md) and model. This combination of provider and model configuration is called an [🤖 agent](./agents.md). Each purpose can be served by a different **handler** agent.\n\nSee a [🖼️ Screenshot of an example room configuration](./screenshots/config-status-handlers.webp).\n\nFor more information about configuring handlers, see the [🤝 Handlers / Configuring](./configuration/handlers.md#configuring) documentation section.\n\n\n### 💬 Text Generation\n\nText Generation is the bot's ability to **respond to users' messages with text**.\n\n![Screenshot of Text Generation - a user sends a message and the bot replies in a new conversation thread](./screenshots/text-generation.webp)\n\nSome models also support vision and document understanding, so you may be able to mix text, images, and files (PDFs, text documents, etc.) in the same conversation. Note that certain providers may not support all file types or may have issues with specific files (e.g. scanned/image-based PDFs). If a file is rejected by the provider, the conversation thread may become unusable — start a new thread to work around this.\n\nIn multi-user (group) rooms, to avoid disturbing the normal conversation between people, the bot is auto-configured to only respond to messages starting with the command prefix (`!bai`) or direct mentions via the [💬 Text Generation / 🗟 Prefix Requirement Type](./configuration/text-generation.md#-prefix-requirement-type) setting.\n\nNormally, the bot only responds to allowed [👥 Users](./access.md#-users). In certain cases, it's useful for an allowed user to provoke the bot to respond even in foreign threads or reply chains. You can learn more about this feature in the [On-demand involvement](./features.md#on-demand-involvement) section below.\n\nIf needed, the bot can also attach sender metadata to conversation messages before sending them to the model, which can help the model distinguish between participants in multi-user rooms. See [🛠️ Configuration / 💬 Text Generation / 👤 Sender Context Mode](./configuration/text-generation.md#-sender-context-mode).\n\nA few other features (like [🗣️ Text-to-Speech](#️-text-to-speech) and [🦻 Speech-to-Text](#-speech-to-text)) combine well with Text Generation, so you **don't necessarily need to communicate with the bot via text** (with [Seamless voice interaction](#seamless-voice-interaction), you can communicate only with voice).\n\nYou may also wish to see:\n\n- [🛠️ Configuration / 💬 Text Generation](./configuration/text-generation.md) for configuration options related to Text Generation\n- [📖 Usage / 💬 Text Generation](./usage.md#-text-generation) section for more details on how to use the bot for Text Generation in a room\n\n\n#### 🛠️ Built-in Tools (OpenAI only)\n\n\n\nThe [OpenAI provider](./providers.md#openai) supports built-in tools that extend the model's capabilities:\n\n- [🔍 Web Search](https://platform.openai.com/docs/guides/tools-web-search) (`web_search`): allows the model to search the web for up-to-date information. [🖼️ Screenshot](./screenshots/text-generation-tools-web-search.webp)\n\n- [💻 Code Interpreter](https://platform.openai.com/docs/guides/tools-code-interpreter) (`code_interpreter`): allows the model to write and execute Python code in a sandbox\n\nThese tools are **disabled by default** and need to be explicitly enabled in the agent's `text_generation.tools` configuration. See the [OpenAI sample configuration](https://github.com/etkecc/baibot/blob/c70387b0c38d8d0f30bba2179a2a21a3710dbeaf/docs/sample-provider-configs/openai.yml#L12-L15) for reference.\n\nTo enable tools on an existing dynamically-created agent, you need to [update the agent](./agents.md#updating-agents) to re-create it with the `text_generation.tools` section added and enable the tools you need\n\n💡 **Note**: These tools run on OpenAI's infrastructure and may incur additional costs. Web search results include citations that are incorporated into the response.\n\n\n#### On-demand involvement\n\nIn the following 2 cases, it's useful to involve the bot in conversations on-demand:\n\n1. In multi-user rooms (with the [🗟 Prefix Requirement](./configuration/text-generation.md#-prefix-requirement-type) setting set to \"required\")\n2. In rooms with foreign users (users that are not authorized bot [👥 users](./access.md#-users))\n\nIn these instances, an allowed [👥 user](./access.md#-users) can also provoke the bot to respond to **any** thread or reply chain by [mentioning](https://spec.matrix.org/latest/client-server-api/#user-and-room-mentions) the bot (e.g. `@baibot Hello!`). The following screenshots demonstrate this behavior:\n\n- [🖼️ On-demand involvement in the room](./screenshots/text-generation-prefix-requirement.webp)\n- [🖼️ On-demand involvement in a thread](./screenshots/text-generation-on-demand-thread-involvement.webp) (the Alice user in this example is not an allowed user, yet her messages are still considered as part of the conversation context)\n- [🖼️ On-demand involvement in a reply chain](./screenshots/text-generation-on-demand-reply-involvement.webp) (the Alice user in this example is not an allowed user, yet her messages are still considered as part of the conversation context)\n\n💡 **NOTE**: Normally, the bot **only considers messages from allowed [👥 Users](./access.md#-users)** and ignores all other messages when responding. However, **when the bot is explicitly invoked (via mention)** in a thread or reply chain, **it will consider all messages** in the thread and reply chain (even those from foreign users) as part of the conversation context.\n\n\n### 🗣️ Text-to-Speech\n\nText-to-Speech is the bot's ability to **turn text messages into voice messages**.\n\nIt can be performed **on the bot's own text messages** (responses to yours due to [💬 Text Generation](#-text-generation)) and/or **on your own text messages**.\n\nText-to-Speech can be enabled to be done automatically or on-demand (only after reacting to a message with 🗣️), and is configurable for different message types ([🪄 Bot Messages Flow Type](./configuration/README.md#-bot-messages-flow-type) vs [🪄 User Messages Flow Type](./configuration/README.md#-user-messages-flow-type)).\n\nBy default, the bot **doesn't** perform text-to-speech. It can be configured for [Seamless voice interaction](#seamless-voice-interaction), where you can **speak to the bot** (instead of typing) and then **hear its responses**.\n\nAnother use-case is to have the bot operate in [Text-to-Speech-only mode](#text-to-speech-only-mode).\n\nYou may also wish to see:\n\n- [🛠️ Configuration / 🗣️ Text-to-Speech](./configuration/text-to-speech.md) for configuration options related to Text-to-Speech\n- [📖 Usage / 🗣️ Text-to-Speech](./usage.md#-text-to-speech) section for more details on how to use the bot for Text-to-Speech in a room\n\n\n#### Text-to-Speech-only mode\n\nYou may wish to have the bot **automatically turn your text messages into voice messages**, but **without** doing [💬 Text Generation](#-text-generation).\n\n![Screenshot of Text-to-Speech-only mode - text messages are turned to audio and posted as a reply, without Text Generation happening](./screenshots/text-to-speech-only-mode.webp)\n\nThis could be useful in a room with others, where you'd like to post text messages and have people in the room consume them more easily (by listening to audio).\n\nTo allow for this use-case, you can:\n\n- disable [💬 Text Generation](#-text-generation) (via [💬 Text Generation / 🪄 Auto Usage](./configuration/text-generation.md#-auto-usage) setting): `!bai config room text-generation set-auto-usage never`\n\n- enable [🗣️ Text-to-Speech](#️-text-to-speech) for user messages (via [🗣️ Text-to-Speech / 🪄 User Messages Flow Type](./configuration/text-to-speech.md#-user-messages-flow-type)): `!bai config room text-to-speech set-user-msgs-flow-type always` (or `on_demand`)\n\n\n### 🦻 Speech-to-Text\n\nSpeech-to-Text is the bot's ability to **turn voice messages into text**.\n\n![Default flow for Speech-to-Text and Text-Generation - your voice messages are transcribed to text and then answered via Text Generation](./screenshots/speech-to-text-default-flow.webp)\n\nThe default flow is shown in the screenshot above: your voice messages are transcribed to text and [💬 Text Generation](#-text-generation) is performed. By default, the bot offers [🗣️ Text-to-Speech](#️-text-to-speech) for its answers via a 🗣️ emoji. You can click it to trigger text-to-speech on-demand.\n\nYou may also configure the bot for [Seamless voice interaction](#seamless-voice-interaction) or [Transcribe-only mode](#transcribe-only-mode), etc.\n\nYou may also wish to see:\n\n- [🛠️ Configuration / 🦻 Speech-to-Text](./configuration/speech-to-text.md) for configuration options related to Speech-to-Text\n- [📖 Usage / 🦻 Speech-to-Text](./usage.md#-speech-to-text) section for more details on how to use the bot for Speech-to-Text in a room\n\n#### Seamless voice interaction\n\nThe bot can perform seamless voice interaction (🗣️-to-🗣️), allowing you to **speak to the bot** (instead of typing) and then **hear its responses**.\n\n![Screenshot of the Seamless voice interaction mode - your voice messages are transcribed to text, then answered via Text Generation, and finally the answer is turned into a voice message](./screenshots/text-to-speech-seamless-voice-interaction.webp)\n\nThe flow is like this:\n\n1. 👤 You sending a voice message\n2. 🤖 The bot:\n  - (default) first turning your **voice message into text** ([🦻 Speech-to-Text](#-speech-to-text)) and posting it as a reply. This lets you  you see what the bot heard.\n  - (default) then **answering in text** ([💬 Text Generation](#-text-generation)). This lets you read/skim text, if you so prefer.\n  - (can be enabled) finally **turning the answer's text into a voice message** ([🗣️ Text-to-Speech](#️-text-to-speech))\n3. 👤 You continuing the conversation via text or voice messages\n\n⚠️ Certain clients (like [Element](https://element.io/)) only support sending voice messages as top-level room messages, not as thread replies. Until this client limitation is fixed, Element users can only send the 1st message as a voice message - subsequent replies in the same conversation thread will need to be sent as text messages.\n\nBy default, the last part of the aforementioned flow is **not enabled**, because we assume **a saner default is to reply with text and merely *offer* text-to-speech to those who want it**. Offering is done by the bot reacting to its own message with 🗣️, and letting you click this emoji to trigger text-to-speech on-demand.\n\nTo enable automatic text-to-speech for the bot's messages, set the [🗣️ Text-to-Speech / 🪄 Bot Messages Flow Type](./configuration/text-to-speech.md#-bot-messages-flow-type) setting to `only_for_voice` or `always` (e.g. `!bai config room text-to-speech set-bot-msgs-flow-type only_for_voice`).\n\n\n#### Transcribe-only mode\n\nIf you'd like to have the bot **only turn voice messages into text** (without generating text messages or voice messages), you can configure the bot for that.\n\n![Screenshot of Transcribe-only-mode for Speech-to-Text - your voice messages are transcribed to text, and the bot does not generate text messages or voice messages](./screenshots/speech-to-text-transcribe-only-mode.webp)\n\nTo operate in this mode, you can:\n\n- disable [💬 Text Generation](#-text-generation) (via [💬 Text Generation / 🪄 Auto Usage](./configuration/text-generation.md#-auto-usage) setting): `!bai config room text-generation set-auto-usage never`\n\n- adjust the [🦻 Speech-to-Text / 🪄 Flow Type](./configuration/speech-to-text.md#-flow-type) setting to make the bot only transcribe (without doing [💬 Text Generation](#-text-generation)): `!bai config room speech-to-text set-flow-type only_transcribe`\n\n- optionally adjust [🦻 Speech-to-Text / 🪄 Message Type for non-threaded only-transcribed messages](./configuration/speech-to-text.md#-message-type-for-non-threaded-only-transcribed-messages), if you'd like to bot to send messages of type `notice` (for better compatibility with other bots in the room) instead of sending regular `text` messages (default)\n\n\n### Image Generation\n\n#### 🖌️ Image Creation\n\nImage creation is the bot's ability to **create images** based on text prompts.\n\nSee a [🖼️ Screenshot of the Image Creation feature](./screenshots/image-creation.webp).\n\nYou may also wish to see:\n\n- [🛠️ Configuration / 🖌️ Image Generation](./configuration/image-generation.md) for configuration options related to Image Generation\n- [📖 Usage / Image Generation / 🖌️ Creating Images](./usage.md#-creating-images) section for more details on how to use the bot for Image Creation in a room\n- [🖌️ Image Editing](#️-image-editing) - another image generation feature\n- [🫵 Sticker Creation](#-sticker-creation) - a special case of Image Creation\n\n\n#### 🎨 Image Editing\n\nImage editing is the bot's ability to **edit images** based on a prompt and one or more existing images.\n\nSee a [🖼️ Screenshot of the Image Editing feature (manipulating a single image)](./screenshots/image-editing-single-image.webp) and a [🖼️ Screenshot of the Image Editing feature (manipulating multiple images)](./screenshots/image-editing-multiple-images.webp).\n\nYou may also wish to see:\n\n- [🛠️ Configuration / 🖌️ Image Generation](./configuration/image-generation.md) for configuration options related to Image Generation\n- [📖 Usage / Image Generation / 🎨 Editing images](./usage.md#-editing-images) section for more details on how to use the bot for Image Editing in a room\n- [🖌️ Image Creation](#️-image-creation) - another image generation feature\n\n\n#### 🫵 Sticker Creation\n\nSticker generation is the bot's ability to **generate sticker** images based on text prompts. It's a special case of [🖌️ Image Creation](#️-image-creation).\n\nSee a [🖼️ Screenshot of the Sticker Creation feature](./screenshots/sticker-generation.webp).\n\nSee [📖 Usage / Image Generation / 🫵 Creating Stickers](./usage.md#-creating-stickers) for details.\n\n\n### 🔒 Encryption\n\n#### Message exchange\n\nThe bot works in both **unencrypted and encrypted Matrix rooms**.\n\nIf configured, the bot can make use of **Matrix's Secure Storage (Recovery) feature**, so that it can restore its encryption keys even its local database gets lost.\n\n#### Configuration\n\nThe bot also stores its [🛠️ configuration](./configuration/README.md) (both 📍 per-room and 🌐globally) in Matrix Account Data, which is **generally stored as plain-text in the server**.\n\nTo overcome this Matrix limitation, the bot can **optionally encrypt the configuration data** before storing it in Account Data. This allows for the bot to be used securely even against untrusted servers, without leaking sensitive configuration data to them.\n"
  },
  {
    "path": "docs/installation.md",
    "content": "## 🚀 Installation\n\n☁️ The easiest way to use the bot is to **get a managed Matrix server from [etke.cc](https://etke.cc/)** and order baibot via the [order form](https://etke.cc/order/). Existing customers can request the inclusion of this additional service by [contacting support](https://etke.cc/contacts/).\n\n💻 If you're managing your Matrix server with the help of the [matrix-docker-ansible-deploy](https://github.com/spantaleev/matrix-docker-ansible-deploy) Ansible playbook, you can easily **install the bot via the Ansible playbook**. See the playbook's [Setting up baibot](https://github.com/spantaleev/matrix-docker-ansible-deploy/blob/master/docs/configuring-playbook-bot-baibot.md) documentation page.\n\n🐋 In other cases, we **recommend using our [prebuilt container images](https://github.com/etkecc/baibot/pkgs/container/baibot) and [running in a container](#-running-in-a-container)**. You can also [build a container image](#building-a-container-image) yourself.\n\n🔨 If containers are not your thing, you can [build a binary](#-building-a-binary) yourself and [run it](#-running-a-binary).\n\n🗲 For a quick experiment, you can refer to the [🧑‍💻 development documentation](./development.md) which contains information on how to build and run the bot (and its various dependency services) locally.\n\n\n### 🐋 Building a container image\n\nWe provide prebuilt container images for the `amd64` and `arm64` architectures, so **you don't necessarily need to build images yourself** and can jump to [Running in a container](#-running-in-a-container).\n\nIf you nevertheless wish to build a container image yourself, you can do so by running:\n\n- (recommended) `just build-container-image-release` to build a release version of the container image\n\n- or `just build-container-image-debug` to build a debug version of the container image\n\nDebug images are faster to build but are larger in size.\nRelease images are ~5x smaller in size, but are slower to build.\n\nBoth of these commands will build and tag your container image as `localhost/baibot:latest`.\n\n\n### 🐋 Running in a container\n\nWe recommend using a **tagged-release** (e.g. `v1.0.0`, not `latest`) of our [prebuilt container images](https://github.com/etkecc/baibot/pkgs/container/baibot), but you can also [build a container image](#-building-a-container-image) yourself.\n\nYou should:\n\n- [🛠️ prepare a configuration file](#-preparing-a-configuration-file) (e.g. `cp etc/app/config.yml.dist /path/to/config.yml` & edit it)\n- prepare a data directory (`mkdir /path/to/data`)\n\nThe example below uses [🐋 Docker](https://www.docker.com/) to run the container, but other container runtimes like [Podman](https://podman.io/) should work as well.\n\n```sh\n# Adjust the version tag to point to the latest available tagged version.\n# If building your own container image name, adjust to something like `localhost/baibot:latest`.\nCONTAINER_IMAGE_NAME=ghcr.io/etkecc/baibot:v1.0.0\n\n/usr/bin/env docker run \\\n  -it \\\n  --rm \\\n  --name=baibot \\\n  --user=$(id -u):$(id -g) \\\n  --cap-drop=ALL \\\n  --read-only \\\n  --env BAIBOT_PERSISTENCE_DATA_DIR_PATH=/data \\\n  --mount type=bind,src=/path/to/config.yml,dst=/app/config.yml,ro \\\n  --mount type=bind,src=/path/to/data,dst=/data \\\n  --tmpfs=/tmp:rw,noexec,nosuid,size=1024m \\\n  $CONTAINER_IMAGE_NAME\n```\n\n💡 If you've defined the `persistence.data_dir_path` setting in the `config.yml` file, you can skip the `BAIBOT_PERSISTENCE_DATA_DIR_PATH` environment variable.\n\n\n### 🔨 Building a binary\n\nTo build a binary, you need a [🦀 Rust](https://www.rust-lang.org/) toolchain.\n\nConsult the [Dockerfile](../Dockerfile) file to learn what some of the build dependencies are (e.g. `libssl-dev`, `libsqlite3-dev`, etc., on Debian-based distros).\n\nYou can build a binary from the current project's source code:\n\n- in `debug` mode via: `just build-debug`, yielding a binary in `target/debug/baibot`\n- (recommended) in `release` mode via: `just build-release`, yielding a binary in `target/release/baibot`\n\n💡 Unless you're [🧑‍💻 developing](./development.md), you probably wish to build in release mode, as that provides a much smaller and more optimized binary.\n\n📦 You can also install from the [baibot](https://crates.io/crates/baibot) crate published to [crates.io](https://crates.io) with the help of the [cargo](https://doc.rust-lang.org/cargo/) package manager by running: `cargo install baibot`.\n\n\n### 🖥️ Running a binary\n\nOnce you've [🔨 built a binary](#-building-a-binary) and [🛠️ prepared a configuration file](#-preparing-a-configuration-file), you can run it.\n\nConsult the [Dockerfile](../Dockerfile) file to learn what some of the runtime dependencies are (e.g. `ca-certificates`, `sqlite3`, etc., on Debian-based distros).\n\nYou can run the binary like this:\n\n```sh\nBAIBOT_CONFIG_FILE_PATH=/path/to/config.yml \\\nBAIBOT_PERSISTENCE_DATA_DIR_PATH=/path/to/data \\\n./target/release/baibot\n```\n\n💡 If you've defined the `persistence.data_dir_path` setting in the `config.yml` file, you can skip the `BAIBOT_PERSISTENCE_DATA_DIR_PATH` environment variable.\n\n💡 If your `config.yml` file is in your working directory (which may be different than the directory the binary lives in), you can skip the `BAIBOT_CONFIG_FILE_PATH` environment variable.\n\n\n### 🛠️ Preparing a configuration file\n\nFor an introduction to the configuration file, see the [🛠️ Configuration](./configuration/README.md) page.\n\nGenerally, you need to copy the configuration file template ([etc/app/config.yml.dist](../etc/app/config.yml.dist)) and make modifications as needed.\n"
  },
  {
    "path": "docs/providers.md",
    "content": "## ☁️ Providers\n\n[🤖 Agents](./agents.md) are powered by a provider. The provider could be a **local service** or a **cloud service**.\n\nThe list of supported providers is below.\n\n\n### Table of contents\n\n- [How to choose a provider](#how-to-choose-a-provider)\n- [How to use a provider](#how-to-use-a-provider)\n- [Supported providers](#supported-providers)\n  - [Anthropic](#anthropic)\n  - [Groq](#groq)\n  - [LocalAI](#localai)\n  - [Mistral](#mistral)\n  - [Ollama](#ollama)\n  - [OpenAI](#openai)\n  - [OpenAI Compatible](#openai-compatible)\n  - [OpenRouter](#openrouter)\n  - [Together AI](#together-ai)\n\n\n### How to choose a provider\n\nIf you're not sure which provider to start with, **we recommend [OpenAI](#openai)** as it's the most popular and has the **widest range of capabilities**: [💬 text-generation](./features.md#-text-generation) (incl. vision, incl. [🛠️ tools](./features.md#️-built-in-tools-openai-only)), [🖌️ image-generation](./features.md#️image-generation), [🦻 speech-to-text](./features.md#-speech-to-text), [🗣️ text-to-speech](./features.md#️-text-to-speech).\n\nYou don't need to choose just one though. The bot supports [mixing & matching models](./features.md#-mixing--matching-models), so you can use multiple providers at the same time.\n\n\n### How to use a provider\n\n1. 📝 **Sign up for it**\n\n2. 🔑 **Obtain an API key**\n\n3. 🤖 **Create one or more agents** in a given room or globally. Next to each provider in the [list below](#supported-providers) you'll see **🗲 Quick start** commands, but you may also refer to the [agent creation guide](./agents.md#creating-agents).\n\n4. 🤝 **Set the new agent as a handler** for a given use-purpose like text-generation, image-generation, etc. The agent creation wizard will tell you how, but you may also refer to the [🤝 Handlers](./configuration/handlers.md) guide.\n\n\n### Supported providers\n\n### Anthropic\n\n[Anthropic](https://www.anthropic.com/) is an American AI company founded by former OpenAI engineers and providing powerful language models.\n\n- 🆔 Identifier: `anthropic`\n- 🔗 Links: [🏠 Home page](https://www.anthropic.com/), [🌐 Wiki](https://en.wikipedia.org/wiki/Anthropic), [👤 Sign up](https://console.anthropic.com/), [📋 Models list](https://docs.anthropic.com/en/docs/about-claude/models)\n- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (incl. vision, no tools)\n- 🗲 Quick start:\n  - create a room-local agent: `!bai agent create-room-local anthropic my-anthropic-agent`\n  - create a global agent: `!bai agent create-global anthropic my-anthropic-agent`\n\n💡 When creating an agent, the bot will show you an up-to-date sample configuration for this provider which looks [like this](./sample-provider-configs/anthropic.yml).\n\n\n### Groq\n\n[Groq](https://groq.com/) is an American company developing optimized Language Processing Units (LPU) and offering cloud service which runs various models (built by others) with very high performance.\n\n- 🆔 Identifier: `groq`\n- 🔗 Links: [🏠 Home page](https://groq.com/), [🌐 Wiki](https://en.wikipedia.org/wiki/Groq), [👤 Sign up](https://console.groq.com/login), [📋 Models list](https://console.groq.com/docs/models)\n- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision, no tools), [🦻 speech-to-text](./features.md#-speech-to-text)\n- 🗲 Quick start:\n  - create a room-local agent: `!bai agent create-room-local groq my-groq-agent`\n  - create a global agent: `!bai agent create-global groq my-groq-agent`\n\n💡 When creating an agent, the bot will show you an up-to-date sample configuration for this provider which looks [like this](./sample-provider-configs/groq.yml).\n\n\n### LocalAI\n\n[LocalAI](https://localai.io/) is the free, Open Source OpenAI alternative. LocalAI act as a drop-in replacement REST API that’s compatible with OpenAI API specifications for local inferencing. It allows you to run LLMs, generate images, audio (and not only) locally or on-prem with consumer grade hardware, supporting multiple model families and architectures.\n\n- 🆔 Identifier: `localai`\n- 🔗 Links: [🏠 Home page](https://localai.io/), [📋 Models list](https://localai.io/gallery.html)\n- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision, no tools), [🗣️ text-to-speech](./features.md#️-text-to-speech), [🦻 speech-to-text](./features.md#-speech-to-text)\n- 🗲 Quick start:\n  - create a room-local agent: `!bai agent create-room-local localai my-localai-agent`\n  - create a global agent: `!bai agent create-global localai my-localai-agent`\n\n💡 When creating an agent, the bot will show you an up-to-date sample configuration for this provider which looks [like this](./sample-provider-configs/localai.yml).\n\n\n### Mistral\n\n[Mistral AI](https://mistral.ai/) is a research lab based in Europe (France) which produces their own language models.\n\n- 🆔 Identifier: `mistral`\n- 🔗 Links: [🏠 Home page](https://mistral.ai/), [🌐 Wiki](https://en.wikipedia.org/wiki/Mistral_AI), [👤 Sign up](https://auth.mistral.ai/ui/registration), [📋 Models list](https://docs.mistral.ai/getting-started/models/)\n- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision, no tools)\n- 🗲 Quick start:\n  - create a room-local agent: `!bai agent create-room-local mistral my-mistral-agent`\n  - create a global agent: `!bai agent create-global mistral my-mistral-agent`\n\n💡 When creating an agent, the bot will show you an up-to-date sample configuration for this provider which looks [like this](./sample-provider-configs/mistral.yml).\n\n\n### Ollama\n\n[Ollama](https://ollama.com/) lets you run various models in a [self-hosted](https://github.com/ollama/ollama?tab=readme-ov-file#ollama) way. This is more advanced and requires powerful hardware for running some of the better models, but ensures your data stays with you.\n\n- 🆔 Identifier: `ollama`\n- 🔗 Links: [🏠 Home page](https://ollama.com/), [📋 Models list](https://ollama.com/library)\n- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision, no tools)\n- 🗲 Quick start:\n  - create a room-local agent: `!bai agent create-room-local ollama my-ollama-agent`\n  - create a global agent: `!bai agent create-global ollama my-ollama-agent`\n\n💡 When creating an agent, the bot will show you an up-to-date sample configuration for this provider which looks [like this](./sample-provider-configs/ollama.yml).\n\n\n### OpenAI\n\n[OpenAI](https://openai.com/) is an American AI company providing powerful language models.\n\nUse this provider either with the OpenAI API or with other OpenAI-compatible API services which **fully** adhere to the [OpenAI API spec](https://github.com/openai/openai-openapi/).\nFor services which are not fully compatible with the OpenAI API, consider using the [OpenAI Compatible](#openai-compatible) provider.\n\n- 🆔 Identifier: `openai`\n- 🔗 Links: [🏠 Home page](https://openai.com/), [🌐 Wiki](https://en.wikipedia.org/wiki/OpenAI), [👤 Sign up](https://platform.openai.com/signup), [📋 Models list](https://platform.openai.com/docs/models)\n- 🌟 Capabilities: [🖌️ image-generation](./features.md#️-image-creation), [💬 text-generation](./features.md#-text-generation) (incl. vision, incl. [🛠️ tools](./features.md#️-built-in-tools-openai-only)), [🗣️ text-to-speech](./features.md#️-text-to-speech), [🦻 speech-to-text](./features.md#-speech-to-text)\n- 🗲 Quick start:\n  - create a room-local agent: `!bai agent create-room-local openai my-openai-agent`\n  - create a global agent: `!bai agent create-global openai my-openai-agent`\n\n💡 When creating an agent, the bot will show you an up-to-date sample configuration for this provider which looks [like this](./sample-provider-configs/openai.yml).\n\n\n### OpenAI Compatible\n\nThis provider allows you to use OpenAI-compatible API services like [OpenRouter](https://openrouter.ai/), [Together AI](https://www.together.ai/), etc.\n\nSome of these popular services already have **shortcut** providers (leading to this one behind the scenes) - this make it easier to get started.\n\nThis provider is just as featureful as the [OpenAI](#openai) provider, but is more compatible with services which do not fully adhere to the [OpenAI API spec](https://github.com/openai/openai-openapi/).\n\n- 🆔 Identifier: `openai-compatible`\n- 🌟 Capabilities: [🖌️ image-generation](./features.md#️-image-creation), [💬 text-generation](./features.md#-text-generation) (no vision, no tools), [🗣️ text-to-speech](./features.md#️-text-to-speech), [🦻 speech-to-text](./features.md#-speech-to-text)\n- 🗲 Quick start:\n  - create a room-local agent: `!bai agent create-room-local openai-compatible my-openai-compatible-agent`\n  - create a global agent: `!bai agent create-global openai-compatible my-openai-compatible-agent`\n\n💡 When creating an agent, the bot will show you an up-to-date sample configuration for this provider which looks [like this](./sample-provider-configs/openai-compatible.yml).\n\n\n### OpenRouter\n\n[OpenRouter](https://openrouter.ai/) is a unified interface for LLMs. The platform scouts for the lowest prices and best latencies/throughputs across dozens of providers, and lets you choose how to [prioritize](https://openrouter.ai/docs/provider-routing) them.\n\n- 🆔 Identifier: `openrouter`\n- 🔗 Links: [🏠 Home page](https://openrouter.ai/), [👤 Sign up](https://openrouter.ai/), [📋 Models list](https://openrouter.ai/models)\n- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision, no tools)\n- 🗲 Quick start:\n  - create a room-local agent: `!bai agent create-room-local openrouter my-openrouter-agent`\n  - create a global agent: `!bai agent create-global openrouter my-openrouter-agent`\n\n💡 When creating an agent, the bot will show you an up-to-date sample configuration for this provider which looks [like this](./sample-provider-configs/openrouter.yml).\n\n\n### Together AI\n\n[Together AI](https://www.together.ai/) makes it easy to run or [fine-tune](https://docs.together.ai/docs/fine-tuning-overview) leading open source models with only a few lines of code.\n\n- 🆔 Identifier: `together-ai`\n- 🔗 Links: [🏠 Home page](https://www.together.ai/), [👤 Sign up](https://api.together.ai/signup), [📋 Models list](https://api.together.xyz/models)\n- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision, no tools)\n- 🗲 Quick start:\n  - create a room-local agent: `!bai agent create-room-local together-ai my-together-ai-agent`\n  - create a global agent: `!bai agent create-global together-ai my-together-ai-agent`\n\n💡 When creating an agent, the bot will show you an up-to-date sample configuration for this provider which looks [like this](./sample-provider-configs/together-ai.yml).\n"
  },
  {
    "path": "docs/sample-provider-configs/anthropic.yml",
    "content": "base_url: https://api.anthropic.com/v1\napi_key: YOUR_API_KEY_HERE\ntext_generation:\n  model_id: claude-3-7-sonnet-20250219\n  prompt: \"You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}.\"\n  temperature: 1.0\n  max_response_tokens: 8192\n  max_context_tokens: 204800\n"
  },
  {
    "path": "docs/sample-provider-configs/groq.yml",
    "content": "base_url: https://api.groq.com/openai/v1\napi_key: YOUR_API_KEY_HERE\ntext_generation:\n  model_id: llama3-70b-8192\n  prompt: \"You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}.\"\n  temperature: 1.0\n  max_response_tokens: 4096\n  max_context_tokens: 131072\nspeech_to_text:\n  model_id: whisper-large-v3\n"
  },
  {
    "path": "docs/sample-provider-configs/localai.yml",
    "content": "base_url: http://my-localai-self-hosted-service:8080/v1\napi_key: YOUR_API_KEY_HERE\ntext_generation:\n  model_id: gpt-4\n  prompt: \"You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}.\"\n  temperature: 1.0\n  max_response_tokens: 4096\n  max_context_tokens: 128000\nspeech_to_text:\n  model_id: whisper-1\ntext_to_speech:\n  model_id: tts-1\n  voice: onyx\n  speed: 1.0\n  response_format: opus\nimage_generation:\n  model_id: stablediffusion\n  style: vivid\n  size: 1024x1024\n  quality: standard\n"
  },
  {
    "path": "docs/sample-provider-configs/mistral.yml",
    "content": "base_url: https://api.mistral.ai/v1\napi_key: YOUR_API_KEY_HERE\ntext_generation:\n  model_id: mistral-large-latest\n  prompt: \"You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}.\"\n  temperature: 1.0\n  max_response_tokens: 4096\n  max_context_tokens: 128000\n"
  },
  {
    "path": "docs/sample-provider-configs/ollama.yml",
    "content": "base_url: http://my-ollama-self-hosted-service:11434/v1\napi_key: YOUR_API_KEY_HERE\ntext_generation:\n  model_id: gemma2:2b\n  prompt: \"You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}.\"\n  temperature: 1.0\n  max_response_tokens: 4096\n  max_context_tokens: 128000\n"
  },
  {
    "path": "docs/sample-provider-configs/openai-compatible.yml",
    "content": "base_url: ''\napi_key: YOUR_API_KEY_HERE\ntext_generation:\n  model_id: some-model\n  prompt: \"You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}.\"\n  temperature: 1.0\n  max_response_tokens: 4096\n  max_context_tokens: 128000\nspeech_to_text:\n  model_id: whisper-1\n"
  },
  {
    "path": "docs/sample-provider-configs/openai.yml",
    "content": "base_url: https://api.openai.com/v1\napi_key: YOUR_API_KEY_HERE\ntext_generation:\n  model_id: gpt-5.4\n  prompt: \"You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}.\"\n  temperature: 1.0\n  # Reasoning models need to use `max_completion_tokens` instead of `max_response_tokens`.\n  # If you're dealing with a non-reasoning model, specify `max_response_tokens` and unset `max_completion_tokens`.\n  max_response_tokens: null\n  max_completion_tokens: 128000\n  max_context_tokens: 400000\n  # Built-in tools\n  tools:\n    web_search: false\n    code_interpreter: false\nspeech_to_text:\n  model_id: whisper-1\ntext_to_speech:\n  model_id: tts-1-hd\n  voice: onyx\n  speed: 1.0\n  response_format: opus\nimage_generation:\n  model_id: gpt-image-1.5\n  style: null\n  size: null\n  quality: null\n"
  },
  {
    "path": "docs/sample-provider-configs/openrouter.yml",
    "content": "base_url: https://openrouter.ai/api/v1\napi_key: YOUR_API_KEY_HERE\ntext_generation:\n  model_id: mattshumer/reflection-70b:free\n  prompt: \"You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}.\"\n  temperature: 1.0\n  max_response_tokens: 2048\n  max_context_tokens: 8192\n"
  },
  {
    "path": "docs/sample-provider-configs/together-ai.yml",
    "content": "base_url: https://api.together.xyz/v1\napi_key: YOUR_API_KEY_HERE\ntext_generation:\n  model_id: meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo\n  prompt: \"You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}.\"\n  temperature: 1.0\n  max_response_tokens: 2048\n  max_context_tokens: 8192\n"
  },
  {
    "path": "docs/usage.md",
    "content": "## 📖 Usage\n\nThis document covers how to use the bot in a room.\n\nThe [🌟 Features](./features.md) page also includes details about how each feature works and can be configured.\n\n\n### 💬 Text Generation\n\nThis is related to the [💬 Text Generation](./features.md#-text-generation) feature.\n\nIf there's a text-generation handler agent configured, the bot **may** respond to messages sent in the room.\n\nSome models also support vision and document understanding, so you may be able to mix text, images, and files (PDFs, text documents, etc.) in the same conversation.\n\nSee screenshots of:\n\n- 🖼️ [the default Text Generation flow](./screenshots/text-generation.webp) in 1:1 rooms\n- 🖼️ [the Text Generation flow in multi-user rooms](./screenshots/text-generation-prefix-requirement.webp) (where the [🗟 Prefix Requirement](./configuration/text-generation.md#-prefix-requirement-type) setting is auto-configured to \"required\")\n- the [on-demand involvement](./features.md#on-demand-involvement) feature\n\nWhether the bot responds depends on:\n\n- ([🔒 access](./access.md)) whether you're a whitelisted bot [👥 user](./access.md#-users)\n\n- [🛠️ configuration](./configuration/README.md) whether there's a configured `text-generation` handler agent (or a `catch-all` handler agent). See [Mixing & matching models](./features.md#-mixing--matching-models)\n\n- (🎨 agent capabilities) whether the configured `text-generation` (or `catch-all`) handler agent actually supports text-generation. The provider may lack support for this feature or it may be disabled in the [🤖 agents](./agents.md) configuration\n\n- (the [🗟 Prefix Requirement](./configuration/text-generation.md#-prefix-requirement-type) setting) whether a prefix (e.g. `!bai`) or user mention (e.g. `@baibot`) is required for messages sent to the room. For multi-user rooms, this setting defaults to \"required\". See [🌟 Features / 💬 Text Generation / On-demand involvement](./features.md#on-demand-involvement) for details.\n\nRoom messages start a threaded conversation where you can continue back-and-forth communication with the bot. Using [on-demand involvement](./features.md#on-demand-involvement), you can can also mention the bot to provoke it to get involved in any conversation thread or reply chain.\n\nUnless you've enabled the [♻️ Context Management](./features.md#️-context-management) feature, all messages will be sent to the agent's API each time. If the context management feature is enabled, older messages may be dropped.\n\n\n### 🗣️ Text-to-Speech\n\nThis is related to the [🗣️ Text-to-Speech](./features.md#️-text-to-speech) feature.\n\nIf there's a text-to-speech handler agent configured, the bot **may** convert text messages sent to the room to audio (voice).\n\nSee:\n\n- a [🖼️ screenshot](./screenshots/text-to-speech-only-mode.webp) of the bot's [Text-to-Speech-only](./features.md#text-to-speech-only-mode) mode\n\n- a [🖼️ screenshot](./screenshots/text-to-speech-seamless-voice-interaction.webp) of the bot's [Seamless voice interaction](./features.md#seamless-voice-interaction) mode\n\nBy default, the bot:\n\n- will offer tex-to-speech for its own messages which are a response to voice message from your, as part of the [Seamless voice interaction](./features.md#seamless-voice-interaction) feature. This can be adjusted via the [🗣️ Text-to-Speech / 🪄 Bot Messages Flow Type](./configuration/text-to-speech.md#-bot-messages-flow-type) setting.\n\n- does not turn your own text messages to audio (voice). If you'd like for the bot to operate in such a mode, use the [🗣️ Text-to-Speech / 🪄 User Messages Flow Type](./configuration/text-to-speech.md#-user-messages-flow-type) setting (see [Text-to-Speech-only mode](./features.md#text-to-speech-only-mode)).\n\n\n### 🦻 Speech-to-Text\n\nThis is related to the [🦻 Speech-to-Text](./features.md#-speech-to-text) feature.\n\nIf there's a speech-to-text handler agent configured, the bot **may** transcribe voice messages sent to the room to text.\n\nSee a [🖼️ Screenshot of the default flow for Speech-to-Text and Text-Generation](./screenshots/speech-to-text-default-flow.webp).\n\nThe speech-to-text feature triggers automatically by default, but can be adjusted via the [🦻 Speech-to-Text / 🪄 Flow Type](./features.md#-speech-to-text-flow-type) setting.\n\nIf all your messages are in the same language, you can improve accuracy & latency by configuring the language (see [🦻 Speech-to-Text / 🔤 Language](./configuration/speech-to-text.md#-language)).\n\n\n### Image Generation\n\nThis feature is not configurable at the moment. The configuration (size, quality, style) specified at the [🤖 agent](./agents.md) level will be used.\n\nCapabilities depend on the [☁️ provider](./providers.md) and model used.\n\n\n#### 🖌️ Creating images\n\nSimply send a command like `!bai image create A beautiful sunset over the ocean` and the bot will start a threaded conversation and post an image based on your prompt.\n\nSee a [🖼️ Screenshot of the Image Creation feature](./screenshots/image-creation.webp).\n\nYou can then respond in the same message thread with:\n\n- more messages, to add more criteria to your prompt.\n- a message saying `again`, to generate one more image with the current prompt.\n\n\n#### 🎨 Editing images\n\nSimply send a command like `!bai image edit Turn the following image into an anime-style drawing` and the bot will start a threaded conversation asking for more details.\n\nSee a [🖼️ Screenshot of the Image Editing feature (manipulating a single image)](./screenshots/image-editing-single-image.webp) and a [🖼️ Screenshot of the Image Editing feature (manipulating multiple images)](./screenshots/image-editing-multiple-images.webp).\n\nYou can then respond in the same message thread with:\n\n- more messages, to add more criteria to your prompt.\n- one or more images, to provide the images that the bot will operate on.\n- a message saying `go`, to start the image generation process.\n- a message saying `again`, to prompt the bot to generate one more image edit with the current prompt.\n\n\n#### 🫵 Creating stickers\n\nA variation of [creating images](#creating-images) is creating \"sticker images\".\n\nSee a [🖼️ Screenshot of the Sticker Creation feature](./screenshots/sticker-generation.webp).\n\nTo create a sticker, send a command like `!bai sticker A huge ramen bowl with lots of chashu and a mountain of beansprouts on top`.\n\nThe difference from [creating images](#creating-images) is that the bot will:\n\n- generate a smaller-resolution image (currently hardcoded to `256x256`) - smaller/quicker, but still good enough for a sticker\n- potentially switch to a different (cheaper or otherwise more suitable) model, if available\n- post the image directly to the room (as a reply to your message), without starting a threaded conversation\n\nSome models (like [OpenAI](./providers.md#openai)'s [Dall-E-3](https://openai.com/index/dall-e-3/)) can only generate larger images (`1024x1024`, etc., for a higher charge), so we switching to a smaller/cheaper model (like [Dall-E-2](https://openai.com/index/dall-e-2/)) is a way to generate a sticker cheaply.\n"
  },
  {
    "path": "etc/app/config.yml.dist",
    "content": "homeserver:\n  # The canonical homeserver domain name\n  server_name: __HOMESERVER_SERVER_NAME__\n  url: __HOMESERVER_URL__\n\nuser:\n  mxid_localpart: baibot\n\n  # Authentication: set EITHER password OR access_token + device_id.\n  #\n  # Password-based login (traditional homeservers):\n  password: baibot\n\n  # Access token login (for Matrix Authentication Service/OIDC-enabled homeservers):\n  # Generate a token via: mas-cli manage issue-compatibility-token <username> [device_id]\n  # access_token: null\n  # device_id: null\n\n  # The name the bot uses as a display name and when it refers to itself.\n  # Leave empty to use the default (baibot).\n  name: baibot\n\n  # An optional path to an image file to be used as a custom avatar image.\n  # - null or empty string: use the default avatar\n  # - \"keep\": don't touch the avatar, keep whatever is already set\n  # - any other value: path to a custom avatar image file\n  avatar: null\n\n  encryption:\n    # An optional passphrase to use for backing up and recovering the bot's encryption keys.\n    # You can use any string here.\n    #\n    # If set to null, the recovery module will not be used and losing your session/database (see persistence)\n    # will mean you lose access to old messages in encrypted room.\n    #\n    # Changing this subsequently will also cause you to lose access to old messages in encrypted rooms.\n    # If you really need to change this:\n    # - Set `encryption_recovery_reset_allowed` to `true` and adjust the passphrase\n    # - Remove your session file and database (see persistence)\n    # - Restart the bot\n    # - Then restore `encryption_recovery_reset_allowed` to `false` to prevent accidental resets in the future\n    recovery_passphrase: long-and-secure-passphrase-here\n\n    # An optional flag to reset the encryption recovery passphrase.\n    recovery_reset_allowed: false\n\n# Command prefix. Leave empty to use the default (!bai).\ncommand_prefix: \"!bai\"\n\nroom:\n  # Whether the bot should send an introduction message after joining a room.\n  post_join_self_introduction_enabled: true\n\naccess:\n  # Space-separated list of MXID patterns which specify who is an admin.\n  admin_patterns:\n    - \"@admin:__HOMESERVER_SERVER_NAME__\"\n\npersistence:\n  # This is unset here, because we expect the configuration to come from an environment variable (BAIBOT_PERSISTENCE_DATA_DIR_PATH).\n  # In your setup, you may wish to set this to a directory path.\n  data_dir_path: null\n\n  # An optional secret for encrypting the bot's session data (stored in data_dir_path).\n  # This must be 32-bytes (64 characters when HEX-encoded).\n  # Generate it with: `openssl rand -hex 32`\n  # Leave null or empty to avoid using encryption.\n  # Changing this subsequently requires that you also throw away all data stored in data_dir_path.\n  session_encryption_key: 9701cd109ed56770687dd8410f7d7371a4390dd3feb8ed721f189a0756c40098\n\n  # An optional secret for encrypting bot configuration stored in Matrix's account data.\n  # This must be 32-bytes (64 characters when HEX-encoded).\n  # Generate it with: `openssl rand -hex 32`\n  # Leave null or empty to avoid using encryption.\n  # Changing this subsequently will make you lose your configuration.\n  config_encryption_key: a9f1df98d288802ead20a8be2c701a627eabd31cf3d9e2aea28867ccd7a4ded7\n\nagents:\n  # A list of statically-defined agents.\n  #\n  # Below are a few common choices on popular providers, preconfigured for development purposes (see docs/development.md).\n  # You may enable some of the ones you see below or define others.\n  # You can also leave this list empty and only define agents dynamically (via chat).\n  #\n  # Uncomment one or more of these and potentially adjust their configuration (API key, etc).\n  # Consider setting `initial_global_config.handler.*` to an agent that you enable here.\n  static_definitions:\n    # - id: openai\n    #   provider: openai\n    #   config:\n    #     base_url: https://api.openai.com/v1\n    #     api_key: \"\"\n    #     text_generation:\n    #       model_id: gpt-5.4\n    #       prompt: \"You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}.\"\n    #       temperature: 1.0\n    #       # Reasoning models need to use `max_completion_tokens` instead of `max_response_tokens`.\n    #       # If you're dealing with a non-reasoning model, specify `max_response_tokens` and unset `max_completion_tokens`.\n    #       max_response_tokens: null\n    #       max_completion_tokens: 128000\n    #       max_context_tokens: 400000\n    #       # Built-in tools\n    #       tools:\n    #         web_search: false\n    #         code_interpreter: false\n    #     speech_to_text:\n    #       model_id: whisper-1\n    #     text_to_speech:\n    #       model_id: tts-1-hd\n    #       voice: onyx\n    #       speed: 1.0\n    #       response_format: opus\n    #     image_generation:\n    #       model_id: gpt-image-1.5\n    #       style: null\n    #       size: null\n    #       quality: null\n    #\n    # - id: localai\n    #   provider: localai\n    #   config:\n    #     base_url: http://127.0.0.1:42027/v1\n    #     api_key: null\n    #     text_generation:\n    #       model_id: gpt-4\n    #       prompt: \"You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}.\"\n    #       temperature: 1.0\n    #       max_response_tokens: 16384\n    #       max_context_tokens: 128000\n    #     speech_to_text:\n    #       model_id: whisper-1\n    #     text_to_speech:\n    #       model_id: tts-1\n    #       voice: onyx\n    #       speed: 1.0\n    #       response_format: opus\n    #     image_generation:\n    #       model_id: stablediffusion\n    #       style: vivid\n    #       # Intentionally defaults to a small value to improve performance\n    #       size: 256x256\n    #       quality: standard\n    #\n    # - id: ollama\n    #   provider: ollama\n    #   config:\n    #     base_url: \"http://127.0.0.1:42026/v1\"\n    #     api_key: null\n    #     text_generation:\n    #       model_id: \"gemma2:2b\"\n    #       prompt: \"You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}.\"\n    #       temperature: 1.0\n    #       max_response_tokens: 4096\n    #       max_context_tokens: 128000\n\n# Initial global configuration. This only affects the first run of the bot.\n# Configuration is later managed at runtime.\ninitial_global_config:\n  handler:\n    catch_all: null\n    text_generation: null\n    text_to_speech: null\n    speech_to_text: null\n    image_generation: null\n\n  # Space-separated list of MXID patterns which specify who can use the bot.\n  # By default, we let anyone on the homeserver use the bot.\n  user_patterns:\n    - \"@*:__HOMESERVER_SERVER_NAME__\"\n\n# Controls logging.\n#\n# Sets all tracing targets (external crates) to warn, and our own logs to debug.\n# For even more verbose logging, one may also use trace.\n#\n# matrix_sdk_crypto may be chatty and could be added with an error level.\n#\n# Learn more here: https://stackoverflow.com/a/73735203\nlogging: warn,mxlink=debug,baibot=debug\n"
  },
  {
    "path": "etc/services/continuwuity/compose.yml",
    "content": "services:\n  continuwuity:\n    image: forgejo.ellis.link/continuwuation/continuwuity:v0.5.9\n    user: \"${UID}:${GID}\"\n    restart: unless-stopped\n    cap_drop:\n      - ALL\n    read_only: true\n    environment:\n      CONDUWUIT_CONFIG: /etc/continuwuity/continuwuity.toml\n      CONDUWUIT_DATABASE_PATH: /var/lib/continuwuity\n    ports:\n      - \"${SERVICE_CONTINUWUITY_BIND_PORT_CLIENT_API}:6167\"\n    volumes:\n      - ../../etc/services/continuwuity/config:/etc/continuwuity:ro\n      - ./continuwuity/data:/var/lib/continuwuity\n    tmpfs:\n      - /tmp:rw,noexec,nosuid,size=500m\n\nnetworks:\n  default:\n    name: ${NETWORK_NAME}\n    external: true\n"
  },
  {
    "path": "etc/services/continuwuity/config/continuwuity.toml",
    "content": "[global]\nserver_name = \"continuwuity.127.0.0.1.nip.io\"\n\naddress = \"0.0.0.0\"\nport = 6167\n\ndatabase_path = \"/var/lib/continuwuity\"\n\nallow_registration = true\nyes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse = true\n\nnew_user_displayname_suffix = \"\"\n\nmax_request_size = 20_000_000\n\nallow_federation = false\ntrusted_servers = [\"matrix.org\"]\n\nlog = \"info,state_res=warn,rocket=off,_=off,sled=off\"\n"
  },
  {
    "path": "etc/services/continuwuity/register-user.sh",
    "content": "#!/bin/sh\nset -eu\n\nif [ $# -ne 3 ]; then\n\techo \"Usage: $0 <env-file> <username> <password>\"\n\texit 1\nfi\n\nENV_FILE=\"$1\"\nUSERNAME=\"$2\"\nPASSWORD=\"$3\"\n\nSERVER=\"http://$(grep '^SERVICE_CONTINUWUITY_BIND_PORT_CLIENT_API=' \"${ENV_FILE}\" | cut -d= -f2)\"\nREGISTER_URL=\"${SERVER}/_matrix/client/v3/register\"\n\necho \"Registering user '${USERNAME}' on ${SERVER}...\"\n\nSESSION_RESPONSE=$(curl -s -X POST \"${REGISTER_URL}\" \\\n\t-H 'Content-Type: application/json' \\\n\t-d \"{\\\"username\\\": \\\"${USERNAME}\\\", \\\"password\\\": \\\"${PASSWORD}\\\"}\")\n\nSESSION_ID=$(echo \"${SESSION_RESPONSE}\" | grep -o '\"session\":\"[^\"]*\"' | head -1 | cut -d'\"' -f4)\nif [ -z \"${SESSION_ID}\" ]; then\n\techo \"Error: Could not get session ID. Response: ${SESSION_RESPONSE}\"\n\texit 1\nfi\n\n# Determine the required auth flow from the server response.\n# The first user requires m.login.registration_token (bootstrap token from logs).\n# Subsequent users use m.login.dummy (open registration).\nif echo \"${SESSION_RESPONSE}\" | grep -q 'm.login.registration_token'; then\n\tCONTAINER_ID=$(docker ps -q --filter name=baibot-continuwuity-continuwuity)\n\tREG_TOKEN=$(docker logs \"${CONTAINER_ID}\" 2>&1 | sed 's/\\x1b\\[[0-9;]*m//g' | grep 'using the registration token' | grep -oP 'registration token \\K[A-Za-z0-9]+' | head -1)\n\tAUTH_BODY=\"{\\\"type\\\": \\\"m.login.registration_token\\\", \\\"token\\\": \\\"${REG_TOKEN}\\\", \\\"session\\\": \\\"${SESSION_ID}\\\"}\"\nelse\n\tAUTH_BODY=\"{\\\"type\\\": \\\"m.login.dummy\\\", \\\"session\\\": \\\"${SESSION_ID}\\\"}\"\nfi\n\nRESULT=$(curl -s -X POST \"${REGISTER_URL}\" \\\n\t-H 'Content-Type: application/json' \\\n\t-d \"{\\\"username\\\": \\\"${USERNAME}\\\", \\\"password\\\": \\\"${PASSWORD}\\\", \\\"auth\\\": ${AUTH_BODY}}\")\n\nif echo \"${RESULT}\" | grep -q '\"user_id\"'; then\n\techo \"Successfully registered user: $(echo \"${RESULT}\" | grep -o '\"user_id\":\"[^\"]*\"' | cut -d'\"' -f4)\"\nelse\n\techo \"Registration failed. Response: ${RESULT}\"\n\texit 1\nfi\n"
  },
  {
    "path": "etc/services/element-web/compose.yml",
    "content": "services:\n  element-web:\n    image: ghcr.io/element-hq/element-web:v1.12.18\n    user: \"${UID}:${GID}\"\n    restart: unless-stopped\n    environment:\n      ELEMENT_WEB_PORT: 8080\n    ports:\n      - \"${SERVICE_ELEMENT_WEB_BIND_PORT_HTTP}:8080\"\n    volumes:\n    - ./element-web/config.json:/app/config.json:ro\n    tmpfs:\n      - /var/cache/nginx:rw,mode=777\n      - /var/run:rw,mode=777\n      - /tmp/element-web-config:rw,mode=777\n      - /etc/nginx/conf.d:rw,mode=777\n\nnetworks:\n  default:\n    name: ${NETWORK_NAME}\n    external: true\n"
  },
  {
    "path": "etc/services/element-web/config.json.dist",
    "content": "{\n    \"default_hs_url\": \"__HOMESERVER_CLIENT_URL__\",\n    \"default_is_url\": \"https://vector.im\",\n    \"integrations_ui_url\": \"https://scalar.vector.im/\",\n    \"integrations_rest_url\": \"https://scalar.vector.im/api\",\n    \"bug_report_endpoint_url\": \"https://element.io/bugreports/submit\",\n    \"enableLabs\": true,\n    \"roomDirectory\": {\n        \"servers\": [\n            \"matrix.org\"\n        ]\n    }\n}\n"
  },
  {
    "path": "etc/services/env.dist",
    "content": "SERVICE_SYNAPSE_BIND_PORT_CLIENT_API=127.0.0.1:42020\nSERVICE_SYNAPSE_BIND_PORT_FEDERATION_API=127.0.0.1:42028\n\nSERVICE_ELEMENT_WEB_BIND_PORT_HTTP=127.0.0.1:42025\n\nSERVICE_CONTINUWUITY_BIND_PORT_CLIENT_API=127.0.0.1:42030\n\nSERVICE_OLLAMA_BIND_PORT_HTTP=127.0.0.1:42026\n\n# See https://localai.io/basics/container/#all-in-one-images for the list of available images\nSERVICE_LOCALAI_IMAGE_NAME=docker.io/localai/localai:latest-aio-cpu\nSERVICE_LOCALAI_BIND_PORT_HTTP=127.0.0.1:42027\n\n# Variables below are added later on, dynamically\n"
  },
  {
    "path": "etc/services/localai/compose.yml",
    "content": "services:\n  localai:\n    image: ${SERVICE_LOCALAI_IMAGE_NAME}\n    restart: unless-stopped\n    healthcheck:\n      test: [\"CMD\", \"curl\", \"-f\", \"http://localhost:8080/readyz\"]\n      interval: 1m\n      timeout: 20m\n      retries: 5\n    ports:\n      - ${SERVICE_LOCALAI_BIND_PORT_HTTP}:8080\n    environment:\n      - DEBUG=true\n    volumes:\n      - ./localai/models:/build/models:cached\n\nnetworks:\n  default:\n    name: ${NETWORK_NAME}\n    external: true\n"
  },
  {
    "path": "etc/services/ollama/compose.yml",
    "content": "services:\n  ollama:\n    image: docker.io/ollama/ollama:0.24.0\n    restart: unless-stopped\n    ports:\n      - \"${SERVICE_OLLAMA_BIND_PORT_HTTP}:11434\"\n    volumes:\n      - ./ollama:/root/.ollama\n\nnetworks:\n  default:\n    name: ${NETWORK_NAME}\n    external: true\n"
  },
  {
    "path": "etc/services/synapse/compose.yml",
    "content": "services:\n  postgres:\n    image: docker.io/postgres:18.4-alpine\n    user: ${UID}:${GID}\n    restart: unless-stopped\n    environment:\n     POSTGRES_USER: synapse\n     POSTGRES_PASSWORD: synapse-password\n     POSTGRES_DB: homeserver\n     POSTGRES_INITDB_ARGS: --lc-collate C --lc-ctype C --encoding UTF8\n     PGDATA: /data\n    volumes:\n    - ./postgres:/data\n    - /etc/passwd:/etc/passwd:ro\n\n  synapse:\n    image: ghcr.io/element-hq/synapse:v1.153.0\n    user: \"${UID}:${GID}\"\n    restart: unless-stopped\n    entrypoint: python\n    command: \"-m synapse.app.homeserver -c /config/homeserver.yaml\"\n    ports:\n    - \"${SERVICE_SYNAPSE_BIND_PORT_CLIENT_API}:8008\"\n    - \"${SERVICE_SYNAPSE_BIND_PORT_FEDERATION_API}:8008\"\n    volumes:\n    - ../../etc/services/synapse/config:/config:ro\n    - ./synapse/media-store:/media-store\n\nnetworks:\n  default:\n    name: ${NETWORK_NAME}\n    external: true\n"
  },
  {
    "path": "etc/services/synapse/config/homeserver.yaml",
    "content": "#jinja2: lstrip_blocks: \"True\"\n# Configuration file for Synapse.\n#\n# This is a YAML file: see [1] for a quick introduction. Note in particular\n# that *indentation is important*: all the elements of a list or dictionary\n# should have the same indentation.\n#\n# [1] https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html\n\n\n## Modules ##\n\n# Server admins can expand Synapse's functionality with external modules.\n#\n# See https://matrix-org.github.io/synapse/latest/modules/index.html for more\n# documentation on how to configure or create custom modules for Synapse.\n#\n#modules:\n  #- module: my_super_module.MySuperClass\n  #  config:\n  #    do_thing: true\n  #- module: my_other_super_module.SomeClass\n  #  config: {}\nmodules: []\n\n## Server ##\n\n# The domain name of the server, with optional explicit port.\n# This is used by remote servers to connect to this server,\n# e.g. matrix.org, localhost:8080, etc.\n# This is also the last part of your UserID.\n#\nserver_name: \"synapse.127.0.0.1.nip.io\"\n\n# When running as a daemon, the file to store the pid in\n#\npid_file: /homeserver.pid\n\n# The path to the web client which will be served at /_matrix/client/\n# if 'webclient' is configured under the 'listeners' configuration.\n#\n#web_client_location: \"/path/to/web/root\"\n\n# The public-facing base URL that clients use to access this HS\n# (not including _matrix/...). This is the same URL a user would\n# enter into the 'custom HS URL' field on their client. If you\n# use synapse with a reverse proxy, this should be the URL to reach\n# synapse via the proxy.\n#\n#public_baseurl: https://example.com/\n\n# Set the soft limit on the number of file descriptors synapse can use\n# Zero is used to indicate synapse should set the soft limit to the\n# hard limit.\n#\n#soft_file_limit: 0\n\n# Set to false to disable presence tracking on this homeserver.\n#\n#use_presence: false\n\n# Whether to require authentication to retrieve profile data (avatars,\n# display names) of other users through the client API. Defaults to\n# 'false'. Note that profile data is also available via the federation\n# API, so this setting is of limited value if federation is enabled on\n# the server.\n#\n#require_auth_for_profile_requests: true\n\n# If set to 'false', requires authentication to access the server's public rooms\n# directory through the client API. Defaults to 'true'.\n#\n#allow_public_rooms_without_auth: false\n\n# If set to 'false', forbids any other homeserver to fetch the server's public\n# rooms directory via federation. Defaults to 'true'.\n#\n#allow_public_rooms_over_federation: false\n\n# The default room version for newly created rooms.\n#\n# Known room versions are listed here:\n# https://matrix.org/docs/spec/#complete-list-of-room-versions\n#\n# For example, for room version 1, default_room_version should be set\n# to \"1\".\n#\n#default_room_version: \"4\"\n\n# The GC threshold parameters to pass to `gc.set_threshold`, if defined\n#\n#gc_thresholds: [700, 10, 10]\n\n# Set the limit on the returned events in the timeline in the get\n# and sync operations. The default value is -1, means no upper limit.\n#\n#filter_timeline_limit: 5000\n\n# Whether room invites to users on this server should be blocked\n# (except those sent by local server admins). The default is False.\n#\n#block_non_admin_invites: True\n\n# Room searching\n#\n# If disabled, new messages will not be indexed for searching and users\n# will receive errors when searching for messages. Defaults to enabled.\n#\n#enable_search: false\n\n# Restrict federation to the following whitelist of domains.\n# N.B. we recommend also firewalling your federation listener to limit\n# inbound federation traffic as early as possible, rather than relying\n# purely on this application-layer restriction.  If not specified, the\n# default is to whitelist everything.\n#\n#federation_domain_whitelist:\n#  - lon.example.com\n#  - nyc.example.com\n#  - syd.example.com\n\n# Prevent federation requests from being sent to the following\n# blacklist IP address CIDR ranges. If this option is not specified, or\n# specified with an empty list, no ip range blacklist will be enforced.\n#\n# (0.0.0.0 and :: are always blacklisted, whether or not they are explicitly\n# listed here, since they correspond to unroutable addresses.)\n#\nfederation_ip_range_blacklist:\n  - '127.0.0.0/8'\n  - '10.0.0.0/8'\n  - '172.16.0.0/12'\n  - '192.168.0.0/16'\n  - '100.64.0.0/10'\n  - '169.254.0.0/16'\n  - '::1/128'\n  - 'fe80::/64'\n  - 'fc00::/7'\n\n# List of ports that Synapse should listen on, their purpose and their\n# configuration.\n#\n# Options for each listener include:\n#\n#   port: the TCP port to bind to\n#\n#   bind_addresses: a list of local addresses to listen on. The default is\n#       'all local interfaces'.\n#\n#   type: the type of listener. Normally 'http', but other valid options are:\n#       'manhole' (see docs/manhole.md),\n#       'metrics' (see docs/metrics-howto.rst),\n#       'replication' (see docs/workers.rst).\n#\n#   tls: set to true to enable TLS for this listener. Will use the TLS\n#       key/cert specified in tls_private_key_path / tls_certificate_path.\n#\n#   x_forwarded: Only valid for an 'http' listener. Set to true to use the\n#       X-Forwarded-For header as the client IP. Useful when Synapse is\n#       behind a reverse-proxy.\n#\n#   resources: Only valid for an 'http' listener. A list of resources to host\n#       on this port. Options for each resource are:\n#\n#       names: a list of names of HTTP resources. See below for a list of\n#           valid resource names.\n#\n#       compress: set to true to enable HTTP comression for this resource.\n#\n#   additional_resources: Only valid for an 'http' listener. A map of\n#        additional endpoints which should be loaded via dynamic modules.\n#\n# Valid resource names are:\n#\n#   client: the client-server API (/_matrix/client), and the synapse admin\n#       API (/_synapse/admin). Also implies 'media' and 'static'.\n#\n#   consent: user consent forms (/_matrix/consent). See\n#       docs/consent_tracking.md.\n#\n#   federation: the server-server API (/_matrix/federation). Also implies\n#       'media', 'keys', 'openid'\n#\n#   keys: the key discovery API (/_matrix/keys).\n#\n#   media: the media API (/_matrix/media).\n#\n#   metrics: the metrics interface. See docs/metrics-howto.rst.\n#\n#   openid: OpenID authentication.\n#\n#   replication: the HTTP replication API (/_synapse/replication). See\n#       docs/workers.rst.\n#\n#   static: static resources under synapse/static (/_matrix/static). (Mostly\n#       useful for 'fallback authentication'.)\n#\n#   webclient: A web client. Requires web_client_location to be set.\n#\nlisteners:\n  # TLS-enabled listener: for when matrix traffic is sent directly to synapse.\n  #\n  # Disabled by default. To enable it, uncomment the following. (Note that you\n  # will also need to give Synapse a TLS key and certificate: see the TLS section\n  # below.)\n  #\n  #- port: 8448\n  #  type: http\n  #  tls: true\n  #  resources:\n  #    - names: [client, federation]\n\n  # Unsecure HTTP listener: for when matrix traffic passes through a reverse proxy\n  # that unwraps TLS.\n  #\n  # If you plan to use a reverse proxy, please see\n  # https://github.com/matrix-org/synapse/blob/master/docs/reverse_proxy.rst.\n  #\n  - port: 8008\n    tls: false\n    type: http\n    x_forwarded: true\n\n    resources:\n      - names: [client, federation]\n        compress: false\n\n    # example additional_resources:\n    #\n    #additional_resources:\n    #  \"/_matrix/my/custom/endpoint\":\n    #    module: my_module.CustomRequestHandler\n    #    config: {}\n\n  # Turn on the twisted ssh manhole service on localhost on the given\n  # port.\n  #\n  #- port: 9000\n  #  bind_addresses: ['::1', '127.0.0.1']\n  #  type: manhole\n\n\n## Homeserver blocking ##\n\n# How to reach the server admin, used in ResourceLimitError\n#\n#admin_contact: 'mailto:admin@server.com'\n\n# Global blocking\n#\n#hs_disabled: False\n#hs_disabled_message: 'Human readable reason for why the HS is blocked'\n#hs_disabled_limit_type: 'error code(str), to help clients decode reason'\n\n# Monthly Active User Blocking\n#\n# Used in cases where the admin or server owner wants to limit to the\n# number of monthly active users.\n#\n# 'limit_usage_by_mau' disables/enables monthly active user blocking. When\n# anabled and a limit is reached the server returns a 'ResourceLimitError'\n# with error type Codes.RESOURCE_LIMIT_EXCEEDED\n#\n# 'max_mau_value' is the hard limit of monthly active users above which\n# the server will start blocking user actions.\n#\n# 'mau_trial_days' is a means to add a grace period for active users. It\n# means that users must be active for this number of days before they\n# can be considered active and guards against the case where lots of users\n# sign up in a short space of time never to return after their initial\n# session.\n#\n#limit_usage_by_mau: False\n#max_mau_value: 50\n#mau_trial_days: 2\n\n# If enabled, the metrics for the number of monthly active users will\n# be populated, however no one will be limited. If limit_usage_by_mau\n# is true, this is implied to be true.\n#\n#mau_stats_only: False\n\n# Sometimes the server admin will want to ensure certain accounts are\n# never blocked by mau checking. These accounts are specified here.\n#\n#mau_limit_reserved_threepids:\n#  - medium: 'email'\n#    address: 'reserved_user@example.com'\n\n# Used by phonehome stats to group together related servers.\n#server_context: context\n\n# Whether to require a user to be in the room to add an alias to it.\n# Defaults to 'true'.\n#\n#require_membership_for_aliases: false\n\n# Whether to allow per-room membership profiles through the send of membership\n# events with profile information that differ from the target's global profile.\n# Defaults to 'true'.\n#\n#allow_per_room_profiles: false\n\n\n## TLS ##\n\n# PEM-encoded X509 certificate for TLS.\n# This certificate, as of Synapse 1.0, will need to be a valid and verifiable\n# certificate, signed by a recognised Certificate Authority.\n#\n# See 'ACME support' below to enable auto-provisioning this certificate via\n# Let's Encrypt.\n#\n# If supplying your own, be sure to use a `.pem` file that includes the\n# full certificate chain including any intermediate certificates (for\n# instance, if using certbot, use `fullchain.pem` as your certificate,\n# not `cert.pem`).\n#\n#tls_certificate_path: \"/data/synapse.127.0.0.1.nip.io.tls.crt\"\n\n# PEM-encoded private key for TLS\n#\n#tls_private_key_path: \"/data/synapse.127.0.0.1.nip.io.tls.key\"\n\n# Whether to verify TLS server certificates for outbound federation requests.\n#\n# Defaults to `true`. To disable certificate verification, uncomment the\n# following line.\n#\n#federation_verify_certificates: false\n\n# The minimum TLS version that will be used for outbound federation requests.\n#\n# Defaults to `1`. Configurable to `1`, `1.1`, `1.2`, or `1.3`. Note\n# that setting this value higher than `1.2` will prevent federation to most\n# of the public Matrix network: only configure it to `1.3` if you have an\n# entirely private federation setup and you can ensure TLS 1.3 support.\n#\n#federation_client_minimum_tls_version: 1.2\n\n# Skip federation certificate verification on the following whitelist\n# of domains.\n#\n# This setting should only be used in very specific cases, such as\n# federation over Tor hidden services and similar. For private networks\n# of homeservers, you likely want to use a private CA instead.\n#\n# Only effective if federation_verify_certicates is `true`.\n#\n#federation_certificate_verification_whitelist:\n#  - lon.example.com\n#  - *.domain.com\n#  - *.onion\n\n# List of custom certificate authorities for federation traffic.\n#\n# This setting should only normally be used within a private network of\n# homeservers.\n#\n# Note that this list will replace those that are provided by your\n# operating environment. Certificates must be in PEM format.\n#\n#federation_custom_ca_list:\n#  - myCA1.pem\n#  - myCA2.pem\n#  - myCA3.pem\n\n# ACME support: This will configure Synapse to request a valid TLS certificate\n# for your configured `server_name` via Let's Encrypt.\n#\n# Note that provisioning a certificate in this way requires port 80 to be\n# routed to Synapse so that it can complete the http-01 ACME challenge.\n# By default, if you enable ACME support, Synapse will attempt to listen on\n# port 80 for incoming http-01 challenges - however, this will likely fail\n# with 'Permission denied' or a similar error.\n#\n# There are a couple of potential solutions to this:\n#\n#  * If you already have an Apache, Nginx, or similar listening on port 80,\n#    you can configure Synapse to use an alternate port, and have your web\n#    server forward the requests. For example, assuming you set 'port: 8009'\n#    below, on Apache, you would write:\n#\n#    ProxyPass /.well-known/acme-challenge http://localhost:8009/.well-known/acme-challenge\n#\n#  * Alternatively, you can use something like `authbind` to give Synapse\n#    permission to listen on port 80.\n#\nacme:\n    # ACME support is disabled by default. Uncomment the following line\n    # (and tls_certificate_path and tls_private_key_path above) to enable it.\n    #\n    #enabled: true\n\n    # Endpoint to use to request certificates. If you only want to test,\n    # use Let's Encrypt's staging url:\n    #     https://acme-staging.api.letsencrypt.org/directory\n    #\n    #url: https://acme-v01.api.letsencrypt.org/directory\n\n    # Port number to listen on for the HTTP-01 challenge. Change this if\n    # you are forwarding connections through Apache/Nginx/etc.\n    #\n    #port: 80\n\n    # Local addresses to listen on for incoming connections.\n    # Again, you may want to change this if you are forwarding connections\n    # through Apache/Nginx/etc.\n    #\n    #bind_addresses: ['::', '0.0.0.0']\n\n    # How many days remaining on a certificate before it is renewed.\n    #\n    #reprovision_threshold: 30\n\n    # The domain that the certificate should be for. Normally this\n    # should be the same as your Matrix domain (i.e., 'server_name'), but,\n    # by putting a file at 'https://<server_name>/.well-known/matrix/server',\n    # you can delegate incoming traffic to another server. If you do that,\n    # you should give the target of the delegation here.\n    #\n    # For example: if your 'server_name' is 'example.com', but\n    # 'https://example.com/.well-known/matrix/server' delegates to\n    # 'matrix.example.com', you should put 'matrix.example.com' here.\n    #\n    # If not set, defaults to your 'server_name'.\n    #\n    #domain: matrix.example.com\n\n    # file to use for the account key. This will be generated if it doesn't\n    # exist.\n    #\n    # If unspecified, we will use CONFDIR/client.key.\n    #\n    account_key_file: /data/acme_account.key\n\n# List of allowed TLS fingerprints for this server to publish along\n# with the signing keys for this server. Other matrix servers that\n# make HTTPS requests to this server will check that the TLS\n# certificates returned by this server match one of the fingerprints.\n#\n# Synapse automatically adds the fingerprint of its own certificate\n# to the list. So if federation traffic is handled directly by synapse\n# then no modification to the list is required.\n#\n# If synapse is run behind a load balancer that handles the TLS then it\n# will be necessary to add the fingerprints of the certificates used by\n# the loadbalancers to this list if they are different to the one\n# synapse is using.\n#\n# Homeservers are permitted to cache the list of TLS fingerprints\n# returned in the key responses up to the \"valid_until_ts\" returned in\n# key. It may be necessary to publish the fingerprints of a new\n# certificate and wait until the \"valid_until_ts\" of the previous key\n# responses have passed before deploying it.\n#\n# You can calculate a fingerprint from a given TLS listener via:\n# openssl s_client -connect $host:$port < /dev/null 2> /dev/null |\n#   openssl x509 -outform DER | openssl sha256 -binary | base64 | tr -d '='\n# or by checking matrix.org/federationtester/api/report?server_name=$host\n#\n#tls_fingerprints: [{\"sha256\": \"<base64_encoded_sha256_fingerprint>\"}]\n\n\n\n## Database ##\n\ndatabase:\n  # The database engine name\n  name: \"psycopg2\"\n  args:\n    user: \"synapse\"\n    password: \"synapse-password\"\n    database: \"homeserver\"\n    host: \"postgres\"\n    cp_min: 5\n    cp_max: 10\n\n# Number of events to cache in memory.\n#\n#event_cache_size: 10K\n\n\n## Logging ##\n\n# A yaml python logging config file\n#\nlog_config: \"/config/synapse.127.0.0.1.nip.io.log.config\"\n\n\n## Ratelimiting ##\n\n# Ratelimiting settings for client actions (registration, login, messaging).\n#\n# Each ratelimiting configuration is made of two parameters:\n#   - per_second: number of requests a client can send per second.\n#   - burst_count: number of requests a client can send before being throttled.\n#\n# Synapse currently uses the following configurations:\n#   - one for messages that ratelimits sending based on the account the client\n#     is using\n#   - one for registration that ratelimits registration requests based on the\n#     client's IP address.\n#   - one for login that ratelimits login requests based on the client's IP\n#     address.\n#   - one for login that ratelimits login requests based on the account the\n#     client is attempting to log into.\n#   - one for login that ratelimits login requests based on the account the\n#     client is attempting to log into, based on the amount of failed login\n#     attempts for this account.\n#\n# The defaults are as shown below.\n#\n#rc_message:\n#  per_second: 0.2\n#  burst_count: 10\n#\n#rc_registration:\n#  per_second: 0.17\n#  burst_count: 3\n#\n#rc_login:\n#  address:\n#    per_second: 0.17\n#    burst_count: 3\n#  account:\n#    per_second: 0.17\n#    burst_count: 3\n#  failed_attempts:\n#    per_second: 0.17\n#    burst_count: 3\nrc_message:\n per_second: 100\n burst_count: 1000\n\nrc_registration:\n per_second: 100\n burst_count: 1000\n\nrc_login:\n address:\n   per_second: 100\n   burst_count: 1000\n account:\n   per_second: 100\n   burst_count: 1000\n failed_attempts:\n   per_second: 100\n   burst_count: 1000\n\n\n# Ratelimiting settings for incoming federation\n#\n# The rc_federation configuration is made up of the following settings:\n#   - window_size: window size in milliseconds\n#   - sleep_limit: number of federation requests from a single server in\n#     a window before the server will delay processing the request.\n#   - sleep_delay: duration in milliseconds to delay processing events\n#     from remote servers by if they go over the sleep limit.\n#   - reject_limit: maximum number of concurrent federation requests\n#     allowed from a single server\n#   - concurrent: number of federation requests to concurrently process\n#     from a single server\n#\n# The defaults are as shown below.\n#\n#rc_federation:\n#  window_size: 1000\n#  sleep_limit: 10\n#  sleep_delay: 500\n#  reject_limit: 50\n#  concurrent: 3\n\n# Target outgoing federation transaction frequency for sending read-receipts,\n# per-room.\n#\n# If we end up trying to send out more read-receipts, they will get buffered up\n# into fewer transactions.\n#\n#federation_rr_transactions_per_room_per_second: 50\n\nenable_authenticated_media: true\n\n# Directory where uploaded images and attachments are stored.\n#\nmedia_store_path: \"/media-store\"\n\n# Media storage providers allow media to be stored in different\n# locations.\n#\n#media_storage_providers:\n#  - module: file_system\n#    # Whether to write new local files.\n#    store_local: false\n#    # Whether to write new remote media\n#    store_remote: false\n#    # Whether to block upload requests waiting for write to this\n#    # provider to complete\n#    store_synchronous: false\n#    config:\n#       directory: /mnt/some/other/directory\n\n# Directory where in-progress uploads are stored.\n#\nuploads_path: \"/tmp\"\n\n# The largest allowed upload size in bytes\n#\n#max_upload_size: 10M\n\n# Maximum number of pixels that will be thumbnailed\n#\n#max_image_pixels: 32M\n\n# Whether to generate new thumbnails on the fly to precisely match\n# the resolution requested by the client. If true then whenever\n# a new resolution is requested by the client the server will\n# generate a new thumbnail. If false the server will pick a thumbnail\n# from a precalculated list.\n#\n#dynamic_thumbnails: false\n\n# List of thumbnails to precalculate when an image is uploaded.\n#\n#thumbnail_sizes:\n#  - width: 32\n#    height: 32\n#    method: crop\n#  - width: 96\n#    height: 96\n#    method: crop\n#  - width: 320\n#    height: 240\n#    method: scale\n#  - width: 640\n#    height: 480\n#    method: scale\n#  - width: 800\n#    height: 600\n#    method: scale\n\n# Is the preview URL API enabled?\n#\n# 'false' by default: uncomment the following to enable it (and specify a\n# url_preview_ip_range_blacklist blacklist).\n#\n#url_preview_enabled: true\n\n# List of IP address CIDR ranges that the URL preview spider is denied\n# from accessing.  There are no defaults: you must explicitly\n# specify a list for URL previewing to work.  You should specify any\n# internal services in your network that you do not want synapse to try\n# to connect to, otherwise anyone in any Matrix room could cause your\n# synapse to issue arbitrary GET requests to your internal services,\n# causing serious security issues.\n#\n# (0.0.0.0 and :: are always blacklisted, whether or not they are explicitly\n# listed here, since they correspond to unroutable addresses.)\n#\n# This must be specified if url_preview_enabled is set. It is recommended that\n# you uncomment the following list as a starting point.\n#\n#url_preview_ip_range_blacklist:\n#  - '127.0.0.0/8'\n#  - '10.0.0.0/8'\n#  - '172.16.0.0/12'\n#  - '192.168.0.0/16'\n#  - '100.64.0.0/10'\n#  - '169.254.0.0/16'\n#  - '::1/128'\n#  - 'fe80::/64'\n#  - 'fc00::/7'\n\n# List of IP address CIDR ranges that the URL preview spider is allowed\n# to access even if they are specified in url_preview_ip_range_blacklist.\n# This is useful for specifying exceptions to wide-ranging blacklisted\n# target IP ranges - e.g. for enabling URL previews for a specific private\n# website only visible in your network.\n#\n#url_preview_ip_range_whitelist:\n#   - '192.168.1.1'\n\n# Optional list of URL matches that the URL preview spider is\n# denied from accessing.  You should use url_preview_ip_range_blacklist\n# in preference to this, otherwise someone could define a public DNS\n# entry that points to a private IP address and circumvent the blacklist.\n# This is more useful if you know there is an entire shape of URL that\n# you know that will never want synapse to try to spider.\n#\n# Each list entry is a dictionary of url component attributes as returned\n# by urlparse.urlsplit as applied to the absolute form of the URL.  See\n# https://docs.python.org/2/library/urlparse.html#urlparse.urlsplit\n# The values of the dictionary are treated as an filename match pattern\n# applied to that component of URLs, unless they start with a ^ in which\n# case they are treated as a regular expression match.  If all the\n# specified component matches for a given list item succeed, the URL is\n# blacklisted.\n#\n#url_preview_url_blacklist:\n#  # blacklist any URL with a username in its URI\n#  - username: '*'\n#\n#  # blacklist all *.google.com URLs\n#  - netloc: 'google.com'\n#  - netloc: '*.google.com'\n#\n#  # blacklist all plain HTTP URLs\n#  - scheme: 'http'\n#\n#  # blacklist http(s)://www.acme.com/foo\n#  - netloc: 'www.acme.com'\n#    path: '/foo'\n#\n#  # blacklist any URL with a literal IPv4 address\n#  - netloc: '^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$'\n\n# The largest allowed URL preview spidering size in bytes\n#\n#max_spider_size: 10M\n\n\n## Captcha ##\n# See docs/CAPTCHA_SETUP for full details of configuring this.\n\n# This Home Server's ReCAPTCHA public key.\n#\n#recaptcha_public_key: \"YOUR_PUBLIC_KEY\"\n\n# This Home Server's ReCAPTCHA private key.\n#\n#recaptcha_private_key: \"YOUR_PRIVATE_KEY\"\n\n# Enables ReCaptcha checks when registering, preventing signup\n# unless a captcha is answered. Requires a valid ReCaptcha\n# public/private key.\n#\n#enable_registration_captcha: false\n\n# A secret key used to bypass the captcha test entirely.\n#\n#captcha_bypass_secret: \"YOUR_SECRET_HERE\"\n\n# The API endpoint to use for verifying m.login.recaptcha responses.\n#\n#recaptcha_siteverify_api: \"https://www.recaptcha.net/recaptcha/api/siteverify\"\n\n\n## TURN ##\n\n# The public URIs of the TURN server to give to clients\n#\n#turn_uris: []\n\n# The shared secret used to compute passwords for the TURN server\n#\n#turn_shared_secret: \"YOUR_SHARED_SECRET\"\n\n# The Username and password if the TURN server needs them and\n# does not use a token\n#\n#turn_username: \"TURNSERVER_USERNAME\"\n#turn_password: \"TURNSERVER_PASSWORD\"\n\n# How long generated TURN credentials last\n#\n#turn_user_lifetime: 1h\n\n# Whether guests should be allowed to use the TURN server.\n# This defaults to True, otherwise VoIP will be unreliable for guests.\n# However, it does introduce a slight security risk as it allows users to\n# connect to arbitrary endpoints without having first signed up for a\n# valid account (e.g. by passing a CAPTCHA).\n#\n#turn_allow_guests: True\n\n\n## Registration ##\n#\n# Registration can be rate-limited using the parameters in the \"Ratelimiting\"\n# section of this file.\n\n# Enable registration for new users.\n#\nenable_registration: true\n\n# Optional account validity configuration. This allows for accounts to be denied\n# any request after a given period.\n#\n# ``enabled`` defines whether the account validity feature is enabled. Defaults\n# to False.\n#\n# ``period`` allows setting the period after which an account is valid\n# after its registration. When renewing the account, its validity period\n# will be extended by this amount of time. This parameter is required when using\n# the account validity feature.\n#\n# ``renew_at`` is the amount of time before an account's expiry date at which\n# Synapse will send an email to the account's email address with a renewal link.\n# This needs the ``email`` and ``public_baseurl`` configuration sections to be\n# filled.\n#\n# ``renew_email_subject`` is the subject of the email sent out with the renewal\n# link. ``%(app)s`` can be used as a placeholder for the ``app_name`` parameter\n# from the ``email`` section.\n#\n# Once this feature is enabled, Synapse will look for registered users without an\n# expiration date at startup and will add one to every account it found using the\n# current settings at that time.\n# This means that, if a validity period is set, and Synapse is restarted (it will\n# then derive an expiration date from the current validity period), and some time\n# after that the validity period changes and Synapse is restarted, the users'\n# expiration dates won't be updated unless their account is manually renewed. This\n# date will be randomly selected within a range [now + period - d ; now + period],\n# where d is equal to 10% of the validity period.\n#\n#account_validity:\n#  enabled: True\n#  period: 6w\n#  renew_at: 1w\n#  renew_email_subject: \"Renew your %(app)s account\"\n\n# Time that a user's session remains valid for, after they log in.\n#\n# Note that this is not currently compatible with guest logins.\n#\n# Note also that this is calculated at login time: changes are not applied\n# retrospectively to users who have already logged in.\n#\n# By default, this is infinite.\n#\n#session_lifetime: 24h\n\n# The user must provide all of the below types of 3PID when registering.\n#\n#registrations_require_3pid:\n#  - email\n#  - msisdn\n\n# Explicitly disable asking for MSISDNs from the registration\n# flow (overrides registrations_require_3pid if MSISDNs are set as required)\n#\n#disable_msisdn_registration: true\n\n# Mandate that users are only allowed to associate certain formats of\n# 3PIDs with accounts on this server.\n#\n#allowed_local_3pids:\n#  - medium: email\n#    pattern: '.*@matrix\\.org'\n#  - medium: email\n#    pattern: '.*@vector\\.im'\n#  - medium: msisdn\n#    pattern: '\\+44'\n\n# Enable 3PIDs lookup requests to identity servers from this server.\n#\n#enable_3pid_lookup: true\n\nregistration_requires_token: true\n\n# If set, allows registration of standard or admin accounts by anyone who\n# has the shared secret, even if registration is otherwise disabled.\n#\nregistration_shared_secret: \"y4aTYam;zxKZ#MnaHRrGDPs4&dS*3VEv_&Ck_;pe1=CrtM8*=7\"\n\n# Set the number of bcrypt rounds used to generate password hash.\n# Larger numbers increase the work factor needed to generate the hash.\n# The default number is 12 (which equates to 2^12 rounds).\n# N.B. that increasing this will exponentially increase the time required\n# to register or login - e.g. 24 => 2^24 rounds which will take >20 mins.\n#\n#bcrypt_rounds: 12\n\n# Allows users to register as guests without a password/email/etc, and\n# participate in rooms hosted on this server which have been made\n# accessible to anonymous users.\n#\n#allow_guest_access: false\n\n# The identity server which we suggest that clients should use when users log\n# in on this server.\n#\n# (By default, no suggestion is made, so it is left up to the client.\n# This setting is ignored unless public_baseurl is also set.)\n#\n#default_identity_server: https://matrix.org\n\n# The list of identity servers trusted to verify third party\n# identifiers by this server.\n#\n# Also defines the ID server which will be called when an account is\n# deactivated (one will be picked arbitrarily).\n#\n#trusted_third_party_id_servers:\n#  - matrix.org\n#  - vector.im\n\n# Users who register on this homeserver will automatically be joined\n# to these rooms\n#\n#auto_join_rooms:\n#  - \"#example:example.com\"\n\n# Where auto_join_rooms are specified, setting this flag ensures that the\n# the rooms exist by creating them when the first user on the\n# homeserver registers.\n# Setting to false means that if the rooms are not manually created,\n# users cannot be auto-joined since they do not exist.\n#\n#autocreate_auto_join_rooms: true\n\n\n## Metrics ###\n\n# Enable collection and rendering of performance metrics\n#\n#enable_metrics: False\n\n# Enable sentry integration\n# NOTE: While attempts are made to ensure that the logs don't contain\n# any sensitive information, this cannot be guaranteed. By enabling\n# this option the sentry server may therefore receive sensitive\n# information, and it in turn may then diseminate sensitive information\n# through insecure notification channels if so configured.\n#\n#sentry:\n#    dsn: \"...\"\n\n# Whether or not to report anonymized homeserver usage statistics.\nreport_stats: false\n\n\n## API Configuration ##\n\n# A list of event types that will be included in the room_invite_state\n#\n#room_invite_state_types:\n#  - \"m.room.join_rules\"\n#  - \"m.room.canonical_alias\"\n#  - \"m.room.avatar\"\n#  - \"m.room.encryption\"\n#  - \"m.room.name\"\n\n\n# A list of application service config files to use\n#\n#app_service_config_files:\n#  - app_service_1.yaml\n#  - app_service_2.yaml\n\n# Uncomment to enable tracking of application service IP addresses. Implicitly\n# enables MAU tracking for application service users.\n#\n#track_appservice_user_ips: True\n\n\n# a secret which is used to sign access tokens. If none is specified,\n# the registration_shared_secret is used, if one is given; otherwise,\n# a secret key is derived from the signing key.\n#\nmacaroon_secret_key: \"q_&1433pm~0X#wN@JF=f+aN=RknLk^U84+7J8fwGrWDQv2;.,F\"\n\n# Used to enable access token expiration.\n#\n#expire_access_token: False\n\n# a secret which is used to calculate HMACs for form values, to stop\n# falsification of values. Must be specified for the User Consent\n# forms to work.\n#\nform_secret: \"g2:tvyaCbugJrP#1w.+6Eta:xxIfvl*HIF:o#8+qTbU7tPlUhY\"\n\n## Signing Keys ##\n\n# Path to the signing key to sign messages with\n#\nsigning_key_path: \"/config/synapse.127.0.0.1.nip.io.signing.key\"\n\n# The keys that the server used to sign messages with but won't use\n# to sign new messages. E.g. it has lost its private key\n#\n#old_signing_keys:\n#  \"ed25519:auto\":\n#    # Base64 encoded public key\n#    key: \"The public part of your old signing key.\"\n#    # Millisecond POSIX timestamp when the key expired.\n#    expired_ts: 123456789123\n\n# How long key response published by this server is valid for.\n# Used to set the valid_until_ts in /key/v2 APIs.\n# Determines how quickly servers will query to check which keys\n# are still valid.\n#\n#key_refresh_interval: 1d\n\n# The trusted servers to download signing keys from.\n#\n# When we need to fetch a signing key, each server is tried in parallel.\n#\n# Normally, the connection to the key server is validated via TLS certificates.\n# Additional security can be provided by configuring a `verify key`, which\n# will make synapse check that the response is signed by that key.\n#\n# This setting supercedes an older setting named `perspectives`. The old format\n# is still supported for backwards-compatibility, but it is deprecated.\n#\n# Options for each entry in the list include:\n#\n#    server_name: the name of the server. required.\n#\n#    verify_keys: an optional map from key id to base64-encoded public key.\n#       If specified, we will check that the response is signed by at least\n#       one of the given keys.\n#\n#    accept_keys_insecurely: a boolean. Normally, if `verify_keys` is unset,\n#       and federation_verify_certificates is not `true`, synapse will refuse\n#       to start, because this would allow anyone who can spoof DNS responses\n#       to masquerade as the trusted key server. If you know what you are doing\n#       and are sure that your network environment provides a secure connection\n#       to the key server, you can set this to `true` to override this\n#       behaviour.\n#\n# An example configuration might look like:\n#\n#trusted_key_servers:\n#  - server_name: \"my_trusted_server.example.com\"\n#    verify_keys:\n#      \"ed25519:auto\": \"abcdefghijklmnopqrstuvwxyzabcdefghijklmopqr\"\n#  - server_name: \"my_other_trusted_server.example.com\"\n#\n# The default configuration is:\n#\n#trusted_key_servers:\n#  - server_name: \"matrix.org\"\n\n\n# Enable SAML2 for registration and login. Uses pysaml2.\n#\n# `sp_config` is the configuration for the pysaml2 Service Provider.\n# See pysaml2 docs for format of config.\n#\n# Default values will be used for the 'entityid' and 'service' settings,\n# so it is not normally necessary to specify them unless you need to\n# override them.\n#\n# Once SAML support is enabled, a metadata file will be exposed at\n# https://<server>:<port>/_matrix/saml2/metadata.xml, which you may be able to\n# use to configure your SAML IdP with. Alternatively, you can manually configure\n# the IdP to use an ACS location of\n# https://<server>:<port>/_matrix/saml2/authn_response.\n#\n#saml2_config:\n#  sp_config:\n#    # point this to the IdP's metadata. You can use either a local file or\n#    # (preferably) a URL.\n#    metadata:\n#      #local: [\"saml2/idp.xml\"]\n#      remote:\n#        - url: https://our_idp/metadata.xml\n#\n#    # By default, the user has to go to our login page first. If you'd like to\n#    # allow IdP-initiated login, set 'allow_unsolicited: True' in a\n#    # 'service.sp' section:\n#    #\n#    #service:\n#    #  sp:\n#    #    allow_unsolicited: True\n#\n#    # The examples below are just used to generate our metadata xml, and you\n#    # may well not need it, depending on your setup. Alternatively you\n#    # may need a whole lot more detail - see the pysaml2 docs!\n#\n#    description: [\"My awesome SP\", \"en\"]\n#    name: [\"Test SP\", \"en\"]\n#\n#    organization:\n#      name: Example com\n#      display_name:\n#        - [\"Example co\", \"en\"]\n#      url: \"http://example.com\"\n#\n#    contact_person:\n#      - given_name: Bob\n#        sur_name: \"the Sysadmin\"\n#        email_address\": [\"admin@example.com\"]\n#        contact_type\": technical\n#\n#  # Instead of putting the config inline as above, you can specify a\n#  # separate pysaml2 configuration file:\n#  #\n#  config_path: \"/data/sp_conf.py\"\n#\n#  # the lifetime of a SAML session. This defines how long a user has to\n#  # complete the authentication process, if allow_unsolicited is unset.\n#  # The default is 5 minutes.\n#  #\n#  # saml_session_lifetime: 5m\n\n\n\n# Enable CAS for registration and login.\n#\n#cas_config:\n#   enabled: true\n#   server_url: \"https://cas-server.com\"\n#   service_url: \"https://homeserver.domain.com:8448\"\n#   #required_attributes:\n#   #    name: value\n\n\n# The JWT needs to contain a globally unique \"sub\" (subject) claim.\n#\n#jwt_config:\n#   enabled: true\n#   secret: \"a secret\"\n#   algorithm: \"HS256\"\n\n\npassword_config:\n   # Uncomment to disable password login\n   #\n   #enabled: false\n\n   # Uncomment to disable authentication against the local password\n   # database. This is ignored if `enabled` is false, and is only useful\n   # if you have other password_providers.\n   #\n   #localdb_enabled: false\n\n   # Uncomment and change to a secret random string for extra security.\n   # DO NOT CHANGE THIS AFTER INITIAL SETUP!\n   #\n   #pepper: \"EVEN_MORE_SECRET\"\n\n\n\n# Enable sending emails for password resets, notification events or\n# account expiry notices\n#\n# If your SMTP server requires authentication, the optional smtp_user &\n# smtp_pass variables should be used\n#\n#email:\n#   enable_notifs: false\n#   smtp_host: \"localhost\"\n#   smtp_port: 25 # SSL: 465, STARTTLS: 587\n#   smtp_user: \"exampleusername\"\n#   smtp_pass: \"examplepassword\"\n#   require_transport_security: False\n#   notif_from: \"Your Friendly %(app)s Home Server <noreply@example.com>\"\n#   app_name: Matrix\n#\n#   # Enable email notifications by default\n#   #\n#   notif_for_new_users: True\n#\n#   # Defining a custom URL for Riot is only needed if email notifications\n#   # should contain links to a self-hosted installation of Riot; when set\n#   # the \"app_name\" setting is ignored\n#   #\n#   riot_base_url: \"http://localhost/riot\"\n#\n#   # Enable sending password reset emails via the configured, trusted\n#   # identity servers\n#   #\n#   # IMPORTANT! This will give a malicious or overtaken identity server\n#   # the ability to reset passwords for your users! Make absolutely sure\n#   # that you want to do this! It is strongly recommended that password\n#   # reset emails be sent by the homeserver instead\n#   #\n#   # If this option is set to false and SMTP options have not been\n#   # configured, resetting user passwords via email will be disabled\n#   #\n#   #trust_identity_server_for_password_resets: false\n#\n#   # Configure the time that a validation email or text message code\n#   # will expire after sending\n#   #\n#   # This is currently used for password resets\n#   #\n#   #validation_token_lifetime: 1h\n#\n#   # Template directory. All template files should be stored within this\n#   # directory. If not set, default templates from within the Synapse\n#   # package will be used\n#   #\n#   # For the list of default templates, please see\n#   # https://github.com/matrix-org/synapse/tree/master/synapse/res/templates\n#   #\n#   #template_dir: res/templates\n#\n#   # Templates for email notifications\n#   #\n#   notif_template_html: notif_mail.html\n#   notif_template_text: notif_mail.txt\n#\n#   # Templates for account expiry notices\n#   #\n#   expiry_template_html: notice_expiry.html\n#   expiry_template_text: notice_expiry.txt\n#\n#   # Templates for password reset emails sent by the homeserver\n#   #\n#   #password_reset_template_html: password_reset.html\n#   #password_reset_template_text: password_reset.txt\n#\n#   # Templates for password reset success and failure pages that a user\n#   # will see after attempting to reset their password\n#   #\n#   #password_reset_template_success_html: password_reset_success.html\n#   #password_reset_template_failure_html: password_reset_failure.html\n\n\n#password_providers:\n#    - module: \"ldap_auth_provider.LdapAuthProvider\"\n#      config:\n#        enabled: true\n#        uri: \"ldap://ldap.example.com:389\"\n#        start_tls: true\n#        base: \"ou=users,dc=example,dc=com\"\n#        attributes:\n#           uid: \"cn\"\n#           mail: \"email\"\n#           name: \"givenName\"\n#        #bind_dn:\n#        #bind_password:\n#        #filter: \"(objectClass=posixAccount)\"\npassword_providers: []\n\n\n# Clients requesting push notifications can either have the body of\n# the message sent in the notification poke along with other details\n# like the sender, or just the event ID and room ID (`event_id_only`).\n# If clients choose the former, this option controls whether the\n# notification request includes the content of the event (other details\n# like the sender are still included). For `event_id_only` push, it\n# has no effect.\n#\n# For modern android devices the notification content will still appear\n# because it is loaded by the app. iPhone, however will send a\n# notification saying only that a message arrived and who it came from.\n#\n#push:\n#  include_content: true\n\n\n#spam_checker:\n#  module: \"my_custom_project.SuperSpamChecker\"\n#  config:\n#    example_option: 'things'\n\n\n\n# User Directory configuration\n#\n# 'enabled' defines whether users can search the user directory. If\n# false then empty responses are returned to all queries. Defaults to\n# true.\n#\n# 'search_all_users' defines whether to search all users visible to your HS\n# when searching the user directory, rather than limiting to users visible\n# in public rooms.  Defaults to false.  If you set it True, you'll have to\n# rebuild the user_directory search indexes, see\n# https://github.com/matrix-org/synapse/blob/master/docs/user_directory.md\n#\n#user_directory:\n#  enabled: true\n#  search_all_users: false\n\n\n# User Consent configuration\n#\n# for detailed instructions, see\n# https://github.com/matrix-org/synapse/blob/master/docs/consent_tracking.md\n#\n# Parts of this section are required if enabling the 'consent' resource under\n# 'listeners', in particular 'template_dir' and 'version'.\n#\n# 'template_dir' gives the location of the templates for the HTML forms.\n# This directory should contain one subdirectory per language (eg, 'en', 'fr'),\n# and each language directory should contain the policy document (named as\n# '<version>.html') and a success page (success.html).\n#\n# 'version' specifies the 'current' version of the policy document. It defines\n# the version to be served by the consent resource if there is no 'v'\n# parameter.\n#\n# 'server_notice_content', if enabled, will send a user a \"Server Notice\"\n# asking them to consent to the privacy policy. The 'server_notices' section\n# must also be configured for this to work. Notices will *not* be sent to\n# guest users unless 'send_server_notice_to_guests' is set to true.\n#\n# 'block_events_error', if set, will block any attempts to send events\n# until the user consents to the privacy policy. The value of the setting is\n# used as the text of the error.\n#\n# 'require_at_registration', if enabled, will add a step to the registration\n# process, similar to how captcha works. Users will be required to accept the\n# policy before their account is created.\n#\n# 'policy_name' is the display name of the policy users will see when registering\n# for an account. Has no effect unless `require_at_registration` is enabled.\n# Defaults to \"Privacy Policy\".\n#\n#user_consent:\n#  template_dir: res/templates/privacy\n#  version: 1.0\n#  server_notice_content:\n#    msgtype: m.text\n#    body: >-\n#      To continue using this homeserver you must review and agree to the\n#      terms and conditions at %(consent_uri)s\n#  send_server_notice_to_guests: True\n#  block_events_error: >-\n#    To continue using this homeserver you must review and agree to the\n#    terms and conditions at %(consent_uri)s\n#  require_at_registration: False\n#  policy_name: Privacy Policy\n#\n\n\n\n# Local statistics collection. Used in populating the room directory.\n#\n# 'bucket_size' controls how large each statistics timeslice is. It can\n# be defined in a human readable short form -- e.g. \"1d\", \"1y\".\n#\n# 'retention' controls how long historical statistics will be kept for.\n# It can be defined in a human readable short form -- e.g. \"1d\", \"1y\".\n#\n#\n#stats:\n#   enabled: true\n#   bucket_size: 1d\n#   retention: 1y\n\n\n# Server Notices room configuration\n#\n# Uncomment this section to enable a room which can be used to send notices\n# from the server to users. It is a special room which cannot be left; notices\n# come from a special \"notices\" user id.\n#\n# If you uncomment this section, you *must* define the system_mxid_localpart\n# setting, which defines the id of the user which will be used to send the\n# notices.\n#\n# It's also possible to override the room name, the display name of the\n# \"notices\" user, and the avatar for the user.\n#\n#server_notices:\n#  system_mxid_localpart: notices\n#  system_mxid_display_name: \"Server Notices\"\n#  system_mxid_avatar_url: \"mxc://server.com/oumMVlgDnLYFaPVkExemNVVZ\"\n#  room_name: \"Server Notices\"\n\n\n\n# Uncomment to disable searching the public room list. When disabled\n# blocks searching local and remote room lists for local and remote\n# users by always returning an empty list for all queries.\n#\n#enable_room_list_search: false\n\n# The `alias_creation` option controls who's allowed to create aliases\n# on this server.\n#\n# The format of this option is a list of rules that contain globs that\n# match against user_id, room_id and the new alias (fully qualified with\n# server name). The action in the first rule that matches is taken,\n# which can currently either be \"allow\" or \"deny\".\n#\n# Missing user_id/room_id/alias fields default to \"*\".\n#\n# If no rules match the request is denied. An empty list means no one\n# can create aliases.\n#\n# Options for the rules include:\n#\n#   user_id: Matches against the creator of the alias\n#   alias: Matches against the alias being created\n#   room_id: Matches against the room ID the alias is being pointed at\n#   action: Whether to \"allow\" or \"deny\" the request if the rule matches\n#\n# The default is:\n#\n#alias_creation_rules:\n#  - user_id: \"*\"\n#    alias: \"*\"\n#    room_id: \"*\"\n#    action: allow\n\n# The `room_list_publication_rules` option controls who can publish and\n# which rooms can be published in the public room list.\n#\n# The format of this option is the same as that for\n# `alias_creation_rules`.\n#\n# If the room has one or more aliases associated with it, only one of\n# the aliases needs to match the alias rule. If there are no aliases\n# then only rules with `alias: *` match.\n#\n# If no rules match the request is denied. An empty list means no one\n# can publish rooms.\n#\n# Options for the rules include:\n#\n#   user_id: Matches agaisnt the creator of the alias\n#   room_id: Matches against the room ID being published\n#   alias: Matches against any current local or canonical aliases\n#            associated with the room\n#   action: Whether to \"allow\" or \"deny\" the request if the rule matches\n#\n# The default is:\n#\n#room_list_publication_rules:\n#  - user_id: \"*\"\n#    alias: \"*\"\n#    room_id: \"*\"\n#    action: allow\n\n\n# Server admins can define a Python module that implements extra rules for\n# allowing or denying incoming events. In order to work, this module needs to\n# override the methods defined in synapse/events/third_party_rules.py.\n#\n# This feature is designed to be used in closed federations only, where each\n# participating server enforces the same rules.\n#\n#third_party_event_rules:\n#  module: \"my_custom_project.SuperRulesSet\"\n#  config:\n#    example_option: 'things'\n\n\n## Opentracing ##\n\n# These settings enable opentracing, which implements distributed tracing.\n# This allows you to observe the causal chains of events across servers\n# including requests, key lookups etc., across any server running\n# synapse or any other other services which supports opentracing\n# (specifically those implemented with Jaeger).\n#\nopentracing:\n    # tracing is disabled by default. Uncomment the following line to enable it.\n    #\n    #enabled: true\n\n    # The list of homeservers we wish to send and receive span contexts and span baggage.\n    # See docs/opentracing.rst\n    # This is a list of regexes which are matched against the server_name of the\n    # homeserver.\n    #\n    # By defult, it is empty, so no servers are matched.\n    #\n    #homeserver_whitelist:\n    #  - \".*\"\n"
  },
  {
    "path": "etc/services/synapse/config/synapse.127.0.0.1.nip.io.log.config",
    "content": "\nversion: 1\n\nformatters:\n    precise:\n        format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'\n\nfilters:\n    context:\n        (): synapse.util.logcontext.LoggingContextFilter\n        request: \"\"\n\nhandlers:\n    console:\n        class: logging.StreamHandler\n        formatter: precise\n        filters: [context]\n\nloggers:\n    synapse:\n        level: INFO\n\n    shared_secret_authenticator:\n        level: INFO\n\n    rest_auth_provider:\n        level: INFO\n\n    synapse.storage.SQL:\n        # beware: increasing this to DEBUG will make synapse log sensitive\n        # information such as access tokens.\n        level: INFO\n\nroot:\n    level: INFO\n    handlers: [console]\n"
  },
  {
    "path": "etc/services/synapse/config/synapse.127.0.0.1.nip.io.signing.key",
    "content": "ed25519 a_FEMe JGs8Fk83GHIrVyhBYa/VRUFbU4+Fxtf8iOsJ7CMamcM\n"
  },
  {
    "path": "justfile",
    "content": "project_name := \"baibot\"\ncontainer_image_name := \"localhost/baibot\"\nproject_container_network := \"baibot\"\n\nadmin_username := \"admin\"\nadmin_password := \"admin\"\nbot_username := \"baibot\"\nbot_password := \"baibot\"\n\nhomeserver := `cat var/homeserver 2>/dev/null || echo continuwuity`\n\nmise_data_dir := env(\"MISE_DATA_DIR\", justfile_directory() / \"var/mise\")\nmise_trusted_config_paths := justfile_directory() / \"mise.toml\"\n\n# Show help by default\ndefault:\n\t@just --list --justfile {{ justfile() }}\n\n# Selects which homeserver implementation to use (continuwuity or synapse)\nhomeserver-init value:\n\t#!/bin/sh\n\tmkdir -p {{ justfile_directory() }}/var\n\techo {{ value }} > {{ justfile_directory() }}/var/homeserver\n\techo \"\"\n\techo \"⚠️  If you had already prepared your app configuration (var/app/local/config.yml or var/app/container/config.yml),\"\n\techo \"    you will need to update it manually or delete it and re-run the prepare step.\"\n\techo \"    You should also delete var/app/local/data and/or var/app/container/data,\"\n\techo \"    as old application state is not compatible across homeserver implementations.\"\n\techo \"\"\n\techo \"⚠️  If Element Web was already prepared, delete var/services/element-web/ to regenerate its config.\"\n\n# Builds and runs a development binary\nrun-locally *extra_args: app-local-prepare\n\tRUST_BACKTRACE=1 \\\n\tBAIBOT_CONFIG_FILE_PATH={{ justfile_directory() }}/var/app/local/config.yml \\\n\tBAIBOT_PERSISTENCE_DATA_DIR_PATH={{ justfile_directory() }}/var/app/local/data \\\n\tcargo run -- {{ extra_args }}\n\n# Builds and runs the bot in a container\nrun-in-container *extra_args: app-container-prepare build-container-image-debug\n\t/usr/bin/env docker run \\\n\t-it \\\n\t--rm \\\n\t--name={{ project_name }} \\\n\t--user=$(id -u):$(id -g) \\\n\t--cap-drop=ALL \\\n\t--read-only \\\n\t--network={{ project_container_network }} \\\n\t--env BAIBOT_PERSISTENCE_DATA_DIR_PATH=/data \\\n\t--mount type=bind,src={{ justfile_directory() }}/var/app/container/config.yml,dst=/app/config.yml,ro \\\n\t--mount type=bind,src={{ justfile_directory() }}/var/app/container/data,dst=/data \\\n\t{{ container_image_name }}:latest {{ extra_args }}\n\n# Runs tests\ntest *extra_args:\n\tRUST_BACKTRACE=1 cargo test {{ extra_args }}\n\n# Formats the code\nfmt:\n\tRUST_BACKTRACE=1 cargo fmt --all\n\n# Builds a debug binary (target/debug/*)\nbuild-debug *extra_args:\n\tRUST_BACKTRACE=1 cargo build {{ extra_args }}\n\n# Builds an optimized release binary (target/release/*)\nbuild-release *extra_args: (build-debug \"--release\")\n\n# Builds a container image (debug mode)\nbuild-container-image-debug tag='latest': (_build-container-image \"false\" tag)\n\n# Builds a container image (release mode)\nbuild-container-image-release tag='latest': (_build-container-image \"true\" tag)\n\n_build-container-image release_build tag:\n\t/usr/bin/env docker build \\\n\t--build-arg RELEASE_BUILD={{ release_build }} \\\n\t-f {{ justfile_directory() }}/Dockerfile \\\n\t-t {{ container_image_name }}:{{ tag }} \\\n\t.\n\n# Runs a docker-compose command\ndocker-compose services_type *extra_args:\n\t/usr/bin/docker compose \\\n\t--project-directory var/services \\\n\t--env-file var/services/env \\\n\t-f etc/services/{{ services_type }}/compose.yml \\\n\t-p {{ project_name }}-{{ services_type }} \\\n\t{{ extra_args }}\n\n# Runs a docker-compose command against the synapse services\ndocker-compose-synapse *extra_args:\n\tjust docker-compose synapse {{ extra_args }}\n\n# Runs a docker-compose command against the element-web services\ndocker-compose-element-web *extra_args:\n\tjust docker-compose element-web {{ extra_args }}\n\n# Runs a docker-compose command against the localai services\ndocker-compose-localai *extra_args:\n\tjust docker-compose localai {{ extra_args }}\n\n# Runs a docker-compose command against the ollama services\ndocker-compose-ollama *extra_args:\n\tjust docker-compose ollama {{ extra_args }}\n\n# Runs a docker-compose command against the continuwuity services\ndocker-compose-continuwuity *extra_args:\n\tjust docker-compose continuwuity {{ extra_args }}\n\n# Runs the homeserver and Element Web (in the background)\nservices-start: services-prepare\n\tjust -f {{ justfile_directory() }}/justfile {{ homeserver }}-start\n\tjust -f {{ justfile_directory() }}/justfile element-web-start\n\n# Stops Element Web and the homeserver\nservices-stop:\n\tjust -f {{ justfile_directory() }}/justfile element-web-stop\n\tjust -f {{ justfile_directory() }}/justfile {{ homeserver }}-stop\n\n# Tails the logs for the homeserver and Element Web\nservices-tail-logs:\n\tjust -f {{ justfile_directory() }}/justfile {{ homeserver }}-tail-logs\n\n# Prepares the homeserver and Element Web for running\nservices-prepare:\n\tjust -f {{ justfile_directory() }}/justfile {{ homeserver }}-prepare\n\tjust -f {{ justfile_directory() }}/justfile element-web-prepare\n\n# Runs Synapse (in the background)\nsynapse-start: synapse-prepare (docker-compose-synapse \"up\" \"-d\")\n\n# Stops Synapse\nsynapse-stop: (docker-compose-synapse \"down\")\n\n# Tails the logs for Synapse\nsynapse-tail-logs: (docker-compose-synapse \"logs\" \"-f\")\n\n# Prepares Synapse for running\nsynapse-prepare: _prepare-var-services-env _prepare-var-services-postgres _prepare-var-services-synapse _prepare-container-network\n\n# Runs Element Web (in the background)\nelement-web-start: element-web-prepare (docker-compose-element-web \"up\" \"-d\")\n\n# Stops Element Web\nelement-web-stop: (docker-compose-element-web \"down\")\n\n# Tails the logs for Element Web\nelement-web-tail-logs: (docker-compose-element-web \"logs\" \"-f\")\n\n# Prepares Element Web for running\nelement-web-prepare: _prepare-var-services-env _prepare-var-services-element-web _prepare-container-network\n\n# Runs LocalAI (in the background)\nlocalai-start: localai-prepare (docker-compose-localai \"up\" \"-d\")\n\n# Stops LocalAI\nlocalai-stop: (docker-compose-localai \"down\")\n\n# Tails the logs for LocalAI\nlocalai-tail-logs: (docker-compose-localai \"logs\" \"-f\")\n\n# Prepares LocalAI for running\nlocalai-prepare: _prepare-var-services-env _prepare-var-services-localai _prepare-container-network\n\n# Runs Ollama (in the background)\nollama-start: ollama-prepare (docker-compose-ollama \"up\" \"-d\")\n\n# Stops Ollama\nollama-stop: (docker-compose-ollama \"down\")\n\n# Tails the logs for Ollama\nollama-tail-logs: (docker-compose-ollama \"logs\" \"-f\")\n\n# Prepares Ollama for running\nollama-prepare: _prepare-var-services-env _prepare-var-services-ollama _prepare-container-network\n\n# Runs Continuwuity (in the background)\ncontinuwuity-start: continuwuity-prepare (docker-compose-continuwuity \"up\" \"-d\")\n\n# Stops Continuwuity\ncontinuwuity-stop: (docker-compose-continuwuity \"down\")\n\n# Tails the logs for Continuwuity\ncontinuwuity-tail-logs: (docker-compose-continuwuity \"logs\" \"-f\")\n\n# Prepares Continuwuity for running\ncontinuwuity-prepare: _prepare-var-services-env _prepare-var-services-continuwuity _prepare-container-network\n\n# Registers a user on Continuwuity via the Matrix Client-Server API\ncontinuwuity-register-user username password:\n\t{{ justfile_directory() }}/etc/services/continuwuity/register-user.sh {{ justfile_directory() }}/var/services/env {{ username }} {{ password }}\n\n# Prepares the Continuwuity user accounts\ncontinuwuity-users-prepare: continuwuity-prepare\n\tjust -f {{ justfile_directory() }}/justfile continuwuity-register-user \"{{ admin_username }}\" \"{{ admin_password }}\"\n\tjust -f {{ justfile_directory() }}/justfile continuwuity-register-user \"{{ bot_username }}\" \"{{ bot_password }}\"\n\n# Pulls an Ollama model\nollama-pull-model model_id:\n\tjust -f {{ justfile_directory() }}/justfile docker-compose-ollama \\\n\t\texec ollama \\\n\t\tollama pull {{ model_id }}\n\n# Prepares the app for running locally\napp-local-prepare: _prepare-var-app-local-config_yml _prepare-var-app-local-data\n\n# Prepares the app for running in a container\napp-container-prepare: _prepare-var-app-container-config_yml _prepare-var-app-container-data\n\n# Prepares the user accounts\nusers-prepare:\n\tjust -f {{ justfile_directory() }}/justfile {{ homeserver }}-users-prepare\n\n# Prepares the Synapse user accounts\nsynapse-users-prepare: synapse-prepare\n\tjust -f {{ justfile_directory() }}/justfile synapse-register-admin-user \"{{ admin_username }}\" \"{{ admin_password }}\"\n\tjust -f {{ justfile_directory() }}/justfile synapse-register-regular-user \"{{ bot_username }}\" \"{{ bot_password }}\"\n\n# Starts a Postgres CLI (psql)\npostgres-cli: synapse-prepare (docker-compose-synapse \"exec\" \"postgres\" \"/bin/sh\" \"-c\" \"'PGUSER=synapse PGPASSWORD=synapse-password PGDATABASE=homeserver psql -h postgres'\")\n\n# Creates an administrator user on Synapse\nsynapse-register-admin-user username password: synapse-prepare\n\tjust -f {{ justfile_directory() }}/justfile docker-compose-synapse \\\n\t\texec synapse \\\n\t\tregister_new_matrix_user \\\n\t\t--admin \\\n\t\t-u {{ username }} \\\n\t\t-p {{ password }} \\\n\t\t-c /config/homeserver.yaml \\\n\t\thttp://localhost:8008\n\n# Creates a regular user on Synapse\nsynapse-register-regular-user username password: synapse-prepare\n\tjust -f {{ justfile_directory() }}/justfile docker-compose-synapse \\\n\t\texec synapse \\\n\t\tregister_new_matrix_user \\\n\t\t--no-admin \\\n\t\t-u {{ username }} \\\n\t\t-p {{ password }} \\\n\t\t-c /config/homeserver.yaml \\\n\t\thttp://localhost:8008\n\n# Runs the clippy linter\nclippy *extra_args:\n\tcargo clippy {{ extra_args }}\n\n# Checks that the code compiles without building\ncheck:\n\tcargo check\n\n# Invokes mise with the project-local data directory\nmise *args: _ensure_mise_data_directory\n\t#!/bin/sh\n\texport MISE_DATA_DIR=\"{{ mise_data_dir }}\"\n\texport MISE_TRUSTED_CONFIG_PATHS=\"{{ mise_trusted_config_paths }}\"\n\tmise {{ args }}\n\n# Runs prek (pre-commit hooks manager) with the given arguments\nprek *args: _ensure_mise_tools_installed\n\t@just --justfile {{ justfile() }} mise exec -- prek {{ args }}\n\n# Runs pre-commit hooks on staged files\nprek-run-on-staged *args: _ensure_mise_tools_installed\n\t@just --justfile {{ justfile() }} mise exec -- prek run {{ args }}\n\n# Runs pre-commit hooks on all files\nprek-run-on-all *args: _ensure_mise_tools_installed\n\t@just --justfile {{ justfile() }} mise exec -- prek run --all-files {{ args }}\n\n# Installs the git pre-commit hook (runs prek automatically before each commit)\nprek-install-git-pre-commit-hook: _ensure_mise_tools_installed\n\t@just --justfile {{ justfile() }} mise exec -- prek install\n\n# Internal - ensures var/mise directory exists\n_ensure_mise_data_directory:\n\t#!/bin/sh\n\tif [ ! -d \"{{ mise_data_dir }}\" ]; then\n\t\tmkdir -p \"{{ mise_data_dir }}\"\n\tfi\n\n# Internal - ensures mise tools are installed\n_ensure_mise_tools_installed: _ensure_mise_data_directory\n\t@just --justfile {{ justfile() }} mise install --quiet\n\n_prepare-var-services-env:\n\t#!/bin/sh\n\tcd {{ justfile_directory() }};\n\n\tif [ ! -f var/services/env ]; then\n\t\tmkdir -p var/services\n\t\tcp {{ justfile_directory() }}/etc/services/env.dist var/services/env\n\t\techo 'UID='`id -u` >> var/services/env;\n\t\techo 'GID='`id -g` >> var/services/env;\n\t\techo 'NETWORK_NAME={{ project_container_network }}' >> var/services/env;\n\tfi\n\n_prepare-var-services-postgres:\n\t#!/bin/sh\n\tcd {{ justfile_directory() }};\n\n\tif [ ! -f var/services/postgres ]; then\n\t\tmkdir -p var/services/postgres\n\t\tchown `id -u`:`id -g` var/services/postgres\n\tfi\n\n_prepare-var-services-synapse:\n\t#!/bin/sh\n\tcd {{ justfile_directory() }};\n\n\tif [ ! -f var/services/synapse ]; then\n\t\tmkdir -p var/services/synapse/media-store\n\tfi\n\n_prepare-var-services-element-web:\n\t#!/bin/sh\n\tcd {{ justfile_directory() }};\n\n\tif [ ! -f var/services/element-web/config.json ]; then\n\t\tmkdir -p var/services/element-web\n\t\tcp {{ justfile_directory() }}/etc/services/element-web/config.json.dist var/services/element-web/config.json\n\n\t\thomeserver=\"{{ homeserver }}\"\n\t\tif [ \"$homeserver\" = \"continuwuity\" ]; then\n\t\t\tsed --in-place 's|__HOMESERVER_CLIENT_URL__|http://continuwuity.127.0.0.1.nip.io:42030|g' var/services/element-web/config.json\n\t\telif [ \"$homeserver\" = \"synapse\" ]; then\n\t\t\tsed --in-place 's|__HOMESERVER_CLIENT_URL__|http://synapse.127.0.0.1.nip.io:42020|g' var/services/element-web/config.json\n\t\tfi\n\tfi\n\n_prepare-var-services-ollama:\n\t#!/bin/sh\n\tcd {{ justfile_directory() }};\n\n\tif [ ! -f var/services/ollama ]; then\n\t\tmkdir -p var/services/ollama\n\tfi\n\n_prepare-var-services-continuwuity:\n\t#!/bin/sh\n\tcd {{ justfile_directory() }};\n\n\tif [ ! -f var/services/continuwuity ]; then\n\t\tmkdir -p var/services/continuwuity/data\n\tfi\n\n_prepare-var-services-localai:\n\t#!/bin/sh\n\tcd {{ justfile_directory() }};\n\n\tif [ ! -f var/services/localai ]; then\n\t\tmkdir -p var/services/localai\n\tfi\n\n_prepare-container-network:\n\t#!/bin/sh\n\tnetwork_definition=$(/usr/bin/env docker network ls --filter='name={{ project_container_network }}' --format=json)\n\n\tif [ \"$network_definition\" = \"\" ]; then\n\t\t/usr/bin/docker network create {{ project_container_network }}\n\tfi\n\n_prepare-var-app-local-config_yml:\n\t#!/bin/sh\n\tcd {{ justfile_directory() }};\n\n\tif [ ! -f var/app/local/config.yml ]; then\n\t\tmkdir -p var/app/local\n\t\tcp {{ justfile_directory() }}/etc/app/config.yml.dist var/app/local/config.yml\n\n\t\thomeserver=\"{{ homeserver }}\"\n\t\tif [ \"$homeserver\" = \"continuwuity\" ]; then\n\t\t\tsed --in-place 's/__HOMESERVER_SERVER_NAME__/continuwuity.127.0.0.1.nip.io/g' var/app/local/config.yml\n\t\t\tsed --in-place 's|__HOMESERVER_URL__|http://continuwuity.127.0.0.1.nip.io:42030|g' var/app/local/config.yml\n\t\telif [ \"$homeserver\" = \"synapse\" ]; then\n\t\t\tsed --in-place 's/__HOMESERVER_SERVER_NAME__/synapse.127.0.0.1.nip.io/g' var/app/local/config.yml\n\t\t\tsed --in-place 's|__HOMESERVER_URL__|http://synapse.127.0.0.1.nip.io:42020|g' var/app/local/config.yml\n\t\tfi\n\tfi\n\n_prepare-var-app-local-data:\n\t#!/bin/sh\n\tcd {{ justfile_directory() }};\n\n\tif [ ! -f var/app/local/data ]; then\n\t\tmkdir -p var/app/local/data\n\tfi\n\n_prepare-var-app-container-config_yml:\n\t#!/bin/sh\n\tcd {{ justfile_directory() }};\n\n\tif [ ! -f var/app/container/config.yml ]; then\n\t\tmkdir -p var/app/container\n\t\tcp {{ justfile_directory() }}/etc/app/config.yml.dist var/app/container/config.yml\n\n\t\thomeserver=\"{{ homeserver }}\"\n\t\tif [ \"$homeserver\" = \"continuwuity\" ]; then\n\t\t\tsed --in-place 's/__HOMESERVER_SERVER_NAME__/continuwuity.127.0.0.1.nip.io/g' var/app/container/config.yml\n\t\t\tsed --in-place 's|__HOMESERVER_URL__|http://continuwuity.127.0.0.1.nip.io:42030|g' var/app/container/config.yml\n\t\t\tsed --in-place 's/continuwuity.127.0.0.1.nip.io:42030/continuwuity:6167/g' var/app/container/config.yml\n\t\telif [ \"$homeserver\" = \"synapse\" ]; then\n\t\t\tsed --in-place 's/__HOMESERVER_SERVER_NAME__/synapse.127.0.0.1.nip.io/g' var/app/container/config.yml\n\t\t\tsed --in-place 's|__HOMESERVER_URL__|http://synapse.127.0.0.1.nip.io:42020|g' var/app/container/config.yml\n\t\t\tsed --in-place 's/synapse.127.0.0.1.nip.io:42020/synapse:8008/g' var/app/container/config.yml\n\t\tfi\n\n\t\tsed --in-place 's/127.0.0.1:42026/ollama:11434/g' var/app/container/config.yml\n\t\tsed --in-place 's/127.0.0.1:42027/localai:8080/g' var/app/container/config.yml\n\tfi\n\n_prepare-var-app-container-data:\n\t#!/bin/sh\n\tcd {{ justfile_directory() }};\n\n\tif [ ! -f var/app/container/data ]; then\n\t\tmkdir -p var/app/container/data\n\tfi\n"
  },
  {
    "path": "mise.toml",
    "content": "[tools]\nprek = \"0.4.1\"\n\n[settings]\n# Disable automatic trust prompts - we trust this config\nyes = true\n"
  },
  {
    "path": "renovate.json",
    "content": "{\n\t\"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n\t\"extends\": [\n\t\t\"config:recommended\"\n\t],\n\t\"labels\": [\n\t\t\"dependencies\"\n\t]\n}\n"
  },
  {
    "path": "rust-toolchain.toml",
    "content": "[toolchain]\nchannel = \"1.95.0\"\ncomponents = [\"rustfmt\", \"clippy\"]\nprofile = \"default\"\n"
  },
  {
    "path": "src/agent/definition.rs",
    "content": "use serde::de::Error as DeError;\nuse serde::{Deserialize, Deserializer, Serialize, Serializer};\n\nuse super::provider::AgentProvider;\n\n// Custom serialization for AgentProvider\npub fn serialize_provider_to_string<S>(\n    value: &AgentProvider,\n    serializer: S,\n) -> Result<S::Ok, S::Error>\nwhere\n    S: Serializer,\n{\n    serializer.serialize_str(value.to_static_str())\n}\n\n// Custom deserialization for AgentProvider\npub fn deserialize_provider_from_string<'de, D>(deserializer: D) -> Result<AgentProvider, D::Error>\nwhere\n    D: Deserializer<'de>,\n{\n    let s = String::deserialize(deserializer)?;\n    AgentProvider::from_string(&s).map_err(DeError::custom)\n}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct AgentDefinition {\n    pub id: String,\n\n    #[serde(\n        serialize_with = \"serialize_provider_to_string\",\n        deserialize_with = \"deserialize_provider_from_string\"\n    )]\n    pub provider: AgentProvider,\n\n    pub config: serde_yaml_ng::Value,\n}\n\nimpl AgentDefinition {\n    pub fn new(id: String, provider: AgentProvider, config: serde_yaml_ng::Value) -> Self {\n        Self {\n            id,\n            provider,\n            config,\n        }\n    }\n}\n\nimpl PartialEq for AgentDefinition {\n    fn eq(&self, other: &Self) -> bool {\n        self.id == other.id\n    }\n}\n\nimpl Eq for AgentDefinition {}\n"
  },
  {
    "path": "src/agent/identifier.rs",
    "content": "use std::fmt;\n\n#[derive(Debug, PartialEq, Eq, Clone)]\npub enum PublicIdentifier {\n    Static(String),\n    DynamicGlobal(String),\n    DynamicRoomLocal(String),\n}\n\nimpl PublicIdentifier {\n    pub fn from_str(s: &str) -> Option<Self> {\n        if let Some(rest) = s.strip_prefix(\"static/\") {\n            return Some(PublicIdentifier::Static(rest.to_string()));\n        } else if let Some(rest) = s.strip_prefix(\"global/\") {\n            return Some(PublicIdentifier::DynamicGlobal(rest.to_string()));\n        } else if let Some(rest) = s.strip_prefix(\"room-local/\") {\n            return Some(PublicIdentifier::DynamicRoomLocal(rest.to_string()));\n        }\n        None\n    }\n\n    pub fn as_string(&self) -> String {\n        match self {\n            PublicIdentifier::Static(s) => format!(\"static/{}\", s),\n            PublicIdentifier::DynamicGlobal(s) => format!(\"global/{}\", s),\n            PublicIdentifier::DynamicRoomLocal(s) => format!(\"room-local/{}\", s),\n        }\n    }\n\n    pub fn prefixless(&self) -> String {\n        match self {\n            PublicIdentifier::Static(s) => s.to_owned(),\n            PublicIdentifier::DynamicGlobal(s) => s.to_owned(),\n            PublicIdentifier::DynamicRoomLocal(s) => s.to_owned(),\n        }\n    }\n\n    pub fn validate(&self) -> Result<(), String> {\n        let prefixless = self.prefixless();\n\n        if prefixless.is_empty() {\n            return Err(\"The agent ID must not be empty.\".to_owned());\n        }\n\n        // We use a slash to separate the agent type from the agent ID.\n        if prefixless.contains(\"/\") {\n            return Err(\"The agent ID must not contain the `/` character.\".to_owned());\n        }\n\n        // Spaces are used for separating command arguments, etc.\n        if prefixless.contains(\" \") {\n            return Err(\"The agent ID must not contain spaces.\".to_owned());\n        }\n\n        Ok(())\n    }\n}\n\nimpl fmt::Display for PublicIdentifier {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(f, \"{}\", self.as_string())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_public_identifier_from_str() {\n        assert_eq!(\n            PublicIdentifier::from_str(\"static/abc\"),\n            Some(PublicIdentifier::Static(\"abc\".to_string()))\n        );\n        assert_eq!(\n            PublicIdentifier::from_str(\"global/abc\"),\n            Some(PublicIdentifier::DynamicGlobal(\"abc\".to_string()))\n        );\n        assert_eq!(\n            PublicIdentifier::from_str(\"room-local/abc\"),\n            Some(PublicIdentifier::DynamicRoomLocal(\"abc\".to_string()))\n        );\n        assert_eq!(PublicIdentifier::from_str(\"abc\"), None);\n    }\n\n    #[test]\n    fn test_public_identifier_as_string() {\n        assert_eq!(\n            PublicIdentifier::Static(\"abc\".to_string()).as_string(),\n            \"static/abc\"\n        );\n        assert_eq!(\n            PublicIdentifier::DynamicGlobal(\"abc\".to_string()).as_string(),\n            \"global/abc\"\n        );\n        assert_eq!(\n            PublicIdentifier::DynamicRoomLocal(\"abc\".to_string()).as_string(),\n            \"room-local/abc\"\n        );\n    }\n}\n"
  },
  {
    "path": "src/agent/instantiation.rs",
    "content": "use super::{\n    AgentDefinition, AgentProvider, PublicIdentifier,\n    provider::{self, ControllerType},\n};\n\n// Dead-code is allowed. We do not use these enum struct payloads directly,\n// but these errors are being print-formatted (`{:?}`) in error messages, so we wish to keep them.\n#[derive(Debug)]\n#[allow(dead_code)]\npub enum Error {\n    // Contains the error message from the validation function\n    ConfigFailsValidation(String),\n    // Contains the agent ID\n    ConfigForAgentIsNotAMapping(String),\n    // Contains the error from the constructor function\n    ConstructionFailed(anyhow::Error),\n    // Contains the error from the YAML deserialization function\n    Yaml(serde_yaml_ng::Error),\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n\n#[derive(Debug, Clone)]\npub struct AgentInstance {\n    identifier: PublicIdentifier,\n    definition: AgentDefinition,\n    controller: ControllerType,\n}\n\nimpl AgentInstance {\n    pub fn new(\n        identifier: PublicIdentifier,\n        definition: AgentDefinition,\n        controller: ControllerType,\n    ) -> Self {\n        Self {\n            identifier,\n            definition,\n            controller,\n        }\n    }\n\n    pub fn identifier(&self) -> &PublicIdentifier {\n        &self.identifier\n    }\n\n    pub fn definition(&self) -> &AgentDefinition {\n        &self.definition\n    }\n\n    pub fn controller(&self) -> &ControllerType {\n        &self.controller\n    }\n}\n\npub(super) fn create(\n    identifier: PublicIdentifier,\n    definition: AgentDefinition,\n) -> Result<AgentInstance> {\n    let controller = create_controller_from_provider_and_json_value_config(\n        &definition.id,\n        &definition.provider,\n        definition.config.clone(),\n    )?;\n\n    Ok(AgentInstance::new(identifier, definition, controller))\n}\n\npub fn create_from_provider_and_yaml_value_config(\n    provider: &AgentProvider,\n    identifier: &PublicIdentifier,\n    config: serde_yaml_ng::Value,\n) -> Result<AgentInstance> {\n    let definition = AgentDefinition::new(identifier.prefixless(), provider.to_owned(), config);\n\n    create(identifier.to_owned(), definition)\n}\n\nfn create_controller_from_provider_and_json_value_config(\n    agent_id: &str,\n    provider: &AgentProvider,\n    config: serde_yaml_ng::Value,\n) -> Result<ControllerType> {\n    match provider {\n        AgentProvider::Anthropic => {\n            provider::anthropic::create_controller_from_yaml_value_config(agent_id, config)\n        }\n        AgentProvider::Groq => {\n            provider::openai_compat::create_controller_from_yaml_value_config(agent_id, config)\n        }\n        AgentProvider::Mistral => {\n            provider::openai_compat::create_controller_from_yaml_value_config(agent_id, config)\n        }\n        AgentProvider::LocalAI => {\n            provider::openai_compat::create_controller_from_yaml_value_config(agent_id, config)\n        }\n        AgentProvider::Ollama => {\n            provider::openai_compat::create_controller_from_yaml_value_config(agent_id, config)\n        }\n        AgentProvider::OpenAI => {\n            provider::openai::create_controller_from_yaml_value_config(agent_id, config)\n        }\n        AgentProvider::OpenAICompat => {\n            provider::openai_compat::create_controller_from_yaml_value_config(agent_id, config)\n        }\n        AgentProvider::OpenRouter => {\n            provider::openai_compat::create_controller_from_yaml_value_config(agent_id, config)\n        }\n        AgentProvider::TogetherAI => {\n            provider::openai_compat::create_controller_from_yaml_value_config(agent_id, config)\n        }\n    }\n}\n\npub fn default_config_for_provider(provider: &AgentProvider) -> serde_yaml_ng::Value {\n    match provider {\n        AgentProvider::Anthropic => {\n            let config = super::provider::anthropic::default_config();\n            serde_yaml_ng::to_value(config).expect(\"Failed to serialize config\")\n        }\n        AgentProvider::Groq => {\n            let config = super::provider::groq::default_config();\n            serde_yaml_ng::to_value(config).expect(\"Failed to serialize config\")\n        }\n        AgentProvider::LocalAI => {\n            let config = super::provider::localai::default_config();\n            serde_yaml_ng::to_value(config).expect(\"Failed to serialize config\")\n        }\n        AgentProvider::Mistral => {\n            let config = super::provider::mistral::default_config();\n            serde_yaml_ng::to_value(config).expect(\"Failed to serialize config\")\n        }\n        AgentProvider::Ollama => {\n            let config = super::provider::ollama::default_config();\n            serde_yaml_ng::to_value(config).expect(\"Failed to serialize config\")\n        }\n        AgentProvider::OpenAI => {\n            let config = super::provider::openai::default_config();\n            serde_yaml_ng::to_value(config).expect(\"Failed to serialize config\")\n        }\n        AgentProvider::OpenAICompat => {\n            let config = super::provider::openai_compat::default_config();\n            serde_yaml_ng::to_value(config).expect(\"Failed to serialize config\")\n        }\n        AgentProvider::OpenRouter => {\n            let config = super::provider::openrouter::default_config();\n            serde_yaml_ng::to_value(config).expect(\"Failed to serialize config\")\n        }\n        AgentProvider::TogetherAI => {\n            let config = super::provider::togetherai::default_config();\n            serde_yaml_ng::to_value(config).expect(\"Failed to serialize config\")\n        }\n    }\n}\n"
  },
  {
    "path": "src/agent/manager.rs",
    "content": "use super::AgentDefinition;\nuse super::PublicIdentifier;\nuse super::instantiation;\nuse super::instantiation::AgentInstance;\nuse crate::entity::RoomConfigContext;\n\n#[derive(Debug)]\npub struct Manager {\n    static_agents: Vec<AgentInstance>,\n}\n\nimpl Manager {\n    pub fn new(static_agent_definitions: Vec<AgentDefinition>) -> anyhow::Result<Self> {\n        let mut static_agents = Vec::with_capacity(static_agent_definitions.len());\n\n        for definition in static_agent_definitions {\n            let identifier = PublicIdentifier::Static(definition.id.clone());\n\n            match instantiation::create(identifier.clone(), definition.to_owned()) {\n                Ok(instance) => static_agents.push(instance),\n                Err(e) => {\n                    return Err(anyhow::anyhow!(\n                        \"Failed to create static agent {}: {:?}\",\n                        identifier,\n                        e\n                    ));\n                }\n            }\n        }\n\n        Ok(Self { static_agents })\n    }\n\n    pub fn available_room_agents_by_room_config_context(\n        &self,\n        room_config_context: &RoomConfigContext,\n    ) -> Vec<AgentInstance> {\n        let mut agents: Vec<AgentInstance> = vec![];\n\n        for agent in &self.static_agents {\n            agents.push(agent.clone());\n        }\n\n        for definition in &room_config_context.global_config.agents {\n            let identifier = PublicIdentifier::DynamicGlobal(definition.id.clone());\n\n            match instantiation::create(identifier.clone(), definition.to_owned()) {\n                Ok(instance) => agents.push(instance),\n                Err(e) => {\n                    tracing::warn!(\"Failed to create {} agent: {:?}. Skipping.\", identifier, e);\n                }\n            }\n        }\n\n        for definition in &room_config_context.room_config.agents {\n            let identifier = PublicIdentifier::DynamicRoomLocal(definition.id.clone());\n\n            match instantiation::create(identifier.clone(), definition.to_owned()) {\n                Ok(instance) => agents.push(instance),\n                Err(e) => {\n                    tracing::warn!(\"Failed to create {} agent: {:?}. Skipping.\", identifier, e);\n                }\n            }\n        }\n\n        agents\n    }\n}\n"
  },
  {
    "path": "src/agent/mod.rs",
    "content": "mod definition;\nmod identifier;\nmod instantiation;\nmod manager;\npub mod provider;\nmod purpose;\npub mod utils;\n\npub use identifier::PublicIdentifier;\npub use manager::Manager;\n\npub use definition::AgentDefinition;\n\npub use instantiation::AgentInstance;\npub use instantiation::Error as AgentInstantiationError;\npub use instantiation::Result as AgentInstantiationResult;\npub use instantiation::create_from_provider_and_yaml_value_config;\npub use instantiation::default_config_for_provider;\n\npub use provider::{AgentProvider, AgentProviderInfo, ControllerTrait};\npub use purpose::AgentPurpose;\n\npub(super) fn default_prompt() -> &'static str {\n    \"You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}.\"\n}\n"
  },
  {
    "path": "src/agent/provider/anthropic/config.rs",
    "content": "use serde::{Deserialize, Serialize};\n\nuse crate::agent::{default_prompt, provider::ConfigTrait};\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Config {\n    pub base_url: String,\n\n    pub api_key: String,\n\n    pub text_generation: Option<TextGenerationConfig>,\n}\n\nimpl Default for Config {\n    fn default() -> Self {\n        Self {\n            base_url: \"https://api.anthropic.com/v1\".to_owned(),\n            api_key: \"YOUR_API_KEY_HERE\".to_owned(),\n            text_generation: Some(TextGenerationConfig::default()),\n        }\n    }\n}\n\nimpl ConfigTrait for Config {\n    fn validate(&self) -> Result<(), String> {\n        if self.base_url.is_empty() {\n            return Err(\"The base URL must not be empty.\".to_owned());\n        }\n        if !self.base_url.ends_with(\"/v1\") {\n            return Err(\"The base URL must end with '/v1'.\".to_owned());\n        }\n        if self.api_key.is_empty() {\n            return Err(\"The API key must not be empty.\".to_owned());\n        }\n\n        Ok(())\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TextGenerationConfig {\n    #[serde(default = \"default_text_model_id\")]\n    pub model_id: String,\n\n    #[serde(default)]\n    pub prompt: Option<String>,\n\n    #[serde(default = \"super::super::default_temperature\")]\n    pub temperature: f32,\n\n    #[serde(default)]\n    pub max_response_tokens: u32,\n\n    #[serde(default)]\n    pub max_context_tokens: u32,\n}\n\nimpl Default for TextGenerationConfig {\n    fn default() -> Self {\n        Self {\n            model_id: default_text_model_id(),\n            prompt: Some(default_prompt().to_owned()),\n            temperature: super::super::default_temperature(),\n            max_response_tokens: 8192,\n            max_context_tokens: 204_800,\n        }\n    }\n}\n\nfn default_text_model_id() -> String {\n    \"claude-3-7-sonnet-20250219\".to_owned()\n}\n"
  },
  {
    "path": "src/agent/provider/anthropic/controller.rs",
    "content": "use std::fmt::Debug;\nuse std::sync::Arc;\n\nuse anthropic::client::{Client, ClientBuilder};\nuse anthropic::types::ContentBlock;\n\nuse super::super::ControllerTrait;\nuse crate::agent::AgentPurpose;\nuse crate::agent::provider::entity::{\n    ImageEditResult, ImageGenerationResult, ImageSource, PingResult, TextGenerationParams,\n    TextGenerationResult, TextToSpeechParams, TextToSpeechResult,\n};\nuse crate::agent::provider::{\n    ImageEditParams, ImageGenerationParams, SpeechToTextParams, SpeechToTextResult,\n};\nuse crate::conversation::llm::{\n    Author as LLMAuthor, Conversation as LLMConversation, Message as LLMMessage,\n    MessageContent as LLMMessageContent, shorten_messages_list_to_context_size,\n};\nuse crate::strings;\n\nuse super::config::Config;\n\nstruct ControllerInner {\n    client: Client,\n}\n\n#[derive(Clone)]\npub struct Controller {\n    config: Config,\n    inner: Arc<ControllerInner>,\n}\n\nimpl Debug for Controller {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"Controller\")\n            .field(\"config\", &self.config)\n            .finish()\n    }\n}\n\nimpl Controller {\n    pub fn new(config: Config) -> anyhow::Result<Self> {\n        // The previous library that we used expected a base URL that ends with \"/v1\"\n        // (e.g. \"https://api.anthropic.com/v1\"), while the new one doesn't.\n        //\n        // To keep backward compatibility, we don't ask people to change their configuration\n        // and rather adapt by removing the \"/v1\" from the base URL.\n        if !config.base_url.ends_with(\"/v1\") {\n            return Err(anyhow::anyhow!(\"base_url must end with '/v1'\"));\n        }\n\n        let base_url = &config.base_url[..config.base_url.len() - 3];\n        let client = ClientBuilder::default()\n            .api_base(base_url.to_string())\n            .api_key(config.api_key.clone())\n            .build()?;\n\n        Ok(Self {\n            config,\n            inner: Arc::new(ControllerInner { client }),\n        })\n    }\n}\n\nimpl ControllerTrait for Controller {\n    async fn ping(&self) -> anyhow::Result<PingResult> {\n        if !self.supports_purpose(AgentPurpose::TextGeneration) {\n            return Ok(PingResult::Inconclusive);\n        }\n\n        let messages = vec![LLMMessage {\n            author: LLMAuthor::User,\n            sender_id: None,\n            content: LLMMessageContent::Text(\"Hello!\".to_string()),\n            timestamp: chrono::Utc::now(),\n        }];\n\n        let conversation = LLMConversation { messages };\n\n        self.generate_text(conversation, TextGenerationParams::default())\n            .await?;\n\n        Ok(PingResult::Successful)\n    }\n\n    async fn generate_text(\n        &self,\n        conversation: LLMConversation,\n        params: TextGenerationParams,\n    ) -> anyhow::Result<TextGenerationResult> {\n        let Some(text_generation_config) = &self.config.text_generation else {\n            return Err(anyhow::anyhow!(\n                strings::agent::no_configuration_for_purpose_so_cannot_be_used(\n                    &AgentPurpose::TextGeneration\n                ),\n            ));\n        };\n\n        let prompt_text = params.prompt_variables.format(\n            params\n                .prompt_override\n                .unwrap_or(self.text_generation_prompt().unwrap_or(\"\".to_owned()))\n                .trim(),\n        );\n\n        let prompt_message = if prompt_text.is_empty() {\n            None\n        } else {\n            Some(LLMMessage {\n                author: LLMAuthor::Prompt,\n                sender_id: None,\n                content: LLMMessageContent::Text(prompt_text),\n                timestamp: chrono::Utc::now(),\n            })\n        };\n\n        // Avoid the situation where multiple user or assistant messages are sent consecutively,\n        // to avoid errors like:\n        // > API error: Error response: error Api error: invalid_request_error messages: roles must alternate between \"user\" and \"assistant\", but found multiple \"user\" roles in a row\n        // as reported here: https://github.com/etkecc/baibot/issues/13\n        //\n        // As https://docs.anthropic.com/en/api/messages says:\n        // > Our models are trained to operate on alternating user and assistant conversational turns.\n        let conversation = conversation.combine_consecutive_messages();\n\n        let mut conversation_messages = conversation.messages;\n\n        if params.context_management_enabled {\n            tracing::trace!(\"Shortening messages list to context size\");\n\n            conversation_messages = shorten_messages_list_to_context_size(\n                &text_generation_config.model_id,\n                &prompt_message,\n                conversation_messages,\n                Some(text_generation_config.max_response_tokens),\n                text_generation_config.max_context_tokens,\n            );\n\n            tracing::trace!(\"Finished shortening messages list to context size\");\n        };\n\n        let messages_count = conversation_messages.len();\n\n        let mut request = super::utils::create_anthropic_message_request(conversation_messages);\n\n        let temperature = params\n            .temperature_override\n            .unwrap_or(text_generation_config.temperature);\n\n        if let Some(prompt_message) = prompt_message\n            && let LLMMessageContent::Text(text) = &prompt_message.content\n        {\n            request.system = text.clone();\n        }\n\n        request.model = text_generation_config.model_id.clone();\n        request.temperature = Some(temperature as f64);\n        request.max_tokens = text_generation_config.max_response_tokens as usize;\n\n        if let Ok(request_as_json) = serde_json::to_string(&request) {\n            tracing::trace!(\n                model = format!(\"{:?}\", request.model),\n                ?messages_count,\n                request = request_as_json,\n                \"Sending Anthropic create message API request\"\n            );\n        }\n\n        let response = self.inner.client.messages(request).await?;\n\n        tracing::trace!(?response, \"Got response from Anthropic create message API\");\n\n        // response.content usually contains a single element, but we support handling multiple to account for all possibilities\n        let mut text_parts = vec![];\n        for content in response.content {\n            match content {\n                ContentBlock::Text { text } => {\n                    text_parts.push(text);\n                }\n                ContentBlock::Image { .. } => {\n                    text_parts.push(\"The model responded with an image\".to_string());\n                }\n            }\n        }\n\n        if text_parts.is_empty() {\n            return Err(anyhow::anyhow!(\n                \"No text content in response from the Anthropic create message API\"\n            ));\n        }\n\n        Ok(TextGenerationResult {\n            text: text_parts.join(\"\\n\\n\"),\n        })\n    }\n\n    async fn speech_to_text(\n        &self,\n        _mime_type: &mxlink::mime::Mime,\n        _media: Vec<u8>,\n        _params: SpeechToTextParams,\n    ) -> anyhow::Result<SpeechToTextResult> {\n        Err(anyhow::anyhow!(\"Speech-to-Text not supported\"))\n    }\n\n    async fn generate_image(\n        &self,\n        _prompt: &str,\n        _params: ImageGenerationParams,\n    ) -> anyhow::Result<ImageGenerationResult> {\n        Err(anyhow::anyhow!(\"Image generation not supported\"))\n    }\n\n    async fn create_image_edit(\n        &self,\n        _prompt: &str,\n        _images: Vec<ImageSource>,\n        _params: ImageEditParams,\n    ) -> anyhow::Result<ImageEditResult> {\n        Err(anyhow::anyhow!(\"Image editing is not supported\"))\n    }\n\n    async fn text_to_speech(\n        &self,\n        _input: &str,\n        _params: TextToSpeechParams,\n    ) -> anyhow::Result<TextToSpeechResult> {\n        Err(anyhow::anyhow!(\"Speech generation not supported\"))\n    }\n\n    fn supports_purpose(&self, purpose: AgentPurpose) -> bool {\n        match purpose {\n            AgentPurpose::TextGeneration => self.config.text_generation.is_some(),\n            AgentPurpose::SpeechToText => false,\n            AgentPurpose::TextToSpeech => false,\n            AgentPurpose::ImageGeneration => false,\n            AgentPurpose::CatchAll => true,\n        }\n    }\n\n    fn text_generation_model_id(&self) -> Option<String> {\n        self.config\n            .text_generation\n            .as_ref()\n            .map(|config| config.model_id.to_owned())\n    }\n\n    fn text_generation_prompt(&self) -> Option<String> {\n        self.config\n            .text_generation\n            .as_ref()\n            .and_then(|config| config.prompt.clone())\n    }\n\n    fn text_generation_temperature(&self) -> Option<f32> {\n        self.config\n            .text_generation\n            .as_ref()\n            .map(|config| config.temperature)\n    }\n\n    fn text_to_speech_voice(&self) -> Option<String> {\n        None\n    }\n\n    fn text_to_speech_speed(&self) -> Option<f32> {\n        None\n    }\n}\n"
  },
  {
    "path": "src/agent/provider/anthropic/mod.rs",
    "content": "mod config;\nmod controller;\nmod utils;\n\npub use config::Config;\npub use controller::Controller;\n\nuse super::super::AgentInstantiationError;\nuse super::super::AgentInstantiationResult;\nuse super::ConfigTrait;\nuse super::controller::ControllerType;\n\npub fn create_controller_from_yaml_value_config(\n    agent_id: &str,\n    config: serde_yaml_ng::Value,\n) -> AgentInstantiationResult<ControllerType> {\n    let config = match &config {\n        serde_yaml_ng::Value::Mapping(_) => {\n            let config: Config =\n                serde_yaml_ng::from_value(config).map_err(AgentInstantiationError::Yaml)?;\n\n            config\n                .validate()\n                .map_err(AgentInstantiationError::ConfigFailsValidation)?;\n\n            config\n        }\n        _ => {\n            return Err(AgentInstantiationError::ConfigForAgentIsNotAMapping(\n                agent_id.to_owned(),\n            ));\n        }\n    };\n\n    let controller =\n        Controller::new(config).map_err(AgentInstantiationError::ConstructionFailed)?;\n\n    Ok(ControllerType::Anthropic(Box::new(controller)))\n}\n\npub fn default_config() -> Config {\n    Config::default()\n}\n"
  },
  {
    "path": "src/agent/provider/anthropic/utils.rs",
    "content": "use anthropic::types::{\n    ContentBlock, ImageSource, Message, MessagesRequest, MessagesRequestBuilder, Role,\n};\n\nuse crate::conversation::llm::{\n    Author as LLMAuthor, Message as LLMMessage, MessageContent as LLMMessageContent,\n};\n\npub(super) fn create_anthropic_message_request(llm_messages: Vec<LLMMessage>) -> MessagesRequest {\n    let mut messages = vec![];\n\n    for message in llm_messages {\n        let role = match message.author {\n            LLMAuthor::User => Role::User,\n            LLMAuthor::Assistant => Role::Assistant,\n            LLMAuthor::Prompt => {\n                continue;\n            }\n        };\n\n        let content = match &message.content {\n            LLMMessageContent::Text(text) => vec![ContentBlock::Text { text: text.clone() }],\n            LLMMessageContent::Image(image_details) => {\n                vec![ContentBlock::Image {\n                    source: ImageSource::Base64 {\n                        media_type: image_details.mime.to_string(),\n                        data: crate::utils::base64::base64_encode(&image_details.data),\n                    },\n                }]\n            }\n            LLMMessageContent::File(file_details) => {\n                tracing::warn!(\n                    \"The Anthropic provider's library does not support file/document content. This file message ({}) will be skipped.\",\n                    file_details.filename(),\n                );\n                continue;\n            }\n        };\n\n        let message = Message { role, content };\n\n        messages.push(message);\n    }\n\n    MessagesRequestBuilder::default()\n        .messages(messages)\n        .stream(false)\n        .build()\n        .expect(\"Failed to build messages request\")\n}\n"
  },
  {
    "path": "src/agent/provider/config.rs",
    "content": "pub trait ConfigTrait {\n    fn validate(&self) -> Result<(), String>;\n}\n"
  },
  {
    "path": "src/agent/provider/controller.rs",
    "content": "use crate::{agent::AgentPurpose, conversation::llm::Conversation};\n\nuse super::{\n    ImageEditParams, ImageGenerationParams, SpeechToTextParams, SpeechToTextResult,\n    entity::{\n        ImageEditResult, ImageGenerationResult, ImageSource, PingResult, TextGenerationParams,\n        TextGenerationResult, TextToSpeechParams, TextToSpeechResult,\n    },\n};\n\npub trait ControllerTrait {\n    fn supports_purpose(&self, purpose: AgentPurpose) -> bool;\n\n    fn ping(&self) -> impl std::future::Future<Output = anyhow::Result<PingResult>> + Send;\n\n    fn text_generation_model_id(&self) -> Option<String>;\n\n    fn text_generation_prompt(&self) -> Option<String>;\n\n    fn text_generation_temperature(&self) -> Option<f32>;\n\n    fn text_to_speech_voice(&self) -> Option<String>;\n\n    fn text_to_speech_speed(&self) -> Option<f32>;\n\n    fn generate_text(\n        &self,\n        conversation: Conversation,\n        params: TextGenerationParams,\n    ) -> impl std::future::Future<Output = anyhow::Result<TextGenerationResult>> + Send;\n\n    fn speech_to_text(\n        &self,\n        mime_type: &mxlink::mime::Mime,\n        media: Vec<u8>,\n        params: SpeechToTextParams,\n    ) -> impl std::future::Future<Output = anyhow::Result<SpeechToTextResult>> + Send;\n\n    fn generate_image(\n        &self,\n        prompt: &str,\n        params: ImageGenerationParams,\n    ) -> impl std::future::Future<Output = anyhow::Result<ImageGenerationResult>> + Send;\n\n    fn create_image_edit(\n        &self,\n        prompt: &str,\n        images: Vec<ImageSource>,\n        params: ImageEditParams,\n    ) -> impl std::future::Future<Output = anyhow::Result<ImageEditResult>> + Send;\n\n    fn text_to_speech(\n        &self,\n        text: &str,\n        params: TextToSpeechParams,\n    ) -> impl std::future::Future<Output = anyhow::Result<TextToSpeechResult>> + Send;\n}\n\n#[derive(Debug, Clone)]\npub enum ControllerType {\n    OpenAI(Box<super::openai::Controller>),\n    OpenAICompat(Box<super::openai_compat::Controller>),\n    Anthropic(Box<super::anthropic::Controller>),\n}\n\nimpl ControllerTrait for ControllerType {\n    fn supports_purpose(&self, purpose: AgentPurpose) -> bool {\n        match &self {\n            ControllerType::OpenAI(controller) => controller.supports_purpose(purpose),\n            ControllerType::OpenAICompat(controller) => controller.supports_purpose(purpose),\n            ControllerType::Anthropic(controller) => controller.supports_purpose(purpose),\n        }\n    }\n\n    fn text_generation_model_id(&self) -> Option<String> {\n        match &self {\n            ControllerType::OpenAI(controller) => controller.text_generation_model_id(),\n            ControllerType::OpenAICompat(controller) => controller.text_generation_model_id(),\n            ControllerType::Anthropic(controller) => controller.text_generation_model_id(),\n        }\n    }\n\n    fn text_generation_prompt(&self) -> Option<String> {\n        match &self {\n            ControllerType::OpenAI(controller) => controller.text_generation_prompt(),\n            ControllerType::OpenAICompat(controller) => controller.text_generation_prompt(),\n            ControllerType::Anthropic(controller) => controller.text_generation_prompt(),\n        }\n    }\n\n    fn text_to_speech_voice(&self) -> Option<String> {\n        match &self {\n            ControllerType::OpenAI(controller) => controller.text_to_speech_voice(),\n            ControllerType::OpenAICompat(controller) => controller.text_to_speech_voice(),\n            ControllerType::Anthropic(controller) => controller.text_to_speech_voice(),\n        }\n    }\n\n    fn text_to_speech_speed(&self) -> Option<f32> {\n        match &self {\n            ControllerType::OpenAI(controller) => controller.text_to_speech_speed(),\n            ControllerType::OpenAICompat(controller) => controller.text_to_speech_speed(),\n            ControllerType::Anthropic(controller) => controller.text_to_speech_speed(),\n        }\n    }\n\n    fn text_generation_temperature(&self) -> Option<f32> {\n        match &self {\n            ControllerType::OpenAI(controller) => controller.text_generation_temperature(),\n            ControllerType::OpenAICompat(controller) => controller.text_generation_temperature(),\n            ControllerType::Anthropic(controller) => controller.text_generation_temperature(),\n        }\n    }\n\n    async fn ping(&self) -> anyhow::Result<PingResult> {\n        match &self {\n            ControllerType::OpenAI(controller) => controller.ping().await,\n            ControllerType::OpenAICompat(controller) => controller.ping().await,\n            ControllerType::Anthropic(controller) => controller.ping().await,\n        }\n    }\n\n    async fn generate_text(\n        &self,\n        conversation: Conversation,\n        params: TextGenerationParams,\n    ) -> anyhow::Result<TextGenerationResult> {\n        match &self {\n            ControllerType::OpenAI(controller) => {\n                controller.generate_text(conversation, params).await\n            }\n            ControllerType::OpenAICompat(controller) => {\n                controller.generate_text(conversation, params).await\n            }\n            ControllerType::Anthropic(controller) => {\n                controller.generate_text(conversation, params).await\n            }\n        }\n    }\n\n    async fn speech_to_text(\n        &self,\n        mime_type: &mxlink::mime::Mime,\n        media: Vec<u8>,\n        params: SpeechToTextParams,\n    ) -> anyhow::Result<SpeechToTextResult> {\n        match &self {\n            ControllerType::OpenAI(controller) => {\n                controller.speech_to_text(mime_type, media, params).await\n            }\n            ControllerType::OpenAICompat(controller) => {\n                controller.speech_to_text(mime_type, media, params).await\n            }\n            ControllerType::Anthropic(controller) => {\n                controller.speech_to_text(mime_type, media, params).await\n            }\n        }\n    }\n\n    async fn generate_image(\n        &self,\n        prompt: &str,\n        params: ImageGenerationParams,\n    ) -> anyhow::Result<ImageGenerationResult> {\n        match &self {\n            ControllerType::OpenAI(controller) => controller.generate_image(prompt, params).await,\n            ControllerType::OpenAICompat(controller) => {\n                controller.generate_image(prompt, params).await\n            }\n            ControllerType::Anthropic(controller) => {\n                controller.generate_image(prompt, params).await\n            }\n        }\n    }\n\n    async fn create_image_edit(\n        &self,\n        prompt: &str,\n        images: Vec<ImageSource>,\n        params: ImageEditParams,\n    ) -> anyhow::Result<ImageEditResult> {\n        match &self {\n            ControllerType::OpenAI(controller) => {\n                controller.create_image_edit(prompt, images, params).await\n            }\n            ControllerType::OpenAICompat(controller) => {\n                controller.create_image_edit(prompt, images, params).await\n            }\n            ControllerType::Anthropic(controller) => {\n                controller.create_image_edit(prompt, images, params).await\n            }\n        }\n    }\n\n    async fn text_to_speech(\n        &self,\n        text: &str,\n        params: TextToSpeechParams,\n    ) -> anyhow::Result<TextToSpeechResult> {\n        match &self {\n            ControllerType::OpenAI(controller) => controller.text_to_speech(text, params).await,\n            ControllerType::OpenAICompat(controller) => {\n                controller.text_to_speech(text, params).await\n            }\n            ControllerType::Anthropic(controller) => controller.text_to_speech(text, params).await,\n        }\n    }\n}\n"
  },
  {
    "path": "src/agent/provider/entity/agent_provider.rs",
    "content": "use crate::agent::AgentPurpose;\n\n#[derive(Debug, Clone)]\npub enum AgentProvider {\n    Anthropic,\n    Groq,\n    LocalAI,\n    Mistral,\n    Ollama,\n    OpenAI,\n    OpenAICompat,\n    OpenRouter,\n    TogetherAI,\n}\n\nimpl AgentProvider {\n    pub fn choices() -> Vec<&'static Self> {\n        vec![\n            &Self::Anthropic,\n            &Self::Groq,\n            &Self::LocalAI,\n            &Self::Mistral,\n            &Self::Ollama,\n            &Self::OpenAI,\n            &Self::OpenAICompat,\n            &Self::OpenRouter,\n            &Self::TogetherAI,\n        ]\n    }\n\n    pub fn to_static_str(&self) -> &'static str {\n        match &self {\n            Self::Anthropic => \"anthropic\",\n            Self::Groq => \"groq\",\n            Self::LocalAI => \"localai\",\n            Self::Mistral => \"mistral\",\n            Self::Ollama => \"ollama\",\n            Self::OpenAI => \"openai\",\n            Self::OpenAICompat => \"openai-compatible\",\n            Self::OpenRouter => \"openrouter\",\n            Self::TogetherAI => \"together-ai\",\n        }\n    }\n\n    pub fn from_string(s: &str) -> Result<Self, &'static str> {\n        match s {\n            \"anthropic\" => Ok(Self::Anthropic),\n            \"groq\" => Ok(Self::Groq),\n            \"localai\" => Ok(Self::LocalAI),\n            \"mistral\" => Ok(Self::Mistral),\n            \"ollama\" => Ok(Self::Ollama),\n            \"openai\" => Ok(Self::OpenAI),\n            \"openai-compatible\" => Ok(Self::OpenAICompat),\n            \"openrouter\" => Ok(Self::OpenRouter),\n            \"together-ai\" => Ok(Self::TogetherAI),\n            _ => Err(\"Unexpected string value\"),\n        }\n    }\n\n    pub fn info(&self) -> AgentProviderInfo {\n        match &self {\n            Self::Anthropic => AgentProviderInfo {\n                id: Self::Anthropic.to_static_str(),\n                name: \"Anthropic\",\n                description: \"Anthropic is an American AI company founded by former OpenAI engineers and providing powerful language models.\",\n                homepage_url: Some(\"https://www.anthropic.com/\"),\n                wiki_url: Some(\"https://en.wikipedia.org/wiki/Anthropic\"),\n                sign_up_url: Some(\"https://console.anthropic.com/\"),\n                models_list_url: Some(\"https://docs.anthropic.com/en/docs/about-claude/models\"),\n                supported_purposes: vec![AgentPurpose::TextGeneration],\n                text_generation_supports_vision: true,\n                text_generation_supports_tools: false,\n            },\n            Self::Groq => AgentProviderInfo {\n                id: Self::Groq.to_static_str(),\n                name: \"Groq\",\n                description: \"Groq is an American company developing optimized Language Processing Units (LPU) and offering cloud service which runs various models (built by others) with very high performance.\",\n                homepage_url: Some(\"https://groq.com/\"),\n                wiki_url: Some(\"https://en.wikipedia.org/wiki/Groq\"),\n                sign_up_url: Some(\"https://console.groq.com/login\"),\n                models_list_url: Some(\"https://console.groq.com/docs/models\"),\n                supported_purposes: vec![AgentPurpose::TextGeneration, AgentPurpose::SpeechToText],\n                text_generation_supports_vision: false,\n                text_generation_supports_tools: false,\n            },\n            Self::LocalAI => AgentProviderInfo {\n                id: Self::LocalAI.to_static_str(),\n                name: \"LocalAI\",\n                description: \"LocalAI is the free, Open Source OpenAI alternative. LocalAI act as a drop-in replacement REST API that's compatible with OpenAI API specifications for local inferencing. It allows you to run LLMs, generate images, audio (and not only) locally or on-prem with consumer grade hardware, supporting multiple model families and architectures.\",\n                homepage_url: Some(\"https://localai.io/\"),\n                wiki_url: None,\n                sign_up_url: None,\n                models_list_url: Some(\"https://localai.io/gallery.html\"),\n                supported_purposes: vec![\n                    AgentPurpose::TextGeneration,\n                    AgentPurpose::TextToSpeech,\n                    AgentPurpose::SpeechToText,\n                ],\n                text_generation_supports_vision: false,\n                text_generation_supports_tools: false,\n            },\n            Self::Mistral => AgentProviderInfo {\n                id: Self::Mistral.to_static_str(),\n                name: \"Mistral\",\n                description: \"Mistral AI is a research lab based in Europe (France) which produces their own language models.\",\n                homepage_url: Some(\"https://mistral.ai/\"),\n                wiki_url: Some(\"https://en.wikipedia.org/wiki/Mistral_AI\"),\n                sign_up_url: Some(\"https://auth.mistral.ai/ui/registration\"),\n                models_list_url: Some(\"https://docs.mistral.ai/getting-started/models/\"),\n                supported_purposes: vec![AgentPurpose::TextGeneration],\n                text_generation_supports_vision: false,\n                text_generation_supports_tools: false,\n            },\n            Self::Ollama => AgentProviderInfo {\n                id: Self::Ollama.to_static_str(),\n                name: \"Ollama\",\n                description: \"Ollama lets you run various models in a [self-hosted](https://github.com/ollama/ollama?tab=readme-ov-file#ollama) way. This is more advanced and requires powerful hardware for running some of the better models, but ensures your data stays with you.\",\n                homepage_url: Some(\"https://ollama.com/\"),\n                wiki_url: None,\n                sign_up_url: None,\n                models_list_url: Some(\"https://ollama.com/library\"),\n                supported_purposes: vec![AgentPurpose::TextGeneration],\n                text_generation_supports_vision: false,\n                text_generation_supports_tools: false,\n            },\n            Self::OpenAI => AgentProviderInfo {\n                id: Self::OpenAI.to_static_str(),\n                name: \"OpenAI\",\n                description: \"OpenAI is an American AI company providing powerful language models.\\n\\nUse this provider either with the OpenAI API or with other OpenAI-compatible API services which **fully** adhere to the [OpenAI API spec](https://github.com/openai/openai-openapi/).\\nFor services which are not fully compatible with the OpenAI API, consider using the **OpenAI Compatible** provider.\",\n                homepage_url: Some(\"https://openai.com/\"),\n                wiki_url: Some(\"https://en.wikipedia.org/wiki/OpenAI\"),\n                sign_up_url: Some(\"https://platform.openai.com/signup\"),\n                models_list_url: Some(\"https://platform.openai.com/docs/models\"),\n                supported_purposes: vec![\n                    AgentPurpose::ImageGeneration,\n                    AgentPurpose::TextGeneration,\n                    AgentPurpose::TextToSpeech,\n                    AgentPurpose::SpeechToText,\n                ],\n                text_generation_supports_vision: true,\n                text_generation_supports_tools: true,\n            },\n            Self::OpenAICompat => AgentProviderInfo {\n                id: Self::OpenAICompat.to_static_str(),\n                name: \"OpenAI Compatible\",\n                description: \"This provider allows you to use OpenAI-compatible API services like [OpenRouter](https://openrouter.ai/), [Together AI](https://www.together.ai/), etc.\\n\\nSome of these popular services already have **shortcut** providers (leading to this one behind the scenes) - this make it easier to get started.\\n\\nThis provider just as featureful as the **OpenAI** provider, but is more compatible with services which do not fully adhere to the [OpenAI API spec](https://github.com/openai/openai-openapi/).\",\n                homepage_url: None,\n                wiki_url: None,\n                sign_up_url: None,\n                models_list_url: None,\n                supported_purposes: vec![\n                    AgentPurpose::ImageGeneration,\n                    AgentPurpose::TextGeneration,\n                    AgentPurpose::TextToSpeech,\n                    AgentPurpose::SpeechToText,\n                ],\n                text_generation_supports_vision: false,\n                text_generation_supports_tools: false,\n            },\n            Self::OpenRouter => AgentProviderInfo {\n                id: Self::OpenRouter.to_static_str(),\n                name: \"OpenRouter\",\n                description: \"OpenRouter is a unified interface for LLMs. The platform scouts for the lowest prices and best latencies/throughputs across dozens of providers, and lets you choose how to [prioritize](https://openrouter.ai/docs/provider-routing) them.\",\n                homepage_url: Some(\"https://openrouter.ai/\"),\n                wiki_url: None,\n                sign_up_url: Some(\"https://openrouter.ai/\"),\n                models_list_url: Some(\"https://openrouter.ai/models\"),\n                supported_purposes: vec![AgentPurpose::TextGeneration],\n                text_generation_supports_vision: false,\n                text_generation_supports_tools: false,\n            },\n            Self::TogetherAI => AgentProviderInfo {\n                id: Self::TogetherAI.to_static_str(),\n                name: \"Together AI\",\n                description: \"Together AI makes it easy to run or [fine-tune](https://docs.together.ai/docs/fine-tuning-overview) leading open source models with only a few lines of code.\",\n                homepage_url: Some(\"https://www.together.ai/\"),\n                wiki_url: None,\n                sign_up_url: Some(\"https://api.together.ai/signup\"),\n                models_list_url: Some(\"https://api.together.xyz/models\"),\n                supported_purposes: vec![AgentPurpose::TextGeneration],\n                text_generation_supports_vision: false,\n                text_generation_supports_tools: false,\n            },\n        }\n    }\n}\n\nimpl std::fmt::Display for AgentProvider {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", self.to_static_str())\n    }\n}\n\npub struct AgentProviderInfo {\n    pub id: &'static str,\n    pub name: &'static str,\n    pub description: &'static str,\n    pub homepage_url: Option<&'static str>,\n    pub wiki_url: Option<&'static str>,\n    pub sign_up_url: Option<&'static str>,\n    pub models_list_url: Option<&'static str>,\n    pub supported_purposes: Vec<AgentPurpose>,\n    pub text_generation_supports_vision: bool,\n    pub text_generation_supports_tools: bool,\n}\n"
  },
  {
    "path": "src/agent/provider/entity/image.rs",
    "content": "use mxlink::mime;\n\n#[derive(Default)]\npub struct ImageGenerationParams {\n    pub smallest_size_possible: bool,\n\n    pub cheaper_model_switching_allowed: bool,\n\n    pub cheaper_quality_switching_allowed: bool,\n}\n\nimpl ImageGenerationParams {\n    pub fn with_smallest_size_possible(mut self, value: bool) -> Self {\n        self.smallest_size_possible = value;\n        self\n    }\n\n    pub fn with_cheaper_model_switching_allowed(mut self, value: bool) -> Self {\n        self.cheaper_model_switching_allowed = value;\n        self\n    }\n\n    pub fn with_cheaper_quality_switching_allowed(mut self, value: bool) -> Self {\n        self.cheaper_quality_switching_allowed = value;\n        self\n    }\n}\n\npub struct ImageGenerationResult {\n    pub bytes: Vec<u8>,\n    pub mime_type: mime::Mime,\n    pub revised_prompt: Option<String>,\n}\n\n#[derive(Default)]\npub struct ImageEditParams {}\n\npub struct ImageEditResult {\n    pub bytes: Vec<u8>,\n    pub mime_type: mime::Mime,\n}\n\npub struct ImageSource {\n    pub filename: String,\n    pub bytes: Vec<u8>,\n    pub mime_type: mime::Mime,\n}\n\nimpl ImageSource {\n    pub fn new(filename: String, bytes: Vec<u8>, mime_type: mime::Mime) -> Self {\n        Self {\n            filename,\n            bytes,\n            mime_type,\n        }\n    }\n}\n\nimpl From<ImageSource> for async_openai::types::images::ImageInput {\n    fn from(value: ImageSource) -> Self {\n        async_openai::types::images::ImageInput::from_vec_u8(value.filename, value.bytes)\n    }\n}\n"
  },
  {
    "path": "src/agent/provider/entity/mod.rs",
    "content": "mod agent_provider;\nmod image;\nmod ping;\nmod speech_to_text;\nmod text_generation;\nmod text_to_speech;\n\npub use agent_provider::{AgentProvider, AgentProviderInfo};\npub use image::{\n    ImageEditParams, ImageEditResult, ImageGenerationParams, ImageGenerationResult, ImageSource,\n};\npub use ping::PingResult;\npub use speech_to_text::{SpeechToTextParams, SpeechToTextResult};\npub use text_generation::{\n    TextGenerationParams, TextGenerationPromptVariables, TextGenerationResult,\n};\npub use text_to_speech::{TextToSpeechParams, TextToSpeechResult};\n"
  },
  {
    "path": "src/agent/provider/entity/ping.rs",
    "content": "pub enum PingResult {\n    Inconclusive,\n    Successful,\n}\n"
  },
  {
    "path": "src/agent/provider/entity/speech_to_text.rs",
    "content": "#[derive(Default)]\npub struct SpeechToTextParams {\n    pub language_override: Option<String>,\n}\n\npub struct SpeechToTextResult {\n    pub text: String,\n}\n"
  },
  {
    "path": "src/agent/provider/entity/text_generation/mod.rs",
    "content": "mod prompt_variables;\n\npub use prompt_variables::TextGenerationPromptVariables;\n\n#[derive(Default)]\npub struct TextGenerationParams {\n    pub context_management_enabled: bool,\n    pub prompt_override: Option<String>,\n    pub temperature_override: Option<f32>,\n    pub prompt_variables: TextGenerationPromptVariables,\n}\n\npub struct TextGenerationResult {\n    pub text: String,\n}\n"
  },
  {
    "path": "src/agent/provider/entity/text_generation/prompt_variables.rs",
    "content": "use chrono::{DateTime, Utc};\nuse std::collections::HashMap;\n\npub struct TextGenerationPromptVariables {\n    map: HashMap<String, String>,\n}\n\nimpl Default for TextGenerationPromptVariables {\n    fn default() -> Self {\n        let now = Utc::now();\n        Self::new(\"unnamed\", \"unknown-model\", now, Some(now))\n    }\n}\n\nimpl TextGenerationPromptVariables {\n    pub fn new(\n        bot_name: &str,\n        model_id: &str,\n        now_time: DateTime<Utc>,\n        conversation_start_time: Option<DateTime<Utc>>,\n    ) -> Self {\n        let mut map = HashMap::new();\n\n        map.insert(\"baibot_name\".to_string(), bot_name.to_string());\n        map.insert(\"baibot_model_id\".to_string(), model_id.to_string());\n        map.insert(\"baibot_now_utc\".to_string(), format_utc_time(now_time));\n\n        let baibot_conversation_start_time_utc = match conversation_start_time {\n            Some(conversation_start_time) => format_utc_time(conversation_start_time),\n            None => \"unknown\".to_string(),\n        };\n\n        map.insert(\n            \"baibot_conversation_start_time_utc\".to_string(),\n            baibot_conversation_start_time_utc,\n        );\n\n        Self { map }\n    }\n\n    pub fn format(&self, text: &str) -> String {\n        let mut formatted_text = text.to_string();\n\n        for (key, value) in &self.map {\n            let placeholder = format!(\"{{{{ {} }}}}\", key);\n            formatted_text = formatted_text.replace(&placeholder, value);\n        }\n\n        formatted_text\n    }\n}\n\nfn format_utc_time(time: DateTime<Utc>) -> String {\n    time.format(\"%Y-%m-%d (%A), %H:%M:%S UTC\").to_string()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use chrono::{TimeZone, Timelike};\n\n    #[test]\n    fn test_new() {\n        // Intentionally injecting some sub-seconds to ensure formatting would ignore them.\n        let now_utc = Utc\n            .with_ymd_and_hms(2024, 9, 20, 18, 34, 15)\n            .unwrap()\n            .with_nanosecond(250000000)\n            .unwrap();\n\n        let conversation_start_time_utc = Utc\n            .with_ymd_and_hms(2024, 9, 19, 18, 34, 15)\n            .unwrap()\n            .with_nanosecond(250000000)\n            .unwrap();\n\n        let variables = TextGenerationPromptVariables::new(\n            \"baibot\",\n            \"gpt-4o\",\n            now_utc,\n            Some(conversation_start_time_utc),\n        );\n\n        assert_eq!(\n            variables.map.get(\"baibot_name\"),\n            Some(&\"baibot\".to_string())\n        );\n        assert_eq!(\n            variables.map.get(\"baibot_model_id\"),\n            Some(&\"gpt-4o\".to_string())\n        );\n        assert_eq!(\n            variables.map.get(\"baibot_now_utc\"),\n            Some(&format_utc_time(now_utc))\n        );\n        assert_eq!(\n            variables.map.get(\"baibot_conversation_start_time_utc\"),\n            Some(&format_utc_time(conversation_start_time_utc))\n        );\n\n        let prompt = \"Hello, I'm {{ baibot_name }} using {{ baibot_model_id }}. The date/time now is {{ baibot_now_utc }} and this conversation started at {{ baibot_conversation_start_time_utc }}.\";\n        let expected = \"Hello, I'm baibot using gpt-4o. The date/time now is 2024-09-20 (Friday), 18:34:15 UTC and this conversation started at 2024-09-19 (Thursday), 18:34:15 UTC.\";\n\n        assert_eq!(variables.format(prompt), expected);\n    }\n}\n"
  },
  {
    "path": "src/agent/provider/entity/text_to_speech.rs",
    "content": "#[derive(Default)]\npub struct TextToSpeechParams {\n    pub speed_override: Option<f32>,\n    pub voice_override: Option<String>,\n}\n\npub struct TextToSpeechResult {\n    pub bytes: Vec<u8>,\n    pub mime_type: mxlink::mime::Mime,\n}\n"
  },
  {
    "path": "src/agent/provider/groq/mod.rs",
    "content": "// Groq is based on openai_compat, because it's not fully compatible with async-openai.\n\nuse super::openai_compat::Config;\n\npub fn default_config() -> Config {\n    let mut config = Config {\n        base_url: \"https://api.groq.com/openai/v1\".to_owned(),\n\n        text_to_speech: None,\n        image_generation: None,\n\n        ..Default::default()\n    };\n\n    if let Some(ref mut config) = config.text_generation.as_mut() {\n        config.model_id = \"llama3-70b-8192\".to_owned();\n        config.max_context_tokens = 131_072;\n        config.max_response_tokens = Some(4096);\n    }\n\n    if let Some(ref mut config) = config.speech_to_text.as_mut() {\n        config.model_id = \"whisper-large-v3\".to_owned();\n    }\n\n    config\n}\n"
  },
  {
    "path": "src/agent/provider/localai/mod.rs",
    "content": "// At the time of testing, LocalAI can be powered by `openai`, but we use `openai_compat` for better reliability\n// in the event of future updates to `async-openai`.\n\nuse super::openai_compat::Config;\n\npub fn default_config() -> Config {\n    let mut config = Config {\n        base_url: \"http://my-localai-self-hosted-service:8080/v1\".to_owned(),\n\n        ..Default::default()\n    };\n\n    if let Some(ref mut config) = config.text_generation.as_mut() {\n        config.model_id = \"gpt-4\".to_owned();\n        config.max_context_tokens = 128_000;\n        config.max_response_tokens = Some(4096);\n    }\n\n    if let Some(ref mut config) = config.text_to_speech.as_mut() {\n        config.model_id = \"tts-1\".to_owned();\n    }\n\n    if let Some(ref mut config) = config.speech_to_text.as_mut() {\n        config.model_id = \"whisper-1\".to_owned();\n    }\n\n    if let Some(ref mut config) = config.image_generation.as_mut() {\n        config.model_id = \"stablediffusion\".to_owned();\n    }\n\n    config\n}\n"
  },
  {
    "path": "src/agent/provider/mistral/mod.rs",
    "content": "// Mistral is based on openai_compat, because it's not fully compatible with async-openai.\n\nuse super::openai_compat::Config;\n\npub fn default_config() -> Config {\n    let mut config = Config {\n        base_url: \"https://api.mistral.ai/v1\".to_owned(),\n\n        speech_to_text: None,\n        text_to_speech: None,\n        image_generation: None,\n\n        ..Default::default()\n    };\n\n    if let Some(ref mut config) = config.text_generation.as_mut() {\n        config.model_id = \"mistral-large-latest\".to_owned();\n        config.max_context_tokens = 128_000;\n    }\n\n    config\n}\n"
  },
  {
    "path": "src/agent/provider/mod.rs",
    "content": "pub mod anthropic;\nmod config;\nmod controller;\nmod entity;\npub(super) mod groq;\npub mod localai;\npub(super) mod mistral;\npub mod ollama;\npub mod openai;\npub mod openai_compat;\npub(super) mod openrouter;\npub(super) mod togetherai;\n\nfn default_temperature() -> f32 {\n    1.0\n}\n\npub use controller::{ControllerTrait, ControllerType};\n\npub use config::ConfigTrait;\n\npub use entity::{\n    AgentProvider, AgentProviderInfo, ImageEditParams, ImageGenerationParams, ImageSource,\n    PingResult, SpeechToTextParams, SpeechToTextResult, TextGenerationParams,\n    TextGenerationPromptVariables, TextToSpeechParams,\n};\n"
  },
  {
    "path": "src/agent/provider/ollama/mod.rs",
    "content": "// At the time of testing, Ollama can be powered by `openai`, but we use `openai_compat` for better reliability\n// in the event of future updates to `async-openai`.\n\nuse super::openai_compat::Config;\n\npub fn default_config() -> Config {\n    let mut config = Config {\n        base_url: \"http://my-ollama-self-hosted-service:11434/v1\".to_owned(),\n\n        text_to_speech: None,\n        image_generation: None,\n        speech_to_text: None,\n\n        ..Default::default()\n    };\n\n    if let Some(ref mut config) = config.text_generation.as_mut() {\n        config.model_id = \"gemma2:2b\".to_owned();\n        config.max_context_tokens = 128_000;\n        config.max_response_tokens = Some(4096);\n    }\n\n    config\n}\n"
  },
  {
    "path": "src/agent/provider/openai/config.rs",
    "content": "use serde::{Deserialize, Serialize};\n\nuse super::OPENAI_IMAGE_MODEL_GPT_IMAGE_1_DOT_5;\nuse crate::agent::{default_prompt, provider::ConfigTrait};\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Config {\n    pub base_url: String,\n\n    pub api_key: String,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub text_generation: Option<TextGenerationConfig>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub speech_to_text: Option<SpeechToTextConfig>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub text_to_speech: Option<TextToSpeechConfig>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub image_generation: Option<ImageGenerationConfig>,\n}\n\nimpl Default for Config {\n    fn default() -> Self {\n        Self {\n            base_url: \"https://api.openai.com/v1\".to_owned(),\n            api_key: \"YOUR_API_KEY_HERE\".to_owned(),\n            text_generation: Some(TextGenerationConfig::default()),\n            speech_to_text: Some(SpeechToTextConfig::default()),\n            text_to_speech: Some(TextToSpeechConfig::default()),\n            image_generation: Some(ImageGenerationConfig::default()),\n        }\n    }\n}\n\nimpl ConfigTrait for Config {\n    fn validate(&self) -> Result<(), String> {\n        if self.base_url.is_empty() {\n            return Err(\"The base URL must not be empty.\".to_owned());\n        }\n\n        Ok(())\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TextGenerationConfig {\n    #[serde(default = \"default_text_model_id\")]\n    pub model_id: String,\n\n    #[serde(default)]\n    pub prompt: Option<String>,\n\n    #[serde(default = \"super::super::default_temperature\")]\n    pub temperature: f32,\n\n    #[serde(default)]\n    pub max_response_tokens: Option<u32>,\n\n    #[serde(default)]\n    pub max_completion_tokens: Option<u32>,\n\n    #[serde(default)]\n    pub max_context_tokens: u32,\n\n    #[serde(default)]\n    pub tools: ToolsConfig,\n}\n\nimpl Default for TextGenerationConfig {\n    fn default() -> Self {\n        Self {\n            model_id: default_text_model_id(),\n            prompt: Some(default_prompt().to_owned()),\n            temperature: super::super::default_temperature(),\n            max_response_tokens: None,\n            max_completion_tokens: Some(128_000),\n            max_context_tokens: 400_000,\n            tools: ToolsConfig::default(),\n        }\n    }\n}\n\nfn default_text_model_id() -> String {\n    \"gpt-5.4\".to_owned()\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ToolsConfig {\n    #[serde(default)]\n    pub web_search: bool,\n\n    #[serde(default)]\n    pub code_interpreter: bool,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SpeechToTextConfig {\n    #[serde(default = \"default_speech_to_text_model_id\")]\n    pub model_id: String,\n}\n\nimpl Default for SpeechToTextConfig {\n    fn default() -> Self {\n        Self {\n            model_id: default_speech_to_text_model_id(),\n        }\n    }\n}\n\nfn default_speech_to_text_model_id() -> String {\n    \"whisper-1\".to_owned()\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TextToSpeechConfig {\n    #[serde(default = \"default_text_to_speech_model_id\")]\n    pub model_id: async_openai::types::audio::SpeechModel,\n\n    #[serde(default = \"default_text_to_speech_voice\")]\n    pub voice: async_openai::types::audio::Voice,\n\n    #[serde(default = \"default_text_to_speech_speed\")]\n    pub speed: f32,\n\n    #[serde(default = \"default_text_to_speech_response_format\")]\n    pub response_format: async_openai::types::audio::SpeechResponseFormat,\n}\n\nimpl Default for TextToSpeechConfig {\n    fn default() -> Self {\n        Self {\n            model_id: default_text_to_speech_model_id(),\n            voice: default_text_to_speech_voice(),\n            speed: default_text_to_speech_speed(),\n            response_format: default_text_to_speech_response_format(),\n        }\n    }\n}\n\nfn default_text_to_speech_model_id() -> async_openai::types::audio::SpeechModel {\n    async_openai::types::audio::SpeechModel::Tts1Hd\n}\n\nfn default_text_to_speech_voice() -> async_openai::types::audio::Voice {\n    async_openai::types::audio::Voice::Onyx\n}\n\nfn default_text_to_speech_speed() -> f32 {\n    1.0\n}\n\nfn default_text_to_speech_response_format() -> async_openai::types::audio::SpeechResponseFormat {\n    // The API defaults to mp3, but we prefer Opus because it's smaller.\n    // Our clients should all have support for it.\n    async_openai::types::audio::SpeechResponseFormat::Opus\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ImageGenerationConfig {\n    pub model_id: String,\n\n    #[serde(default = \"default_image_style\")]\n    pub style: Option<async_openai::types::images::ImageStyle>,\n\n    #[serde(default = \"default_image_size\")]\n    pub size: Option<async_openai::types::images::ImageSize>,\n\n    #[serde(default = \"default_image_quality\")]\n    pub quality: Option<async_openai::types::images::ImageQuality>,\n}\n\nimpl Default for ImageGenerationConfig {\n    fn default() -> Self {\n        Self {\n            model_id: OPENAI_IMAGE_MODEL_GPT_IMAGE_1_DOT_5.to_owned(),\n            style: default_image_style(),\n            size: default_image_size(),\n            quality: default_image_quality(),\n        }\n    }\n}\n\nimpl ImageGenerationConfig {\n    pub fn model_id_as_openai_image_model(\n        &self,\n    ) -> Result<async_openai::types::images::ImageModel, String> {\n        match self.model_id.as_str() {\n            \"dall-e-2\" => Ok(async_openai::types::images::ImageModel::DallE2),\n            \"dall-e-3\" => Ok(async_openai::types::images::ImageModel::DallE3),\n            \"gpt-image-1\" => Ok(async_openai::types::images::ImageModel::GptImage1),\n            \"gpt-image-1.5\" => Ok(async_openai::types::images::ImageModel::GptImage1dot5),\n            \"gpt-image-1-mini\" => Ok(async_openai::types::images::ImageModel::GptImage1Mini),\n            other => Ok(async_openai::types::images::ImageModel::Other(\n                other.to_owned(),\n            )),\n        }\n    }\n}\n\nfn default_image_style() -> Option<async_openai::types::images::ImageStyle> {\n    None\n}\n\nfn default_image_size() -> Option<async_openai::types::images::ImageSize> {\n    None\n}\n\nfn default_image_quality() -> Option<async_openai::types::images::ImageQuality> {\n    None\n}\n"
  },
  {
    "path": "src/agent/provider/openai/controller.rs",
    "content": "use std::ops::Deref;\n\nuse async_openai::{\n    Client as OpenAIClient,\n    config::OpenAIConfig,\n    types::{\n        audio::{AudioInput, CreateSpeechRequestArgs, CreateTranscriptionRequestArgs},\n        images::{\n            CreateImageEditRequestArgs, CreateImageRequestArgs, Image, ImageInput, ImageModel,\n            ImageResponseFormat,\n        },\n        responses::{\n            CodeInterpreterContainerAuto, CodeInterpreterTool, CodeInterpreterToolContainer,\n            CreateResponseArgs, OutputItem, OutputMessageContent, Tool, WebSearchTool,\n        },\n    },\n};\n\nuse super::super::ControllerTrait;\nuse crate::{\n    agent::provider::{\n        ImageEditParams, ImageGenerationParams, SpeechToTextParams, SpeechToTextResult,\n        entity::{TextGenerationParams, TextGenerationResult},\n    },\n    conversation::llm::{\n        Author as LLMAuthor, Conversation as LLMConversation, Message as LLMMessage,\n        MessageContent as LLMMessageContent, shorten_messages_list_to_context_size,\n    },\n    utils::base64::base64_decode,\n};\nuse crate::{\n    agent::{\n        AgentPurpose,\n        provider::entity::{\n            ImageEditResult, ImageGenerationResult, ImageSource, PingResult, TextToSpeechParams,\n            TextToSpeechResult,\n        },\n    },\n    strings,\n};\n\nuse super::config::Config;\n\n#[derive(Debug, Clone)]\npub struct Controller {\n    config: Config,\n    client: OpenAIClient<OpenAIConfig>,\n}\n\nimpl Controller {\n    pub fn new(config: Config) -> Self {\n        let openai_config = OpenAIConfig::new()\n            .with_api_base(config.base_url.clone())\n            .with_api_key(config.api_key.clone());\n\n        let client = OpenAIClient::with_config(openai_config);\n\n        Self { config, client }\n    }\n}\n\nimpl ControllerTrait for Controller {\n    async fn ping(&self) -> anyhow::Result<PingResult> {\n        if !self.supports_purpose(AgentPurpose::TextGeneration) {\n            return Ok(PingResult::Inconclusive);\n        }\n\n        let messages = vec![LLMMessage {\n            author: LLMAuthor::User,\n            sender_id: None,\n            content: LLMMessageContent::Text(\"Hello!\".to_string()),\n            timestamp: chrono::Utc::now(),\n        }];\n\n        let conversation = LLMConversation { messages };\n\n        self.generate_text(conversation, TextGenerationParams::default())\n            .await?;\n\n        Ok(PingResult::Successful)\n    }\n\n    async fn generate_text(\n        &self,\n        conversation: LLMConversation,\n        params: TextGenerationParams,\n    ) -> anyhow::Result<TextGenerationResult> {\n        let Some(text_generation_config) = &self.config.text_generation else {\n            return Err(anyhow::anyhow!(\n                strings::agent::no_configuration_for_purpose_so_cannot_be_used(\n                    &AgentPurpose::TextGeneration\n                ),\n            ));\n        };\n\n        let prompt_text = params.prompt_variables.format(\n            params\n                .prompt_override\n                .unwrap_or(self.text_generation_prompt().unwrap_or(\"\".to_owned()))\n                .trim(),\n        );\n\n        let prompt_message = if prompt_text.is_empty() {\n            None\n        } else {\n            Some(LLMMessage {\n                author: LLMAuthor::Prompt,\n                sender_id: None,\n                content: LLMMessageContent::Text(prompt_text),\n                timestamp: chrono::Utc::now(),\n            })\n        };\n\n        let mut conversation_messages = conversation.messages;\n\n        if params.context_management_enabled {\n            tracing::trace!(\"Shortening messages list to context size\");\n\n            conversation_messages = shorten_messages_list_to_context_size(\n                &text_generation_config.model_id,\n                &prompt_message,\n                conversation_messages,\n                text_generation_config.max_response_tokens,\n                text_generation_config.max_context_tokens,\n            );\n\n            tracing::trace!(\"Finished shortening messages list to context size\");\n        };\n\n        if let Some(prompt_message) = prompt_message {\n            conversation_messages.insert(0, prompt_message);\n        }\n\n        let input =\n            super::utils::convert_llm_messages_to_openai_response_input(conversation_messages);\n\n        let messages_count = match &input {\n            async_openai::types::responses::InputParam::Items(items) => items.len(),\n            _ => 1,\n        };\n\n        let temperature = params\n            .temperature_override\n            .unwrap_or(text_generation_config.temperature);\n\n        let mut request_builder = CreateResponseArgs::default();\n\n        request_builder\n            .model(&text_generation_config.model_id)\n            .temperature(temperature)\n            .input(input);\n\n        let mut tools = Vec::new();\n        if text_generation_config.tools.web_search {\n            tools.push(Tool::WebSearch(WebSearchTool::default()));\n        }\n        if text_generation_config.tools.code_interpreter {\n            tools.push(Tool::CodeInterpreter(CodeInterpreterTool {\n                container: CodeInterpreterToolContainer::Auto(\n                    CodeInterpreterContainerAuto::default(),\n                ),\n            }));\n        }\n\n        if !tools.is_empty() {\n            request_builder.tools(tools);\n        }\n\n        if let Some(max_response_tokens) = text_generation_config.max_response_tokens {\n            request_builder.max_output_tokens(max_response_tokens);\n        } else if let Some(max_completion_tokens) = text_generation_config.max_completion_tokens {\n            request_builder.max_output_tokens(max_completion_tokens);\n        }\n\n        let request = request_builder.build()?;\n\n        if let Ok(request_as_json) = serde_json::to_string(&request) {\n            tracing::trace!(\n                model = format!(\"{:?}\", request.model),\n                ?messages_count,\n                request = request_as_json,\n                \"Sending OpenAI response API request\"\n            );\n        }\n\n        let response = self.client.responses().create(request).await?;\n\n        tracing::trace!(?response, \"Got response from the OpenAI response API\");\n\n        for item in response.output {\n            if let OutputItem::Message(message) = item {\n                for content in message.content {\n                    if let OutputMessageContent::OutputText(text_content) = content {\n                        return Ok(TextGenerationResult {\n                            text: text_content.text,\n                        });\n                    }\n                }\n            }\n        }\n\n        Err(anyhow::anyhow!(\n            \"No response messages choices were returned from the OpenAI response API\"\n        ))\n    }\n\n    async fn speech_to_text(\n        &self,\n        mime_type: &mxlink::mime::Mime,\n        media: Vec<u8>,\n        params: SpeechToTextParams,\n    ) -> anyhow::Result<SpeechToTextResult> {\n        let Some(speech_to_text_config) = &self.config.speech_to_text else {\n            return Err(anyhow::anyhow!(\n                strings::agent::no_configuration_for_purpose_so_cannot_be_used(\n                    &AgentPurpose::SpeechToText\n                ),\n            ));\n        };\n\n        let filename = audio_mime_type_to_file_name(mime_type).unwrap_or(\"audio.ogg\".to_string());\n\n        let language = params.language_override.unwrap_or(\"\".to_string());\n\n        let request = CreateTranscriptionRequestArgs::default()\n            .model(&speech_to_text_config.model_id)\n            .file(AudioInput::from_vec_u8(filename, media))\n            .language(language.clone())\n            .build()?;\n\n        tracing::trace!(\n            model_id = speech_to_text_config.model_id,\n            ?language,\n            \"Sending OpenAI speech-to-text API request\"\n        );\n\n        let response = self.client.audio().transcription().create(request).await?;\n\n        tracing::trace!(\n            ?response,\n            \"Got response from the OpenAI audio transcription API\"\n        );\n\n        Ok(SpeechToTextResult {\n            text: response.text,\n        })\n    }\n\n    async fn generate_image(\n        &self,\n        prompt: &str,\n        params: ImageGenerationParams,\n    ) -> anyhow::Result<ImageGenerationResult> {\n        let Some(image_generation_config) = &self.config.image_generation else {\n            return Err(anyhow::anyhow!(\n                strings::agent::no_configuration_for_purpose_so_cannot_be_used(\n                    &AgentPurpose::ImageGeneration\n                ),\n            ));\n        };\n\n        let original_model = image_generation_config\n            .model_id_as_openai_image_model()\n            .map_err(|err| anyhow::anyhow!(err))?;\n\n        let model = if params.cheaper_model_switching_allowed {\n            // Switch to a cheaper model\n            match original_model {\n                ImageModel::DallE2 => ImageModel::DallE2,\n                ImageModel::DallE3 => ImageModel::DallE2,\n                ImageModel::GptImage1 => ImageModel::GptImage1Mini,\n                ImageModel::GptImage1dot5 => ImageModel::GptImage1Mini,\n                ImageModel::GptImage1Mini => ImageModel::GptImage1Mini,\n                ImageModel::Other(_) => ImageModel::DallE2,\n            }\n        } else {\n            original_model\n        };\n\n        let quality = if params.cheaper_quality_switching_allowed {\n            // Switch to a cheaper quality\n            match &image_generation_config.quality {\n                Some(quality) => match quality {\n                    async_openai::types::images::ImageQuality::Standard => {\n                        Some(async_openai::types::images::ImageQuality::Standard)\n                    }\n                    async_openai::types::images::ImageQuality::HD => {\n                        Some(async_openai::types::images::ImageQuality::Standard)\n                    }\n                    // New quality levels - keep as-is or downgrade to Standard\n                    async_openai::types::images::ImageQuality::High => {\n                        Some(async_openai::types::images::ImageQuality::Standard)\n                    }\n                    async_openai::types::images::ImageQuality::Medium => {\n                        Some(async_openai::types::images::ImageQuality::Medium)\n                    }\n                    async_openai::types::images::ImageQuality::Low => {\n                        Some(async_openai::types::images::ImageQuality::Low)\n                    }\n                    async_openai::types::images::ImageQuality::Auto => {\n                        Some(async_openai::types::images::ImageQuality::Auto)\n                    }\n                },\n                None => None,\n            }\n        } else {\n            image_generation_config.quality.clone()\n        };\n\n        let size = if params.smallest_size_possible {\n            Some(get_sticker_size(&model))\n        } else {\n            image_generation_config.size\n        };\n\n        let response_format = match model.clone() {\n            ImageModel::DallE2 => Some(ImageResponseFormat::B64Json),\n            ImageModel::DallE3 => Some(ImageResponseFormat::B64Json),\n            // gpt-image-1 only outputs base64 and we don't need to specify the response format.\n            // In fact, specifying the response format results in an error.\n            ImageModel::GptImage1 => None,\n            ImageModel::GptImage1Mini => None,\n            ImageModel::GptImage1dot5 => None,\n            ImageModel::Other(_) => Some(ImageResponseFormat::B64Json),\n        };\n\n        let mut request_builder = CreateImageRequestArgs::default();\n\n        request_builder.model(model).prompt(prompt.to_owned());\n\n        if let Some(response_format) = response_format {\n            request_builder.response_format(response_format);\n        }\n\n        if let Some(style) = &image_generation_config.style {\n            request_builder.style(style.clone());\n        }\n\n        if let Some(quality) = quality {\n            request_builder.quality(quality.clone());\n        }\n\n        if let Some(size) = size {\n            request_builder.size(size);\n        }\n\n        let request = request_builder.build()?;\n\n        tracing::trace!(\n            ?prompt,\n            model = format!(\"{:?}\", request.model),\n            size = format!(\"{:?}\", request.size),\n            style = format!(\"{:?}\", request.style),\n            quality = format!(\"{:?}\", request.quality),\n            \"Sending OpenAI image generation API request\"\n        );\n\n        let response = self.client.images().generate(request).await?;\n\n        if let Some(image) = response.data.into_iter().next() {\n            match image.deref() {\n                Image::B64Json {\n                    b64_json,\n                    revised_prompt,\n                } => {\n                    let bytes = base64_decode(b64_json.as_ref())?;\n\n                    return Ok(ImageGenerationResult {\n                        bytes,\n                        mime_type: mxlink::mime::IMAGE_PNG,\n                        revised_prompt: revised_prompt.clone(),\n                    });\n                }\n                _ => {\n                    return Err(anyhow::anyhow!(\"Unexpected image type\"));\n                }\n            }\n        }\n\n        Err(anyhow::anyhow!(\n            \"The OpenAI image generation API returned no images\"\n        ))\n    }\n\n    async fn create_image_edit(\n        &self,\n        prompt: &str,\n        images: Vec<ImageSource>,\n        _params: ImageEditParams,\n    ) -> anyhow::Result<ImageEditResult> {\n        let Some(image_generation_config) = &self.config.image_generation else {\n            return Err(anyhow::anyhow!(\n                strings::agent::no_configuration_for_purpose_so_cannot_be_used(\n                    &AgentPurpose::ImageGeneration\n                ),\n            ));\n        };\n\n        if images.is_empty() {\n            return Err(anyhow::anyhow!(\"No image sources provided\"));\n        }\n\n        let mut image_inputs: Vec<ImageInput> = Vec::new();\n        for image in images {\n            image_inputs.push(image.into());\n        }\n\n        let dalle2_size = match image_generation_config.size {\n            Some(async_openai::types::images::ImageSize::S256x256) => {\n                Some(async_openai::types::images::ImageSize::S256x256)\n            }\n            Some(async_openai::types::images::ImageSize::S512x512) => {\n                Some(async_openai::types::images::ImageSize::S512x512)\n            }\n            Some(async_openai::types::images::ImageSize::S1024x1024) => {\n                Some(async_openai::types::images::ImageSize::S1024x1024)\n            }\n            _ => None,\n        };\n\n        let model = image_generation_config\n            .model_id_as_openai_image_model()\n            .map_err(|err| anyhow::anyhow!(err))?;\n\n        let response_format = match model.clone() {\n            ImageModel::DallE2 => Some(ImageResponseFormat::B64Json),\n            ImageModel::DallE3 => Some(ImageResponseFormat::B64Json),\n            // gpt-image-1 only outputs base64 and we don't need to specify the response format.\n            // In fact, specifying the response format results in an error.\n            ImageModel::GptImage1 => None,\n            ImageModel::GptImage1Mini => None,\n            ImageModel::GptImage1dot5 => None,\n            ImageModel::Other(_) => Some(ImageResponseFormat::B64Json),\n        };\n\n        let mut request_builder = CreateImageEditRequestArgs::default();\n\n        request_builder\n            .image(image_inputs)\n            .prompt(prompt.to_owned())\n            .model(model);\n\n        if let Some(size) = dalle2_size {\n            request_builder.size(size);\n        }\n\n        if let Some(response_format) = response_format {\n            request_builder.response_format(response_format);\n        }\n\n        let request = request_builder\n            .build()\n            .map_err(|e| anyhow::anyhow!(\"Failed to build CreateImageEditRequest: {}\", e))?;\n\n        tracing::trace!(\n            model = format!(\"{:?}\", request.model),\n            size = format!(\"{:?}\", request.size),\n            response_format = format!(\"{:?}\", request.response_format),\n            \"Sending OpenAI image edit API request\"\n        );\n\n        let response = self.client.images().edit(request).await?;\n\n        if let Some(image_data) = response.data.into_iter().next() {\n            match image_data.deref() {\n                Image::B64Json { b64_json, .. } => {\n                    let bytes = base64_decode(b64_json.as_ref())?;\n                    return Ok(ImageEditResult {\n                        bytes,\n                        mime_type: mxlink::mime::IMAGE_PNG,\n                    });\n                }\n                Image::Url { url, .. } => {\n                    tracing::warn!(?url, \"Received URL instead of B64Json for image edit\");\n                    return Err(anyhow::anyhow!(\n                        \"Unexpected image type (URL) when B64Json was requested\"\n                    ));\n                }\n            }\n        }\n\n        Err(anyhow::anyhow!(\n            \"The OpenAI image edit API returned no images\"\n        ))\n    }\n\n    async fn text_to_speech(\n        &self,\n        input: &str,\n        params: TextToSpeechParams,\n    ) -> anyhow::Result<TextToSpeechResult> {\n        let Some(text_to_speech_config) = &self.config.text_to_speech else {\n            return Err(anyhow::anyhow!(\n                strings::agent::no_configuration_for_purpose_so_cannot_be_used(\n                    &AgentPurpose::TextToSpeech\n                ),\n            ));\n        };\n\n        let speed = params.speed_override.unwrap_or(text_to_speech_config.speed);\n\n        let voice = if let Some(voice_string) = params.voice_override {\n            // This is a hacky way to construct a Voice enum from the string we have.\n            let voice: serde_json::Result<async_openai::types::audio::Voice> =\n                serde_json::from_str(&format!(\"\\\"{}\\\"\", voice_string));\n            match voice {\n                Ok(voice) => voice,\n                Err(err) => {\n                    tracing::debug!(?voice_string, ?err, \"Failed to parse voice\");\n\n                    return Err(anyhow::anyhow!(\n                        \"The configured voice ({}) is not supported.\",\n                        voice_string\n                    ));\n                }\n            }\n        } else {\n            text_to_speech_config.voice.clone()\n        };\n\n        let response_format = text_to_speech_config.response_format;\n\n        let mime_type = response_format_to_mime_type(&response_format).unwrap_or(\n            \"audio/mp3\"\n                .parse()\n                .expect(\"Failed parsing default mime type\"),\n        );\n\n        let request = CreateSpeechRequestArgs::default()\n            .model(text_to_speech_config.model_id.clone())\n            .voice(voice)\n            .speed(speed)\n            .response_format(response_format)\n            .input(input)\n            .build()?;\n\n        tracing::trace!(\n            model = format!(\"{:?}\", request.model),\n            voice = format!(\"{:?}\", request.voice),\n            speed = format!(\"{:?}\", request.speed),\n            \"Sending OpenAI text-to-speech API request\"\n        );\n\n        let result = self.client.audio().speech().create(request).await?;\n\n        Ok(TextToSpeechResult {\n            bytes: result.bytes.into(),\n            mime_type,\n        })\n    }\n\n    fn supports_purpose(&self, purpose: AgentPurpose) -> bool {\n        match purpose {\n            AgentPurpose::TextGeneration => self.config.text_generation.is_some(),\n            AgentPurpose::SpeechToText => self.config.speech_to_text.is_some(),\n            AgentPurpose::TextToSpeech => self.config.text_to_speech.is_some(),\n            AgentPurpose::ImageGeneration => self.config.image_generation.is_some(),\n            AgentPurpose::CatchAll => true,\n        }\n    }\n\n    fn text_generation_model_id(&self) -> Option<String> {\n        self.config\n            .text_generation\n            .as_ref()\n            .map(|config| config.model_id.to_owned())\n    }\n\n    fn text_generation_prompt(&self) -> Option<String> {\n        self.config\n            .text_generation\n            .as_ref()\n            .and_then(|config| config.prompt.clone())\n    }\n\n    fn text_generation_temperature(&self) -> Option<f32> {\n        self.config\n            .text_generation\n            .as_ref()\n            .map(|config| config.temperature)\n    }\n\n    fn text_to_speech_voice(&self) -> Option<String> {\n        let Some(text_to_speech_config) = &self.config.text_to_speech else {\n            return None;\n        };\n\n        // A hacky way to turn this enum to a string\n        let voice_as_string = serde_json::to_string(&text_to_speech_config.voice).ok()?;\n        Some(voice_as_string.replace(\"\\\"\", \"\"))\n    }\n\n    fn text_to_speech_speed(&self) -> Option<f32> {\n        let Some(text_to_speech_config) = &self.config.text_to_speech else {\n            return None;\n        };\n\n        Some(text_to_speech_config.speed)\n    }\n}\n\nfn response_format_to_mime_type(\n    response_format: &async_openai::types::audio::SpeechResponseFormat,\n) -> Option<mxlink::mime::Mime> {\n    let content_type = match response_format {\n        async_openai::types::audio::SpeechResponseFormat::Mp3 => \"audio/mp3\".to_owned(),\n        async_openai::types::audio::SpeechResponseFormat::Wav => \"audio/wav\".to_owned(),\n        async_openai::types::audio::SpeechResponseFormat::Opus => \"audio/ogg\".to_owned(),\n        async_openai::types::audio::SpeechResponseFormat::Aac => \"audio/aac\".to_owned(),\n        async_openai::types::audio::SpeechResponseFormat::Flac => \"audio/flac\".to_owned(),\n        async_openai::types::audio::SpeechResponseFormat::Pcm => \"audio/L8\".to_owned(),\n    };\n\n    match content_type.parse() {\n        Ok(content_type) => Some(content_type),\n        Err(err) => {\n            tracing::error!(?err, \"Failed to parse content type\");\n            None\n        }\n    }\n}\n\nfn audio_mime_type_to_file_name(mime_type: &mxlink::mime::Mime) -> Option<String> {\n    let mime_type_string = mime_type.to_string();\n\n    let file_extension = match mime_type_string.as_str() {\n        \"audio/flac\" => \"flac\",\n        \"audio/x-m4a\" | \"audio/m4a\" => \"m4a\",\n        \"audio/mp3\" | \"audio/mpeg\" => \"mp3\",\n        \"audio/mp4\" => \"mp4\",\n        \"application/ogg\" | \"audio/ogg\" => \"ogg\",\n        \"audio/wav\" | \"audio/x-wav\" => \"wav\",\n        \"audio/webm\" => \"webm\",\n        _ => return None,\n    };\n\n    Some(format!(\"audio.{}\", file_extension))\n}\n\n/// Returns the smallest supported size for stickers based on what the image model supports.\nfn get_sticker_size(model: &ImageModel) -> async_openai::types::images::ImageSize {\n    use async_openai::types::images::ImageSize;\n\n    match model {\n        ImageModel::DallE2 => ImageSize::S256x256,\n        ImageModel::DallE3 => ImageSize::S1024x1024,\n        ImageModel::GptImage1 => ImageSize::S1024x1024,\n        ImageModel::GptImage1Mini => ImageSize::S1024x1024,\n        ImageModel::GptImage1dot5 => ImageSize::S1024x1024,\n        ImageModel::Other(_) => ImageSize::S1024x1024,\n    }\n}\n"
  },
  {
    "path": "src/agent/provider/openai/mod.rs",
    "content": "mod config;\nmod controller;\nmod utils;\n\npub use config::Config;\npub use controller::Controller;\n\n// openai_compat needs these, so it can convert from its own config types to these\npub(super) use config::ImageGenerationConfig;\npub(super) use config::SpeechToTextConfig;\npub(super) use config::TextGenerationConfig;\npub(super) use config::TextToSpeechConfig;\n\nuse super::super::AgentInstantiationError;\nuse super::super::AgentInstantiationResult;\nuse super::ConfigTrait;\nuse super::controller::ControllerType;\n\npub const OPENAI_IMAGE_MODEL_GPT_IMAGE_1_DOT_5: &str = \"gpt-image-1.5\";\n\npub fn create_controller_from_yaml_value_config(\n    agent_id: &str,\n    config: serde_yaml_ng::Value,\n) -> AgentInstantiationResult<ControllerType> {\n    let config = match &config {\n        serde_yaml_ng::Value::Mapping(_) => {\n            let config: Config =\n                serde_yaml_ng::from_value(config).map_err(AgentInstantiationError::Yaml)?;\n\n            config\n                .validate()\n                .map_err(AgentInstantiationError::ConfigFailsValidation)?;\n\n            config\n        }\n        _ => {\n            return Err(AgentInstantiationError::ConfigForAgentIsNotAMapping(\n                agent_id.to_owned(),\n            ));\n        }\n    };\n\n    Ok(ControllerType::OpenAI(Box::new(Controller::new(config))))\n}\n\npub fn default_config() -> Config {\n    Config::default()\n}\n"
  },
  {
    "path": "src/agent/provider/openai/utils.rs",
    "content": "use async_openai::types::responses::{\n    EasyInputContent, EasyInputMessage, ImageDetail, InputContent, InputFileArgs,\n    InputImageContent, InputItem, InputParam, MessageType, Role,\n};\n\nuse crate::conversation::llm::{\n    Author as LLMAuthor, Message as LLMMessage, MessageContent as LLMMessageContent,\n};\nuse crate::utils::base64::base64_encode;\n\npub fn convert_llm_messages_to_openai_response_input(\n    conversation_messages: Vec<LLMMessage>,\n) -> InputParam {\n    let mut items = Vec::with_capacity(conversation_messages.len());\n\n    for message in conversation_messages {\n        let role = match message.author {\n            LLMAuthor::Prompt => Role::System,\n            LLMAuthor::Assistant => Role::Assistant,\n            LLMAuthor::User => Role::User,\n        };\n\n        let content = match message.content {\n            LLMMessageContent::Text(text) => EasyInputContent::Text(text),\n            LLMMessageContent::Image(image_details) => {\n                let image_url = format!(\n                    \"data:{};base64,{}\",\n                    image_details.mime,\n                    base64_encode(&image_details.data)\n                );\n\n                EasyInputContent::ContentList(vec![InputContent::InputImage(InputImageContent {\n                    image_url: Some(image_url),\n                    detail: ImageDetail::Auto,\n                    file_id: None,\n                })])\n            }\n            LLMMessageContent::File(file_details) => {\n                let file_data = format!(\n                    \"data:{};base64,{}\",\n                    file_details.mime,\n                    base64_encode(&file_details.data)\n                );\n\n                let file_content = InputFileArgs::default()\n                    .file_data(file_data)\n                    .filename(file_details.filename())\n                    .build()\n                    .expect(\"Failed to build InputFileContent\");\n\n                EasyInputContent::ContentList(vec![InputContent::InputFile(file_content)])\n            }\n        };\n\n        items.push(InputItem::EasyMessage(EasyInputMessage {\n            r#type: MessageType::Message,\n            role,\n            content,\n            phase: None,\n        }));\n    }\n\n    InputParam::Items(items)\n}\n"
  },
  {
    "path": "src/agent/provider/openai_compat/config.rs",
    "content": "use serde::{Deserialize, Serialize};\n\nuse crate::agent::default_prompt;\nuse crate::agent::provider::openai::{\n    ImageGenerationConfig as OpenAIImageGenerationConfig,\n    SpeechToTextConfig as OpenAISpeechToTextConfig,\n    TextGenerationConfig as OpenAITextGenerationConfig,\n    TextToSpeechConfig as OpenAITextToSpeechConfig,\n};\n\nuse crate::agent::provider::ConfigTrait;\n\nuse super::utils::convert_string_to_enum;\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Config {\n    pub base_url: String,\n\n    pub api_key: Option<String>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub text_generation: Option<TextGenerationConfig>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub speech_to_text: Option<SpeechToTextConfig>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub text_to_speech: Option<TextToSpeechConfig>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub image_generation: Option<ImageGenerationConfig>,\n}\n\nimpl Default for Config {\n    fn default() -> Self {\n        Self {\n            base_url: \"\".to_owned(),\n            api_key: Some(\"YOUR_API_KEY_HERE\".to_owned()),\n            text_generation: Some(TextGenerationConfig::default()),\n            speech_to_text: Some(SpeechToTextConfig::default()),\n            text_to_speech: Some(TextToSpeechConfig::default()),\n            image_generation: Some(ImageGenerationConfig::default()),\n        }\n    }\n}\n\nimpl ConfigTrait for Config {\n    fn validate(&self) -> Result<(), String> {\n        if self.base_url.is_empty() {\n            return Err(\"The base URL must not be empty.\".to_owned());\n        }\n\n        Ok(())\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TextGenerationConfig {\n    #[serde(default = \"default_text_model_id\")]\n    pub model_id: String,\n\n    #[serde(default)]\n    pub prompt: Option<String>,\n\n    #[serde(default = \"super::super::default_temperature\")]\n    pub temperature: f32,\n\n    #[serde(default)]\n    pub max_response_tokens: Option<u32>,\n\n    #[serde(default)]\n    pub max_context_tokens: u32,\n}\n\nimpl Default for TextGenerationConfig {\n    fn default() -> Self {\n        Self {\n            model_id: default_text_model_id(),\n            prompt: Some(default_prompt().to_owned()),\n            temperature: super::super::default_temperature(),\n            max_response_tokens: Some(4096),\n            max_context_tokens: 128_000,\n        }\n    }\n}\n\nimpl TryInto<OpenAITextGenerationConfig> for TextGenerationConfig {\n    type Error = anyhow::Error;\n\n    fn try_into(self) -> Result<OpenAITextGenerationConfig, Self::Error> {\n        Ok(OpenAITextGenerationConfig {\n            model_id: self.model_id,\n            prompt: self.prompt,\n            temperature: self.temperature,\n            max_response_tokens: self.max_response_tokens,\n            max_completion_tokens: None,\n            max_context_tokens: self.max_context_tokens,\n            tools: Default::default(),\n        })\n    }\n}\n\nfn default_text_model_id() -> String {\n    \"some-model\".to_owned()\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SpeechToTextConfig {\n    #[serde(default = \"default_speech_to_text_model_id\")]\n    pub model_id: String,\n}\n\nimpl Default for SpeechToTextConfig {\n    fn default() -> Self {\n        Self {\n            model_id: default_speech_to_text_model_id(),\n        }\n    }\n}\n\nimpl TryInto<OpenAISpeechToTextConfig> for SpeechToTextConfig {\n    type Error = anyhow::Error;\n\n    fn try_into(self) -> Result<OpenAISpeechToTextConfig, Self::Error> {\n        Ok(OpenAISpeechToTextConfig {\n            model_id: self.model_id,\n        })\n    }\n}\n\nfn default_speech_to_text_model_id() -> String {\n    \"whisper-1\".to_owned()\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct TextToSpeechConfig {\n    #[serde(default = \"default_text_to_speech_model_id\")]\n    pub model_id: String,\n\n    #[serde(default = \"default_text_to_speech_voice\")]\n    pub voice: String,\n\n    #[serde(default = \"default_text_to_speech_speed\")]\n    pub speed: f32,\n\n    #[serde(default = \"default_text_to_speech_response_format\")]\n    pub response_format: String,\n}\n\nimpl Default for TextToSpeechConfig {\n    fn default() -> Self {\n        Self {\n            model_id: default_text_to_speech_model_id(),\n            voice: default_text_to_speech_voice(),\n            speed: default_text_to_speech_speed(),\n            response_format: default_text_to_speech_response_format(),\n        }\n    }\n}\n\nimpl TryInto<OpenAITextToSpeechConfig> for TextToSpeechConfig {\n    type Error = String;\n\n    fn try_into(self) -> Result<OpenAITextToSpeechConfig, Self::Error> {\n        let model_id =\n            convert_string_to_enum::<async_openai::types::audio::SpeechModel>(&self.model_id)?;\n\n        let voice = convert_string_to_enum::<async_openai::types::audio::Voice>(&self.voice)?;\n\n        let response_format = convert_string_to_enum::<\n            async_openai::types::audio::SpeechResponseFormat,\n        >(&self.response_format)?;\n\n        Ok(OpenAITextToSpeechConfig {\n            model_id,\n            voice,\n            speed: self.speed,\n            response_format,\n        })\n    }\n}\n\nfn default_text_to_speech_model_id() -> String {\n    \"tts-1\".to_owned()\n}\n\nfn default_text_to_speech_voice() -> String {\n    \"onyx\".to_owned()\n}\n\nfn default_text_to_speech_speed() -> f32 {\n    1.0\n}\n\nfn default_text_to_speech_response_format() -> String {\n    \"opus\".to_owned()\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ImageGenerationConfig {\n    pub model_id: String,\n\n    #[serde(default = \"default_image_style\")]\n    pub style: Option<String>,\n\n    #[serde(default = \"default_image_size\")]\n    pub size: Option<String>,\n\n    #[serde(default = \"default_image_quality\")]\n    pub quality: Option<String>,\n}\n\nimpl Default for ImageGenerationConfig {\n    fn default() -> Self {\n        Self {\n            model_id: \"stablediffusion\".to_owned(),\n            style: default_image_style(),\n            size: default_image_size(),\n            quality: default_image_quality(),\n        }\n    }\n}\n\nimpl TryInto<OpenAIImageGenerationConfig> for ImageGenerationConfig {\n    type Error = String;\n\n    fn try_into(self) -> Result<OpenAIImageGenerationConfig, Self::Error> {\n        let size = if let Some(size) = &self.size {\n            Some(convert_string_to_enum::<\n                async_openai::types::images::ImageSize,\n            >(size)?)\n        } else {\n            None\n        };\n\n        let style = if let Some(style) = &self.style {\n            Some(convert_string_to_enum::<\n                async_openai::types::images::ImageStyle,\n            >(style)?)\n        } else {\n            None\n        };\n\n        let quality = if let Some(quality) = &self.quality {\n            Some(convert_string_to_enum::<\n                async_openai::types::images::ImageQuality,\n            >(quality)?)\n        } else {\n            None\n        };\n\n        Ok(OpenAIImageGenerationConfig {\n            model_id: self.model_id,\n            style,\n            size,\n            quality,\n        })\n    }\n}\n\nfn default_image_style() -> Option<String> {\n    Some(\"vivid\".to_owned())\n}\n\nfn default_image_size() -> Option<String> {\n    Some(\"1024x1024\".to_owned())\n}\n\nfn default_image_quality() -> Option<String> {\n    Some(\"standard\".to_owned())\n}\n"
  },
  {
    "path": "src/agent/provider/openai_compat/controller.rs",
    "content": "use etke_openai_api_rust::audio::{AudioApi, AudioBody};\nuse etke_openai_api_rust::chat::{ChatApi, ChatBody};\nuse etke_openai_api_rust::images::{ImagesApi, ImagesBody};\nuse etke_openai_api_rust::{Auth, Message, OpenAI};\n\nconst SMALLEST_IMAGE_SIZE: &str = \"256x256\";\n\nuse super::super::ControllerTrait;\nuse crate::utils::base64::base64_decode;\nuse crate::{\n    agent::provider::{\n        ImageEditParams, ImageGenerationParams, ImageSource, SpeechToTextParams,\n        SpeechToTextResult,\n        entity::{TextGenerationParams, TextGenerationResult},\n    },\n    conversation::llm::{\n        Author as LLMAuthor, Conversation as LLMConversation, Message as LLMMessage,\n        MessageContent as LLMMessageContent, shorten_messages_list_to_context_size,\n    },\n};\nuse crate::{\n    agent::{\n        AgentPurpose,\n        provider::entity::{\n            ImageEditResult, ImageGenerationResult, PingResult, TextToSpeechParams,\n            TextToSpeechResult,\n        },\n    },\n    strings,\n};\n\nuse super::Config;\n\n#[derive(Debug, Clone)]\npub struct Controller {\n    config: Config,\n    client: OpenAI,\n}\n\nimpl Controller {\n    pub fn new(config: Config) -> Self {\n        let api_key = config.api_key.clone().unwrap_or(\"\".to_owned());\n\n        let auth = Auth::new(&api_key);\n\n        // The library we use chokes if there's no trailing slash\n        let base_url = if config.base_url.ends_with(\"/\") {\n            config.base_url.clone()\n        } else {\n            format!(\"{}/\", config.base_url)\n        };\n\n        let client = OpenAI::new(auth, &base_url);\n\n        Self { config, client }\n    }\n}\n\nimpl ControllerTrait for Controller {\n    async fn ping(&self) -> anyhow::Result<PingResult> {\n        if !self.supports_purpose(AgentPurpose::TextGeneration) {\n            return Ok(PingResult::Inconclusive);\n        }\n\n        let messages = vec![LLMMessage {\n            author: LLMAuthor::User,\n            sender_id: None,\n            content: LLMMessageContent::Text(\"Hello!\".to_string()),\n            timestamp: chrono::Utc::now(),\n        }];\n\n        let conversation = LLMConversation { messages };\n\n        self.generate_text(conversation, TextGenerationParams::default())\n            .await?;\n\n        Ok(PingResult::Successful)\n    }\n\n    async fn generate_text(\n        &self,\n        conversation: LLMConversation,\n        params: TextGenerationParams,\n    ) -> anyhow::Result<TextGenerationResult> {\n        let Some(text_generation_config) = &self.config.text_generation else {\n            return Err(anyhow::anyhow!(\n                strings::agent::no_configuration_for_purpose_so_cannot_be_used(\n                    &AgentPurpose::TextGeneration\n                ),\n            ));\n        };\n\n        let prompt_text = params.prompt_variables.format(\n            params\n                .prompt_override\n                .unwrap_or(self.text_generation_prompt().unwrap_or(\"\".to_owned()))\n                .trim(),\n        );\n\n        let prompt_message = if prompt_text.is_empty() {\n            None\n        } else {\n            Some(LLMMessage {\n                author: LLMAuthor::Prompt,\n                sender_id: None,\n                content: LLMMessageContent::Text(prompt_text),\n                timestamp: chrono::Utc::now(),\n            })\n        };\n\n        let mut conversation_messages = conversation.messages;\n\n        if params.context_management_enabled {\n            tracing::trace!(\"Shortening messages list to context size\");\n\n            conversation_messages = shorten_messages_list_to_context_size(\n                &text_generation_config.model_id,\n                &prompt_message,\n                conversation_messages,\n                text_generation_config.max_response_tokens,\n                text_generation_config.max_context_tokens,\n            );\n\n            tracing::trace!(\"Finished shortening messages list to context size\");\n        };\n\n        if let Some(prompt_message) = prompt_message {\n            conversation_messages.insert(0, prompt_message);\n        }\n\n        let openai_conversation_messages: Vec<Message> =\n            super::utils::convert_llm_messages_to_openai_messages(conversation_messages);\n\n        let messages_count = openai_conversation_messages.len();\n\n        let temperature = params\n            .temperature_override\n            .unwrap_or(text_generation_config.temperature);\n\n        let max_tokens = text_generation_config\n            .max_response_tokens\n            .map(|max_response_tokens| {\n                max_response_tokens\n                    .try_into()\n                    .expect(\"Failed converting max_response_tokens from u32 to i32\")\n            });\n\n        let request = ChatBody {\n            model: text_generation_config.model_id.clone(),\n            max_tokens,\n            temperature: Some(temperature),\n            top_p: None,\n            n: Some(1),\n            stream: Some(false),\n            stop: None,\n            presence_penalty: None,\n            frequency_penalty: None,\n            logit_bias: None,\n            user: None,\n            messages: openai_conversation_messages,\n        };\n\n        if let Ok(request_as_json) = serde_json::to_string(&request) {\n            tracing::trace!(\n                model = format!(\"{:?}\", request.model),\n                ?messages_count,\n                request = request_as_json,\n                \"Sending OpenAI-compat chat completion API request\"\n            );\n        }\n\n        // This library is not async-aware, so we need to use `spawn_blocking` to run the request on a separate thread.\n        let client = self.client.clone();\n        let response =\n            tokio::task::spawn_blocking(move || client.chat_completion_create(&request)).await?;\n\n        let response = match response {\n            Ok(response) => response,\n            Err(err) => {\n                return Err(anyhow::anyhow!(\n                    \"Failed to get response from the OpenAI-compat chat completion API: {:?}\",\n                    err\n                ));\n            }\n        };\n\n        tracing::trace!(\n            ?response,\n            \"Got response from the OpenAI-compat chat completion API\"\n        );\n\n        // We only request 1 result, so there should only be 1 choice.\n        if let Some(choice) = response.choices.into_iter().next() {\n            let Some(message) = choice.message else {\n                return Err(anyhow::anyhow!(\n                    \"No response message in choice was returned from the OpenAI-compat chat completion API\"\n                ));\n            };\n\n            return Ok(TextGenerationResult {\n                text: message.content,\n            });\n        }\n\n        Err(anyhow::anyhow!(\n            \"No response messages choices were returned from the OpenAI-compat chat completion API\"\n        ))\n    }\n\n    async fn speech_to_text(\n        &self,\n        _mime_type: &mxlink::mime::Mime,\n        media: Vec<u8>,\n        params: SpeechToTextParams,\n    ) -> anyhow::Result<SpeechToTextResult> {\n        let Some(speech_to_text_config) = &self.config.speech_to_text else {\n            return Err(anyhow::anyhow!(\n                strings::agent::no_configuration_for_purpose_so_cannot_be_used(\n                    &AgentPurpose::SpeechToText\n                ),\n            ));\n        };\n\n        // This library does not support passing the audio data as a byte slice, so we need to write it to a temporary file :/\n        //\n        // This temporary file will get auto-deleted when the variable goes out of scope.\n        let temp_file = tokio::task::spawn_blocking(move || {\n            let mut temp_file = match tempfile::NamedTempFile::new() {\n                Ok(file) => file,\n                Err(e) => return Err(e),\n            };\n\n            match std::io::Write::write_all(&mut temp_file, &media) {\n                Ok(_) => (),\n                Err(e) => return Err(e),\n            }\n\n            Ok(temp_file)\n        })\n        .await??;\n\n        let file_path = temp_file\n            .path()\n            .to_str()\n            .ok_or_else(|| anyhow::anyhow!(\"Failed to get temporary file path\"))?;\n\n        let language = params.language_override.clone();\n\n        let request = AudioBody {\n            file: std::fs::File::open(file_path)?,\n            model: speech_to_text_config.model_id.to_owned(),\n            prompt: None,\n            response_format: None,\n            temperature: None,\n            language: language.clone(),\n        };\n\n        tracing::trace!(\n            model_id = speech_to_text_config.model_id,\n            ?language,\n            \"Sending OpenAI-compat speech-to-text API request\"\n        );\n\n        // This library is not async-aware, so we need to use `spawn_blocking` to run the request on a separate thread.\n        let client = self.client.clone();\n        let response =\n            tokio::task::spawn_blocking(move || client.audio_transcription_create(request)).await?;\n\n        let response = match response {\n            Ok(response) => response,\n            Err(err) => {\n                return Err(anyhow::anyhow!(\n                    \"Failed to get response from the OpenAI-compat audio transcription API: {:?}\",\n                    err\n                ));\n            }\n        };\n\n        tracing::trace!(\n            ?response,\n            \"Got response from the OpenAI-compat audio transcription API\"\n        );\n\n        let Some(text) = response.text else {\n            return Err(anyhow::anyhow!(\n                \"No response text was returned from the OpenAI-compat audio transcription API\"\n            ));\n        };\n\n        Ok(SpeechToTextResult { text })\n    }\n\n    async fn generate_image(\n        &self,\n        prompt: &str,\n        params: ImageGenerationParams,\n    ) -> anyhow::Result<ImageGenerationResult> {\n        let Some(image_generation_config) = &self.config.image_generation else {\n            return Err(anyhow::anyhow!(\n                strings::agent::no_configuration_for_purpose_so_cannot_be_used(\n                    &AgentPurpose::ImageGeneration\n                ),\n            ));\n        };\n\n        // It seems like some OpenAI-compatible providers (e.g. LocalAI with StableDiffusion) skip some requirements\n        // when they span multiple lines.\n        let prompt = prompt.replace(\"\\n\", \" \");\n\n        let size: Option<String> = if params.smallest_size_possible {\n            Some(SMALLEST_IMAGE_SIZE.to_owned())\n        } else {\n            image_generation_config.size.clone()\n        };\n\n        let request = ImagesBody {\n            model: Some(image_generation_config.model_id.to_owned()),\n            prompt: prompt.to_owned(),\n            n: Some(1),\n            quality: image_generation_config.quality.clone(),\n            size,\n            style: image_generation_config.style.clone(),\n            response_format: Some(\"b64_json\".to_string()),\n            user: None,\n        };\n\n        tracing::trace!(\n            ?prompt,\n            model = format!(\"{:?}\", request.model),\n            size = format!(\"{:?}\", request.size),\n            style = format!(\"{:?}\", request.style),\n            quality = format!(\"{:?}\", request.quality),\n            \"Sending OpenAI-compat image generation API request\"\n        );\n\n        // This library is not async-aware, so we need to use `spawn_blocking` to run the request on a separate thread.\n        let client = self.client.clone();\n        let response = tokio::task::spawn_blocking(move || client.image_create(&request)).await?;\n\n        let response = match response {\n            Ok(response) => response,\n            Err(err) => {\n                return Err(anyhow::anyhow!(\n                    \"Failed to get response from the OpenAI-compat image creation API: {:?}\",\n                    err\n                ));\n            }\n        };\n\n        let Some(data) = response.data else {\n            return Err(anyhow::anyhow!(\n                \"The OpenAI-compat image generationAPI returned no image data\"\n            ));\n        };\n\n        if let Some(image) = data.into_iter().next() {\n            let Some(b64_json) = &image.b64_json else {\n                return Err(anyhow::anyhow!(\n                    \"The OpenAI-compat image generation API returned no b64_json image data\"\n                ));\n            };\n\n            let bytes = base64_decode(b64_json)?;\n\n            return Ok(ImageGenerationResult {\n                bytes,\n                mime_type: mxlink::mime::IMAGE_PNG,\n                revised_prompt: image.revised_prompt,\n            });\n        }\n\n        Err(anyhow::anyhow!(\n            \"The OpenAI image generation API returned no images\"\n        ))\n    }\n\n    async fn create_image_edit(\n        &self,\n        _prompt: &str,\n        _images: Vec<ImageSource>,\n        _params: ImageEditParams,\n    ) -> anyhow::Result<ImageEditResult> {\n        Err(anyhow::anyhow!(\n            \"The OpenAI image edit API is not supported by the OpenAI-compat provider\"\n        ))\n    }\n\n    async fn text_to_speech(\n        &self,\n        input: &str,\n        params: TextToSpeechParams,\n    ) -> anyhow::Result<TextToSpeechResult> {\n        // openai_api_rust does not support text-to-speech, so our only bet is to do it via async-openai and hope it works.\n        // At the time of testing (2024-09-09), providers like LocalAI can be used for text-to-speech via async-openai.\n        //\n        // So.. below we try to convert our Config struct to the Config struct from the openai module\n        // and invoke the openai controller.\n\n        // Quick check to make sure doing work below is worth it\n        let Some(_text_to_speech_config) = &self.config.text_to_speech else {\n            return Err(anyhow::anyhow!(\n                strings::agent::no_configuration_for_purpose_so_cannot_be_used(\n                    &AgentPurpose::TextToSpeech\n                ),\n            ));\n        };\n\n        tracing::debug!(\"Converting OpenAI-compact config to OpenAI config..\");\n\n        let openai_config = super::utils::convert_config_to_openai_config_lossy(&self.config);\n\n        let Some(_text_to_speech_config) = &openai_config.text_to_speech else {\n            return Err(anyhow::anyhow!(\n                strings::agent::no_configuration_for_purpose_after_conversion_so_cannot_be_used(\n                    &AgentPurpose::TextToSpeech\n                ),\n            ));\n        };\n\n        let openai_controller = super::super::openai::Controller::new(openai_config);\n\n        tracing::error!(\"Invoking text-to-speech via the OpenAI controller..\");\n\n        openai_controller.text_to_speech(input, params).await\n    }\n\n    fn supports_purpose(&self, purpose: AgentPurpose) -> bool {\n        match purpose {\n            AgentPurpose::ImageGeneration => self.config.image_generation.is_some(),\n            AgentPurpose::TextGeneration => self.config.text_generation.is_some(),\n            AgentPurpose::SpeechToText => self.config.speech_to_text.is_some(),\n            AgentPurpose::TextToSpeech => self.config.text_to_speech.is_some(),\n            AgentPurpose::CatchAll => true,\n        }\n    }\n\n    fn text_generation_model_id(&self) -> Option<String> {\n        self.config\n            .text_generation\n            .as_ref()\n            .map(|config| config.model_id.to_owned())\n    }\n\n    fn text_generation_prompt(&self) -> Option<String> {\n        self.config\n            .text_generation\n            .as_ref()\n            .and_then(|config| config.prompt.clone())\n    }\n\n    fn text_generation_temperature(&self) -> Option<f32> {\n        self.config\n            .text_generation\n            .as_ref()\n            .map(|config| config.temperature)\n    }\n\n    fn text_to_speech_voice(&self) -> Option<String> {\n        let Some(text_to_speech_config) = &self.config.text_to_speech else {\n            return None;\n        };\n\n        // A hacky way to turn this enum to a string\n        let voice_as_string = serde_json::to_string(&text_to_speech_config.voice).ok()?;\n        Some(voice_as_string.replace(\"\\\"\", \"\"))\n    }\n\n    fn text_to_speech_speed(&self) -> Option<f32> {\n        let Some(text_to_speech_config) = &self.config.text_to_speech else {\n            return None;\n        };\n\n        Some(text_to_speech_config.speed)\n    }\n}\n"
  },
  {
    "path": "src/agent/provider/openai_compat/mod.rs",
    "content": "// The openai_compat provider aims to support a wider ranger of OpenAI-compatible providers.\n//\n// The `openai` provider is based on `async-openai`, which only aims to support the OpenAI API spec. See:\n// - https://github.com/64bit/async-openai/issues/266\n// - https://github.com/64bit/async-openai/blob/05d5a1b4fa6476829dd1a34447b80279cf89d4f8/async-openai/README.md#contributing\n//\n// This module uses its own configuration, which avoids using strict types tied to OpenAI,\n// and thus allows for more flexibility.\n//\n// Communication with the OpenAI-compatible API is handled by the `openai_api_rust` crate.\n// Since this crate is not async-aware, we need to use tokio's `spawn_blocking` to invoke it.\n//\n// Certain features (e.g. text-to-speech) are not supported by `openai_api_rust` yet, so we may try to delegate them to the `openai` provider.\n\nmod config;\nmod controller;\nmod utils;\n\npub use config::Config;\npub use controller::Controller;\n\nuse super::super::AgentInstantiationError;\nuse super::super::AgentInstantiationResult;\nuse super::ConfigTrait;\nuse super::controller::ControllerType;\n\npub fn create_controller_from_yaml_value_config(\n    agent_id: &str,\n    config: serde_yaml_ng::Value,\n) -> AgentInstantiationResult<ControllerType> {\n    let config = match &config {\n        serde_yaml_ng::Value::Mapping(_) => {\n            let config: Config =\n                serde_yaml_ng::from_value(config).map_err(AgentInstantiationError::Yaml)?;\n\n            config\n                .validate()\n                .map_err(AgentInstantiationError::ConfigFailsValidation)?;\n\n            config\n        }\n        _ => {\n            return Err(AgentInstantiationError::ConfigForAgentIsNotAMapping(\n                agent_id.to_owned(),\n            ));\n        }\n    };\n\n    Ok(ControllerType::OpenAICompat(Box::new(Controller::new(\n        config,\n    ))))\n}\n\npub fn default_config() -> Config {\n    let mut config = Config::default();\n\n    if let Some(text_generation) = &mut config.text_generation {\n        text_generation.model_id = \"some-model\".to_string();\n        text_generation.max_response_tokens = Some(4096);\n        text_generation.max_context_tokens = 128_000;\n    }\n\n    // We don't support these, so let's remove them from the configuration.\n    config.text_to_speech = None;\n    config.image_generation = None;\n\n    config.base_url = \"\".to_owned();\n\n    config\n}\n"
  },
  {
    "path": "src/agent/provider/openai_compat/utils.rs",
    "content": "use etke_openai_api_rust::{Message, Role};\n\nuse crate::agent::provider::openai::Config as OpenAIConfig;\n\nuse crate::conversation::llm::{\n    Author as LLMAuthor, Message as LLMMessage, MessageContent as LLMMessageContent,\n};\n\npub fn convert_llm_messages_to_openai_messages(\n    conversation_messages: Vec<LLMMessage>,\n) -> Vec<Message> {\n    let mut openai_conversation_messages: Vec<Message> =\n        Vec::with_capacity(conversation_messages.len());\n\n    for message in conversation_messages {\n        let openai_message = convert_llm_message_to_openai_message(message);\n        if let Some(openai_message) = openai_message {\n            openai_conversation_messages.push(openai_message);\n        }\n    }\n\n    openai_conversation_messages\n}\n\nfn convert_llm_message_to_openai_message(llm_message: LLMMessage) -> Option<Message> {\n    let role = match llm_message.author {\n        LLMAuthor::Prompt => Role::System,\n        LLMAuthor::Assistant => Role::Assistant,\n        LLMAuthor::User => Role::User,\n    };\n\n    match &llm_message.content {\n        LLMMessageContent::Text(text) => Some(Message {\n            role,\n            content: text.clone(),\n        }),\n        LLMMessageContent::Image(_image_details) => {\n            tracing::warn!(\n                \"The OpenAI-compat provider's library does not support image content. This image message will be skipped.\"\n            );\n            None\n        }\n        LLMMessageContent::File(_file_details) => {\n            tracing::warn!(\n                \"The OpenAI-compat provider's library does not support file content. This file message will be skipped.\"\n            );\n            None\n        }\n    }\n}\n\npub(super) fn convert_config_to_openai_config_lossy(config: &super::Config) -> OpenAIConfig {\n    let text_generation = config\n        .text_generation\n        .as_ref()\n        .and_then(|tg| tg.clone().try_into().ok());\n\n    let speech_to_text = config\n        .speech_to_text\n        .as_ref()\n        .and_then(|stt| stt.clone().try_into().ok());\n\n    let text_to_speech = config\n        .text_to_speech\n        .as_ref()\n        .and_then(|tts| tts.clone().try_into().ok());\n\n    let image_generation = config\n        .image_generation\n        .as_ref()\n        .and_then(|ig| ig.clone().try_into().ok());\n\n    OpenAIConfig {\n        api_key: config.api_key.clone().unwrap_or(\"\".to_string()),\n        text_generation,\n        speech_to_text,\n        text_to_speech,\n        image_generation,\n        base_url: config.base_url.clone(),\n    }\n}\n\npub(super) fn convert_string_to_enum<T>(value: &str) -> Result<T, String>\nwhere\n    T: serde::de::DeserializeOwned,\n{\n    // This is a hacky way to construct an enum from the string we have.\n    let enum_result: serde_json::Result<T> = serde_json::from_str(&format!(\"\\\"{}\\\"\", value));\n    match enum_result {\n        Ok(enum_result) => Ok(enum_result),\n        Err(err) => {\n            tracing::debug!(?err, \"Failed to parse into enum\");\n\n            Err(format!(\"The value ({}) is not supported.\", value))\n        }\n    }\n}\n"
  },
  {
    "path": "src/agent/provider/openrouter/mod.rs",
    "content": "use super::openai_compat::Config;\n\npub fn default_config() -> Config {\n    let mut config = Config {\n        base_url: \"https://openrouter.ai/api/v1\".to_owned(),\n\n        text_to_speech: None,\n        image_generation: None,\n        speech_to_text: None,\n\n        ..Default::default()\n    };\n\n    if let Some(ref mut config) = config.text_generation.as_mut() {\n        config.model_id = \"mattshumer/reflection-70b:free\".to_owned();\n        config.max_context_tokens = 8192;\n        config.max_response_tokens = Some(2048);\n    }\n\n    config\n}\n"
  },
  {
    "path": "src/agent/provider/togetherai/mod.rs",
    "content": "use super::openai_compat::Config;\n\npub fn default_config() -> Config {\n    let mut config = Config {\n        base_url: \"https://api.together.xyz/v1\".to_owned(),\n\n        text_to_speech: None,\n        image_generation: None,\n        speech_to_text: None,\n\n        ..Default::default()\n    };\n\n    if let Some(ref mut config) = config.text_generation.as_mut() {\n        config.model_id = \"meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo\".to_owned();\n        config.max_context_tokens = 8192;\n        config.max_response_tokens = Some(2048);\n    }\n\n    config\n}\n"
  },
  {
    "path": "src/agent/purpose.rs",
    "content": "#[derive(Debug, Clone, Copy, PartialEq)]\npub enum AgentPurpose {\n    CatchAll,\n    ImageGeneration,\n    TextGeneration,\n    TextToSpeech,\n    SpeechToText,\n}\n\nimpl AgentPurpose {\n    pub fn from_str(s: &str) -> Option<Self> {\n        match s {\n            \"catch-all\" => Some(Self::CatchAll),\n            \"image-generation\" => Some(Self::ImageGeneration),\n            \"text-generation\" => Some(Self::TextGeneration),\n            \"text-to-speech\" => Some(Self::TextToSpeech),\n            \"speech-to-text\" => Some(Self::SpeechToText),\n            _ => None,\n        }\n    }\n\n    pub fn as_str(&self) -> &'static str {\n        match self {\n            Self::CatchAll => \"catch-all\",\n            Self::ImageGeneration => \"image-generation\",\n            Self::TextGeneration => \"text-generation\",\n            Self::TextToSpeech => \"text-to-speech\",\n            Self::SpeechToText => \"speech-to-text\",\n        }\n    }\n\n    pub fn choices() -> Vec<&'static Self> {\n        vec![\n            &Self::TextGeneration,\n            &Self::SpeechToText,\n            &Self::TextToSpeech,\n            &Self::ImageGeneration,\n            &Self::CatchAll,\n        ]\n    }\n\n    pub fn emoji(&self) -> &'static str {\n        match self {\n            Self::CatchAll => \"❓\",\n            Self::TextGeneration => \"💬\",\n            Self::SpeechToText => \"🦻\",\n            Self::TextToSpeech => \"🗣️\",\n            Self::ImageGeneration => \"🖌️\",\n        }\n    }\n\n    pub fn heading(&self) -> &'static str {\n        match self {\n            Self::CatchAll => \"Catch-All\",\n            Self::TextGeneration => \"Text Generation\",\n            Self::SpeechToText => \"Speech-to-Text\",\n            Self::TextToSpeech => \"Text-to-Speech\",\n            Self::ImageGeneration => \"Image Generation\",\n        }\n    }\n}\n\nimpl std::fmt::Display for AgentPurpose {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.write_str(self.as_str())\n    }\n}\n"
  },
  {
    "path": "src/agent/utils.rs",
    "content": "use crate::{\n    agent::{\n        AgentInstance, AgentPurpose, ControllerTrait, Manager as AgentManager, PublicIdentifier,\n    },\n    entity::RoomConfigContext,\n    strings,\n};\n\n#[derive(Debug)]\npub struct AgentForPurposeDeterminationInfo {\n    pub instance: AgentInstance,\n    pub configuration_source: AgentForPurposeDeterminationInfoConfigurationSource,\n}\n\n#[derive(Debug)]\npub enum AgentForPurposeDeterminationInfoConfigurationSource {\n    Room,\n    Global,\n}\n\n#[derive(Debug)]\npub enum AgentForPurposeDeterminationError {\n    Unknown(String),\n\n    NoneConfigured,\n\n    ConfiguredButMissing(PublicIdentifier),\n\n    ConfiguredButLacksSupport(PublicIdentifier),\n}\n\npub async fn get_effective_agent_for_purpose(\n    agent_manager: &AgentManager,\n    room_config_context: &RoomConfigContext,\n    agent_purpose: AgentPurpose,\n) -> Result<AgentForPurposeDeterminationInfo, AgentForPurposeDeterminationError> {\n    let (agent_identifier, configuration_source) =\n        match get_effective_room_agent_identifier_for_purpose(room_config_context, agent_purpose)\n            .await\n        {\n            Ok((agent_identifier, configuration_source)) => {\n                (agent_identifier, configuration_source)\n            }\n            Err(err) => {\n                return Err(AgentForPurposeDeterminationError::Unknown(err));\n            }\n        };\n\n    let Some(agent_identifier) = agent_identifier else {\n        return Err(AgentForPurposeDeterminationError::NoneConfigured);\n    };\n\n    let agents = agent_manager.available_room_agents_by_room_config_context(room_config_context);\n\n    let Some(agent_instance) = agents.iter().find(|a| *a.identifier() == agent_identifier) else {\n        return Err(AgentForPurposeDeterminationError::ConfiguredButMissing(\n            agent_identifier,\n        ));\n    };\n\n    let agent_instance = agent_instance.clone();\n\n    let supports_purpose = agent_instance.controller().supports_purpose(agent_purpose);\n\n    if !supports_purpose {\n        return Err(AgentForPurposeDeterminationError::ConfiguredButLacksSupport(agent_identifier));\n    }\n\n    Ok(AgentForPurposeDeterminationInfo {\n        instance: agent_instance,\n        configuration_source,\n    })\n}\n\nasync fn get_effective_room_agent_identifier_for_purpose(\n    room_config_context: &RoomConfigContext,\n    purpose: AgentPurpose,\n) -> Result<\n    (\n        Option<PublicIdentifier>,\n        AgentForPurposeDeterminationInfoConfigurationSource,\n    ),\n    String,\n> {\n    let (agent_id, configuration_source) =\n        get_effective_room_agent_raw_id_for_purpose(room_config_context, purpose).await;\n\n    let Some(agent_id) = agent_id else {\n        return Ok((None, configuration_source));\n    };\n\n    let agent_identifier = match PublicIdentifier::from_str(agent_id.as_str()) {\n        Some(agent_identifier) => agent_identifier,\n        None => return Err(strings::agent::invalid_id_generic()),\n    };\n\n    Ok((Some(agent_identifier), configuration_source))\n}\n\nasync fn get_effective_room_agent_raw_id_for_purpose(\n    room_config_context: &RoomConfigContext,\n    purpose: AgentPurpose,\n) -> (\n    Option<String>,\n    AgentForPurposeDeterminationInfoConfigurationSource,\n) {\n    let agent_id = room_config_context\n        .room_config\n        .settings\n        .handler\n        .get_by_purpose_with_catch_all_fallback(purpose);\n\n    if let Some(agent_id) = agent_id {\n        return (\n            Some(agent_id),\n            AgentForPurposeDeterminationInfoConfigurationSource::Room,\n        );\n    }\n\n    tracing::trace!(\n        ?purpose,\n        \"No specific agent found for purpose in room, falling back to global.\",\n    );\n\n    (\n        get_global_agent_id_for_purpose(room_config_context, purpose).await,\n        AgentForPurposeDeterminationInfoConfigurationSource::Global,\n    )\n}\n\nasync fn get_global_agent_id_for_purpose(\n    room_config_context: &RoomConfigContext,\n    purpose: AgentPurpose,\n) -> Option<String> {\n    room_config_context\n        .global_config\n        .fallback_room_settings\n        .handler\n        .get_by_purpose_with_catch_all_fallback(purpose)\n}\n"
  },
  {
    "path": "src/bot/implementation.rs",
    "content": "use std::fs;\nuse std::sync::Arc;\nuse std::{future::Future, pin::Pin};\n\nuse mxlink::matrix_sdk::Room;\nuse mxlink::matrix_sdk::media::{MediaFormat, MediaRequestParameters};\nuse mxlink::matrix_sdk::ruma::api::client::profile::{AvatarUrl, DisplayName};\nuse mxlink::matrix_sdk::ruma::{\n    MilliSecondsSinceUnixEpoch, OwnedUserId, events::room::MediaSource,\n};\n\nuse mxlink::{\n    InitConfig, LoginConfig, LoginCredentials, LoginEncryption, MatrixLink, PersistenceConfig,\n    TypingNoticeGuard,\n};\n\nuse mxlink::helpers::account_data_config::{\n    ConfigError, GlobalConfigManager as AccountDataGlobalConfigManager,\n    RoomConfigManager as AccountDataRoomConfigManager,\n};\nuse mxlink::helpers::encryption::Manager as EncryptionManager;\nuse mxlink::mime::Mime;\n\nuse crate::agent::Manager as AgentManager;\nuse crate::entity::catch_up_marker::{\n    CatchUpMarker, CatchUpMarkerManager, DelayedCatchUpMarkerManager,\n};\nuse crate::entity::cfg::{Avatar, Config, ConfigUserAuth};\nuse crate::entity::globalconfig::{GlobalConfig, GlobalConfigurationManager};\nuse crate::entity::roomconfig::{RoomConfig, RoomConfigurationManager};\n\nuse crate::agent::Manager;\n\nuse crate::conversation::matrix::{RoomDisplayNameFetcher, RoomEventFetcher};\n\nconst ROOM_EVENT_FETCHER_LRU_CACHE_SIZE: usize = 1000;\nconst ROOM_DISPLAY_NAME_FETCHER_LRU_CACHE_SIZE: usize = 1000;\nconst ROOM_CONFIG_MANAGER_LRU_CACHE_SIZE: usize = 1000;\n\nconst LOGO_BYTES: &[u8] = include_bytes!(\"../../etc/assets/baibot-torso-768.png\");\nconst LOGO_MIME_TYPE: &str = \"image/png\";\n\n/// Controls how often we persist the catch-up marker to Account Data.\n/// Consult the `DelayedCatchUpMarkerManager` documentation for more information.\nconst DELAYED_CATCH_UP_MARKER_MANAGER_PERSIST_INTERVAL_DURATION: std::time::Duration =\n    std::time::Duration::from_secs(10);\n\n/// Controls what federation delay we will tolerate. The timestamp that gets persisted\n/// will be based on the last seen event's `origin_server_ts` minus this duration.\n/// Consult the `DelayedCatchUpMarkerManager` documentation for more information.\nconst DELAYED_CATCH_UP_MARKER_MANAGER_FEDERATION_DELAY_TOLERANCE_DURATION: std::time::Duration =\n    std::time::Duration::from_secs(90);\n\nstruct BotInner {\n    config: Config,\n\n    matrix_link: MatrixLink,\n    delayed_catch_up_marker_manager: DelayedCatchUpMarkerManager,\n    global_config_manager: tokio::sync::Mutex<GlobalConfigurationManager>,\n    room_config_manager: tokio::sync::Mutex<RoomConfigurationManager>,\n    room_event_fetcher: Arc<RoomEventFetcher>,\n    room_display_name_fetcher: Arc<RoomDisplayNameFetcher>,\n    agent_manager: Manager,\n    admin_pattern_regexes: Vec<regex::Regex>,\n}\n\n/// Bot represents a bot instance.\n///\n/// All of the state is held in an `Arc` so the `Bot` can be cloned freely.\n#[derive(Clone)]\npub struct Bot {\n    inner: Arc<BotInner>,\n}\n\nimpl Bot {\n    pub async fn new(config: Config) -> anyhow::Result<Self> {\n        // Take some potentially problematic configuration values out of the config early on.\n        // If we'd be failing, we'd like it to happen early, before we log in, etc.\n\n        let initial_global_config: GlobalConfig =\n            config.initial_global_config.clone().try_into()?;\n\n        let admin_pattern_regexes = config.access.admin_pattern_regexes()?;\n        let persistence_config_encryption_key = config.persistence.config_encryption_key()?;\n\n        let agent_manager = AgentManager::new(config.agents.static_definitions.clone())?;\n\n        let encryption_manager = EncryptionManager::new(persistence_config_encryption_key);\n\n        let matrix_link = create_matrix_link(&config).await?;\n\n        let catch_up_marker_manager = create_catch_up_marker_manager(matrix_link.clone());\n\n        let delayed_catch_up_marker_manager = DelayedCatchUpMarkerManager::new(\n            catch_up_marker_manager,\n            DELAYED_CATCH_UP_MARKER_MANAGER_PERSIST_INTERVAL_DURATION,\n            DELAYED_CATCH_UP_MARKER_MANAGER_FEDERATION_DELAY_TOLERANCE_DURATION,\n        );\n\n        let global_config_manager = tokio::sync::Mutex::new(create_global_configuration_manager(\n            matrix_link.clone(),\n            encryption_manager.clone(),\n            initial_global_config,\n        ));\n\n        let room_config_manager = tokio::sync::Mutex::new(create_room_configuration_manager(\n            matrix_link.clone(),\n            encryption_manager.clone(),\n        ));\n\n        let room_event_fetcher = RoomEventFetcher::new(Some(ROOM_EVENT_FETCHER_LRU_CACHE_SIZE));\n\n        let room_display_name_fetcher = RoomDisplayNameFetcher::new(\n            matrix_link.clone(),\n            Some(ROOM_DISPLAY_NAME_FETCHER_LRU_CACHE_SIZE),\n        );\n\n        Ok(Self {\n            inner: Arc::new(BotInner {\n                config,\n\n                matrix_link,\n                delayed_catch_up_marker_manager,\n                global_config_manager,\n                room_config_manager,\n                room_event_fetcher: Arc::new(room_event_fetcher),\n                room_display_name_fetcher: Arc::new(room_display_name_fetcher),\n                agent_manager,\n                admin_pattern_regexes,\n            }),\n        })\n    }\n\n    pub(crate) fn admin_patterns(&self) -> &Vec<String> {\n        &self.inner.config.access.admin_patterns\n    }\n\n    pub(crate) fn name(&self) -> &str {\n        &self.inner.config.user.name\n    }\n\n    pub(crate) fn command_prefix(&self) -> &str {\n        &self.inner.config.command_prefix\n    }\n\n    pub(crate) fn post_join_self_introduction_enabled(&self) -> bool {\n        self.inner.config.room.post_join_self_introduction_enabled\n    }\n\n    pub(crate) fn homeserver_name(&self) -> &str {\n        &self.inner.config.homeserver.server_name\n    }\n\n    pub(crate) fn global_config_manager(&self) -> &tokio::sync::Mutex<GlobalConfigurationManager> {\n        &self.inner.global_config_manager\n    }\n\n    pub(crate) fn room_config_manager(&self) -> &tokio::sync::Mutex<RoomConfigurationManager> {\n        &self.inner.room_config_manager\n    }\n\n    pub(crate) fn room_event_fetcher(&self) -> Arc<RoomEventFetcher> {\n        self.inner.room_event_fetcher.clone()\n    }\n\n    pub(crate) fn room_display_name_fetcher(&self) -> Arc<RoomDisplayNameFetcher> {\n        self.inner.room_display_name_fetcher.clone()\n    }\n\n    pub(crate) fn agent_manager(&self) -> &Manager {\n        &self.inner.agent_manager\n    }\n\n    pub(crate) fn matrix_link(&self) -> &MatrixLink {\n        &self.inner.matrix_link\n    }\n\n    pub(crate) fn user_id(&self) -> &OwnedUserId {\n        self.matrix_link().user_id()\n    }\n\n    pub(crate) async fn user_display_name_in_room(&self, room: &Room) -> Option<String> {\n        let bot_display_name = self\n            .room_display_name_fetcher()\n            .own_display_name_in_room(room)\n            .await;\n\n        match bot_display_name {\n            Ok(value) => value,\n            Err(err) => {\n                tracing::warn!(\n                    ?err,\n                    \"Failed to fetch bot display name. Proceeding without it\"\n                );\n                None\n            }\n        }\n    }\n\n    pub(crate) fn reacting(&self) -> super::reacting::Reacting {\n        super::reacting::Reacting::new(self.clone())\n    }\n\n    pub(crate) fn rooms(&self) -> super::rooms::Rooms {\n        super::rooms::Rooms::new(self.clone())\n    }\n\n    pub(crate) fn messaging(&self) -> super::messaging::Messaging {\n        super::messaging::Messaging::new(self.clone())\n    }\n\n    pub(crate) fn admin_pattern_regexes(&self) -> &Vec<regex::Regex> {\n        &self.inner.admin_pattern_regexes\n    }\n\n    pub(crate) async fn global_config(&self) -> Result<GlobalConfig, ConfigError> {\n        let mut global_config_manager_guard = self.inner.global_config_manager.lock().await;\n\n        global_config_manager_guard.get_or_create().await\n    }\n\n    pub(crate) async fn is_caught_up(\n        &self,\n        event_origin_server_ts: MilliSecondsSinceUnixEpoch,\n    ) -> Result<bool, ConfigError> {\n        self.inner\n            .delayed_catch_up_marker_manager\n            .is_caught_up(event_origin_server_ts.0.into())\n            .await\n    }\n\n    pub(crate) async fn catch_up(&self, event_origin_server_ts: MilliSecondsSinceUnixEpoch) {\n        self.inner\n            .delayed_catch_up_marker_manager\n            .catch_up(event_origin_server_ts.0.into())\n            .await\n    }\n\n    pub(crate) async fn start_typing_notice(&self, room: &Room) -> TypingNoticeGuard {\n        self.inner\n            .matrix_link\n            .rooms()\n            .start_typing_notice(room)\n            .await\n    }\n\n    pub async fn start(&self) -> anyhow::Result<()> {\n        self.rooms().attach_event_handlers().await;\n        self.messaging().attach_event_handlers().await;\n        self.reacting().attach_event_handlers().await;\n\n        self.inner.delayed_catch_up_marker_manager.start().await;\n\n        self.prepare_profile().await?;\n\n        self.inner\n            .matrix_link\n            .start()\n            .await\n            .map_err(|e| anyhow::anyhow!(\"Failed to sync: {:?}\", e))\n    }\n\n    async fn prepare_profile(&self) -> anyhow::Result<()> {\n        use std::time::Duration;\n        use tokio::time::sleep;\n\n        let mut delay = Duration::from_secs(3);\n        let max_delay = Duration::from_secs(30);\n\n        loop {\n            match self.do_prepare_profile().await {\n                Ok(_) => return Ok(()),\n                Err(err) => {\n                    tracing::warn!(\n                        ?err,\n                        ?delay,\n                        \"Failed to prepare profile.. Will retry after delay...\"\n                    );\n\n                    sleep(delay).await;\n\n                    delay = std::cmp::min(delay * 2, max_delay);\n                }\n            }\n        }\n    }\n\n    async fn do_prepare_profile(&self) -> anyhow::Result<()> {\n        tracing::debug!(\"Preparing profile..\");\n\n        let desired_display_name = self.inner.config.user.name.clone();\n\n        let account = self.inner.matrix_link.client().account();\n        let media = self.inner.matrix_link.client().media();\n\n        let profile = account\n            .fetch_user_profile()\n            .await\n            .map_err(|e| anyhow::anyhow!(\"Failed fetching profile: {:?}\", e))?;\n\n        let current_display_name = profile.get_static::<DisplayName>()?;\n        let current_avatar_url = profile.get_static::<AvatarUrl>()?;\n\n        let should_update_display_name = match &current_display_name {\n            Some(displayname) => displayname != &desired_display_name,\n            None => true,\n        };\n\n        if should_update_display_name {\n            tracing::info!(\n                ?current_display_name,\n                ?desired_display_name,\n                \"Updating display name..\"\n            );\n\n            if let Err(err) = account.set_display_name(Some(&desired_display_name)).await {\n                return Err(anyhow::anyhow!(\"Failed setting display name: {:?}\", err));\n            }\n        }\n\n        let desired_avatar: Option<(Vec<u8>, Mime)> = match &self.inner.config.user.avatar {\n            Avatar::Keep => {\n                tracing::info!(\"Avatar configured to keep current, skipping avatar management\");\n                None\n            }\n            Avatar::Default => {\n                tracing::info!(\"Avatar configured to use default\");\n                Some((\n                    LOGO_BYTES.to_vec(),\n                    LOGO_MIME_TYPE\n                        .parse()\n                        .expect(\"Failed parsing mime type for logo\"),\n                ))\n            }\n            Avatar::Custom(avatar_path) => {\n                tracing::info!(?avatar_path, \"Avatar configured to use custom path\");\n                let bytes = fs::read(avatar_path).map_err(|e| {\n                    anyhow::anyhow!(\"Failed reading avatar from {:?}: {:?}\", avatar_path, e)\n                })?;\n                let mime = mime_guess::from_path(avatar_path).first_or_octet_stream();\n                tracing::debug!(?mime, bytes_len = bytes.len(), \"Loaded custom avatar\");\n                Some((bytes, mime))\n            }\n        };\n\n        if let Some((desired_bytes, mime_type)) = desired_avatar {\n            let should_update_avatar = match &current_avatar_url {\n                Some(avatar_url) => {\n                    tracing::debug!(?avatar_url, \"Fetching current avatar to compare\");\n                    let request = MediaRequestParameters {\n                        source: MediaSource::Plain(avatar_url.to_owned()),\n                        format: MediaFormat::File,\n                    };\n\n                    let content = media\n                        .get_media_content(&request, true)\n                        .await\n                        .map_err(|e| anyhow::anyhow!(\"Failed fetching existing avatar: {:?}\", e))?;\n\n                    let needs_update = content.as_slice() != desired_bytes;\n\n                    tracing::debug!(\n                        current_bytes_len = content.len(),\n                        desired_bytes_len = desired_bytes.len(),\n                        ?needs_update,\n                        \"Compared current and desired avatar\"\n                    );\n\n                    needs_update\n                }\n                None => {\n                    tracing::debug!(\"No current avatar set, will upload\");\n                    true\n                }\n            };\n\n            if should_update_avatar {\n                tracing::info!(\"Updating avatar..\");\n                account\n                    .upload_avatar(&mime_type, desired_bytes)\n                    .await\n                    .map_err(|e| anyhow::anyhow!(\"Failed uploading avatar: {:?}\", e))?;\n                tracing::info!(\"Avatar updated successfully\");\n            } else {\n                tracing::debug!(\"Avatar already up to date, skipping upload\");\n            }\n        }\n\n        Ok(())\n    }\n}\n\nasync fn create_matrix_link(config: &Config) -> anyhow::Result<MatrixLink> {\n    let session_file_path = config.persistence.session_file_path()?;\n    let session_encryption_key = config.persistence.session_encryption_key()?;\n    let db_dir_path: std::path::PathBuf = config.persistence.db_dir_path()?;\n\n    let user_auth = config.user.auth_config(&config.homeserver.server_name)?;\n\n    let login_creds = match user_auth {\n        ConfigUserAuth::UserPassword { username, password } => {\n            LoginCredentials::UserPassword(username, password)\n        }\n        ConfigUserAuth::AccessToken {\n            user_id,\n            device_id,\n            access_token,\n        } => LoginCredentials::AccessToken {\n            user_id,\n            device_id,\n            access_token,\n        },\n    };\n\n    let login_encryption = LoginEncryption::new(\n        config.user.encryption.recovery_passphrase.clone(),\n        config.user.encryption.recovery_reset_allowed,\n    );\n\n    let login_config = LoginConfig::new(\n        config.homeserver.url.to_owned(),\n        login_creds,\n        Some(login_encryption),\n        config.user.name.to_owned(),\n    );\n\n    let persistence_config =\n        PersistenceConfig::new(session_file_path, session_encryption_key, db_dir_path);\n\n    let init_config = InitConfig::new(login_config, persistence_config);\n\n    mxlink::init(&init_config).await.map_err(|e| e.into())\n}\n\npub fn create_global_configuration_manager(\n    matrix_link: MatrixLink,\n    encryption_manager: EncryptionManager,\n    initial_global_config: GlobalConfig,\n) -> GlobalConfigurationManager {\n    let initial_global_config_callback = move || {\n        let initial_global_config = initial_global_config.clone();\n\n        let future = create_initial_global_config(initial_global_config);\n\n        // Explicitly box the future to match the expected type\n        Box::pin(future) as Pin<Box<dyn Future<Output = GlobalConfig> + Send>>\n    };\n\n    AccountDataGlobalConfigManager::new(\n        matrix_link,\n        encryption_manager,\n        initial_global_config_callback,\n    )\n}\n\nasync fn create_initial_global_config(initial_global_config: GlobalConfig) -> GlobalConfig {\n    initial_global_config\n}\n\npub fn create_room_configuration_manager(\n    matrix_link: MatrixLink,\n    encryption_manager: EncryptionManager,\n) -> RoomConfigurationManager {\n    let initial_room_config_callback = |room: Room| {\n        let future = create_initial_room_config(room);\n\n        // Explicitly box the future to match the expected type\n        Box::pin(future) as Pin<Box<dyn Future<Output = RoomConfig> + Send>>\n    };\n\n    AccountDataRoomConfigManager::new(\n        matrix_link.user_id().clone(),\n        encryption_manager,\n        initial_room_config_callback,\n        Some(ROOM_CONFIG_MANAGER_LRU_CACHE_SIZE),\n    )\n}\n\nasync fn create_initial_room_config(room: Room) -> RoomConfig {\n    RoomConfig::default().with_room(room).await\n}\n\npub fn create_catch_up_marker_manager(matrix_link: MatrixLink) -> CatchUpMarkerManager {\n    let initial_global_config_callback = || {\n        let future = create_initial_catch_up_marker();\n\n        // Explicitly box the future to match the expected type\n        Box::pin(future) as Pin<Box<dyn Future<Output = CatchUpMarker> + Send>>\n    };\n\n    // Intentionally not using encryption, to make this resilient even if we lose our encryption key.\n    // We're not worried about the catch-up marker being read or tampered with, as it's not sensitive data.\n    let encryption_manager = EncryptionManager::new(None);\n\n    let catch_up_marker_manager: CatchUpMarkerManager = AccountDataGlobalConfigManager::new(\n        matrix_link.clone(),\n        encryption_manager,\n        initial_global_config_callback,\n    );\n\n    catch_up_marker_manager\n}\n\nasync fn create_initial_catch_up_marker() -> CatchUpMarker {\n    CatchUpMarker::new(0)\n}\n"
  },
  {
    "path": "src/bot/load_config.rs",
    "content": "use std::env;\nuse std::path::PathBuf;\n\nuse anyhow::anyhow;\n\nuse crate::agent::AgentPurpose;\n\npub use crate::entity::cfg::{Avatar, Config, defaults as cfg_defaults, env as cfg_env};\n\npub fn load() -> anyhow::Result<Config> {\n    let config_file_path = env::var(cfg_env::BAIBOT_CONFIG_FILE_PATH)\n        .unwrap_or_else(|_| cfg_defaults::config_file_path().to_owned());\n    let config_file_path = PathBuf::from(config_file_path);\n\n    if !config_file_path.exists() {\n        return Err(anyhow!(\n            \"Config file ({}) not found. Adjust the {} environment variable to use another config file.\",\n            config_file_path.display(),\n            cfg_env::BAIBOT_CONFIG_FILE_PATH,\n        ));\n    }\n\n    let config_str = std::fs::read_to_string(config_file_path)?;\n    let mut config: Config = serde_yaml_ng::from_str(&config_str)?;\n\n    // Allow environment variables to override some configuration keys\n    for (key, value) in env::vars() {\n        match key.as_str() {\n            cfg_env::BAIBOT_HOMESERVER_SERVER_NAME => config.homeserver.server_name = value,\n            cfg_env::BAIBOT_HOMESERVER_URL => config.homeserver.url = value,\n            cfg_env::BAIBOT_USER_MXID_LOCALPART => config.user.mxid_localpart = value,\n            cfg_env::BAIBOT_USER_PASSWORD => {\n                config.user.password = optional_non_empty(value);\n            }\n            cfg_env::BAIBOT_USER_ACCESS_TOKEN => {\n                config.user.access_token = optional_non_empty(value);\n            }\n            cfg_env::BAIBOT_USER_DEVICE_ID => {\n                config.user.device_id = optional_non_empty(value);\n            }\n            cfg_env::BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE => {\n                config.user.encryption.recovery_passphrase = Some(value);\n            }\n            cfg_env::BAIBOT_USER_ENCRYPTION_RECOVERY_RESET_ALLOWED => {\n                config.user.encryption.recovery_reset_allowed = value.parse::<bool>()?;\n            }\n            cfg_env::BAIBOT_USER_NAME => config.user.name = value,\n            cfg_env::BAIBOT_USER_AVATAR => {\n                config.user.avatar = Avatar::from_string(value);\n            }\n            cfg_env::BAIBOT_COMMAND_PREFIX => config.command_prefix = value,\n            cfg_env::BAIBOT_ROOM_POST_JOIN_SELF_INTRODUCTION_ENABLED => {\n                config.room.post_join_self_introduction_enabled = value.parse::<bool>()?;\n            }\n            cfg_env::BAIBOT_LOGGING => {\n                config.logging = value;\n            }\n            cfg_env::BAIBOT_ACCESS_ADMIN_PATTERNS => {\n                config.access.admin_patterns = value\n                    .split(' ')\n                    .map(|s| s.trim().to_string())\n                    .filter(|s| !s.is_empty())\n                    .collect();\n            }\n            cfg_env::BAIBOT_PERSISTENCE_DATA_DIR_PATH => {\n                config.persistence.data_dir_path = Some(value);\n            }\n            cfg_env::BAIBOT_PERSISTENCE_SESSION_ENCRYPTION_KEY => {\n                config.persistence.session_encryption_key = Some(value);\n            }\n            cfg_env::BAIBOT_PERSISTENCE_CONFIG_ENCRYPTION_KEY => {\n                config.persistence.config_encryption_key = Some(value);\n            }\n            cfg_env::BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_CATCH_ALL => {\n                let value = if value.is_empty() { None } else { Some(value) };\n\n                config\n                    .initial_global_config\n                    .handler\n                    .set_by_purpose(AgentPurpose::CatchAll, value);\n            }\n            cfg_env::BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_TEXT_GENERATION => {\n                let value = if value.is_empty() { None } else { Some(value) };\n\n                config\n                    .initial_global_config\n                    .handler\n                    .set_by_purpose(AgentPurpose::TextGeneration, value);\n            }\n            cfg_env::BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_TEXT_TO_SPEECH => {\n                let value = if value.is_empty() { None } else { Some(value) };\n\n                config\n                    .initial_global_config\n                    .handler\n                    .set_by_purpose(AgentPurpose::TextToSpeech, value);\n            }\n            cfg_env::BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_SPEECH_TO_TEXT => {\n                let value = if value.is_empty() { None } else { Some(value) };\n\n                config\n                    .initial_global_config\n                    .handler\n                    .set_by_purpose(AgentPurpose::SpeechToText, value);\n            }\n            cfg_env::BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_IMAGE_GENERATION => {\n                let value = if value.is_empty() { None } else { Some(value) };\n\n                config\n                    .initial_global_config\n                    .handler\n                    .set_by_purpose(AgentPurpose::ImageGeneration, value);\n            }\n            cfg_env::BAIBOT_INITIAL_GLOBAL_CONFIG_USER_PATTERNS => {\n                config.initial_global_config.user_patterns = Some(\n                    value\n                        .split(' ')\n                        .map(|s| s.trim().to_string())\n                        .filter(|s| !s.is_empty())\n                        .collect(),\n                );\n            }\n            _ => {}\n        }\n    }\n\n    config.validate().map_err(|s| anyhow!(s))?;\n\n    Ok(config)\n}\n\nfn optional_non_empty(value: String) -> Option<String> {\n    if value.is_empty() { None } else { Some(value) }\n}\n"
  },
  {
    "path": "src/bot/messaging.rs",
    "content": "use mxlink::matrix_sdk::{\n    Room,\n    ruma::{\n        OwnedEventId, api::client::receipt::create_receipt::v3::ReceiptType,\n        events::room::message::OriginalSyncRoomMessageEvent,\n    },\n};\n\nuse mxlink::{CallbackError, MessageResponseType};\n\nuse tracing::Instrument;\n\nuse crate::{\n    conversation::matrix::determine_interaction_context_for_room_event,\n    entity::{MessageContext, MessagePayload, RoomConfigContext, TriggerEventInfo},\n};\n\n#[derive(Clone)]\npub struct Messaging {\n    bot: super::Bot,\n}\n\nimpl Messaging {\n    pub fn new(bot: super::Bot) -> Self {\n        Self { bot }\n    }\n\n    pub async fn send_text_markdown_no_fail(\n        &self,\n        room: &Room,\n        message: String,\n        response_type: MessageResponseType,\n    ) -> Option<mxlink::matrix_sdk::ruma::api::client::message::send_message_event::v3::Response>\n    {\n        let result = self\n            .bot\n            .matrix_link()\n            .messaging()\n            .send_text_markdown(room, message, response_type)\n            .await;\n\n        match result {\n            Ok(result) => Some(result),\n            Err(err) => {\n                tracing::error!(\n                    room_id = format!(\"{:?}\", room.room_id()),\n                    ?err,\n                    \"Failed to send text message to room\",\n                );\n                None\n            }\n        }\n    }\n\n    pub async fn send_notice_markdown_no_fail(\n        &self,\n        room: &Room,\n        message: String,\n        response_type: MessageResponseType,\n    ) -> Option<mxlink::matrix_sdk::ruma::api::client::message::send_message_event::v3::Response>\n    {\n        let result = self\n            .bot\n            .matrix_link()\n            .messaging()\n            .send_notice_markdown(room, message, response_type)\n            .await;\n\n        match result {\n            Ok(result) => Some(result),\n            Err(err) => {\n                tracing::error!(\n                    room_id = format!(\"{:?}\", room.room_id()),\n                    ?err,\n                    \"Failed to send notice message to room\",\n                );\n                None\n            }\n        }\n    }\n\n    pub async fn send_tooltip_markdown_no_fail(\n        &self,\n        room: &Room,\n        message: &str,\n        response_type: MessageResponseType,\n    ) -> Option<mxlink::matrix_sdk::ruma::api::client::message::send_message_event::v3::Response>\n    {\n        self.send_notice_markdown_no_fail(\n            room,\n            crate::utils::status::create_tooltip_message_text(message),\n            response_type,\n        )\n        .await\n    }\n\n    pub async fn send_success_markdown_no_fail(\n        &self,\n        room: &Room,\n        message: &str,\n        response_type: MessageResponseType,\n    ) -> Option<mxlink::matrix_sdk::ruma::api::client::message::send_message_event::v3::Response>\n    {\n        self.send_notice_markdown_no_fail(\n            room,\n            crate::utils::status::create_success_message_text(message),\n            response_type,\n        )\n        .await\n    }\n\n    pub async fn send_error_markdown_no_fail(\n        &self,\n        room: &Room,\n        err: &str,\n        response_type: MessageResponseType,\n    ) -> Option<mxlink::matrix_sdk::ruma::api::client::message::send_message_event::v3::Response>\n    {\n        self.send_notice_markdown_no_fail(\n            room,\n            crate::utils::status::create_error_message_text(err),\n            response_type,\n        )\n        .await\n    }\n\n    pub async fn redact_event_no_fail(\n        &self,\n        room: &Room,\n        target_event_id: OwnedEventId,\n        reason: Option<String>,\n    ) -> Option<mxlink::matrix_sdk::ruma::api::client::redact::redact_event::v3::Response> {\n        let result = self\n            .bot\n            .matrix_link()\n            .messaging()\n            .redact_event(room, target_event_id.clone(), reason)\n            .await;\n\n        match result {\n            Ok(result) => Some(result),\n            Err(err) => {\n                tracing::error!(\n                    room_id = format!(\"{:?}\", room.room_id()),\n                    ?target_event_id,\n                    ?err,\n                    \"Failed to send redaction to room\",\n                );\n\n                None\n            }\n        }\n    }\n\n    pub(super) async fn attach_event_handlers(&self) {\n        let matrix_link_messaging = self.bot.matrix_link().messaging();\n\n        let this = self.clone();\n        matrix_link_messaging.on_actionable_room_message(|event, room| async move {\n            this.on_actionable_message(event, room).await\n        });\n    }\n\n    #[tracing::instrument(name = \"bot_on_actionable_message\", skip_all, fields(room_id = room.room_id().as_str(), event_id = event.event_id.as_str()))]\n    async fn on_actionable_message(\n        &self,\n        event: OriginalSyncRoomMessageEvent,\n        room: Room,\n    ) -> Result<(), CallbackError> {\n        if self\n            .bot\n            .is_caught_up(event.origin_server_ts)\n            .await\n            .map_err(|e| {\n                CallbackError::Unknown(\n                    format!(\"Failed to determine catch-up state: {:?}\", e).into(),\n                )\n            })?\n        {\n            tracing::debug!(\n                event_origin_server_ts = format!(\"{:?}\", event.origin_server_ts),\n                \"Ignoring old message event\",\n            );\n\n            return Ok(());\n        }\n\n        tracing::info!(\"Processing message\");\n\n        let global_config = self\n            .bot\n            .global_config()\n            .await\n            .map_err(|err| CallbackError::Unknown(err.into()))?;\n\n        tracing::trace!(?global_config, \"Global config\");\n\n        let room_config = self\n            .bot\n            .room_config_manager()\n            .lock()\n            .await\n            .get_or_create_for_room(&room)\n            .await\n            .map_err(|err| CallbackError::Unknown(err.into()))?;\n\n        tracing::trace!(?room_config, \"Room config\");\n\n        let trigger_event_sender_is_admin = mxidwc::match_user_id(\n            event.sender.clone().as_str(),\n            self.bot.admin_pattern_regexes(),\n        );\n\n        let trigger_event_sender_is_allowed_user = match &global_config.access.user_patterns {\n            Some(user_patterns) => {\n                let allowed_user_regexes = mxidwc::parse_patterns_vector(user_patterns)\n                    .map_err(|err| CallbackError::Unknown(err.into()))?;\n\n                mxidwc::match_user_id(event.sender.clone().as_str(), &allowed_user_regexes)\n            }\n            None => false,\n        };\n\n        if !trigger_event_sender_is_admin && !trigger_event_sender_is_allowed_user {\n            tracing::debug!(\"Ignoring message from non-admin/non-allowed user\");\n            return Ok(());\n        }\n\n        let payload: Result<MessagePayload, String> = event.content.msgtype.clone().try_into();\n        let payload = match payload {\n            Ok(payload) => payload,\n            Err(err) => {\n                tracing::debug!(\n                    msg_type = event.content.msgtype(),\n                    ?err,\n                    \"Ignoring message not supported by us\",\n                );\n                return Ok(());\n            }\n        };\n\n        let bot_display_name = self.bot.user_display_name_in_room(&room).await;\n\n        let interaction_context = determine_interaction_context_for_room_event(\n            self.bot.user_id(),\n            &bot_display_name,\n            &room,\n            &event,\n            &payload,\n            &self.bot.room_event_fetcher(),\n        )\n        .await;\n\n        let interaction_context = match interaction_context {\n            Ok(value) => value,\n            Err(err) => {\n                tracing::error!(?err, \"Failed to determine interaction context for event\");\n                return Ok(());\n            }\n        };\n\n        let Some(interaction_context) = interaction_context else {\n            tracing::debug!(\n                \"Ignoring message with unknown interaction context (likely not a message for us)\"\n            );\n            return Ok(());\n        };\n\n        let room_config_context =\n            RoomConfigContext::new(global_config.clone(), room_config.clone());\n\n        let trigger_event_info = TriggerEventInfo::new(\n            event.event_id.clone(),\n            event.sender.clone(),\n            payload,\n            trigger_event_sender_is_admin,\n        );\n\n        let message_context = MessageContext::new(\n            room.clone(),\n            room_config_context,\n            self.bot.admin_pattern_regexes().clone(),\n            trigger_event_info,\n            interaction_context.thread_info.clone(),\n        )\n        .with_bot_display_name(bot_display_name);\n\n        let controller_type = crate::controller::determine_controller(\n            self.bot.command_prefix(),\n            &interaction_context.trigger,\n            &message_context,\n        );\n\n        tracing::info!(?controller_type, \"Determined controller\");\n\n        let _ = room\n            .send_single_receipt(\n                ReceiptType::Read,\n                interaction_context.thread_info.clone().into(),\n                event.event_id.clone(),\n            )\n            .await;\n\n        let start_time = std::time::Instant::now();\n\n        let event_span = tracing::error_span!(\"message_controller\", ?controller_type);\n\n        crate::controller::dispatch_controller(&controller_type, &message_context, &self.bot)\n            .instrument(event_span)\n            .await;\n\n        let duration = std::time::Instant::now().duration_since(start_time);\n\n        tracing::debug!(?duration, \"Controller finished\");\n\n        self.bot.catch_up(event.origin_server_ts).await;\n\n        return Ok(());\n    }\n}\n"
  },
  {
    "path": "src/bot/mod.rs",
    "content": "mod implementation;\nmod load_config;\nmod messaging;\nmod reacting;\nmod rooms;\n\npub use implementation::Bot;\npub use load_config::load as load_config;\n"
  },
  {
    "path": "src/bot/reacting.rs",
    "content": "use mxlink::matrix_sdk::{\n    Room,\n    ruma::{\n        OwnedEventId, OwnedUserId,\n        events::{\n            AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncMessageLikeEvent,\n            room::message::Relation,\n        },\n    },\n};\n\nuse mxlink::CallbackError;\nuse mxlink::ThreadInfo;\n\nuse tracing::Instrument;\n\nuse crate::entity::{MessageContext, MessagePayload, RoomConfigContext, TriggerEventInfo};\n\n#[derive(Clone)]\npub struct Reacting {\n    bot: super::Bot,\n}\n\nimpl Reacting {\n    pub fn new(bot: super::Bot) -> Self {\n        Self { bot }\n    }\n\n    pub async fn react_no_fail(\n        &self,\n        room: &Room,\n        target_event_id: OwnedEventId,\n        reaction_key: String,\n    ) -> Option<mxlink::matrix_sdk::ruma::api::client::message::send_message_event::v3::Response>\n    {\n        let result = self\n            .bot\n            .matrix_link()\n            .reacting()\n            .react(room, target_event_id.clone(), reaction_key)\n            .await;\n\n        match result {\n            Ok(result) => Some(result),\n            Err(err) => {\n                tracing::error!(\n                    \"Failed to send reaction to {} in room {:?}: {:?}\",\n                    target_event_id,\n                    room.room_id(),\n                    err\n                );\n                None\n            }\n        }\n    }\n\n    pub(super) async fn attach_event_handlers(&self) {\n        let matrix_link_reacting = self.bot.matrix_link().reacting();\n\n        let this = self.clone();\n        matrix_link_reacting.on_actionable_reaction(\n            |event, room, reaction_event_content| async move {\n                this.on_actionable_reaction(event, room, reaction_event_content)\n                    .await\n            },\n        );\n    }\n\n    #[tracing::instrument(name = \"bot_on_actionable_reaction\", skip_all, fields(room_id = room.room_id().as_str(), event_id = event.event_id().as_str()))]\n    async fn on_actionable_reaction(\n        &self,\n        event: AnySyncTimelineEvent,\n        room: Room,\n        reaction_event_content: mxlink::matrix_sdk::ruma::events::reaction::ReactionEventContent,\n    ) -> Result<(), CallbackError> {\n        if self\n            .bot\n            .is_caught_up(event.origin_server_ts())\n            .await\n            .map_err(|e| {\n                CallbackError::Unknown(\n                    format!(\"Failed to determine catch-up state: {:?}\", e).into(),\n                )\n            })?\n        {\n            tracing::debug!(\n                event_origin_server_ts = format!(\"{:?}\", event.origin_server_ts()),\n                \"Ignoring old reaction event\",\n            );\n\n            return Ok(());\n        }\n\n        tracing::info!(\"Handling reaction\");\n\n        let global_config = self\n            .bot\n            .global_config()\n            .await\n            .map_err(|err| CallbackError::Unknown(err.into()))?;\n\n        tracing::trace!(?global_config, \"Global config\");\n\n        let trigger_event_sender_is_admin =\n            mxidwc::match_user_id(event.sender().as_str(), self.bot.admin_pattern_regexes());\n\n        let trigger_event_sender_is_allowed_user = match &global_config.access.user_patterns {\n            Some(user_patterns) => {\n                let allowed_user_regexes = mxidwc::parse_patterns_vector(user_patterns)\n                    .map_err(|err| CallbackError::Unknown(err.into()))?;\n\n                mxidwc::match_user_id(event.sender().as_str(), &allowed_user_regexes)\n            }\n            None => false,\n        };\n\n        if !trigger_event_sender_is_admin && !trigger_event_sender_is_allowed_user {\n            tracing::debug!(\"Ignoring reaction from non-admin/non-allowed user\");\n            return Ok(());\n        }\n\n        let reacted_to_event_id = &reaction_event_content.relates_to.event_id;\n\n        let reacted_to_event = self\n            .bot\n            .room_event_fetcher()\n            .fetch_event_in_room(reacted_to_event_id, &room)\n            .await;\n\n        let reacted_to_event = match reacted_to_event {\n            Ok(value) => value,\n            Err(err) => {\n                tracing::error!(\n                    ?reacted_to_event_id,\n                    ?err,\n                    \"Failed to fetch reacted-to event\",\n                );\n                return Ok(());\n            }\n        };\n\n        let reacted_to_event_any_timeline_event = match reacted_to_event.raw().deserialize() {\n            Ok(value) => value,\n            Err(err) => {\n                tracing::error!(\n                    ?reacted_to_event_id,\n                    ?err,\n                    \"Failed to deserialize reacted-to event event\",\n                );\n                return Ok(());\n            }\n        };\n\n        let reacted_to_event_sender_id: OwnedUserId =\n            reacted_to_event_any_timeline_event.sender().to_owned();\n\n        let AnySyncTimelineEvent::MessageLike(reacted_to_event_message_like) =\n            reacted_to_event_any_timeline_event\n        else {\n            tracing::debug!(\n                ?reacted_to_event_id,\n                \"Ignoring non-MessageLike reacted-to event\",\n            );\n            return Ok(());\n        };\n\n        let AnySyncMessageLikeEvent::RoomMessage(reacted_to_event_room_message) =\n            reacted_to_event_message_like\n        else {\n            tracing::debug!(\n                ?reacted_to_event_id,\n                \"Ignoring non-RoomMessage reacted-to event\",\n            );\n            return Ok(());\n        };\n\n        let SyncMessageLikeEvent::Original(reacted_to_event_room_message_original) =\n            reacted_to_event_room_message\n        else {\n            tracing::debug!(?reacted_to_event_id, \"Ignoring redacted reacted-to event\",);\n            return Ok(());\n        };\n\n        let reacted_to_event_payload: Result<MessagePayload, String> =\n            reacted_to_event_room_message_original\n                .content\n                .msgtype\n                .clone()\n                .try_into();\n        let Ok(reacted_to_event_payload) = reacted_to_event_payload else {\n            tracing::debug!(\n                msg_type = reacted_to_event_room_message_original.content.msgtype(),\n                \"Ignoring reaction to message of unknown type\",\n            );\n            return Ok(());\n        };\n\n        let thread_root_event_id = match reacted_to_event_room_message_original.content.relates_to {\n            Some(relation) => {\n                if let Relation::Thread(thread_id) = relation {\n                    thread_id.event_id.clone()\n                } else {\n                    reacted_to_event_id.clone()\n                }\n            }\n            None => reacted_to_event_id.clone(),\n        };\n\n        let thread_info = ThreadInfo::new(thread_root_event_id, reacted_to_event_id.clone());\n\n        let room_config = self\n            .bot\n            .room_config_manager()\n            .lock()\n            .await\n            .get_or_create_for_room(&room)\n            .await\n            .map_err(|err| CallbackError::Unknown(err.into()))?;\n\n        tracing::trace!(?room_config, \"Room config\");\n\n        let room_config_context =\n            RoomConfigContext::new(global_config.clone(), room_config.clone());\n\n        let trigger_event_info = TriggerEventInfo::new(\n            event.event_id().to_owned(),\n            event.sender().to_owned(),\n            MessagePayload::Reaction {\n                key: reaction_event_content.relates_to.key,\n                reacted_to_event_payload: Box::new(reacted_to_event_payload),\n                reacted_to_event_id: reaction_event_content.relates_to.event_id.clone(),\n                reacted_to_event_sender_id,\n            },\n            trigger_event_sender_is_admin,\n        );\n\n        let message_context = MessageContext::new(\n            room,\n            room_config_context,\n            self.bot.admin_pattern_regexes().clone(),\n            trigger_event_info,\n            thread_info,\n        );\n\n        tracing::info!(\"Handling reaction via reaction controller\");\n\n        let event_span = tracing::error_span!(\"reaction_controller\");\n\n        crate::controller::reaction::handle(\n            &self.bot,\n            self.bot.matrix_link().clone(),\n            &message_context,\n        )\n        .instrument(event_span)\n        .await\n        .map_err(|err| CallbackError::Unknown(err.into()))?;\n\n        self.bot.catch_up(event.origin_server_ts()).await;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src/bot/rooms.rs",
    "content": "use mxlink::{\n    InvitationDecision,\n    matrix_sdk::{\n        Room,\n        ruma::events::{AnySyncTimelineEvent, room::member::StrippedRoomMemberEvent},\n    },\n};\n\nuse mxlink::CallbackError;\n\nuse tracing::Instrument;\n\nuse crate::entity::RoomConfigContext;\n\n#[derive(Clone)]\npub struct Rooms {\n    bot: super::Bot,\n}\n\nimpl Rooms {\n    pub fn new(bot: super::Bot) -> Self {\n        Self { bot }\n    }\n\n    pub(super) async fn attach_event_handlers(&self) {\n        let matrix_link_rooms = self.bot.matrix_link().rooms();\n\n        let this = self.clone();\n        matrix_link_rooms.on_being_last_member(|event, room| async move {\n            this.on_being_last_member(event, room).await\n        });\n\n        let this = self.clone();\n        matrix_link_rooms\n            .on_invitation(|event, room| async move { this.on_invitation(event, room).await });\n\n        let this = self.clone();\n        matrix_link_rooms.on_joined(|event, room| async move { this.on_joined(event, room).await });\n    }\n\n    async fn on_invitation(\n        &self,\n        room_member: StrippedRoomMemberEvent,\n        _room: Room,\n    ) -> Result<InvitationDecision, CallbackError> {\n        tracing::debug!(\"Deciding on room invitation\");\n\n        let global_config = self\n            .bot\n            .global_config()\n            .await\n            .map_err(|e| CallbackError::Unknown(e.into()))?;\n\n        let sender_is_admin = mxidwc::match_user_id(\n            room_member.sender.clone().as_str(),\n            self.bot.admin_pattern_regexes(),\n        );\n\n        let sender_is_allowed_user = match &global_config.access.user_patterns {\n            Some(user_patterns) => {\n                let allowed_user_regexes = mxidwc::parse_patterns_vector(user_patterns)\n                    .map_err(|e| CallbackError::Unknown(e.into()))?;\n\n                mxidwc::match_user_id(room_member.sender.clone().as_str(), &allowed_user_regexes)\n            }\n            None => false,\n        };\n\n        if !(sender_is_admin || sender_is_allowed_user) {\n            return Ok(InvitationDecision::Reject);\n        }\n\n        Ok(InvitationDecision::Join)\n    }\n\n    #[tracing::instrument(name = \"bot_on_joined\", skip_all, fields(room_id = room.room_id().as_str(), event_id = event.event_id().as_str()))]\n    async fn on_joined(\n        &self,\n        event: AnySyncTimelineEvent,\n        room: Room,\n    ) -> Result<(), CallbackError> {\n        if self\n            .bot\n            .is_caught_up(event.origin_server_ts())\n            .await\n            .map_err(|e| {\n                CallbackError::Unknown(\n                    format!(\"Failed to determine catch-up state: {:?}\", e).into(),\n                )\n            })?\n        {\n            tracing::debug!(\n                event_origin_server_ts = format!(\"{:?}\", event.origin_server_ts()),\n                \"Ignoring old room join event\",\n            );\n\n            return Ok(());\n        }\n\n        tracing::info!(\"Handling room join\");\n\n        let global_config = self\n            .bot\n            .global_config()\n            .await\n            .map_err(|e| CallbackError::Unknown(e.into()))?;\n\n        let room_config_manager = self.bot.room_config_manager().lock().await;\n\n        // We force-create a new config when we join anew to ensure we:\n        // - always start from a known clean state\n        // - record the last join timestamp, so we can accurately service the room (ignoring past messages, etc.)\n        let room_config = room_config_manager\n            .create_new_for_room(&room)\n            .await\n            .map_err(|e| CallbackError::Unknown(e.into()))?;\n\n        let room_config_context = RoomConfigContext::new(global_config, room_config);\n\n        let event_span = tracing::error_span!(\"join_controller\");\n\n        let result = crate::controller::join::handle(&self.bot, &room, &room_config_context)\n            .instrument(event_span)\n            .await\n            .map_err(|e| CallbackError::Unknown(e.into()));\n\n        self.bot.catch_up(event.origin_server_ts()).await;\n\n        result\n    }\n\n    async fn on_being_last_member(\n        &self,\n        _event: AnySyncTimelineEvent,\n        room: mxlink::matrix_sdk::Room,\n    ) -> Result<(), CallbackError> {\n        tracing::info!(\n            \"Leaving room {} because we are the last member\",\n            room.room_id()\n        );\n\n        // We are last in this room. Let's just leave\n        room.leave().await.map_err(|e| e.into())\n    }\n}\n"
  },
  {
    "path": "src/controller/access/determination/mod.rs",
    "content": "#[cfg(test)]\nmod tests;\n\nuse super::super::ControllerType;\n\n#[derive(Debug, PartialEq)]\npub enum AccessControllerType {\n    Help,\n\n    GetUsers,\n    SetUsers(Option<Vec<String>>),\n\n    GetRoomLocalAgentManagers,\n    SetRoomLocalAgentManagers(Option<Vec<String>>),\n}\n\npub fn determine_controller(text: &str) -> ControllerType {\n    if text.starts_with(\"users\") {\n        return ControllerType::Access(AccessControllerType::GetUsers);\n    }\n\n    if let Some(patterns_string) = text.strip_prefix(\"set-users\") {\n        let patterns_string = patterns_string.trim().to_owned();\n\n        let patterns_option = if patterns_string.is_empty() {\n            None\n        } else {\n            let patterns_vector = patterns_string\n                .split(\" \")\n                .map(|s| s.to_string())\n                .collect::<Vec<String>>();\n\n            Some(patterns_vector)\n        };\n\n        return ControllerType::Access(AccessControllerType::SetUsers(patterns_option));\n    }\n\n    if text.starts_with(\"room-local-agent-managers\") {\n        return ControllerType::Access(AccessControllerType::GetRoomLocalAgentManagers);\n    }\n\n    if let Some(patterns_string) = text.strip_prefix(\"set-room-local-agent-managers\") {\n        let patterns_string = patterns_string.trim().to_owned();\n\n        let patterns_option = if patterns_string.is_empty() {\n            None\n        } else {\n            let patterns_vector = patterns_string\n                .split(\" \")\n                .map(|s| s.to_string())\n                .collect::<Vec<String>>();\n\n            Some(patterns_vector)\n        };\n\n        return ControllerType::Access(AccessControllerType::SetRoomLocalAgentManagers(\n            patterns_option,\n        ));\n    }\n\n    ControllerType::Access(AccessControllerType::Help)\n}\n"
  },
  {
    "path": "src/controller/access/determination/tests.rs",
    "content": "#[test]\nfn determine_controller() {\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: super::ControllerType,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"Top-level is help\",\n            input: \"\",\n            expected: super::ControllerType::Access(super::AccessControllerType::Help),\n        },\n        TestCase {\n            name: \"Anything else goes to top-level\",\n            input: \"whatever\",\n            expected: super::ControllerType::Access(super::AccessControllerType::Help),\n        },\n        TestCase {\n            name: \"Users\",\n            input: \"users\",\n            expected: super::ControllerType::Access(super::AccessControllerType::GetUsers),\n        },\n        TestCase {\n            name: \"Set-users\",\n            input: \"set-users @user:example.com @bot.*:example.org\",\n            expected: super::ControllerType::Access(super::AccessControllerType::SetUsers(Some(\n                vec![\n                    \"@user:example.com\".to_owned(),\n                    \"@bot.*:example.org\".to_owned(),\n                ],\n            ))),\n        },\n        TestCase {\n            name: \"Room-local-agent-managers\",\n            input: \"room-local-agent-managers\",\n            expected: super::ControllerType::Access(\n                super::AccessControllerType::GetRoomLocalAgentManagers,\n            ),\n        },\n        TestCase {\n            name: \"Set-room-local-agent-managers\",\n            input: \"set-room-local-agent-managers @user:example.com @bot.*:example.org\",\n            expected: super::ControllerType::Access(\n                super::AccessControllerType::SetRoomLocalAgentManagers(Some(vec![\n                    \"@user:example.com\".to_owned(),\n                    \"@bot.*:example.org\".to_owned(),\n                ])),\n            ),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine_controller(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n"
  },
  {
    "path": "src/controller/access/dispatching.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{Bot, entity::MessageContext, strings};\n\nuse super::AccessControllerType;\n\npub async fn dispatch_controller(\n    handler: &AccessControllerType,\n    message_context: &MessageContext,\n    bot: &Bot,\n) -> anyhow::Result<()> {\n    // Only the help command is available without access control, so that all users can get familiar with how the bot's access system works.\n    match handler {\n        AccessControllerType::Help => {}\n        _ => {\n            if !message_context.sender_can_manage_global_config() {\n                bot.messaging()\n                    .send_error_markdown_no_fail(\n                        message_context.room(),\n                        strings::global_config::no_permissions_to_administrate(),\n                        MessageResponseType::Reply(\n                            message_context.thread_info().root_event_id.clone(),\n                        ),\n                    )\n                    .await;\n\n                return Ok(());\n            }\n        }\n    };\n\n    match handler {\n        AccessControllerType::Help => super::help::handle(bot, message_context).await,\n        AccessControllerType::GetUsers => super::users::handle_get(bot, message_context).await,\n        AccessControllerType::SetUsers(patterns) => {\n            super::users::handle_set(bot, message_context, patterns).await\n        }\n        AccessControllerType::GetRoomLocalAgentManagers => {\n            super::room_local_agent_managers::handle_get(bot, message_context).await\n        }\n        AccessControllerType::SetRoomLocalAgentManagers(patterns) => {\n            super::room_local_agent_managers::handle_set(bot, message_context, patterns).await\n        }\n    }\n}\n"
  },
  {
    "path": "src/controller/access/help.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{Bot, entity::MessageContext, strings};\n\npub async fn handle(bot: &Bot, message_context: &MessageContext) -> anyhow::Result<()> {\n    let mut message = String::new();\n    message.push_str(&build_section_intro());\n    message.push_str(\"\\n\\n\");\n    message.push_str(&build_section_joining_rooms());\n    message.push_str(\"\\n\\n\");\n    message.push_str(&build_section_users(\n        bot.command_prefix(),\n        bot.homeserver_name(),\n        message_context,\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&build_section_administrators(bot.admin_patterns()));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&build_section_room_local_agent_managers(\n        bot.command_prefix(),\n        bot.homeserver_name(),\n        message_context,\n    ));\n\n    bot.messaging()\n        .send_text_markdown_no_fail(\n            message_context.room(),\n            message,\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n\nfn build_section_intro() -> String {\n    let mut message = String::new();\n\n    message.push_str(&format!(\"## {}\", strings::help::access::heading()));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::access::intro());\n\n    message\n}\n\nfn build_section_joining_rooms() -> String {\n    let mut message = String::new();\n\n    message.push_str(&format!(\n        \"### {}\",\n        strings::help::access::room_auto_join_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::access::room_auto_join_intro());\n    message.push_str(\"\\n\\n\");\n\n    message\n}\n\nfn build_section_users(\n    command_prefix: &str,\n    homeserver_name: &str,\n    message_context: &MessageContext,\n) -> String {\n    let mut message = String::new();\n\n    message.push_str(&format!(\"### {}\", strings::help::access::users_heading()));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::access::users_intro());\n    message.push('\\n');\n    message.push_str(&strings::help::access::users_access());\n    message.push_str(\"\\n\\n\");\n    if let Some(user_patterns) = &message_context.global_config().access.user_patterns {\n        if user_patterns.is_empty() {\n            message.push_str(&strings::access::users_no_patterns());\n        } else {\n            message.push_str(&strings::access::users_now_match_patterns(user_patterns));\n        }\n    } else {\n        message.push_str(&strings::access::users_no_patterns());\n    }\n\n    if message_context.sender_can_manage_global_config() {\n        message.push_str(\"\\n\\n\");\n\n        message.push_str(strings::the_following_commands_are_available());\n        message.push('\\n');\n\n        message.push_str(&strings::help::access::users_command_get(command_prefix));\n        message.push('\\n');\n\n        message.push_str(&strings::help::access::users_command_set(command_prefix));\n        message.push_str(\"\\n\\n\");\n\n        message.push_str(&strings::help::access::example_user_patterns(\n            homeserver_name,\n        ));\n    }\n\n    message\n}\n\nfn build_section_administrators(admin_patterns: &[String]) -> String {\n    let mut message = String::new();\n\n    message.push_str(&format!(\n        \"### {}\",\n        strings::help::access::administrators_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::access::administrators_intro());\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::access::administrators_now_match_patterns(\n        admin_patterns,\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::access::administrators_outro());\n\n    message\n}\n\nfn build_section_room_local_agent_managers(\n    command_prefix: &str,\n    homeserver_name: &str,\n    message_context: &MessageContext,\n) -> String {\n    let mut message = String::new();\n\n    message.push_str(&format!(\n        \"### {}\",\n        strings::help::access::room_local_agent_managers_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::access::room_local_agent_managers_intro(\n        command_prefix,\n    ));\n    message.push('\\n');\n    message.push_str(&strings::help::access::room_local_agent_managers_security_warning());\n    message.push_str(\"\\n\\n\");\n    if let Some(user_patterns) = &message_context\n        .global_config()\n        .access\n        .room_local_agent_manager_patterns\n    {\n        if user_patterns.is_empty() {\n            message.push_str(&strings::access::room_local_agent_managers_no_patterns());\n        } else {\n            message.push_str(\n                &strings::access::room_local_agent_managers_now_match_patterns(user_patterns),\n            );\n        }\n    } else {\n        message.push_str(&strings::access::room_local_agent_managers_no_patterns());\n    }\n\n    if message_context.sender_can_manage_global_config() {\n        message.push_str(\"\\n\\n\");\n        message.push_str(strings::the_following_commands_are_available());\n        message.push('\\n');\n\n        message.push_str(\n            &strings::help::access::room_local_agent_managers_command_get(command_prefix),\n        );\n        message.push('\\n');\n\n        message.push_str(\n            &strings::help::access::room_local_agent_managers_command_set(command_prefix),\n        );\n        message.push_str(\"\\n\\n\");\n\n        message.push_str(&strings::help::access::example_user_patterns(\n            homeserver_name,\n        ));\n    }\n\n    message\n}\n"
  },
  {
    "path": "src/controller/access/mod.rs",
    "content": "mod determination;\nmod dispatching;\npub mod help;\nmod room_local_agent_managers;\nmod users;\n\npub use determination::{AccessControllerType, determine_controller};\npub use dispatching::dispatch_controller;\n"
  },
  {
    "path": "src/controller/access/room_local_agent_managers.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{Bot, entity::MessageContext, strings};\n\npub async fn handle_get(bot: &Bot, message_context: &MessageContext) -> anyhow::Result<()> {\n    let message = match &message_context\n        .global_config()\n        .access\n        .room_local_agent_manager_patterns\n    {\n        Some(patterns) => strings::access::room_local_agent_managers_now_match_patterns(patterns),\n        None => strings::access::room_local_agent_managers_no_patterns(),\n    };\n\n    bot.messaging()\n        .send_text_markdown_no_fail(\n            message_context.room(),\n            message,\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n\npub async fn handle_set(\n    bot: &Bot,\n    message_context: &MessageContext,\n    patterns: &Option<Vec<String>>,\n) -> anyhow::Result<()> {\n    if let Some(patterns) = patterns\n        && let Err(err) = mxidwc::parse_patterns_vector(patterns)\n    {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                &strings::access::failed_to_parse_patterns(&err.to_string()),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    }\n\n    let mut global_config_manager_guard = bot.global_config_manager().lock().await;\n\n    let mut global_config = global_config_manager_guard.get_or_create().await?;\n\n    global_config.access.room_local_agent_manager_patterns = patterns.clone();\n\n    global_config_manager_guard.persist(&global_config).await?;\n\n    let message = match patterns {\n        Some(patterns) => strings::access::room_local_agent_managers_now_match_patterns(patterns),\n        None => strings::access::room_local_agent_managers_no_patterns(),\n    };\n\n    bot.messaging()\n        .send_success_markdown_no_fail(\n            message_context.room(),\n            &message,\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/access/users.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{Bot, entity::MessageContext, strings};\n\npub async fn handle_get(bot: &Bot, message_context: &MessageContext) -> anyhow::Result<()> {\n    let message = match &message_context.global_config().access.user_patterns {\n        Some(patterns) => strings::access::users_now_match_patterns(patterns),\n        None => strings::access::users_no_patterns(),\n    };\n\n    bot.messaging()\n        .send_text_markdown_no_fail(\n            message_context.room(),\n            message,\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n\npub async fn handle_set(\n    bot: &Bot,\n    message_context: &MessageContext,\n    patterns: &Option<Vec<String>>,\n) -> anyhow::Result<()> {\n    if let Some(patterns) = patterns\n        && let Err(err) = mxidwc::parse_patterns_vector(patterns)\n    {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                &strings::access::failed_to_parse_patterns(&err.to_string()),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    }\n\n    let mut global_config_manager_guard = bot.global_config_manager().lock().await;\n\n    let mut global_config = global_config_manager_guard.get_or_create().await?;\n\n    global_config.access.user_patterns = patterns.clone();\n\n    global_config_manager_guard.persist(&global_config).await?;\n\n    let message = match patterns {\n        Some(patterns) => strings::access::users_now_match_patterns(patterns),\n        None => strings::access::users_no_patterns(),\n    };\n\n    bot.messaging()\n        .send_success_markdown_no_fail(\n            message_context.room(),\n            &message,\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/agent/create/mod.rs",
    "content": "#[cfg(test)]\nmod tests;\n\nuse mxlink::MessageResponseType;\n\nuse crate::agent::PublicIdentifier;\nuse crate::agent::provider::{ControllerTrait, PingResult};\nuse crate::agent::{AgentDefinition, create_from_provider_and_yaml_value_config};\nuse crate::agent::{AgentInstance, AgentProvider};\nuse crate::controller::utils::get_text_body_or_complain;\nuse crate::entity::globalconfig::GlobalConfigurationManager;\nuse crate::entity::roomconfig::RoomConfigurationManager;\nuse crate::strings;\nuse crate::{Bot, entity::MessageContext};\n\nstruct ParsedAgentConfig {\n    agent: AgentInstance,\n    config: serde_yaml_ng::Value,\n}\n\npub async fn handle_room_local(\n    bot: &Bot,\n    room_config_manager: &tokio::sync::Mutex<RoomConfigurationManager>,\n    message_context: &MessageContext,\n    provider: &str,\n    agent_id_prefixless: &str,\n) -> anyhow::Result<()> {\n    if !message_context.sender_can_manage_room_local_agents()? {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                &strings::agent::not_allowed_to_manage_room_local_agents_in_room(),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    }\n\n    let Ok(provider) = AgentProvider::from_string(provider) else {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                &strings::provider::invalid(provider),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    };\n\n    let agent_identifier = PublicIdentifier::DynamicRoomLocal(agent_id_prefixless.to_owned());\n    if let Err(err) = agent_identifier.validate() {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                &strings::agent::invalid_id_validation_error(err),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    }\n\n    let agent_exists = bot\n        .agent_manager()\n        .available_room_agents_by_room_config_context(message_context.room_config_context())\n        .iter()\n        .any(|agent| *agent.identifier() == agent_identifier);\n\n    if agent_exists {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                &strings::agent::already_exists_see_help(agent_id_prefixless, bot.command_prefix()),\n                MessageResponseType::InThread(message_context.thread_info().clone()),\n            )\n            .await;\n\n        return Ok(());\n    }\n\n    if message_context.thread_info().is_thread_root_only() {\n        return send_guide(bot, message_context, &agent_identifier, &provider).await;\n    }\n\n    let Some(text_message_content) = get_text_body_or_complain(bot, message_context).await else {\n        return Ok(());\n    };\n\n    let parsed_config = parse_agent_config_from_message_or_complain(\n        bot,\n        message_context,\n        &provider,\n        &agent_identifier,\n        text_message_content,\n    )\n    .await;\n    let Some(parsed_config) = parsed_config else {\n        return Ok(());\n    };\n\n    if !try_to_ping_agent_or_complain(bot, message_context, &parsed_config.agent).await {\n        return Ok(());\n    }\n\n    let agent_definition = AgentDefinition::new(\n        agent_identifier.prefixless(),\n        provider,\n        parsed_config.config.clone(),\n    );\n\n    let mut room_config = message_context.room_config().clone();\n\n    room_config.agents.push(agent_definition.clone());\n\n    room_config_manager\n        .lock()\n        .await\n        .persist(message_context.room(), &room_config)\n        .await?;\n\n    send_completion_wrap_up(\n        bot,\n        message_context,\n        &agent_identifier,\n        &parsed_config.agent,\n    )\n    .await;\n\n    Ok(())\n}\n\npub async fn handle_global(\n    bot: &Bot,\n    global_config_manager: &tokio::sync::Mutex<GlobalConfigurationManager>,\n    message_context: &MessageContext,\n    provider: &str,\n    agent_id_prefixless: &str,\n) -> anyhow::Result<()> {\n    if !message_context.sender_can_manage_global_config() {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                strings::global_config::no_permissions_to_administrate(),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    }\n\n    let Ok(provider) = AgentProvider::from_string(provider) else {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                &strings::provider::invalid(provider),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    };\n\n    let agent_identifier = PublicIdentifier::DynamicGlobal(agent_id_prefixless.to_owned());\n    if let Err(err) = agent_identifier.validate() {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                &strings::agent::invalid_id_validation_error(err),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    }\n\n    let agent_exists = bot\n        .agent_manager()\n        .available_room_agents_by_room_config_context(message_context.room_config_context())\n        .iter()\n        .any(|agent| *agent.identifier() == agent_identifier);\n\n    if agent_exists {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                &strings::agent::already_exists_see_help(agent_id_prefixless, bot.command_prefix()),\n                MessageResponseType::InThread(message_context.thread_info().clone()),\n            )\n            .await;\n\n        return Ok(());\n    }\n\n    if message_context.thread_info().is_thread_root_only() {\n        return send_guide(bot, message_context, &agent_identifier, &provider).await;\n    }\n\n    let Some(text_message_content) = get_text_body_or_complain(bot, message_context).await else {\n        return Ok(());\n    };\n\n    let parsed_config = parse_agent_config_from_message_or_complain(\n        bot,\n        message_context,\n        &provider,\n        &agent_identifier,\n        text_message_content,\n    )\n    .await;\n    let Some(parsed_config) = parsed_config else {\n        return Ok(());\n    };\n\n    if !try_to_ping_agent_or_complain(bot, message_context, &parsed_config.agent).await {\n        return Ok(());\n    }\n\n    let agent_definition = AgentDefinition::new(\n        agent_identifier.prefixless(),\n        provider,\n        parsed_config.config.clone(),\n    );\n\n    let mut global_config = message_context.global_config().clone();\n    global_config.agents.push(agent_definition.clone());\n\n    global_config_manager\n        .lock()\n        .await\n        .persist(&global_config)\n        .await?;\n\n    send_completion_wrap_up(\n        bot,\n        message_context,\n        &agent_identifier,\n        &parsed_config.agent,\n    )\n    .await;\n\n    Ok(())\n}\n\nasync fn send_guide(\n    bot: &Bot,\n    message_context: &MessageContext,\n    agent_identifier: &PublicIdentifier,\n    provider: &AgentProvider,\n) -> anyhow::Result<()> {\n    let sample_config = crate::agent::default_config_for_provider(provider);\n    let sample_config_pretty_yaml = serde_yaml_ng::to_string(&sample_config)?;\n\n    bot.messaging()\n        .send_text_markdown_no_fail(\n            message_context.room(),\n            strings::agent::creation_guide(agent_identifier, provider, &sample_config_pretty_yaml),\n            MessageResponseType::InThread(message_context.thread_info().clone()),\n        )\n        .await;\n\n    Ok(())\n}\n\nfn parse_from_message_to_yaml_value(text: &str) -> Result<serde_yaml_ng::Value, String> {\n    let mut text = text.trim();\n\n    if text.starts_with(\"```\") {\n        // Try to strip ```yml and ```yaml first and fall back to the generic ``` later.\n        text = text.trim_start_matches(\"```yml\");\n        text = text.trim_start_matches(\"```yaml\");\n        text = text.trim_start_matches(\"```\");\n        text = text.trim_end_matches(\"```\");\n    }\n\n    let config: serde_yaml_ng::Value = serde_yaml_ng::from_str(text).map_err(|e| e.to_string())?;\n\n    match config {\n        serde_yaml_ng::Value::Mapping(_) => {}\n        _ => {\n            return Err(\"Not a valid YAML hashmap\".to_owned());\n        }\n    };\n\n    Ok(config)\n}\n\nasync fn parse_agent_config_from_message_or_complain(\n    bot: &Bot,\n    message_context: &MessageContext,\n    provider: &AgentProvider,\n    agent_identifier: &PublicIdentifier,\n    text: &str,\n) -> Option<ParsedAgentConfig> {\n    let config_yaml_value = parse_from_message_to_yaml_value(text);\n\n    let config_yaml_value = match config_yaml_value {\n        Ok(config_yaml_value) => config_yaml_value,\n        Err(err) => {\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::agent::configuration_not_a_valid_yaml_hashmap(err),\n                    MessageResponseType::InThread(message_context.thread_info().clone()),\n                )\n                .await;\n\n            return None;\n        }\n    };\n\n    let agent = create_from_provider_and_yaml_value_config(\n        provider,\n        agent_identifier,\n        config_yaml_value.clone(),\n    );\n\n    let agent = match agent {\n        Ok(agent) => ParsedAgentConfig {\n            agent,\n            config: config_yaml_value,\n        },\n        Err(err) => {\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::provider::invalid_configuration_for_provider(provider, err),\n                    MessageResponseType::InThread(message_context.thread_info().clone()),\n                )\n                .await;\n\n            return None;\n        }\n    };\n\n    Some(agent)\n}\n\nasync fn try_to_ping_agent_or_complain(\n    bot: &Bot,\n    message_context: &MessageContext,\n    agent_instance: &AgentInstance,\n) -> bool {\n    bot.messaging()\n        .send_notice_markdown_no_fail(\n            message_context.room(),\n            format!(\"⏳ {}\", strings::agent::configuration_agent_will_ping()),\n            MessageResponseType::InThread(message_context.thread_info().clone()),\n        )\n        .await;\n\n    match agent_instance.controller().ping().await {\n        Ok(ping_result) => {\n            let message = match ping_result {\n                PingResult::Inconclusive => format!(\n                    \"❓ {}\",\n                    strings::agent::configuration_agent_ping_inconclusive()\n                ),\n                PingResult::Successful => {\n                    format!(\"✅ {}\", strings::agent::configuration_agent_ping_ok())\n                }\n            };\n\n            bot.messaging()\n                .send_notice_markdown_no_fail(\n                    message_context.room(),\n                    message,\n                    MessageResponseType::InThread(message_context.thread_info().clone()),\n                )\n                .await;\n\n            true\n        }\n        Err(err) => {\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::agent::configuration_does_not_result_in_a_working_agent(err),\n                    MessageResponseType::InThread(message_context.thread_info().clone()),\n                )\n                .await;\n\n            false\n        }\n    }\n}\n\nasync fn send_completion_wrap_up(\n    bot: &Bot,\n    message_context: &MessageContext,\n    agent_identifier: &PublicIdentifier,\n    agent_instance: &AgentInstance,\n) {\n    bot.messaging()\n        .send_success_markdown_no_fail(\n            message_context.room(),\n            &strings::agent::created(agent_identifier),\n            MessageResponseType::InThread(message_context.thread_info().clone()),\n        )\n        .await;\n\n    bot.messaging()\n        .send_tooltip_markdown_no_fail(\n            message_context.room(),\n            &strings::agent::post_creation_helpful_commands(\n                agent_identifier,\n                agent_instance,\n                bot.command_prefix(),\n            ),\n            MessageResponseType::InThread(message_context.thread_info().clone()),\n        )\n        .await;\n}\n"
  },
  {
    "path": "src/controller/agent/create/tests.rs",
    "content": "#[test]\nfn agent_config_parsing_works() {\n    struct TestCase {\n        input: String,\n        expected: Option<serde_yaml_ng::Value>,\n    }\n\n    let provider = crate::agent::AgentProvider::OpenAI;\n    let sample_config = crate::agent::default_config_for_provider(&provider);\n    let sample_config_pretty_yaml = serde_yaml_ng::to_string(&sample_config).unwrap();\n\n    let test_cases = vec![\n        // Invalid input\n        TestCase {\n            input: r#\"Hello\"#.to_owned(),\n            expected: None,\n        },\n        // Plain text\n        TestCase {\n            input: sample_config_pretty_yaml.clone(),\n            expected: Some(sample_config.clone()),\n        },\n        // Generic code block\n        TestCase {\n            input: format!(\"```\\n{}```\", sample_config_pretty_yaml),\n            expected: Some(sample_config.clone()),\n        },\n        // YAML code block (yaml)\n        TestCase {\n            input: format!(\"```yaml\\n{}```\", sample_config_pretty_yaml),\n            expected: Some(sample_config.clone()),\n        },\n        // YAML code block (yml)\n        TestCase {\n            input: format!(\"```yml\\n{}```\", sample_config_pretty_yaml),\n            expected: Some(sample_config.clone()),\n        },\n        // JSON code block\n        TestCase {\n            input: format!(\"```json\\n{}```\", sample_config_pretty_yaml),\n            expected: None,\n        },\n    ];\n\n    for (i, test_case) in test_cases.iter().enumerate() {\n        let result = super::parse_from_message_to_yaml_value(&test_case.input);\n        match result {\n            Ok(config) => {\n                assert_eq!(\n                    config,\n                    test_case.expected.clone().unwrap(),\n                    \"Test case {} failed\",\n                    i\n                );\n            }\n            Err(_) => {\n                assert_eq!(test_case.expected, None, \"Test case {} failed\", i);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/controller/agent/delete/mod.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::entity::{\n    MessageContext, globalconfig::GlobalConfigurationManager, roomconfig::RoomConfigurationManager,\n};\nuse crate::{Bot, agent::PublicIdentifier, strings};\n\npub async fn handle(\n    bot: &Bot,\n    room_config_manager: &tokio::sync::Mutex<RoomConfigurationManager>,\n    global_config_manager: &tokio::sync::Mutex<GlobalConfigurationManager>,\n    message_context: &MessageContext,\n    agent_identifier: &PublicIdentifier,\n) -> anyhow::Result<()> {\n    let agents = bot\n        .agent_manager()\n        .available_room_agents_by_room_config_context(message_context.room_config_context());\n\n    let agent = agents.iter().find(|a| a.identifier() == agent_identifier);\n\n    let Some(_) = agent else {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                &strings::agent::agent_with_given_identifier_missing(agent_identifier),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    };\n\n    match &agent_identifier {\n        PublicIdentifier::DynamicRoomLocal(_) => {\n            if !message_context.sender_can_manage_room_local_agents()? {\n                bot.messaging()\n                    .send_error_markdown_no_fail(\n                        message_context.room(),\n                        &strings::agent::not_allowed_to_manage_room_local_agents_in_room(),\n                        MessageResponseType::Reply(\n                            message_context.thread_info().root_event_id.clone(),\n                        ),\n                    )\n                    .await;\n\n                return Ok(());\n            }\n\n            delete_room_local_agent(bot, room_config_manager, message_context, agent_identifier)\n                .await\n        }\n        PublicIdentifier::DynamicGlobal(_) => {\n            if !message_context.sender_can_manage_global_config() {\n                bot.messaging()\n                    .send_error_markdown_no_fail(\n                        message_context.room(),\n                        strings::global_config::no_permissions_to_administrate(),\n                        MessageResponseType::Reply(\n                            message_context.thread_info().root_event_id.clone(),\n                        ),\n                    )\n                    .await;\n\n                return Ok(());\n            }\n\n            delete_global_agent(\n                bot,\n                global_config_manager,\n                message_context,\n                agent_identifier,\n            )\n            .await\n        }\n        PublicIdentifier::Static(_) => {\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::agent::not_allowed_to_manage_static_agents(),\n                    MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n                )\n                .await;\n\n            Ok(())\n        }\n    }\n}\n\nasync fn delete_room_local_agent(\n    bot: &Bot,\n    room_config_manager: &tokio::sync::Mutex<RoomConfigurationManager>,\n    message_context: &MessageContext,\n    agent_id: &PublicIdentifier,\n) -> anyhow::Result<()> {\n    let mut room_config = message_context.room_config().clone();\n\n    let mut was_deleted = false;\n\n    let agent_id_prefixless = agent_id.prefixless();\n\n    let mut agents = Vec::new();\n    for agent_config in room_config.agents {\n        if agent_config.id == agent_id_prefixless {\n            was_deleted = true;\n        } else {\n            agents.push(agent_config.clone());\n        }\n    }\n\n    if !was_deleted {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                &strings::agent::agent_with_given_identifier_missing(agent_id),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    }\n\n    room_config.agents = agents;\n\n    let room_config_manager = room_config_manager.lock().await;\n\n    // We may unset all handlers in the room config which refer to this agent.\n    // We intentionally do not do this, because we do not support \"agent edit\" yet and ask people to do \"agent delete\" and \"agent create\" instead.\n    // We'd rather not magically reconfigure the room on agent deletion and obstruct this use case.\n\n    room_config_manager\n        .persist(message_context.room(), &room_config)\n        .await?;\n\n    bot.messaging()\n        .send_success_markdown_no_fail(\n            message_context.room(),\n            &strings::agent::removed_room_local(agent_id, bot.command_prefix()),\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n\nasync fn delete_global_agent(\n    bot: &Bot,\n    global_config_manager: &tokio::sync::Mutex<GlobalConfigurationManager>,\n    message_context: &MessageContext,\n    agent_id: &PublicIdentifier,\n) -> anyhow::Result<()> {\n    let mut global_config = message_context.global_config().clone();\n\n    let mut was_deleted = false;\n\n    let agent_id_prefixless = agent_id.prefixless();\n\n    let mut agents = Vec::new();\n    for agent_config in global_config.agents {\n        if agent_config.id == agent_id_prefixless {\n            was_deleted = true;\n        } else {\n            agents.push(agent_config.clone());\n        }\n    }\n\n    if !was_deleted {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                &strings::agent::agent_with_given_identifier_missing(agent_id),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    }\n\n    global_config.agents = agents;\n\n    global_config_manager\n        .lock()\n        .await\n        .persist(&global_config)\n        .await?;\n\n    bot.messaging()\n        .send_success_markdown_no_fail(\n            message_context.room(),\n            &strings::agent::removed_global(agent_id, bot.command_prefix()),\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/agent/details/mod.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{Bot, agent::PublicIdentifier, entity::MessageContext, strings};\n\npub async fn handle(\n    bot: &Bot,\n    message_context: &MessageContext,\n    agent_identifier: &PublicIdentifier,\n) -> anyhow::Result<()> {\n    let agents = bot\n        .agent_manager()\n        .available_room_agents_by_room_config_context(message_context.room_config_context());\n\n    let agent = agents.iter().find(|a| a.identifier() == agent_identifier);\n\n    let agent = match agent {\n        Some(agent) => agent,\n        None => {\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::agent::agent_with_given_identifier_missing(agent_identifier),\n                    MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n                )\n                .await;\n\n            return Ok(());\n        }\n    };\n\n    // Access checks\n\n    match &agent_identifier {\n        PublicIdentifier::DynamicRoomLocal(_) => {\n            if !message_context.sender_can_manage_room_local_agents()? {\n                bot.messaging()\n                    .send_error_markdown_no_fail(\n                        message_context.room(),\n                        &strings::agent::not_allowed_to_manage_room_local_agents_in_room(),\n                        MessageResponseType::Reply(\n                            message_context.thread_info().root_event_id.clone(),\n                        ),\n                    )\n                    .await;\n\n                return Ok(());\n            }\n        }\n        PublicIdentifier::DynamicGlobal(_) => {\n            if !message_context.sender_can_manage_global_config() {\n                bot.messaging()\n                    .send_error_markdown_no_fail(\n                        message_context.room(),\n                        strings::global_config::no_permissions_to_administrate(),\n                        MessageResponseType::Reply(\n                            message_context.thread_info().root_event_id.clone(),\n                        ),\n                    )\n                    .await;\n\n                return Ok(());\n            }\n        }\n        PublicIdentifier::Static(_) => {}\n    };\n\n    let config_yaml_pretty = serde_yaml_ng::to_string(&agent.definition().config)?;\n\n    bot.messaging()\n        .send_text_markdown_no_fail(\n            message_context.room(),\n            format!(\n                \"Configuration for agent `{}` (powered by the `{}` provider):\\n```yml\\n{}\\n```\",\n                agent_identifier,\n                agent.definition().provider.to_static_str(),\n                config_yaml_pretty.trim(),\n            )\n            .to_owned(),\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/agent/determination/mod.rs",
    "content": "#[cfg(test)]\nmod tests;\n\nuse crate::{agent::PublicIdentifier, controller::ControllerType, strings};\n\n#[derive(Debug, PartialEq)]\npub enum AgentControllerType {\n    List,\n    Details(PublicIdentifier),\n    CreateRoomLocal { provider: String, agent_id: String },\n    CreateGlobal { provider: String, agent_id: String },\n    Delete(PublicIdentifier),\n    Help,\n}\n\npub fn determine_controller(command_prefix: &str, text: &str) -> ControllerType {\n    if text.starts_with(\"list\") {\n        return ControllerType::Agent(AgentControllerType::List);\n    }\n    if let Some(agent_id_string) = text.strip_prefix(\"details\") {\n        let agent_id_string = agent_id_string.trim();\n\n        if agent_id_string.is_empty() || agent_id_string.contains(\" \") {\n            return ControllerType::Error(\n                strings::agent::incorrect_invocation_expects_agent_id_arg(command_prefix),\n            );\n        }\n\n        let Some(agent_identifier) = PublicIdentifier::from_str(agent_id_string) else {\n            return ControllerType::Error(strings::agent::invalid_id_generic());\n        };\n\n        return ControllerType::Agent(AgentControllerType::Details(agent_identifier));\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"create-room-local\") {\n        // `remaining_text` should be something like: `PROVIDER ID`\n        let remaining_text = remaining_text.trim();\n\n        let parts = remaining_text.split_once(' ');\n        let Some((provider, agent_id_string)) = parts else {\n            return ControllerType::Error(strings::agent::incorrect_creation_invocation(\n                command_prefix,\n            ));\n        };\n\n        if agent_id_string.contains(\" \") {\n            return ControllerType::Error(strings::agent::incorrect_creation_invocation(\n                command_prefix,\n            ));\n        }\n\n        return ControllerType::Agent(AgentControllerType::CreateRoomLocal {\n            provider: provider.to_owned(),\n            agent_id: agent_id_string.trim().to_owned(),\n        });\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"create-global\") {\n        // `remaining_text` should be something like: `PROVIDER ID`\n        let remaining_text = remaining_text.trim();\n\n        let parts = remaining_text.split_once(' ');\n        let Some((provider, agent_id_string)) = parts else {\n            return ControllerType::Error(strings::agent::incorrect_creation_invocation(\n                command_prefix,\n            ));\n        };\n\n        if agent_id_string.contains(\" \") {\n            return ControllerType::Error(strings::agent::incorrect_creation_invocation(\n                command_prefix,\n            ));\n        }\n\n        return ControllerType::Agent(AgentControllerType::CreateGlobal {\n            provider: provider.to_owned(),\n            agent_id: agent_id_string.trim().to_owned(),\n        });\n    }\n\n    if let Some(agent_id_string) = text.strip_prefix(\"delete\") {\n        let agent_id_string = agent_id_string.trim();\n\n        if agent_id_string.is_empty() || agent_id_string.contains(\" \") {\n            return ControllerType::Error(\n                strings::agent::incorrect_invocation_expects_agent_id_arg(command_prefix),\n            );\n        }\n\n        let Some(agent_identifier) = PublicIdentifier::from_str(agent_id_string) else {\n            return ControllerType::Error(strings::agent::invalid_id_generic());\n        };\n\n        return ControllerType::Agent(AgentControllerType::Delete(agent_identifier));\n    }\n\n    ControllerType::Agent(AgentControllerType::Help)\n}\n"
  },
  {
    "path": "src/controller/agent/determination/tests.rs",
    "content": "#[test]\nfn determine_controller() {\n    use crate::agent::PublicIdentifier;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: super::ControllerType,\n    }\n\n    let command_prefix = \"!bai\";\n\n    let test_cases = vec![\n        TestCase {\n            name: \"Top-level is help\",\n            input: \"\",\n            expected: super::ControllerType::Agent(super::AgentControllerType::Help),\n        },\n        TestCase {\n            name: \"Anything else goes to top-level\",\n            input: \"whatever\",\n            expected: super::ControllerType::Agent(super::AgentControllerType::Help),\n        },\n        TestCase {\n            name: \"List\",\n            input: \"list\",\n            expected: super::ControllerType::Agent(super::AgentControllerType::List),\n        },\n        TestCase {\n            name: \"details\",\n            input: \"details static/agent-id\",\n            expected: super::ControllerType::Agent(super::AgentControllerType::Details(\n                PublicIdentifier::Static(\"agent-id\".to_owned()),\n            )),\n        },\n        TestCase {\n            name: \"details with invalid agent identifier\",\n            input: \"details agent-id\",\n            expected: super::ControllerType::Error(crate::strings::agent::invalid_id_generic()),\n        },\n        TestCase {\n            name: \"create-room-local no arguments\",\n            input: \"create-room-local\",\n            expected: super::ControllerType::Error(\n                crate::strings::agent::incorrect_creation_invocation(command_prefix),\n            ),\n        },\n        TestCase {\n            name: \"create-room-local only with provider\",\n            input: \"create-room-local openai\",\n            expected: super::ControllerType::Error(\n                crate::strings::agent::incorrect_creation_invocation(command_prefix),\n            ),\n        },\n        TestCase {\n            name: \"create-room-local correct\",\n            input: \"create-room-local openai my-agent-id\",\n            expected: super::ControllerType::Agent(super::AgentControllerType::CreateRoomLocal {\n                provider: \"openai\".to_owned(),\n                agent_id: \"my-agent-id\".trim().to_owned(),\n            }),\n        },\n        TestCase {\n            name: \"create-global extra arguments\",\n            input: \"create-global openai my-agent-id more arguments here\",\n            expected: super::ControllerType::Error(\n                crate::strings::agent::incorrect_creation_invocation(command_prefix),\n            ),\n        },\n        TestCase {\n            name: \"create-global no arguments\",\n            input: \"create-global\",\n            expected: super::ControllerType::Error(\n                crate::strings::agent::incorrect_creation_invocation(command_prefix),\n            ),\n        },\n        TestCase {\n            name: \"create-global only with provider\",\n            input: \"create-global openai\",\n            expected: super::ControllerType::Error(\n                crate::strings::agent::incorrect_creation_invocation(command_prefix),\n            ),\n        },\n        TestCase {\n            name: \"create-global correct\",\n            input: \"create-global openai my-agent-id\",\n            expected: super::ControllerType::Agent(super::AgentControllerType::CreateGlobal {\n                provider: \"openai\".to_owned(),\n                agent_id: \"my-agent-id\".trim().to_owned(),\n            }),\n        },\n        TestCase {\n            name: \"create-global extra arguments\",\n            input: \"create-global openai my-agent-id more arguments here\",\n            expected: super::ControllerType::Error(\n                crate::strings::agent::incorrect_creation_invocation(command_prefix),\n            ),\n        },\n        TestCase {\n            name: \"delete no arguments\",\n            input: \"delete\",\n            expected: super::ControllerType::Error(\n                crate::strings::agent::incorrect_invocation_expects_agent_id_arg(command_prefix),\n            ),\n        },\n        TestCase {\n            name: \"delete too many arguments\",\n            input: \"delete agent-id extra arguments\",\n            expected: super::ControllerType::Error(\n                crate::strings::agent::incorrect_invocation_expects_agent_id_arg(command_prefix),\n            ),\n        },\n        TestCase {\n            name: \"delete\",\n            input: \"delete static/agent-id\",\n            expected: super::ControllerType::Agent(super::AgentControllerType::Delete(\n                PublicIdentifier::Static(\"agent-id\".to_owned()),\n            )),\n        },\n        TestCase {\n            name: \"delete with invalid agent identifier\",\n            input: \"delete agent-id\",\n            expected: super::ControllerType::Error(crate::strings::agent::invalid_id_generic()),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine_controller(command_prefix, test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n"
  },
  {
    "path": "src/controller/agent/help/mod.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{Bot, entity::MessageContext, strings};\n\npub async fn handle(bot: &Bot, message_context: &MessageContext) -> anyhow::Result<()> {\n    // Anyone can access this help command, because certain subcommands (\"list\")\n    // are also useful to regular users and it'd be great for them to learn about them.\n\n    let mut message = String::new();\n\n    let can_manage_agents = message_context.sender_can_manage_room_local_agents()?;\n\n    message.push_str(&format!(\"## {}\", strings::help::agent::heading()));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::agent::intro(bot.command_prefix()));\n    message.push('\\n');\n    message.push_str(&strings::help::agent::intro_capabilities());\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::agent::intro_handler_relation(\n        bot.command_prefix(),\n    ));\n\n    if can_manage_agents {\n        message.push_str(\"\\n\\n\");\n\n        message.push_str(strings::help::available_commands_intro());\n        message.push('\\n');\n\n        message.push_str(&strings::help::agent::list_agents(bot.command_prefix()));\n        message.push('\\n');\n\n        message.push_str(strings::help::agent::create_agent_intro());\n        message.push('\\n');\n        message.push_str(&strings::help::agent::create_agent_room_local(\n            bot.command_prefix(),\n        ));\n        message.push('\\n');\n\n        if message_context.sender_can_manage_global_config() {\n            message.push_str(&strings::help::agent::create_agent_global(\n                bot.command_prefix(),\n            ));\n            message.push('\\n');\n        }\n\n        message.push_str(&strings::help::agent::create_agent_example(\n            bot.command_prefix(),\n        ));\n        message.push('\\n');\n\n        message.push_str(&strings::help::agent::show_agent_details(\n            bot.command_prefix(),\n        ));\n        message.push('\\n');\n\n        message.push_str(&strings::help::agent::delete_agent(bot.command_prefix()));\n\n        message.push_str(\"\\n\\n\");\n\n        message.push_str(strings::help::agent::available_commands_outro_update_note());\n    } else {\n        message.push_str(\"\\n\\n\");\n\n        message.push_str(strings::help::agent::no_permission_to_create_agents());\n    }\n\n    bot.messaging()\n        .send_text_markdown_no_fail(\n            message_context.room(),\n            message,\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/agent/list/mod.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::agent::AgentPurpose;\nuse crate::strings;\nuse crate::{Bot, entity::MessageContext};\n\npub async fn handle(bot: &Bot, message_context: &MessageContext) -> anyhow::Result<()> {\n    let agents = bot\n        .agent_manager()\n        .available_room_agents_by_room_config_context(message_context.room_config_context());\n\n    let mut message = String::new();\n    if agents.is_empty() {\n        message.push_str(strings::agent::agent_list_empty().as_str());\n    } else {\n        message.push_str(&strings::agent::non_empty_agent_list_block(&agents));\n        message.push_str(\"\\n\\n\");\n\n        message.push_str(strings::agent::agent_list_legend_intro().as_str());\n        for purpose in AgentPurpose::choices() {\n            message.push_str(&format!(\n                \"\\n- {} `{}` ({})\",\n                purpose.emoji(),\n                purpose.as_str(),\n                strings::agent::purpose_howto(purpose),\n            ));\n        }\n    }\n\n    bot.messaging()\n        .send_text_markdown_no_fail(\n            message_context.room(),\n            message,\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/agent/mod.rs",
    "content": "use crate::{Bot, entity::MessageContext};\n\npub mod create;\npub mod delete;\npub mod details;\npub mod determination;\npub mod help;\npub mod list;\n\npub use determination::{AgentControllerType, determine_controller};\n\npub async fn dispatch_controller(\n    handler: &AgentControllerType,\n    message_context: &MessageContext,\n    bot: &Bot,\n) -> anyhow::Result<()> {\n    match handler {\n        AgentControllerType::CreateRoomLocal { provider, agent_id } => {\n            create::handle_room_local(\n                bot,\n                bot.room_config_manager(),\n                message_context,\n                provider,\n                agent_id,\n            )\n            .await\n        }\n        AgentControllerType::CreateGlobal { provider, agent_id } => {\n            create::handle_global(\n                bot,\n                bot.global_config_manager(),\n                message_context,\n                provider,\n                agent_id,\n            )\n            .await\n        }\n        AgentControllerType::List => list::handle(bot, message_context).await,\n        AgentControllerType::Details(agent_identifier) => {\n            details::handle(bot, message_context, agent_identifier).await\n        }\n        AgentControllerType::Delete(agent_identifier) => {\n            delete::handle(\n                bot,\n                bot.room_config_manager(),\n                bot.global_config_manager(),\n                message_context,\n                agent_identifier,\n            )\n            .await\n        }\n        AgentControllerType::Help => help::handle(bot, message_context).await,\n    }\n}\n"
  },
  {
    "path": "src/controller/cfg/common/generic_setting.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{Bot, entity::MessageContext, strings};\n\npub async fn handle_get<T>(\n    bot: &Bot,\n    message_context: &MessageContext,\n    value: &Option<T>,\n) -> anyhow::Result<()>\nwhere\n    T: std::fmt::Display,\n{\n    match value {\n        Some(value) => {\n            bot.messaging()\n                .send_text_markdown_no_fail(\n                    message_context.room(),\n                    strings::cfg::value_currently_set_to(value),\n                    MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n                )\n                .await;\n        }\n        None => {\n            bot.messaging()\n                .send_text_markdown_no_fail(\n                    message_context.room(),\n                    strings::cfg::value_currently_unset(),\n                    MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n                )\n                .await;\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/cfg/common/mod.rs",
    "content": "pub(super) mod generic_setting;\n"
  },
  {
    "path": "src/controller/cfg/controller_type.rs",
    "content": "use crate::{\n    agent::{AgentPurpose, PublicIdentifier},\n    entity::roomconfig::{\n        SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,\n        TextGenerationAutoUsage, TextGenerationPrefixRequirementType,\n        TextGenerationSenderContextMode, TextToSpeechBotMessagesFlowType,\n        TextToSpeechUserMessagesFlowType,\n    },\n};\n\n#[derive(Debug, PartialEq)]\npub enum SettingsStorageSource {\n    Room,\n    Global,\n}\n\n#[derive(Debug, PartialEq)]\npub enum ConfigControllerType {\n    Help,\n\n    Status,\n\n    SettingsRelated(SettingsStorageSource, ConfigSettingRelatedControllerType),\n}\n\n#[derive(Debug, PartialEq)]\npub enum ConfigSettingRelatedControllerType {\n    GetHandler(AgentPurpose),\n    SetHandler(AgentPurpose, Option<PublicIdentifier>),\n\n    TextGeneration(ConfigTextGenerationSettingRelatedControllerType),\n    SpeechToText(ConfigSpeechToTextSettingRelatedControllerType),\n    TextToSpeech(ConfigTextToSpeechSettingRelatedControllerType),\n}\n\n#[derive(Debug, PartialEq)]\npub enum ConfigTextGenerationSettingRelatedControllerType {\n    GetContextManagementEnabled,\n    SetContextManagementEnabled(Option<bool>),\n\n    GetPrefixRequirementType,\n    SetPrefixRequirementType(Option<TextGenerationPrefixRequirementType>),\n\n    GetAutoUsage,\n    SetAutoUsage(Option<TextGenerationAutoUsage>),\n\n    GetPromptOverride,\n    SetPromptOverride(Option<String>),\n\n    GetTemperatureOverride,\n    SetTemperatureOverride(Option<f32>),\n\n    GetSenderContextMode,\n    SetSenderContextMode(Option<TextGenerationSenderContextMode>),\n}\n\n#[derive(Debug, PartialEq)]\npub enum ConfigSpeechToTextSettingRelatedControllerType {\n    GetFlowType,\n    SetFlowType(Option<SpeechToTextFlowType>),\n\n    GetMsgTypeForNonThreadedOnlyTranscribedMessages,\n    SetMsgTypeForNonThreadedOnlyTranscribedMessages(\n        Option<SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages>,\n    ),\n\n    GetLanguage,\n    SetLanguage(Option<String>),\n}\n\n#[derive(Debug, PartialEq)]\npub enum ConfigTextToSpeechSettingRelatedControllerType {\n    GetBotMessagesFlowType,\n    SetBotMessagesFlowType(Option<TextToSpeechBotMessagesFlowType>),\n\n    GetUserMessagesFlowType,\n    SetUserMessagesFlowType(Option<TextToSpeechUserMessagesFlowType>),\n\n    GetSpeedOverride,\n    SetSpeedOverride(Option<f32>),\n\n    GetVoiceOverride,\n    SetVoiceOverride(Option<String>),\n}\n"
  },
  {
    "path": "src/controller/cfg/determination/mod.rs",
    "content": "#[cfg(test)]\nmod tests;\n\nmod speech_to_text;\nmod text_generation;\nmod text_to_speech;\n\nuse crate::{\n    agent::{AgentPurpose, PublicIdentifier},\n    controller::ControllerType,\n    strings,\n};\n\nuse super::controller_type::{\n    ConfigControllerType, ConfigSettingRelatedControllerType, SettingsStorageSource,\n};\n\npub fn determine_controller(text: &str) -> ControllerType {\n    if text.starts_with(\"status\") {\n        return ControllerType::Config(ConfigControllerType::Status);\n    }\n\n    // Someone pasted our instructions verbatim.\n    if text.strip_prefix(\"CONFIG_TYPE\").is_some() {\n        return ControllerType::Error(strings::cfg::error_config_type_not_replaced());\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"room \") {\n        return match do_determine_controller(remaining_text.trim()) {\n            Ok(handler) => ControllerType::Config(ConfigControllerType::SettingsRelated(\n                SettingsStorageSource::Room,\n                handler,\n            )),\n            Err(controller_type) => controller_type,\n        };\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"global \") {\n        return match do_determine_controller(remaining_text.trim()) {\n            Ok(handler) => ControllerType::Config(ConfigControllerType::SettingsRelated(\n                SettingsStorageSource::Global,\n                handler,\n            )),\n            Err(controller_type) => controller_type,\n        };\n    }\n\n    ControllerType::Config(ConfigControllerType::Help)\n}\n\nfn do_determine_controller(\n    text: &str,\n) -> Result<ConfigSettingRelatedControllerType, ControllerType> {\n    if let Some(purpose_str) = text.strip_prefix(\"handler\") {\n        let purpose_str = purpose_str.trim();\n\n        if purpose_str.is_empty() {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_invocation_incorrect_more_values_expected().to_owned(),\n            ));\n        }\n\n        let Some(purpose) = AgentPurpose::from_str(purpose_str) else {\n            return Err(ControllerType::Error(\n                strings::agent::purpose_unrecognized(purpose_str).to_owned(),\n            ));\n        };\n\n        return Ok(ConfigSettingRelatedControllerType::GetHandler(purpose));\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"set-handler\") {\n        // Something like:\n        // - `PURPOSE ID`\n        // - `PURPOSE`\n        let remaining_text = remaining_text.trim();\n\n        if remaining_text.is_empty() {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_invocation_incorrect_more_values_expected().to_owned(),\n            ));\n        }\n\n        // This will be None if we're just dealing with `PURPOSE` and lack an `ID`.\n        // In such cases, the whole thing is the purpose string.\n        let parts = remaining_text.split_once(' ');\n\n        let (purpose_str, agent_id_string_option) = if let Some(parts) = parts {\n            (parts.0, Some(parts.1.to_owned()))\n        } else {\n            (remaining_text, None)\n        };\n\n        let Some(purpose) = AgentPurpose::from_str(purpose_str) else {\n            return Err(ControllerType::Error(\n                strings::agent::purpose_unrecognized(purpose_str).to_owned(),\n            ));\n        };\n\n        let agent_identifier = match agent_id_string_option {\n            Some(agent_id_string) => {\n                let Some(agent_identifier) = PublicIdentifier::from_str(&agent_id_string) else {\n                    return Err(ControllerType::Error(\n                        strings::agent::invalid_id_generic().to_owned(),\n                    ));\n                };\n\n                Some(agent_identifier)\n            }\n            None => None,\n        };\n\n        return Ok(ConfigSettingRelatedControllerType::SetHandler(\n            purpose,\n            agent_identifier,\n        ));\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"text-generation\") {\n        return match text_generation::determine(remaining_text.trim()) {\n            Ok(handler) => Ok(ConfigSettingRelatedControllerType::TextGeneration(handler)),\n            Err(controller_type) => Err(controller_type),\n        };\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"text-to-speech\") {\n        return match text_to_speech::determine(remaining_text.trim()) {\n            Ok(handler) => Ok(ConfigSettingRelatedControllerType::TextToSpeech(handler)),\n            Err(controller_type) => Err(controller_type),\n        };\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"speech-to-text\") {\n        return match speech_to_text::determine(remaining_text.trim()) {\n            Ok(handler) => Ok(ConfigSettingRelatedControllerType::SpeechToText(handler)),\n            Err(controller_type) => Err(controller_type),\n        };\n    }\n\n    Err(ControllerType::Unknown)\n}\n"
  },
  {
    "path": "src/controller/cfg/determination/speech_to_text/mod.rs",
    "content": "#[cfg(test)]\nmod tests;\n\nuse crate::{\n    controller::ControllerType,\n    entity::roomconfig::{\n        SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,\n    },\n    strings,\n};\n\nuse super::super::controller_type::ConfigSpeechToTextSettingRelatedControllerType;\n\npub(super) fn determine(\n    text: &str,\n) -> Result<ConfigSpeechToTextSettingRelatedControllerType, ControllerType> {\n    // Flow Type\n\n    if let Some(remaining_text) = text.strip_prefix(\"flow-type\") {\n        let remaining_text = remaining_text.trim();\n\n        if !remaining_text.is_empty() {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_getter_used_with_extra_text(\n                    \"flow-type\",\n                    remaining_text,\n                )\n                .to_owned(),\n            ));\n        }\n\n        return Ok(ConfigSpeechToTextSettingRelatedControllerType::GetFlowType);\n    }\n\n    if let Some(value_string) = text.strip_prefix(\"set-flow-type\") {\n        let value_string = value_string.trim().to_owned();\n\n        let value_choice = if value_string.is_empty() {\n            None\n        } else {\n            let value_choice = SpeechToTextFlowType::from_str(&value_string.to_lowercase());\n\n            if value_choice.is_none() {\n                return Err(ControllerType::Error(\n                    strings::cfg::configuration_value_unrecognized(&value_string).to_owned(),\n                ));\n            }\n\n            value_choice\n        };\n\n        return Ok(ConfigSpeechToTextSettingRelatedControllerType::SetFlowType(\n            value_choice,\n        ));\n    }\n\n    // msg_type_for_non_threaded_only_transcribed_messages\n\n    if let Some(remaining_text) =\n        text.strip_prefix(\"msg-type-for-non-threaded-only-transcribed-messages\")\n    {\n        let remaining_text = remaining_text.trim();\n\n        if !remaining_text.is_empty() {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_getter_used_with_extra_text(\n                    \"msg-type-for-non-threaded-only-transcribed-messages\",\n                    remaining_text,\n                )\n                .to_owned(),\n            ));\n        }\n\n        return Ok(ConfigSpeechToTextSettingRelatedControllerType::GetMsgTypeForNonThreadedOnlyTranscribedMessages);\n    }\n\n    if let Some(value_string) =\n        text.strip_prefix(\"set-msg-type-for-non-threaded-only-transcribed-messages\")\n    {\n        let value_string = value_string.trim().to_owned();\n\n        let value_choice = if value_string.is_empty() {\n            None\n        } else {\n            let value_choice =\n                SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages::from_str(\n                    &value_string.to_lowercase(),\n                );\n\n            if value_choice.is_none() {\n                return Err(ControllerType::Error(\n                    strings::cfg::configuration_value_unrecognized(&value_string).to_owned(),\n                ));\n            }\n\n            value_choice\n        };\n\n        return Ok(ConfigSpeechToTextSettingRelatedControllerType::SetMsgTypeForNonThreadedOnlyTranscribedMessages(\n            value_choice,\n        ));\n    }\n\n    // Language\n\n    if let Some(remaining_text) = text.strip_prefix(\"language\") {\n        let remaining_text = remaining_text.trim();\n\n        if !remaining_text.is_empty() {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_getter_used_with_extra_text(\"language\", remaining_text)\n                    .to_owned(),\n            ));\n        }\n\n        return Ok(ConfigSpeechToTextSettingRelatedControllerType::GetLanguage);\n    }\n\n    if let Some(value_string) = text.strip_prefix(\"set-language\") {\n        let value_string = value_string.trim().to_owned();\n\n        let value_string = if value_string.is_empty() {\n            None\n        } else {\n            if value_string.len() != 2 {\n                return Err(ControllerType::Error(\n                    strings::speech_to_text::language_code_invalid(&value_string).to_owned(),\n                ));\n            }\n\n            Some(value_string)\n        };\n\n        return Ok(ConfigSpeechToTextSettingRelatedControllerType::SetLanguage(\n            value_string,\n        ));\n    }\n\n    Err(ControllerType::Unknown)\n}\n"
  },
  {
    "path": "src/controller/cfg/determination/speech_to_text/tests.rs",
    "content": "#[test]\nfn determine_controller_other() {\n    use super::ConfigSpeechToTextSettingRelatedControllerType;\n    use super::ControllerType;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: Result<ConfigSpeechToTextSettingRelatedControllerType, ControllerType>,\n    }\n\n    let test_cases = vec![TestCase {\n        name: \"Unknown\",\n        input: \"whatever\",\n        expected: Err(ControllerType::Unknown),\n    }];\n\n    for test_case in test_cases {\n        let result = super::determine(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n\n#[test]\nfn determine_controller_flow_type() {\n    use super::ConfigSpeechToTextSettingRelatedControllerType;\n    use super::ControllerType;\n    use crate::entity::roomconfig::SpeechToTextFlowType;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: Result<ConfigSpeechToTextSettingRelatedControllerType, ControllerType>,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"flow-type getter ok\",\n            input: \"flow-type\",\n            expected: Ok(ConfigSpeechToTextSettingRelatedControllerType::GetFlowType),\n        },\n        TestCase {\n            name: \"flow-type getter extra args\",\n            input: \"flow-type some values here\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_getter_used_with_extra_text(\n                    \"flow-type\",\n                    \"some values here\",\n                ),\n            )),\n        },\n        TestCase {\n            name: \"flow-type setter\",\n            input: \"set-flow-type transcribe_and_generate_text\",\n            expected: Ok(ConfigSpeechToTextSettingRelatedControllerType::SetFlowType(\n                Some(SpeechToTextFlowType::TranscribeAndGenerateText),\n            )),\n        },\n        TestCase {\n            name: \"flow-type setter\",\n            input: \"set-flow-type unknown-Value\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_value_unrecognized(\"unknown-Value\"),\n            )),\n        },\n        TestCase {\n            name: \"flow-type unsetter\",\n            input: \"set-flow-type\",\n            expected: Ok(ConfigSpeechToTextSettingRelatedControllerType::SetFlowType(\n                None,\n            )),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n\n#[test]\nfn determine_controller_language() {\n    use super::ConfigSpeechToTextSettingRelatedControllerType;\n    use super::ControllerType;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: Result<ConfigSpeechToTextSettingRelatedControllerType, ControllerType>,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"language getter ok\",\n            input: \"language\",\n            expected: Ok(ConfigSpeechToTextSettingRelatedControllerType::GetLanguage),\n        },\n        TestCase {\n            name: \"language getter extra args\",\n            input: \"language some values here\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_getter_used_with_extra_text(\n                    \"language\",\n                    \"some values here\",\n                ),\n            )),\n        },\n        TestCase {\n            name: \"language setter 2-letter code (ja)\",\n            input: \"set-language ja\",\n            expected: Ok(ConfigSpeechToTextSettingRelatedControllerType::SetLanguage(\n                Some(\"ja\".to_owned()),\n            )),\n        },\n        // OpenAI does not support 3-letter codes, so we won't be allowing it either\n        TestCase {\n            name: \"language setter 3-letter code (jpn) fails\",\n            input: \"set-language jpn\",\n            expected: Err(ControllerType::Error(\n                crate::strings::speech_to_text::language_code_invalid(\"jpn\"),\n            )),\n        },\n        TestCase {\n            name: \"language unsetter\",\n            input: \"set-language\",\n            expected: Ok(ConfigSpeechToTextSettingRelatedControllerType::SetLanguage(\n                None,\n            )),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n"
  },
  {
    "path": "src/controller/cfg/determination/tests.rs",
    "content": "#[test]\nfn determine_controller() {\n    use super::super::controller_type;\n    use crate::agent::{AgentPurpose, PublicIdentifier};\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: super::ControllerType,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"Top-level is help\",\n            input: \"\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::Help),\n        },\n        TestCase {\n            name: \"unknown commands is help\",\n            input: \"whatever\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::Help),\n        },\n\n        TestCase {\n            name: \"Status\",\n            input: \"status\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::Status),\n        },\n\n\n        TestCase {\n            name: \"per-room handler getter - catch-all\",\n            input: \"room handler catch-all\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Room,\n                controller_type::ConfigSettingRelatedControllerType::GetHandler(AgentPurpose::CatchAll),\n            )),\n        },\n        TestCase {\n            name: \"per-room handler getter - text-generation\",\n            input: \"room handler text-generation\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Room,\n                controller_type::ConfigSettingRelatedControllerType::GetHandler(AgentPurpose::TextGeneration),\n            )),\n        },\n        TestCase {\n            name: \"per-room handler getter - invalid purpose\",\n            input: \"room handler invalid-purpose-here\",\n            expected: super::ControllerType::Error(\n                crate::strings::agent::purpose_unrecognized(\"invalid-purpose-here\").to_owned()\n            ),\n        },\n        TestCase {\n            name: \"per-room handler getter - invalid purpose with spaces\",\n            input: \"room handler invalid purpose here\",\n            expected: super::ControllerType::Error(\n                crate::strings::agent::purpose_unrecognized(\"invalid purpose here\").to_owned()\n            ),\n        },\n        TestCase {\n            name: \"per-room handler setter - too few values\",\n            input: \"room set-handler\",\n            expected: super::ControllerType::Error(\n                crate::strings::cfg::configuration_invocation_incorrect_more_values_expected().to_owned()\n            ),\n        },\n        TestCase {\n            name: \"per-room handler setter - catch-all\",\n            input: \"room set-handler catch-all static/agent-id\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Room,\n                controller_type::ConfigSettingRelatedControllerType::SetHandler(AgentPurpose::CatchAll, Some(\n                    PublicIdentifier::Static(\"agent-id\".to_owned())\n                )),\n            )),\n        },\n        TestCase {\n            name: \"per-room handler setter - catch-all with bare agent id\",\n            input: \"room set-handler catch-all agent-id\",\n            expected: super::ControllerType::Error(\n                crate::strings::agent::invalid_id_generic().to_owned()\n            ),\n        },\n        TestCase {\n            name: \"per-room handler setter - catch-all unsetter\",\n            input: \"room set-handler catch-all\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Room,\n                controller_type::ConfigSettingRelatedControllerType::SetHandler(AgentPurpose::CatchAll, None),\n            )),\n        },\n        TestCase {\n            name: \"per-room handler setter - text-generation\",\n            input: \"room set-handler text-generation room-local/agent-id\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Room,\n                controller_type::ConfigSettingRelatedControllerType::SetHandler(AgentPurpose::TextGeneration, Some(\n                    PublicIdentifier::DynamicRoomLocal(\"agent-id\".to_owned())\n                )),\n            )),\n        },\n        TestCase {\n            name: \"per-room handler setter - too many values\",\n            input: \"room set-handler text-generation agent-id more values here\",\n            expected: super::ControllerType::Error(\n                crate::strings::agent::invalid_id_generic().to_owned()\n            ),\n        },\n\n        // We have few global handler test cases. We've exercised the per-room handlers enough.\n        // These share the same code path, so we don't need to test all the permutations again.\n        TestCase {\n            name: \"global handler getter - catch-all\",\n            input: \"global handler catch-all\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Global,\n                controller_type::ConfigSettingRelatedControllerType::GetHandler(AgentPurpose::CatchAll),\n            )),\n        },\n        TestCase {\n            name: \"global handler setter - text-generation with global agent\",\n            input: \"global set-handler text-generation global/agent-id\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Global,\n                controller_type::ConfigSettingRelatedControllerType::SetHandler(AgentPurpose::TextGeneration, Some(\n                    PublicIdentifier::DynamicGlobal(\"agent-id\".to_owned())\n                )),\n            )),\n        },\n        // This test case passes, even though the handler function will subsequently reject using room-local agents for global handlers.\n        TestCase {\n            name: \"global handler setter - text-generation with room-local agent\",\n            input: \"global set-handler text-generation room-local/agent-id\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Global,\n                controller_type::ConfigSettingRelatedControllerType::SetHandler(AgentPurpose::TextGeneration, Some(\n                    PublicIdentifier::DynamicRoomLocal(\"agent-id\".to_owned())\n                )),\n            )),\n        },\n\n        // We'll only test one handler per sub-category to ensure proper routing is done here.\n        // Extensive tests for each sub-category are done in their respective modules.\n\n        TestCase {\n            name: \"per-room text-generation/context-management-enabled getter\",\n            input: \"room text-generation context-management-enabled\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Room,\n                controller_type::ConfigSettingRelatedControllerType::TextGeneration(\n                    controller_type::ConfigTextGenerationSettingRelatedControllerType::GetContextManagementEnabled,\n                ),\n            )),\n        },\n        TestCase {\n            name: \"global text-generation/context-management-enabled getter\",\n            input: \"global text-generation context-management-enabled\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Global,\n                controller_type::ConfigSettingRelatedControllerType::TextGeneration(\n                    controller_type::ConfigTextGenerationSettingRelatedControllerType::GetContextManagementEnabled,\n                ),\n            )),\n        },\n        TestCase {\n            name: \"per-room text-generation/sender-context-mode getter\",\n            input: \"room text-generation sender-context-mode\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Room,\n                controller_type::ConfigSettingRelatedControllerType::TextGeneration(\n                    controller_type::ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode,\n                ),\n            )),\n        },\n        TestCase {\n            name: \"global text-generation/sender-context-mode getter\",\n            input: \"global text-generation sender-context-mode\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Global,\n                controller_type::ConfigSettingRelatedControllerType::TextGeneration(\n                    controller_type::ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode,\n                ),\n            )),\n        },\n        TestCase {\n            name: \"per-room text-to-speech/speed-override getter\",\n            input: \"room text-to-speech speed-override\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Room,\n                controller_type::ConfigSettingRelatedControllerType::TextToSpeech(\n                    controller_type::ConfigTextToSpeechSettingRelatedControllerType::GetSpeedOverride,\n                ),\n            )),\n        },\n        TestCase {\n            name: \"global text-to-speech/speed-override getter\",\n            input: \"global text-to-speech speed-override\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Global,\n                controller_type::ConfigSettingRelatedControllerType::TextToSpeech(\n                    controller_type::ConfigTextToSpeechSettingRelatedControllerType::GetSpeedOverride,\n                ),\n            )),\n        },\n        TestCase {\n            name: \"room speech-to-text/flow-type getter\",\n            input: \"room speech-to-text flow-type\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Room,\n                controller_type::ConfigSettingRelatedControllerType::SpeechToText(\n                    controller_type::ConfigSpeechToTextSettingRelatedControllerType::GetFlowType,\n                ),\n            )),\n        },\n        TestCase {\n            name: \"global speech-to-text/flow-type getter\",\n            input: \"global speech-to-text flow-type\",\n            expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(\n                controller_type::SettingsStorageSource::Global,\n                controller_type::ConfigSettingRelatedControllerType::SpeechToText(\n                    controller_type::ConfigSpeechToTextSettingRelatedControllerType::GetFlowType,\n                ),\n            )),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine_controller(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n"
  },
  {
    "path": "src/controller/cfg/determination/text_generation/mod.rs",
    "content": "#[cfg(test)]\nmod tests;\n\nuse crate::{\n    controller::ControllerType,\n    entity::roomconfig::{\n        TextGenerationAutoUsage, TextGenerationPrefixRequirementType,\n        TextGenerationSenderContextMode,\n    },\n    strings,\n};\n\nuse super::super::controller_type::ConfigTextGenerationSettingRelatedControllerType;\n\npub(super) fn determine(\n    text: &str,\n) -> Result<ConfigTextGenerationSettingRelatedControllerType, ControllerType> {\n    if let Some(remaining_text) = text.strip_prefix(\"context-management-enabled\") {\n        let remaining_text = remaining_text.trim();\n\n        if !remaining_text.is_empty() {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_getter_used_with_extra_text(\n                    \"context-management-enabled\",\n                    remaining_text,\n                )\n                .to_owned(),\n            ));\n        }\n\n        return Ok(ConfigTextGenerationSettingRelatedControllerType::GetContextManagementEnabled);\n    }\n\n    if let Some(value_string) = text.strip_prefix(\"set-context-management-enabled\") {\n        let value_string = value_string.trim().to_owned();\n        let value_opt = if value_string.is_empty() {\n            None\n        } else {\n            let value_string_lowercase = value_string.to_lowercase();\n            Some(match value_string_lowercase.as_str() {\n                \"true\" => true,\n                \"false\" => false,\n                _ => {\n                    return Err(ControllerType::Error(\n                        strings::cfg::configuration_value_unrecognized(&value_string).to_owned(),\n                    ));\n                }\n            })\n        };\n\n        return Ok(\n            ConfigTextGenerationSettingRelatedControllerType::SetContextManagementEnabled(\n                value_opt,\n            ),\n        );\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"prefix-requirement-type\") {\n        let remaining_text = remaining_text.trim();\n\n        if !remaining_text.is_empty() {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_getter_used_with_extra_text(\n                    \"prefix-requirement-type\",\n                    remaining_text,\n                )\n                .to_owned(),\n            ));\n        }\n\n        return Ok(ConfigTextGenerationSettingRelatedControllerType::GetPrefixRequirementType);\n    }\n\n    if let Some(value_string) = text.strip_prefix(\"set-prefix-requirement-type\") {\n        let value_string = value_string.trim().to_owned();\n\n        let value_choice = if value_string.is_empty() {\n            None\n        } else {\n            let value_choice =\n                TextGenerationPrefixRequirementType::from_str(&value_string.to_lowercase());\n\n            if value_choice.is_none() {\n                return Err(ControllerType::Error(\n                    strings::cfg::configuration_value_unrecognized(&value_string).to_owned(),\n                ));\n            }\n\n            value_choice\n        };\n\n        return Ok(\n            ConfigTextGenerationSettingRelatedControllerType::SetPrefixRequirementType(\n                value_choice,\n            ),\n        );\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"auto-usage\") {\n        let remaining_text = remaining_text.trim();\n\n        if !remaining_text.is_empty() {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_getter_used_with_extra_text(\n                    \"auto-usage\",\n                    remaining_text,\n                )\n                .to_owned(),\n            ));\n        }\n\n        return Ok(ConfigTextGenerationSettingRelatedControllerType::GetAutoUsage);\n    }\n\n    if let Some(value_string) = text.strip_prefix(\"set-auto-usage\") {\n        let value_string = value_string.trim().to_owned();\n\n        let value_choice = if value_string.is_empty() {\n            None\n        } else {\n            let value_choice = TextGenerationAutoUsage::from_str(&value_string.to_lowercase());\n\n            if value_choice.is_none() {\n                return Err(ControllerType::Error(\n                    strings::cfg::configuration_value_unrecognized(&value_string).to_owned(),\n                ));\n            }\n\n            value_choice\n        };\n\n        return Ok(ConfigTextGenerationSettingRelatedControllerType::SetAutoUsage(value_choice));\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"prompt-override\") {\n        let remaining_text = remaining_text.trim();\n\n        if !remaining_text.is_empty() {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_getter_used_with_extra_text(\n                    \"prompt-override\",\n                    remaining_text,\n                )\n                .to_owned(),\n            ));\n        }\n\n        return Ok(ConfigTextGenerationSettingRelatedControllerType::GetPromptOverride);\n    }\n\n    if let Some(value_string) = text.strip_prefix(\"set-prompt-override\") {\n        let value_string = value_string.trim().to_owned();\n\n        if value_string.is_empty() {\n            return Ok(ConfigTextGenerationSettingRelatedControllerType::SetPromptOverride(None));\n        }\n\n        return Ok(\n            ConfigTextGenerationSettingRelatedControllerType::SetPromptOverride(Some(value_string)),\n        );\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"temperature-override\") {\n        let remaining_text = remaining_text.trim();\n\n        if !remaining_text.is_empty() {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_getter_used_with_extra_text(\n                    \"temperature-override\",\n                    remaining_text,\n                )\n                .to_owned(),\n            ));\n        }\n\n        return Ok(ConfigTextGenerationSettingRelatedControllerType::GetTemperatureOverride);\n    }\n\n    if let Some(value_string) = text.strip_prefix(\"set-temperature-override\") {\n        let value_string = value_string.trim().to_owned();\n\n        if value_string.is_empty() {\n            return Ok(\n                ConfigTextGenerationSettingRelatedControllerType::SetTemperatureOverride(None),\n            );\n        }\n\n        let value_f32 = value_string.parse::<f32>();\n\n        let Ok(value_f32) = value_f32 else {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_value_not_f32(&value_string).to_owned(),\n            ));\n        };\n\n        return Ok(\n            ConfigTextGenerationSettingRelatedControllerType::SetTemperatureOverride(Some(\n                value_f32,\n            )),\n        );\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"sender-context-mode\") {\n        let remaining_text = remaining_text.trim();\n\n        if !remaining_text.is_empty() {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_getter_used_with_extra_text(\n                    \"sender-context-mode\",\n                    remaining_text,\n                )\n                .to_owned(),\n            ));\n        }\n\n        return Ok(ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode);\n    }\n\n    if let Some(value_string) = text.strip_prefix(\"set-sender-context-mode\") {\n        let value_string = value_string.trim().to_owned();\n        let value_choice = if value_string.is_empty() {\n            None\n        } else {\n            let value_choice =\n                TextGenerationSenderContextMode::from_str(&value_string.to_lowercase());\n\n            if value_choice.is_none() {\n                return Err(ControllerType::Error(\n                    strings::cfg::configuration_value_unrecognized(&value_string).to_owned(),\n                ));\n            }\n\n            value_choice\n        };\n\n        return Ok(\n            ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(value_choice),\n        );\n    }\n\n    Err(ControllerType::Unknown)\n}\n"
  },
  {
    "path": "src/controller/cfg/determination/text_generation/tests.rs",
    "content": "#[test]\nfn determine_controller_other() {\n    use super::ConfigTextGenerationSettingRelatedControllerType;\n    use super::ControllerType;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: Result<ConfigTextGenerationSettingRelatedControllerType, ControllerType>,\n    }\n\n    let test_cases = vec![TestCase {\n        name: \"Unknown\",\n        input: \"whatever\",\n        expected: Err(ControllerType::Unknown),\n    }];\n\n    for test_case in test_cases {\n        let result = super::determine(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n\n#[test]\nfn determine_controller_context_management() {\n    use super::ConfigTextGenerationSettingRelatedControllerType;\n    use super::ControllerType;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: Result<ConfigTextGenerationSettingRelatedControllerType, ControllerType>,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"context-management-enabled getter ok\",\n            input: \"context-management-enabled\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::GetContextManagementEnabled,\n            ),\n        },\n        TestCase {\n            name: \"context-management-enabled getter extra args\",\n            input: \"context-management-enabled some values here\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_getter_used_with_extra_text(\n                    \"context-management-enabled\",\n                    \"some values here\",\n                ),\n            )),\n        },\n        TestCase {\n            name: \"context-management-enabled setter\",\n            input: \"set-context-management-enabled true\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::SetContextManagementEnabled(\n                    Some(true),\n                ),\n            ),\n        },\n        TestCase {\n            name: \"context-management-enabled setter uppercase\",\n            input: \"set-context-management-enabled TRUE\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::SetContextManagementEnabled(\n                    Some(true),\n                ),\n            ),\n        },\n        TestCase {\n            name: \"context-management-enabled setter non-bool\",\n            input: \"set-context-management-enabled non-Bool-Value\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_value_unrecognized(\"non-Bool-Value\"),\n            )),\n        },\n        TestCase {\n            name: \"context-management-enabled unsetter\",\n            input: \"set-context-management-enabled\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::SetContextManagementEnabled(None),\n            ),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n\n#[test]\nfn determine_controller_sender_context() {\n    use super::ConfigTextGenerationSettingRelatedControllerType;\n    use super::ControllerType;\n    use crate::entity::roomconfig::TextGenerationSenderContextMode;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: Result<ConfigTextGenerationSettingRelatedControllerType, ControllerType>,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"sender-context-mode getter ok\",\n            input: \"sender-context-mode\",\n            expected: Ok(ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode),\n        },\n        TestCase {\n            name: \"sender-context-mode getter extra args\",\n            input: \"sender-context-mode some values here\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_getter_used_with_extra_text(\n                    \"sender-context-mode\",\n                    \"some values here\",\n                ),\n            )),\n        },\n        TestCase {\n            name: \"sender-context-mode setter matrix_user_id\",\n            input: \"set-sender-context-mode matrix_user_id\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(Some(\n                    TextGenerationSenderContextMode::MatrixUserId,\n                )),\n            ),\n        },\n        TestCase {\n            name: \"sender-context-mode setter uppercase\",\n            input: \"set-sender-context-mode MATRIX_USER_ID_AND_TIMESTAMP\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(Some(\n                    TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,\n                )),\n            ),\n        },\n        TestCase {\n            name: \"sender-context-mode setter invalid\",\n            input: \"set-sender-context-mode non-Enum-Value\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_value_unrecognized(\"non-Enum-Value\"),\n            )),\n        },\n        TestCase {\n            name: \"sender-context-mode unsetter\",\n            input: \"set-sender-context-mode\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(None),\n            ),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n\n#[test]\nfn determine_controller_prefix_requirement_type() {\n    use super::ConfigTextGenerationSettingRelatedControllerType;\n    use super::ControllerType;\n    use crate::entity::roomconfig::TextGenerationPrefixRequirementType;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: Result<ConfigTextGenerationSettingRelatedControllerType, ControllerType>,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"prefix-requirement-type getter ok\",\n            input: \"prefix-requirement-type\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::GetPrefixRequirementType,\n            ),\n        },\n        TestCase {\n            name: \"prefix-requirement-type getter extra args\",\n            input: \"prefix-requirement-type some values here\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_getter_used_with_extra_text(\n                    \"prefix-requirement-type\",\n                    \"some values here\",\n                ),\n            )),\n        },\n        TestCase {\n            name: \"prefix-requirement-type setter (command_prefix)\",\n            input: \"set-prefix-requirement-type command_prefix\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::SetPrefixRequirementType(Some(\n                    TextGenerationPrefixRequirementType::CommandPrefix,\n                )),\n            ),\n        },\n        TestCase {\n            name: \"prefix-requirement-type setter (no)\",\n            input: \"set-prefix-requirement-type no\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::SetPrefixRequirementType(Some(\n                    TextGenerationPrefixRequirementType::No,\n                )),\n            ),\n        },\n        TestCase {\n            name: \"prefix-requirement-type setter\",\n            input: \"set-prefix-requirement-type unknown-Value\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_value_unrecognized(\"unknown-Value\"),\n            )),\n        },\n        TestCase {\n            name: \"prefix-requirement-type unsetter\",\n            input: \"set-prefix-requirement-type\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::SetPrefixRequirementType(None),\n            ),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n\n#[test]\nfn determine_controller_auto_usage() {\n    use super::ConfigTextGenerationSettingRelatedControllerType;\n    use super::ControllerType;\n    use crate::entity::roomconfig::TextGenerationAutoUsage;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: Result<ConfigTextGenerationSettingRelatedControllerType, ControllerType>,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"auto-usage getter ok\",\n            input: \"auto-usage\",\n            expected: Ok(ConfigTextGenerationSettingRelatedControllerType::GetAutoUsage),\n        },\n        TestCase {\n            name: \"auto-usage getter extra args\",\n            input: \"auto-usage some values here\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_getter_used_with_extra_text(\n                    \"auto-usage\",\n                    \"some values here\",\n                ),\n            )),\n        },\n        TestCase {\n            name: \"auto-usage setter\",\n            input: \"set-auto-usage only_for_voice\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::SetAutoUsage(Some(\n                    TextGenerationAutoUsage::OnlyForVoice,\n                )),\n            ),\n        },\n        TestCase {\n            name: \"auto-usage setter\",\n            input: \"set-auto-usage unknown-Value\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_value_unrecognized(\"unknown-Value\"),\n            )),\n        },\n        TestCase {\n            name: \"auto-usage unsetter\",\n            input: \"set-auto-usage\",\n            expected: Ok(ConfigTextGenerationSettingRelatedControllerType::SetAutoUsage(None)),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n\n#[test]\nfn determine_controller_prompt_override() {\n    use super::ConfigTextGenerationSettingRelatedControllerType;\n    use super::ControllerType;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: Result<ConfigTextGenerationSettingRelatedControllerType, ControllerType>,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"prompt-override getter ok\",\n            input: \"prompt-override\",\n            expected: Ok(ConfigTextGenerationSettingRelatedControllerType::GetPromptOverride),\n        },\n        TestCase {\n            name: \"prompt-override getter extra args\",\n            input: \"prompt-override some values here\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_getter_used_with_extra_text(\n                    \"prompt-override\",\n                    \"some values here\",\n                ),\n            )),\n        },\n        TestCase {\n            name: \"prompt-override setter with multiple words\",\n            input: \"set-prompt-override Hello! You are a bot\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::SetPromptOverride(Some(\n                    \"Hello! You are a bot\".to_owned(),\n                )),\n            ),\n        },\n        TestCase {\n            name: \"prompt-override setter with multi-line\",\n            input: \"set-prompt-override Hello!\\n\\nYou are a bot\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::SetPromptOverride(Some(\n                    \"Hello!\\n\\nYou are a bot\".to_owned(),\n                )),\n            ),\n        },\n        TestCase {\n            name: \"prompt-override unsetter\",\n            input: \"set-prompt-override\",\n            expected: Ok(ConfigTextGenerationSettingRelatedControllerType::SetPromptOverride(None)),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n\n#[test]\nfn determine_controller_temperature_override() {\n    use super::ConfigTextGenerationSettingRelatedControllerType;\n    use super::ControllerType;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: Result<ConfigTextGenerationSettingRelatedControllerType, ControllerType>,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"temperature-override getter ok\",\n            input: \"temperature-override\",\n            expected: Ok(ConfigTextGenerationSettingRelatedControllerType::GetTemperatureOverride),\n        },\n        TestCase {\n            name: \"temperature-override getter extra args\",\n            input: \"temperature-override some values here\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_getter_used_with_extra_text(\n                    \"temperature-override\",\n                    \"some values here\",\n                ),\n            )),\n        },\n        TestCase {\n            name: \"temperature-override setter\",\n            input: \"set-temperature-override 0.5\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::SetTemperatureOverride(Some(0.5)),\n            ),\n        },\n        TestCase {\n            name: \"temperature-override setter\",\n            input: \"set-temperature-override unknown-Value\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_value_not_f32(\"unknown-Value\"),\n            )),\n        },\n        TestCase {\n            name: \"temperature-override unsetter\",\n            input: \"set-temperature-override\",\n            expected: Ok(\n                ConfigTextGenerationSettingRelatedControllerType::SetTemperatureOverride(None),\n            ),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n"
  },
  {
    "path": "src/controller/cfg/determination/text_to_speech/mod.rs",
    "content": "#[cfg(test)]\nmod tests;\n\nuse crate::{\n    controller::ControllerType,\n    entity::roomconfig::{TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType},\n    strings,\n};\n\nuse super::super::controller_type::ConfigTextToSpeechSettingRelatedControllerType;\n\npub(super) fn determine(\n    text: &str,\n) -> Result<ConfigTextToSpeechSettingRelatedControllerType, ControllerType> {\n    if let Some(remaining_text) = text.strip_prefix(\"bot-msgs-flow-type\") {\n        let remaining_text = remaining_text.trim();\n\n        if !remaining_text.is_empty() {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_getter_used_with_extra_text(\n                    \"bot-msgs-flow-type\",\n                    remaining_text,\n                )\n                .to_owned(),\n            ));\n        }\n\n        return Ok(ConfigTextToSpeechSettingRelatedControllerType::GetBotMessagesFlowType);\n    }\n\n    if let Some(value_string) = text.strip_prefix(\"set-bot-msgs-flow-type\") {\n        let value_string = value_string.trim().to_owned();\n\n        let value_choice = if value_string.is_empty() {\n            None\n        } else {\n            let value_choice =\n                TextToSpeechBotMessagesFlowType::from_str(&value_string.to_lowercase());\n\n            if value_choice.is_none() {\n                return Err(ControllerType::Error(\n                    strings::cfg::configuration_value_unrecognized(&value_string).to_owned(),\n                ));\n            }\n\n            value_choice\n        };\n\n        return Ok(\n            ConfigTextToSpeechSettingRelatedControllerType::SetBotMessagesFlowType(value_choice),\n        );\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"user-msgs-flow-type\") {\n        let remaining_text = remaining_text.trim();\n\n        if !remaining_text.is_empty() {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_getter_used_with_extra_text(\n                    \"user-msgs-flow-type\",\n                    remaining_text,\n                )\n                .to_owned(),\n            ));\n        }\n\n        return Ok(ConfigTextToSpeechSettingRelatedControllerType::GetUserMessagesFlowType);\n    }\n\n    if let Some(value_string) = text.strip_prefix(\"set-user-msgs-flow-type\") {\n        let value_string = value_string.trim().to_owned();\n\n        let value_choice = if value_string.is_empty() {\n            None\n        } else {\n            let value_choice =\n                TextToSpeechUserMessagesFlowType::from_str(&value_string.to_lowercase());\n\n            if value_choice.is_none() {\n                return Err(ControllerType::Error(\n                    strings::cfg::configuration_value_unrecognized(&value_string).to_owned(),\n                ));\n            }\n\n            value_choice\n        };\n\n        return Ok(\n            ConfigTextToSpeechSettingRelatedControllerType::SetUserMessagesFlowType(value_choice),\n        );\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"speed-override\") {\n        let remaining_text = remaining_text.trim();\n\n        if !remaining_text.is_empty() {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_getter_used_with_extra_text(\n                    \"speed-override\",\n                    remaining_text,\n                )\n                .to_owned(),\n            ));\n        }\n\n        return Ok(ConfigTextToSpeechSettingRelatedControllerType::GetSpeedOverride);\n    }\n\n    if let Some(value_string) = text.strip_prefix(\"set-speed-override\") {\n        let value_string = value_string.trim().to_owned();\n\n        if value_string.is_empty() {\n            return Ok(ConfigTextToSpeechSettingRelatedControllerType::SetSpeedOverride(None));\n        }\n\n        let value_f32 = value_string.parse::<f32>();\n\n        let Ok(value_f32) = value_f32 else {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_value_not_f32(&value_string).to_owned(),\n            ));\n        };\n\n        return Ok(\n            ConfigTextToSpeechSettingRelatedControllerType::SetSpeedOverride(Some(value_f32)),\n        );\n    }\n\n    if let Some(remaining_text) = text.strip_prefix(\"voice-override\") {\n        let remaining_text = remaining_text.trim();\n\n        if !remaining_text.is_empty() {\n            return Err(ControllerType::Error(\n                strings::cfg::configuration_getter_used_with_extra_text(\n                    \"voice-override\",\n                    remaining_text,\n                )\n                .to_owned(),\n            ));\n        }\n\n        return Ok(ConfigTextToSpeechSettingRelatedControllerType::GetVoiceOverride);\n    }\n\n    if let Some(value_string) = text.strip_prefix(\"set-voice-override\") {\n        let value_string = value_string.trim().to_owned();\n\n        if value_string.is_empty() {\n            return Ok(ConfigTextToSpeechSettingRelatedControllerType::SetVoiceOverride(None));\n        }\n\n        return Ok(\n            ConfigTextToSpeechSettingRelatedControllerType::SetVoiceOverride(Some(value_string)),\n        );\n    }\n\n    Err(ControllerType::Unknown)\n}\n"
  },
  {
    "path": "src/controller/cfg/determination/text_to_speech/tests.rs",
    "content": "#[test]\nfn determine_controller_other() {\n    use super::ConfigTextToSpeechSettingRelatedControllerType;\n    use super::ControllerType;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: Result<ConfigTextToSpeechSettingRelatedControllerType, ControllerType>,\n    }\n\n    let test_cases = vec![TestCase {\n        name: \"Unknown\",\n        input: \"whatever\",\n        expected: Err(ControllerType::Unknown),\n    }];\n\n    for test_case in test_cases {\n        let result = super::determine(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n\n#[test]\nfn determine_controller_bot_msgs_flow_type() {\n    use super::ConfigTextToSpeechSettingRelatedControllerType;\n    use super::ControllerType;\n    use crate::entity::roomconfig::TextToSpeechBotMessagesFlowType;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: Result<ConfigTextToSpeechSettingRelatedControllerType, ControllerType>,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"bot-msgs-flow-type getter ok\",\n            input: \"bot-msgs-flow-type\",\n            expected: Ok(ConfigTextToSpeechSettingRelatedControllerType::GetBotMessagesFlowType),\n        },\n        TestCase {\n            name: \"bot-msgs-flow-type getter extra args\",\n            input: \"bot-msgs-flow-type some values here\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_getter_used_with_extra_text(\n                    \"bot-msgs-flow-type\",\n                    \"some values here\",\n                ),\n            )),\n        },\n        TestCase {\n            name: \"bot-msgs-flow-type setter\",\n            input: \"set-bot-msgs-flow-type only_for_voice\",\n            expected: Ok(\n                ConfigTextToSpeechSettingRelatedControllerType::SetBotMessagesFlowType(Some(\n                    TextToSpeechBotMessagesFlowType::OnlyForVoice,\n                )),\n            ),\n        },\n        TestCase {\n            name: \"bot-msgs-flow-type setter\",\n            input: \"set-bot-msgs-flow-type unknown-Value\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_value_unrecognized(\"unknown-Value\"),\n            )),\n        },\n        TestCase {\n            name: \"bot-msgs-flow-type unsetter\",\n            input: \"set-bot-msgs-flow-type\",\n            expected: Ok(\n                ConfigTextToSpeechSettingRelatedControllerType::SetBotMessagesFlowType(None),\n            ),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n\n#[test]\nfn determine_controller_user_msgs_flow_type() {\n    use super::ConfigTextToSpeechSettingRelatedControllerType;\n    use super::ControllerType;\n    use crate::entity::roomconfig::TextToSpeechUserMessagesFlowType;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: Result<ConfigTextToSpeechSettingRelatedControllerType, ControllerType>,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"user-msgs-flow-type getter ok\",\n            input: \"user-msgs-flow-type\",\n            expected: Ok(ConfigTextToSpeechSettingRelatedControllerType::GetUserMessagesFlowType),\n        },\n        TestCase {\n            name: \"user-msgs-flow-type getter extra args\",\n            input: \"user-msgs-flow-type some values here\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_getter_used_with_extra_text(\n                    \"user-msgs-flow-type\",\n                    \"some values here\",\n                ),\n            )),\n        },\n        TestCase {\n            name: \"user-msgs-flow-type setter\",\n            input: \"set-user-msgs-flow-type on_demand\",\n            expected: Ok(\n                ConfigTextToSpeechSettingRelatedControllerType::SetUserMessagesFlowType(Some(\n                    TextToSpeechUserMessagesFlowType::OnDemand,\n                )),\n            ),\n        },\n        TestCase {\n            name: \"user-msgs-flow-type setter\",\n            input: \"set-user-msgs-flow-type unknown-Value\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_value_unrecognized(\"unknown-Value\"),\n            )),\n        },\n        TestCase {\n            name: \"user-msgs-flow-type unsetter\",\n            input: \"set-user-msgs-flow-type\",\n            expected: Ok(\n                ConfigTextToSpeechSettingRelatedControllerType::SetUserMessagesFlowType(None),\n            ),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n\n#[test]\nfn determine_controller_speed_override() {\n    use super::ConfigTextToSpeechSettingRelatedControllerType;\n    use super::ControllerType;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: Result<ConfigTextToSpeechSettingRelatedControllerType, ControllerType>,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"speed-override getter ok\",\n            input: \"speed-override\",\n            expected: Ok(ConfigTextToSpeechSettingRelatedControllerType::GetSpeedOverride),\n        },\n        TestCase {\n            name: \"speed-override getter extra args\",\n            input: \"speed-override some values here\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_getter_used_with_extra_text(\n                    \"speed-override\",\n                    \"some values here\",\n                ),\n            )),\n        },\n        TestCase {\n            name: \"speed-override setter\",\n            input: \"set-speed-override 0.5\",\n            expected: Ok(\n                ConfigTextToSpeechSettingRelatedControllerType::SetSpeedOverride(Some(0.5)),\n            ),\n        },\n        TestCase {\n            name: \"speed-override setter\",\n            input: \"set-speed-override unknown-Value\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_value_not_f32(\"unknown-Value\"),\n            )),\n        },\n        TestCase {\n            name: \"speed-override unsetter\",\n            input: \"set-speed-override\",\n            expected: Ok(ConfigTextToSpeechSettingRelatedControllerType::SetSpeedOverride(None)),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n\n#[test]\nfn determine_controller_voice_override() {\n    use super::ConfigTextToSpeechSettingRelatedControllerType;\n    use super::ControllerType;\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: Result<ConfigTextToSpeechSettingRelatedControllerType, ControllerType>,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"voice-override getter ok\",\n            input: \"voice-override\",\n            expected: Ok(ConfigTextToSpeechSettingRelatedControllerType::GetVoiceOverride),\n        },\n        TestCase {\n            name: \"voice-override getter extra args\",\n            input: \"voice-override some values here\",\n            expected: Err(ControllerType::Error(\n                crate::strings::cfg::configuration_getter_used_with_extra_text(\n                    \"voice-override\",\n                    \"some values here\",\n                ),\n            )),\n        },\n        TestCase {\n            name: \"voice-override setter\",\n            input: \"set-voice-override alex\",\n            expected: Ok(\n                ConfigTextToSpeechSettingRelatedControllerType::SetVoiceOverride(Some(\n                    \"alex\".to_owned(),\n                )),\n            ),\n        },\n        TestCase {\n            name: \"voice-override setter preserves case\",\n            input: \"set-voice-override Alex\",\n            expected: Ok(\n                ConfigTextToSpeechSettingRelatedControllerType::SetVoiceOverride(Some(\n                    \"Alex\".to_owned(),\n                )),\n            ),\n        },\n        TestCase {\n            name: \"voice-override unsetter\",\n            input: \"set-voice-override\",\n            expected: Ok(ConfigTextToSpeechSettingRelatedControllerType::SetVoiceOverride(None)),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n"
  },
  {
    "path": "src/controller/cfg/dispatching/mod.rs",
    "content": "use crate::strings;\nuse crate::{Bot, entity::MessageContext};\nuse mxlink::MessageResponseType;\n\nuse super::controller_type::{\n    ConfigControllerType, ConfigSettingRelatedControllerType, SettingsStorageSource,\n};\n\nmod speech_to_text;\nmod text_generation;\nmod text_to_speech;\n\npub async fn dispatch_controller(\n    handler: &ConfigControllerType,\n    message_context: &MessageContext,\n    bot: &Bot,\n) -> anyhow::Result<()> {\n    // Anyone can access Help and Status.\n    // Settings-related access checks are done in dispatch_config_related_handler().\n\n    match handler {\n        ConfigControllerType::Help => super::help::handle(bot, message_context).await,\n        ConfigControllerType::Status => super::status::handle(bot, message_context).await,\n        ConfigControllerType::SettingsRelated(config_type, config_related_handler) => {\n            dispatch_config_related_handler(\n                config_type,\n                config_related_handler,\n                message_context,\n                bot,\n            )\n            .await\n        }\n    }\n}\n\nasync fn dispatch_config_related_handler(\n    config_type: &SettingsStorageSource,\n    handler: &ConfigSettingRelatedControllerType,\n    message_context: &MessageContext,\n    bot: &Bot,\n) -> anyhow::Result<()> {\n    if let SettingsStorageSource::Global = config_type\n        && !message_context.sender_can_manage_global_config()\n    {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                strings::global_config::no_permissions_to_administrate(),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n        return Ok(());\n    }\n\n    let room_settings = match config_type {\n        SettingsStorageSource::Room => &message_context.room_config().settings,\n        SettingsStorageSource::Global => &message_context.global_config().fallback_room_settings,\n    };\n\n    match handler {\n        ConfigSettingRelatedControllerType::GetHandler(purpose) => match config_type {\n            SettingsStorageSource::Room => {\n                super::room_config::handler::handle_get(bot, message_context, *purpose).await\n            }\n            SettingsStorageSource::Global => {\n                super::global_config::handler::handle_get(bot, message_context, *purpose).await\n            }\n        },\n        ConfigSettingRelatedControllerType::SetHandler(purpose, agent_identifier) => {\n            match config_type {\n                SettingsStorageSource::Room => {\n                    super::room_config::handler::handle_set(\n                        bot,\n                        bot.room_config_manager(),\n                        message_context,\n                        *purpose,\n                        agent_identifier,\n                    )\n                    .await\n                }\n                SettingsStorageSource::Global => {\n                    super::global_config::handler::handle_set(\n                        bot,\n                        bot.global_config_manager(),\n                        message_context,\n                        *purpose,\n                        agent_identifier,\n                    )\n                    .await\n                }\n            }\n        }\n        ConfigSettingRelatedControllerType::TextGeneration(controller_type) => {\n            text_generation::dispatch(\n                controller_type,\n                message_context,\n                bot,\n                room_settings,\n                config_type,\n            )\n            .await\n        }\n        ConfigSettingRelatedControllerType::SpeechToText(controller_type) => {\n            speech_to_text::dispatch(\n                controller_type,\n                message_context,\n                bot,\n                room_settings,\n                config_type,\n            )\n            .await\n        }\n        ConfigSettingRelatedControllerType::TextToSpeech(controller_type) => {\n            text_to_speech::dispatch(\n                controller_type,\n                message_context,\n                bot,\n                room_settings,\n                config_type,\n            )\n            .await\n        }\n    }\n}\n"
  },
  {
    "path": "src/controller/cfg/dispatching/speech_to_text.rs",
    "content": "use crate::entity::roomconfig::{\n    RoomSettings, SpeechToTextFlowType,\n    SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,\n};\nuse crate::{Bot, entity::MessageContext};\n\nuse super::super::controller_type::{\n    ConfigSpeechToTextSettingRelatedControllerType, SettingsStorageSource,\n};\n\nuse super::super::common::generic_setting::handle_get as setting_get;\n\nuse super::super::global_config::generic_setting::handle_set as global_setting_set;\n\nuse super::super::room_config::generic_setting::handle_set as room_setting_set;\n\npub(super) async fn dispatch(\n    handler: &ConfigSpeechToTextSettingRelatedControllerType,\n    message_context: &MessageContext,\n    bot: &Bot,\n    room_settings: &RoomSettings,\n    config_type: &SettingsStorageSource,\n) -> anyhow::Result<()> {\n    match handler {\n        ConfigSpeechToTextSettingRelatedControllerType::GetFlowType => {\n            let value = &room_settings.speech_to_text.flow_type;\n            setting_get::<SpeechToTextFlowType>(bot, message_context, value).await\n        }\n        ConfigSpeechToTextSettingRelatedControllerType::SetFlowType(value) => {\n            let value = value.to_owned();\n\n            let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {\n                room_settings.speech_to_text.flow_type = value;\n            });\n\n            match config_type {\n                SettingsStorageSource::Room => {\n                    room_setting_set::<SpeechToTextFlowType>(\n                        bot,\n                        message_context,\n                        &value,\n                        setter_callback,\n                    )\n                    .await\n                }\n                SettingsStorageSource::Global => {\n                    global_setting_set::<SpeechToTextFlowType>(\n                        bot,\n                        message_context,\n                        &value,\n                        setter_callback,\n                    )\n                    .await\n                }\n            }\n        }\n\n        ConfigSpeechToTextSettingRelatedControllerType::GetMsgTypeForNonThreadedOnlyTranscribedMessages => {\n            let value = &room_settings.speech_to_text.msg_type_for_non_threaded_only_transcribed_messages;\n            setting_get::<SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages>(bot, message_context, value).await\n        }\n        ConfigSpeechToTextSettingRelatedControllerType::SetMsgTypeForNonThreadedOnlyTranscribedMessages(value) => {\n            let value = value.to_owned();\n\n            let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {\n                room_settings.speech_to_text.msg_type_for_non_threaded_only_transcribed_messages = value;\n            });\n\n            match config_type {\n                SettingsStorageSource::Room => {\n                    room_setting_set::<SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages>(\n                        bot,\n                        message_context,\n                        &value,\n                        setter_callback,\n                    )\n                    .await\n                }\n                SettingsStorageSource::Global => {\n                    global_setting_set::<SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages>(\n                        bot,\n                        message_context,\n                        &value,\n                        setter_callback,\n                    )\n                    .await\n                }\n            }\n        }\n\n        ConfigSpeechToTextSettingRelatedControllerType::GetLanguage => {\n            let value = &room_settings.speech_to_text.language;\n            setting_get::<String>(bot, message_context, value).await\n        }\n        ConfigSpeechToTextSettingRelatedControllerType::SetLanguage(value) => {\n            let value = value.to_owned();\n\n            let value_setter = value.clone();\n            let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {\n                room_settings.speech_to_text.language = value_setter;\n            });\n\n            match config_type {\n                SettingsStorageSource::Room => {\n                    room_setting_set::<String>(bot, message_context, &value, setter_callback).await\n                }\n                SettingsStorageSource::Global => {\n                    global_setting_set::<String>(bot, message_context, &value, setter_callback)\n                        .await\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/controller/cfg/dispatching/text_generation.rs",
    "content": "use crate::entity::roomconfig::{\n    RoomSettings, TextGenerationAutoUsage, TextGenerationPrefixRequirementType,\n    TextGenerationSenderContextMode,\n};\nuse crate::{Bot, entity::MessageContext};\n\nuse super::super::controller_type::{\n    ConfigTextGenerationSettingRelatedControllerType, SettingsStorageSource,\n};\n\nuse super::super::common::generic_setting::handle_get as setting_get;\n\nuse super::super::global_config::generic_setting::handle_set as global_setting_set;\n\nuse super::super::room_config::generic_setting::handle_set as room_setting_set;\n\npub(super) async fn dispatch(\n    handler: &ConfigTextGenerationSettingRelatedControllerType,\n    message_context: &MessageContext,\n    bot: &Bot,\n    room_settings: &RoomSettings,\n    config_type: &SettingsStorageSource,\n) -> anyhow::Result<()> {\n    match handler {\n        ConfigTextGenerationSettingRelatedControllerType::GetContextManagementEnabled => {\n            let value = &room_settings.text_generation.context_management_enabled;\n            setting_get::<bool>(bot, message_context, value).await\n        }\n        ConfigTextGenerationSettingRelatedControllerType::SetContextManagementEnabled(value) => {\n            let value = value.to_owned();\n\n            let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {\n                room_settings.text_generation.context_management_enabled = value;\n            });\n\n            match config_type {\n                SettingsStorageSource::Room => {\n                    room_setting_set::<bool>(bot, message_context, &value, setter_callback).await\n                }\n                SettingsStorageSource::Global => {\n                    global_setting_set::<bool>(bot, message_context, &value, setter_callback).await\n                }\n            }\n        }\n\n        ConfigTextGenerationSettingRelatedControllerType::GetPrefixRequirementType => {\n            let value = &room_settings.text_generation.prefix_requirement_type;\n            setting_get::<TextGenerationPrefixRequirementType>(bot, message_context, value).await\n        }\n        ConfigTextGenerationSettingRelatedControllerType::SetPrefixRequirementType(value) => {\n            let value = value.to_owned();\n\n            let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {\n                room_settings.text_generation.prefix_requirement_type = value;\n            });\n\n            match config_type {\n                SettingsStorageSource::Room => {\n                    room_setting_set::<TextGenerationPrefixRequirementType>(\n                        bot,\n                        message_context,\n                        &value,\n                        setter_callback,\n                    )\n                    .await\n                }\n                SettingsStorageSource::Global => {\n                    global_setting_set::<TextGenerationPrefixRequirementType>(\n                        bot,\n                        message_context,\n                        &value,\n                        setter_callback,\n                    )\n                    .await\n                }\n            }\n        }\n\n        ConfigTextGenerationSettingRelatedControllerType::GetAutoUsage => {\n            let value = &room_settings.text_generation.auto_usage;\n            setting_get::<TextGenerationAutoUsage>(bot, message_context, value).await\n        }\n        ConfigTextGenerationSettingRelatedControllerType::SetAutoUsage(value) => {\n            let value = value.to_owned();\n\n            let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {\n                room_settings.text_generation.auto_usage = value;\n            });\n\n            match config_type {\n                SettingsStorageSource::Room => {\n                    room_setting_set::<TextGenerationAutoUsage>(\n                        bot,\n                        message_context,\n                        &value,\n                        setter_callback,\n                    )\n                    .await\n                }\n                SettingsStorageSource::Global => {\n                    global_setting_set::<TextGenerationAutoUsage>(\n                        bot,\n                        message_context,\n                        &value,\n                        setter_callback,\n                    )\n                    .await\n                }\n            }\n        }\n\n        ConfigTextGenerationSettingRelatedControllerType::GetPromptOverride => {\n            let value = &room_settings.text_generation.prompt_override;\n            setting_get::<String>(bot, message_context, value).await\n        }\n        ConfigTextGenerationSettingRelatedControllerType::SetPromptOverride(value) => {\n            let value = value.to_owned();\n\n            let value_setter = value.clone();\n            let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {\n                room_settings.text_generation.prompt_override = value_setter;\n            });\n\n            match config_type {\n                SettingsStorageSource::Room => {\n                    room_setting_set::<String>(bot, message_context, &value, setter_callback).await\n                }\n                SettingsStorageSource::Global => {\n                    global_setting_set::<String>(bot, message_context, &value, setter_callback)\n                        .await\n                }\n            }\n        }\n\n        ConfigTextGenerationSettingRelatedControllerType::GetTemperatureOverride => {\n            let value = &room_settings.text_generation.temperature_override;\n            setting_get::<f32>(bot, message_context, value).await\n        }\n        ConfigTextGenerationSettingRelatedControllerType::SetTemperatureOverride(value) => {\n            let value = value.to_owned();\n\n            let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {\n                room_settings.text_generation.temperature_override = value;\n            });\n\n            match config_type {\n                SettingsStorageSource::Room => {\n                    room_setting_set::<f32>(bot, message_context, &value, setter_callback).await\n                }\n                SettingsStorageSource::Global => {\n                    global_setting_set::<f32>(bot, message_context, &value, setter_callback).await\n                }\n            }\n        }\n\n        ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode => {\n            let value = &room_settings.text_generation.sender_context_mode;\n            setting_get::<TextGenerationSenderContextMode>(bot, message_context, value).await\n        }\n        ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(value) => {\n            let value = value.to_owned();\n\n            let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {\n                room_settings.text_generation.sender_context_mode = value;\n            });\n\n            match config_type {\n                SettingsStorageSource::Room => {\n                    room_setting_set::<TextGenerationSenderContextMode>(\n                        bot,\n                        message_context,\n                        &value,\n                        setter_callback,\n                    )\n                    .await\n                }\n                SettingsStorageSource::Global => {\n                    global_setting_set::<TextGenerationSenderContextMode>(\n                        bot,\n                        message_context,\n                        &value,\n                        setter_callback,\n                    )\n                    .await\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/controller/cfg/dispatching/text_to_speech.rs",
    "content": "use crate::entity::roomconfig::{\n    RoomSettings, TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,\n};\nuse crate::{Bot, entity::MessageContext};\n\nuse super::super::controller_type::{\n    ConfigTextToSpeechSettingRelatedControllerType, SettingsStorageSource,\n};\n\nuse super::super::common::generic_setting::handle_get as setting_get;\n\nuse super::super::global_config::generic_setting::handle_set as global_setting_set;\n\nuse super::super::room_config::generic_setting::handle_set as room_setting_set;\n\npub(super) async fn dispatch(\n    handler: &ConfigTextToSpeechSettingRelatedControllerType,\n    message_context: &MessageContext,\n    bot: &Bot,\n    room_settings: &RoomSettings,\n    config_type: &SettingsStorageSource,\n) -> anyhow::Result<()> {\n    match handler {\n        ConfigTextToSpeechSettingRelatedControllerType::GetBotMessagesFlowType => {\n            let value = &room_settings.text_to_speech.bot_msgs_flow_type;\n            setting_get::<TextToSpeechBotMessagesFlowType>(bot, message_context, value).await\n        }\n        ConfigTextToSpeechSettingRelatedControllerType::SetBotMessagesFlowType(value) => {\n            let value = value.to_owned();\n\n            let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {\n                room_settings.text_to_speech.bot_msgs_flow_type = value;\n            });\n\n            match config_type {\n                SettingsStorageSource::Room => {\n                    room_setting_set::<TextToSpeechBotMessagesFlowType>(\n                        bot,\n                        message_context,\n                        &value,\n                        setter_callback,\n                    )\n                    .await\n                }\n                SettingsStorageSource::Global => {\n                    global_setting_set::<TextToSpeechBotMessagesFlowType>(\n                        bot,\n                        message_context,\n                        &value,\n                        setter_callback,\n                    )\n                    .await\n                }\n            }\n        }\n\n        ConfigTextToSpeechSettingRelatedControllerType::GetUserMessagesFlowType => {\n            let value = &room_settings.text_to_speech.user_msgs_flow_type;\n            setting_get::<TextToSpeechUserMessagesFlowType>(bot, message_context, value).await\n        }\n        ConfigTextToSpeechSettingRelatedControllerType::SetUserMessagesFlowType(value) => {\n            let value = value.to_owned();\n\n            let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {\n                room_settings.text_to_speech.user_msgs_flow_type = value;\n            });\n\n            match config_type {\n                SettingsStorageSource::Room => {\n                    room_setting_set::<TextToSpeechUserMessagesFlowType>(\n                        bot,\n                        message_context,\n                        &value,\n                        setter_callback,\n                    )\n                    .await\n                }\n                SettingsStorageSource::Global => {\n                    global_setting_set::<TextToSpeechUserMessagesFlowType>(\n                        bot,\n                        message_context,\n                        &value,\n                        setter_callback,\n                    )\n                    .await\n                }\n            }\n        }\n\n        ConfigTextToSpeechSettingRelatedControllerType::GetSpeedOverride => {\n            let value = &room_settings.text_to_speech.speed_override;\n            setting_get::<f32>(bot, message_context, value).await\n        }\n        ConfigTextToSpeechSettingRelatedControllerType::SetSpeedOverride(value) => {\n            let value = value.to_owned();\n\n            let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {\n                room_settings.text_to_speech.speed_override = value;\n            });\n\n            match config_type {\n                SettingsStorageSource::Room => {\n                    room_setting_set::<f32>(bot, message_context, &value, setter_callback).await\n                }\n                SettingsStorageSource::Global => {\n                    global_setting_set::<f32>(bot, message_context, &value, setter_callback).await\n                }\n            }\n        }\n\n        ConfigTextToSpeechSettingRelatedControllerType::GetVoiceOverride => {\n            let value = &room_settings.text_to_speech.voice_override;\n            setting_get::<String>(bot, message_context, value).await\n        }\n        ConfigTextToSpeechSettingRelatedControllerType::SetVoiceOverride(value) => {\n            let value = value.to_owned();\n\n            let value_setter = value.clone();\n            let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {\n                room_settings.text_to_speech.voice_override = value_setter;\n            });\n\n            match config_type {\n                SettingsStorageSource::Room => {\n                    room_setting_set::<String>(bot, message_context, &value, setter_callback).await\n                }\n                SettingsStorageSource::Global => {\n                    global_setting_set::<String>(bot, message_context, &value, setter_callback)\n                        .await\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/controller/cfg/global_config/generic_setting.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::entity::{MessageContext, roomconfig::RoomSettings};\nuse crate::{Bot, strings};\n\npub async fn handle_set<T>(\n    bot: &Bot,\n    message_context: &MessageContext,\n    value: &Option<T>,\n    setter_callback: Box<dyn FnOnce(&mut RoomSettings) + Send>,\n) -> anyhow::Result<()>\nwhere\n    T: std::fmt::Display,\n{\n    let mut global_config = message_context.global_config().clone();\n    setter_callback(&mut global_config.fallback_room_settings);\n\n    bot.global_config_manager()\n        .lock()\n        .await\n        .persist(&global_config)\n        .await?;\n\n    let message = match value {\n        Some(value) => strings::global_config::value_was_set_to(value),\n        None => strings::global_config::value_was_unset(),\n    };\n\n    bot.messaging()\n        .send_success_markdown_no_fail(\n            message_context.room(),\n            &message,\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/cfg/global_config/handler.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{\n    Bot,\n    agent::{AgentPurpose, PublicIdentifier},\n    entity::{MessageContext, globalconfig::GlobalConfigurationManager},\n    strings,\n};\n\npub async fn handle_get(\n    bot: &Bot,\n    message_context: &MessageContext,\n    purpose: AgentPurpose,\n) -> anyhow::Result<()> {\n    let agent_id = message_context\n        .global_config()\n        .fallback_room_settings\n        .handler\n        .get_by_purpose(purpose);\n\n    let Some(agent_id) = agent_id else {\n        bot.messaging()\n            .send_text_markdown_no_fail(\n                message_context.room(),\n                strings::global_config::global_config_lacks_specific_agent_for_purpose(purpose),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    };\n\n    let agent_identifier = match PublicIdentifier::from_str(agent_id.as_str()) {\n        Some(agent_identifier) => agent_identifier,\n        None => {\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::agent::invalid_id_generic(),\n                    MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n                )\n                .await;\n\n            return Ok(());\n        }\n    };\n\n    let agent_exists = bot\n        .agent_manager()\n        .available_room_agents_by_room_config_context(message_context.room_config_context())\n        .iter()\n        .any(|agent| *agent.identifier() == agent_identifier);\n\n    if agent_exists {\n        bot.messaging()\n            .send_text_markdown_no_fail(\n                message_context.room(),\n                strings::global_config::configured_to_use_agent_for_purpose(\n                    &agent_identifier,\n                    purpose,\n                ),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n    } else {\n        bot.messaging()\n            .send_text_markdown_no_fail(\n                message_context.room(),\n                strings::global_config::configures_agent_for_purpose_but_does_not_exist(\n                    &agent_identifier,\n                    purpose,\n                ),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    }\n\n    Ok(())\n}\n\npub async fn handle_set(\n    bot: &Bot,\n    global_config_manager: &tokio::sync::Mutex<GlobalConfigurationManager>,\n    message_context: &MessageContext,\n    purpose: AgentPurpose,\n    agent_identifier: &Option<PublicIdentifier>,\n) -> anyhow::Result<()> {\n    if let Some(agent_identifier) = agent_identifier {\n        let is_allowed = match &agent_identifier {\n            PublicIdentifier::Static(_) => true,\n            PublicIdentifier::DynamicGlobal(_) => true,\n            PublicIdentifier::DynamicRoomLocal(_) => false,\n        };\n\n        if !is_allowed {\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::global_config::not_allowed_to_use_agent_in_global_config(\n                        agent_identifier,\n                    ),\n                    MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n                )\n                .await;\n\n            return Ok(());\n        }\n\n        let agent_exists = bot\n            .agent_manager()\n            .available_room_agents_by_room_config_context(message_context.room_config_context())\n            .iter()\n            .any(|agent| agent.identifier() == agent_identifier);\n\n        if !agent_exists {\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::agent::agent_with_given_identifier_missing(agent_identifier),\n                    MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n                )\n                .await;\n\n            return Ok(());\n        }\n    }\n\n    let agent_id = agent_identifier\n        .as_ref()\n        .map(|agent_identifier| agent_identifier.as_string());\n\n    let mut global_config = global_config_manager.lock().await.get_or_create().await?;\n\n    global_config\n        .fallback_room_settings\n        .handler\n        .set_by_purpose(purpose, agent_id);\n\n    global_config_manager\n        .lock()\n        .await\n        .persist(&global_config)\n        .await?;\n\n    let message = match agent_identifier {\n        Some(agent_identifier) => {\n            strings::global_config::reconfigured_to_use_agent_for_purpose(agent_identifier, purpose)\n        }\n        None => strings::global_config::reconfigured_to_not_specify_agent_for_purpose(purpose),\n    };\n\n    bot.messaging()\n        .send_success_markdown_no_fail(\n            message_context.room(),\n            &message,\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/cfg/global_config/mod.rs",
    "content": "pub(super) mod generic_setting;\npub(super) mod handler;\n"
  },
  {
    "path": "src/controller/cfg/help.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{\n    Bot,\n    entity::{\n        MessageContext,\n        roomconfig::{\n            SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,\n            TextGenerationAutoUsage, TextGenerationPrefixRequirementType,\n            TextGenerationSenderContextMode, TextToSpeechBotMessagesFlowType,\n            TextToSpeechUserMessagesFlowType,\n        },\n    },\n    strings,\n};\n\npub async fn handle(bot: &Bot, message_context: &MessageContext) -> anyhow::Result<()> {\n    let mut message = String::new();\n    message.push_str(&build_section_intro());\n    message.push_str(\"\\n\\n\");\n    message.push_str(\"\\n---\\n\");\n    message.push_str(&build_section_status(bot.command_prefix()));\n    message.push_str(\"\\n\\n\");\n    message.push_str(\"\\n---\\n\");\n    message.push_str(&build_section_handlers(bot.command_prefix()));\n\n    message.push_str(\"\\n\\n\");\n    message.push_str(\"\\n---\\n\");\n    message.push_str(&build_section_text_generation(\n        bot.command_prefix(),\n        bot.user_id().localpart(),\n    ));\n\n    message.push_str(\"\\n\\n\");\n    message.push_str(\"\\n---\\n\");\n    message.push_str(&build_section_text_to_speech(bot.command_prefix()));\n\n    message.push_str(\"\\n\\n\");\n    message.push_str(\"\\n---\\n\");\n    message.push_str(&build_section_speech_to_text(bot.command_prefix()));\n\n    message.push_str(\"\\n\\n\");\n    message.push_str(\"\\n---\\n\");\n    message.push_str(&build_section_image_generation());\n\n    bot.messaging()\n        .send_text_markdown_no_fail(\n            message_context.room(),\n            message,\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n\nfn build_section_intro() -> String {\n    let mut message = String::new();\n\n    message.push_str(&format!(\"## {}\", strings::help::cfg::heading()));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::cfg::intro_long());\n\n    message\n}\n\nfn build_section_status(command_prefix: &str) -> String {\n    let mut message = String::new();\n\n    message.push_str(&format!(\"### {}\", strings::help::cfg::status_heading()));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::cfg::status_intro(command_prefix));\n\n    message\n}\n\nfn build_section_handlers(command_prefix: &str) -> String {\n    let mut message = String::new();\n\n    message.push_str(&format!(\"### {}\", strings::help::cfg::handlers_heading()));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::cfg::handlers_intro_common());\n    message.push('\\n');\n    message.push_str(&strings::help::cfg::handlers_intro_purposes());\n    message.push_str(\"\\n\\n\");\n\n    message.push_str(strings::help::available_commands_intro());\n    message.push('\\n');\n\n    message.push_str(&format!(\n        \"- {}\",\n        strings::help::cfg::handlers_show(command_prefix)\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        strings::help::cfg::handlers_set(command_prefix)\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        strings::help::cfg::handlers_unset(command_prefix)\n    ));\n\n    message\n}\n\nfn build_section_text_generation(command_prefix: &str, bot_username: &str) -> String {\n    let mut message = String::new();\n\n    message.push_str(&format!(\n        \"### {}\",\n        strings::help::cfg::text_generation_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::cfg::text_generation_common());\n    message.push_str(\"\\n\\n\");\n\n    // Prefix requirement type\n\n    message.push_str(&format!(\n        \"#### {}\",\n        strings::help::cfg::text_generation_prefix_requirement_type_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::cfg::text_generation_prefix_requirement_type_intro());\n    message.push('\\n');\n    message.push_str(\n        &strings::help::cfg::the_following_configuration_values_are_recognized(\n            TextGenerationPrefixRequirementType::choices(),\n        ),\n    );\n    message.push_str(\"\\n\\n\");\n    message\n        .push_str(&strings::help::cfg::text_generation_prefix_requirement_type_outro(bot_username));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_show(\n            command_prefix,\n            \"text-generation prefix-requirement-type\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_set(\n            command_prefix,\n            \"text-generation set-prefix-requirement-type VALUE\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_unset(\n            command_prefix,\n            \"text-generation set-prefix-requirement-type\"\n        )\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // Auto Usage\n\n    message.push_str(&format!(\n        \"#### {}\",\n        strings::help::cfg::text_generation_auto_usage_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::cfg::text_generation_auto_usage_intro());\n    message.push('\\n');\n    message.push_str(\n        &strings::help::cfg::the_following_configuration_values_are_recognized(\n            TextGenerationAutoUsage::choices(),\n        ),\n    );\n    message.push_str(\"\\n\\n\");\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_show(command_prefix, \"text-generation auto-usage\")\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_set(\n            command_prefix,\n            \"text-generation set-auto-usage VALUE\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_unset(\n            command_prefix,\n            \"text-generation set-auto-usage\"\n        )\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // Context Management\n\n    message.push_str(&format!(\n        \"#### {}\",\n        strings::help::cfg::text_generation_context_management_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::cfg::text_generation_context_management_intro());\n    message.push('\\n');\n    message.push_str(\n        &strings::help::cfg::the_following_configuration_values_are_recognized(vec![true, false]),\n    );\n    message.push_str(\"\\n\\n\");\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_show(\n            command_prefix,\n            \"text-generation context-management-enabled\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_set(\n            command_prefix,\n            \"text-generation set-context-management-enabled VALUE\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_unset(\n            command_prefix,\n            \"text-generation set-context-management-enabled\"\n        )\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // Sender Context\n\n    message.push_str(&format!(\n        \"#### {}\",\n        strings::help::cfg::text_generation_sender_context_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::cfg::text_generation_sender_context_intro());\n    message.push('\\n');\n    message.push_str(\n        &strings::help::cfg::the_following_configuration_values_are_recognized(\n            TextGenerationSenderContextMode::choices(),\n        ),\n    );\n    message.push_str(\"\\n\\n\");\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_show(\n            command_prefix,\n            \"text-generation sender-context-mode\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_set(\n            command_prefix,\n            \"text-generation set-sender-context-mode VALUE\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_unset(\n            command_prefix,\n            \"text-generation set-sender-context-mode\"\n        )\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // Prompt override\n\n    message.push_str(&format!(\n        \"#### {}\",\n        strings::help::cfg::text_generation_prompt_override_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::cfg::text_generation_prompt_override_intro());\n    message.push_str(\"\\n\\n\");\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_show(\n            command_prefix,\n            \"text-generation prompt-override\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_set(\n            command_prefix,\n            \"text-generation set-prompt-override VALUE\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_unset(\n            command_prefix,\n            \"text-generation set-prompt-override\"\n        )\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // Speed override\n\n    message.push_str(&format!(\n        \"#### {}\",\n        strings::help::cfg::text_generation_temperature_override_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::cfg::text_generation_temperature_override_intro());\n    message.push_str(\"\\n\\n\");\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_show(\n            command_prefix,\n            \"text-generation temperature-override\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_set(\n            command_prefix,\n            \"text-generation set-temperature-override VALUE\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_unset(\n            command_prefix,\n            \"text-generation set-temperature-override\"\n        )\n    ));\n\n    message\n}\n\nfn build_section_speech_to_text(command_prefix: &str) -> String {\n    let mut message = String::new();\n\n    message.push_str(&format!(\n        \"### {}\",\n        strings::help::cfg::speech_to_text_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::cfg::speech_to_text_common());\n    message.push_str(\"\\n\\n\");\n\n    // Flow Type\n\n    message.push_str(&format!(\n        \"#### {}\",\n        strings::help::cfg::speech_to_text_flow_type_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(strings::help::cfg::speech_to_text_flow_type_intro());\n    message.push('\\n');\n    message.push_str(\n        &strings::help::cfg::the_following_configuration_values_are_recognized(\n            SpeechToTextFlowType::choices(),\n        ),\n    );\n    message.push_str(\"\\n\\n\");\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_show(command_prefix, \"speech-to-text flow-type\")\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_set(\n            command_prefix,\n            \"speech-to-text set-flow-type VALUE\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_unset(command_prefix, \"speech-to-text set-flow-type\")\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // Msg Type For Non Threaded Only Transcribed Messages\n\n    message.push_str(&format!(\n        \"#### {}\",\n        strings::help::cfg::speech_to_text_msg_type_for_non_threaded_only_transcribed_messages_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(strings::help::cfg::speech_to_text_msg_type_for_non_threaded_only_transcribed_messages_intro());\n    message.push('\\n');\n    message.push_str(\n        &strings::help::cfg::the_following_configuration_values_are_recognized(\n            SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages::choices(),\n        ),\n    );\n    message.push_str(\"\\n\\n\");\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_show(\n            command_prefix,\n            \"speech-to-text msg-type-for-non-threaded-only-transcribed-messages\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_set(\n            command_prefix,\n            \"speech-to-text set-msg-type-for-non-threaded-only-transcribed-messages VALUE\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_unset(\n            command_prefix,\n            \"speech-to-text set-msg-type-for-non-threaded-only-transcribed-messages\"\n        )\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // Language\n\n    message.push_str(&format!(\n        \"#### {}\",\n        strings::help::cfg::speech_to_text_language_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(strings::help::cfg::speech_to_text_language_intro());\n    message.push_str(\"\\n\\n\");\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_show(command_prefix, \"speech-to-text language\")\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_set(\n            command_prefix,\n            \"speech-to-text set-language VALUE\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_unset(command_prefix, \"speech-to-text set-language\")\n    ));\n    message.push_str(\"\\n\\n\");\n\n    message\n}\n\nfn build_section_text_to_speech(command_prefix: &str) -> String {\n    let mut message = String::new();\n\n    message.push_str(&format!(\n        \"### {}\",\n        strings::help::cfg::text_to_speech_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(strings::help::cfg::text_to_speech_common());\n    message.push_str(\"\\n\\n\");\n\n    // Bot Messages Flow Type\n\n    message.push_str(&format!(\n        \"#### {}\",\n        strings::help::cfg::text_to_speech_bot_msgs_flow_type_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::cfg::text_to_speech_bot_msgs_flow_type_intro());\n    message.push('\\n');\n    message.push_str(\n        &strings::help::cfg::the_following_configuration_values_are_recognized(\n            TextToSpeechBotMessagesFlowType::choices(),\n        ),\n    );\n    message.push_str(\"\\n\\n\");\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_show(\n            command_prefix,\n            \"text-to-speech bot-msgs-flow-type\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_set(\n            command_prefix,\n            \"text-to-speech set-bot-msgs-flow-type VALUE\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_unset(\n            command_prefix,\n            \"text-to-speech set-bot-msgs-flow-type\"\n        )\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // User Messages Flow Type\n\n    message.push_str(&format!(\n        \"#### {}\",\n        strings::help::cfg::text_to_speech_user_msgs_flow_type_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::cfg::text_to_speech_user_msgs_flow_type_intro());\n    message.push('\\n');\n    message.push_str(\n        &strings::help::cfg::the_following_configuration_values_are_recognized(\n            TextToSpeechUserMessagesFlowType::choices(),\n        ),\n    );\n    message.push_str(\"\\n\\n\");\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_show(\n            command_prefix,\n            \"text-to-speech user-msgs-flow-type\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_set(\n            command_prefix,\n            \"text-to-speech set-user-msgs-flow-type VALUE\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_unset(\n            command_prefix,\n            \"text-to-speech set-user-msgs-flow-type\"\n        )\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // Speed override\n\n    message.push_str(&format!(\n        \"#### {}\",\n        strings::help::cfg::text_to_speech_speed_override_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::cfg::text_to_speech_speed_override_intro());\n    message.push_str(\"\\n\\n\");\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_show(command_prefix, \"text-to-speech speed-override\")\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_set(\n            command_prefix,\n            \"text-to-speech set-speed-override VALUE\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_unset(\n            command_prefix,\n            \"text-to-speech set-speed-override\"\n        )\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // Voice override\n\n    message.push_str(&format!(\n        \"#### {}\",\n        strings::help::cfg::text_to_speech_voice_override_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::cfg::text_to_speech_voice_override_intro());\n    message.push_str(\"\\n\\n\");\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_show(command_prefix, \"text-to-speech voice-override\")\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_set(\n            command_prefix,\n            \"text-to-speech set-voice-override VALUE\"\n        )\n    ));\n    message.push('\\n');\n    message.push_str(&format!(\n        \"- {}\",\n        &strings::help::cfg::current_setting_unset(\n            command_prefix,\n            \"text-to-speech set-voice-override\"\n        )\n    ));\n    message.push_str(\"\\n\\n\");\n\n    message\n}\n\nfn build_section_image_generation() -> String {\n    let mut message = String::new();\n\n    message.push_str(&format!(\n        \"### {}\",\n        strings::help::cfg::image_generation_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(strings::help::cfg::image_generation_common());\n\n    message\n}\n"
  },
  {
    "path": "src/controller/cfg/mod.rs",
    "content": "mod common;\nmod controller_type;\nmod determination;\nmod dispatching;\nmod global_config;\nmod help;\nmod room_config;\nmod status;\n\npub use controller_type::ConfigControllerType;\npub use determination::determine_controller;\npub use dispatching::dispatch_controller;\n"
  },
  {
    "path": "src/controller/cfg/room_config/generic_setting.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::entity::{MessageContext, roomconfig::RoomSettings};\nuse crate::{Bot, strings};\n\npub async fn handle_set<T>(\n    bot: &Bot,\n    message_context: &MessageContext,\n    value: &Option<T>,\n    setter_callback: Box<dyn FnOnce(&mut RoomSettings) + Send>,\n) -> anyhow::Result<()>\nwhere\n    T: std::fmt::Display,\n{\n    let mut room_config = message_context.room_config().clone();\n    setter_callback(&mut room_config.settings);\n\n    bot.room_config_manager()\n        .lock()\n        .await\n        .persist(message_context.room(), &room_config)\n        .await?;\n\n    let message = match value {\n        Some(value) => strings::room_config::value_was_set_to(value),\n        None => strings::room_config::value_was_unset(),\n    };\n\n    bot.messaging()\n        .send_success_markdown_no_fail(\n            message_context.room(),\n            &message,\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/cfg/room_config/handler.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{\n    Bot,\n    agent::{AgentPurpose, PublicIdentifier},\n    entity::MessageContext,\n    strings,\n};\n\nuse crate::entity::roomconfig::RoomConfigurationManager;\n\npub async fn handle_get(\n    bot: &Bot,\n    message_context: &MessageContext,\n    purpose: AgentPurpose,\n) -> anyhow::Result<()> {\n    let agent_id = message_context\n        .room_config()\n        .settings\n        .handler\n        .get_by_purpose(purpose);\n\n    let Some(agent_id) = agent_id else {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                &strings::room_config::room_not_configured_with_specific_agent_for_purpose(purpose),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    };\n\n    let Some(agent_identifier) = PublicIdentifier::from_str(agent_id.as_str()) else {\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                &strings::agent::invalid_id_generic(),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    };\n\n    let agent_exists = bot\n        .agent_manager()\n        .available_room_agents_by_room_config_context(message_context.room_config_context())\n        .iter()\n        .any(|agent| *agent.identifier() == agent_identifier);\n\n    if agent_exists {\n        bot.messaging()\n            .send_text_markdown_no_fail(\n                message_context.room(),\n                strings::room_config::configured_to_use_agent_for_purpose(\n                    &agent_identifier,\n                    purpose,\n                ),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n    } else {\n        bot.messaging()\n            .send_text_markdown_no_fail(\n                message_context.room(),\n                strings::room_config::configures_agent_for_purpose_but_does_not_exist(\n                    &agent_identifier,\n                    purpose,\n                ),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n\n        return Ok(());\n    }\n\n    Ok(())\n}\n\npub async fn handle_set(\n    bot: &Bot,\n    room_config_manager: &tokio::sync::Mutex<RoomConfigurationManager>,\n    message_context: &MessageContext,\n    purpose: AgentPurpose,\n    agent_identifier: &Option<PublicIdentifier>,\n) -> anyhow::Result<()> {\n    if let Some(agent_identifier) = agent_identifier {\n        let agent_exists = bot\n            .agent_manager()\n            .available_room_agents_by_room_config_context(message_context.room_config_context())\n            .iter()\n            .any(|agent| agent.identifier() == agent_identifier);\n\n        if !agent_exists {\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::agent::agent_with_given_identifier_missing(agent_identifier),\n                    MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n                )\n                .await;\n\n            return Ok(());\n        }\n    }\n\n    let mut new_room_config = message_context.room_config().clone();\n\n    let agent_id = agent_identifier\n        .as_ref()\n        .map(|agent_identifier| agent_identifier.as_string());\n\n    new_room_config\n        .settings\n        .handler\n        .set_by_purpose(purpose, agent_id);\n\n    room_config_manager\n        .lock()\n        .await\n        .persist(message_context.room(), &new_room_config)\n        .await?;\n\n    let message = match agent_identifier {\n        Some(agent_identifier) => {\n            strings::room_config::reconfigured_to_use_agent_for_purpose(agent_identifier, purpose)\n        }\n        None => strings::room_config::reconfigured_to_not_specify_agent_for_purpose(purpose),\n    };\n\n    bot.messaging()\n        .send_success_markdown_no_fail(\n            message_context.room(),\n            &message,\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/cfg/room_config/mod.rs",
    "content": "pub(super) mod generic_setting;\npub(super) mod handler;\n"
  },
  {
    "path": "src/controller/cfg/status.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{\n    Bot,\n    agent::{\n        AgentInstance, AgentPurpose, ControllerTrait, Manager as AgentManager, PublicIdentifier,\n        utils::get_effective_agent_for_purpose,\n    },\n    entity::{\n        MessageContext, RoomConfigContext,\n        roomconfig::{RoomConfig, RoomSettingsHandler},\n    },\n    strings,\n};\n\npub async fn handle(bot: &Bot, message_context: &MessageContext) -> anyhow::Result<()> {\n    let mut message = String::new();\n\n    let agent_manager = bot.agent_manager();\n\n    let agents = agent_manager\n        .available_room_agents_by_room_config_context(message_context.room_config_context());\n\n    // Room handlers\n    message.push_str(&generate_room_handlers_section(\n        &message_context.room_config().settings.handler,\n        &agents,\n        bot.command_prefix(),\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // Global handlers\n    message.push_str(&generate_global_config_handlers_section(\n        &message_context\n            .global_config()\n            .fallback_room_settings\n            .handler,\n        &agents,\n        bot.command_prefix(),\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // Agents\n    message.push_str(&generate_room_agents_section(\n        message_context.room_config(),\n        &agents,\n        bot.command_prefix(),\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // Text Generation\n    message.push_str(\n        &generate_text_generation_section(agent_manager, message_context.room_config_context())\n            .await,\n    );\n    message.push_str(\"\\n\\n\");\n\n    // Text-to-Speech\n    message.push_str(\n        &generate_text_to_speech_section(agent_manager, message_context.room_config_context())\n            .await,\n    );\n    message.push_str(\"\\n\\n\");\n\n    // Speech-to-Text\n    message.push_str(\n        &generate_speech_to_text_section(agent_manager, message_context.room_config_context())\n            .await,\n    );\n    message.push_str(\"\\n\\n\");\n\n    // Image Creation\n    message.push_str(\n        &generate_image_generation_section(agent_manager, message_context.room_config_context())\n            .await,\n    );\n    message.push_str(\"\\n\\n\");\n\n    bot.messaging()\n        .send_text_markdown_no_fail(\n            message_context.room(),\n            message,\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n\nfn generate_room_handlers_section(\n    handler_config: &RoomSettingsHandler,\n    agents: &[AgentInstance],\n    command_prefix: &str,\n) -> String {\n    let mut message = String::new();\n\n    message.push_str(\n        format!(\n            \"## {}\\n\",\n            strings::cfg::status_room_config_handlers_heading()\n        )\n        .as_str(),\n    );\n    message.push_str(strings::cfg::status_room_config_handlers_intro());\n    message.push_str(\"\\n\\n\");\n\n    for purpose in AgentPurpose::choices() {\n        message.push_str(&generate_handler_line_for_purpose(\n            purpose,\n            handler_config,\n            agents,\n            false,\n        ));\n        message.push('\\n');\n    }\n\n    message.push_str(\"\\n\\n\");\n\n    message.push_str(strings::cfg::status_room_config_handlers_outro(command_prefix).as_str());\n\n    message\n}\n\nfn generate_global_config_handlers_section(\n    handler_config: &RoomSettingsHandler,\n    agents: &[AgentInstance],\n    command_prefix: &str,\n) -> String {\n    let mut message = String::new();\n\n    message.push_str(\n        format!(\n            \"## {}\\n\",\n            strings::cfg::status_global_config_handlers_heading()\n        )\n        .as_str(),\n    );\n    message.push_str(strings::cfg::status_global_config_handlers_intro());\n    message.push_str(\"\\n\\n\");\n\n    for purpose in AgentPurpose::choices() {\n        message.push_str(&generate_handler_line_for_purpose(\n            purpose,\n            handler_config,\n            agents,\n            true,\n        ));\n        message.push('\\n');\n    }\n\n    message.push_str(\"\\n\\n\");\n\n    message.push_str(strings::cfg::status_global_config_handlers_outro(command_prefix).as_str());\n\n    message\n}\n\nfn generate_handler_line_for_purpose(\n    purpose: &AgentPurpose,\n    handler_config: &RoomSettingsHandler,\n    agents: &[AgentInstance],\n    is_for_global_config: bool,\n) -> String {\n    let agent_id = handler_config.get_by_purpose(*purpose);\n\n    match agent_id {\n        Some(agent_id) => {\n            let agent = agents\n                .iter()\n                .find(|a| *a.identifier().as_string() == agent_id);\n\n            strings::cfg::status_handler_line_agent_found(purpose, &agent_id, agent)\n        }\n        None => match purpose {\n            AgentPurpose::CatchAll => {\n                if is_for_global_config {\n                    return strings::cfg::status_handler_line_catch_all_agent_not_set_globally();\n                }\n\n                strings::cfg::status_handler_line_catch_all_agent_not_set_in_room_default_to_global(\n                )\n            }\n            _ => {\n                if is_for_global_config {\n                    return strings::cfg::status_handler_line_non_catch_all_agent_not_set_globally(\n                        purpose,\n                    );\n                }\n\n                strings::cfg::status_handler_line_non_catch_all_agent_not_set_in_room_default_to_global(\n                        purpose,\n                    )\n            }\n        },\n    }\n}\n\nfn generate_room_agents_section(\n    room_config: &RoomConfig,\n    agents: &Vec<AgentInstance>,\n    command_prefix: &str,\n) -> String {\n    let mut message = String::new();\n\n    message.push_str(format!(\"## {}\\n\", strings::cfg::status_room_agents_heading()).as_str());\n\n    if room_config.agents.is_empty() {\n        message.push_str(strings::cfg::status_room_agents_empty());\n\n        message.push_str(\"\\n\\n\");\n\n        message.push_str(&strings::help::learn_more_send_a_command(\n            command_prefix,\n            \"agent\",\n        ));\n\n        return message;\n    }\n\n    message.push_str(strings::cfg::status_room_agents_intro());\n    message.push_str(\"\\n\\n\");\n\n    for agent in agents {\n        let PublicIdentifier::DynamicRoomLocal(_) = agent.identifier() else {\n            continue;\n        };\n\n        message.push_str(&format!(\n            \"- `{}` ({})\\n\",\n            agent.identifier(),\n            strings::agent::create_support_badges_text(agent.controller()),\n        ));\n    }\n\n    message.push_str(\"\\n\\n\");\n\n    message.push_str(strings::cfg::status_room_agents_outro(command_prefix).as_str());\n\n    message\n}\n\nasync fn generate_text_generation_section(\n    agent_manager: &AgentManager,\n    room_config_context: &RoomConfigContext,\n) -> String {\n    let mut message = String::new();\n\n    message.push_str(format!(\"## {}\\n\", strings::cfg::status_text_generation_heading()).as_str());\n\n    let text_generation_agent_info = get_effective_agent_for_purpose(\n        agent_manager,\n        room_config_context,\n        AgentPurpose::TextGeneration,\n    )\n    .await;\n\n    // Effective agent\n\n    let text_generation_agent = match text_generation_agent_info {\n        Ok(text_generation_agent_info) => {\n            message.push_str(&strings::cfg::status_entry_effective_agent(\n                text_generation_agent_info.instance.identifier(),\n                text_generation_agent_info.configuration_source,\n            ));\n\n            Some(text_generation_agent_info.instance)\n        }\n        Err(err) => {\n            tracing::error!(?err, \"Failed to determine text-generation agent\");\n            message.push_str(&strings::cfg::status_entry_effective_agent_error());\n            None\n        }\n    };\n\n    // Prefix requirement type\n\n    let effective_prefix_requirement_type =\n        room_config_context.text_generation_prefix_requirement_type();\n    let room_config_prefix_requirement_type = room_config_context\n        .room_config\n        .settings\n        .text_generation\n        .prefix_requirement_type;\n    let global_config_prefix_requirement_type = room_config_context\n        .global_config\n        .fallback_room_settings\n        .text_generation\n        .prefix_requirement_type;\n\n    let prefix_requirement_type_set_where = if room_config_prefix_requirement_type.is_some() {\n        strings::cfg::status_badge_set_in_room_config()\n    } else if global_config_prefix_requirement_type.is_some() {\n        strings::cfg::status_badge_set_in_global_config()\n    } else {\n        strings::cfg::status_badge_using_hardcoded_default()\n    };\n\n    message.push_str(\n        &strings::cfg::status_text_generation_entry_prefix_requirement_type(\n            effective_prefix_requirement_type,\n            prefix_requirement_type_set_where,\n        ),\n    );\n\n    // Auto usage\n\n    let effective_auto_usage = room_config_context.auto_text_generation_usage();\n    let room_config_auto_usage = room_config_context\n        .room_config\n        .settings\n        .text_generation\n        .auto_usage;\n    let global_config_auto_usage = room_config_context\n        .global_config\n        .fallback_room_settings\n        .text_generation\n        .auto_usage;\n\n    let auto_usage_set_where = if room_config_auto_usage.is_some() {\n        strings::cfg::status_badge_set_in_room_config()\n    } else if global_config_auto_usage.is_some() {\n        strings::cfg::status_badge_set_in_global_config()\n    } else {\n        strings::cfg::status_badge_using_hardcoded_default()\n    };\n\n    message.push_str(&strings::cfg::status_text_generation_entry_auto_usage(\n        effective_auto_usage,\n        auto_usage_set_where,\n    ));\n\n    // Context Management\n\n    let effective_context_management =\n        room_config_context.text_generation_context_management_enabled();\n    let room_config_context_management = room_config_context\n        .room_config\n        .settings\n        .text_generation\n        .context_management_enabled;\n    let global_config_context_management = room_config_context\n        .global_config\n        .fallback_room_settings\n        .text_generation\n        .context_management_enabled;\n\n    let context_management_set_where = if room_config_context_management.is_some() {\n        strings::cfg::status_badge_set_in_room_config()\n    } else if global_config_context_management.is_some() {\n        strings::cfg::status_badge_set_in_global_config()\n    } else {\n        strings::cfg::status_badge_using_hardcoded_default()\n    };\n\n    message.push_str(\n        &strings::cfg::status_text_generation_entry_context_management(\n            effective_context_management,\n            context_management_set_where,\n        ),\n    );\n\n    // Sender Context\n\n    let effective_sender_context = room_config_context.text_generation_sender_context_mode();\n    let room_config_sender_context = room_config_context\n        .room_config\n        .settings\n        .text_generation\n        .sender_context_mode;\n    let global_config_sender_context = room_config_context\n        .global_config\n        .fallback_room_settings\n        .text_generation\n        .sender_context_mode;\n\n    let sender_context_set_where = if room_config_sender_context.is_some() {\n        strings::cfg::status_badge_set_in_room_config()\n    } else if global_config_sender_context.is_some() {\n        strings::cfg::status_badge_set_in_global_config()\n    } else {\n        strings::cfg::status_badge_using_hardcoded_default()\n    };\n\n    message.push_str(&strings::cfg::status_text_generation_entry_sender_context(\n        effective_sender_context,\n        sender_context_set_where,\n    ));\n\n    // Prompt override\n\n    let text_agent_prompt = if let Some(text_generation_agent) = &text_generation_agent {\n        text_generation_agent.controller().text_generation_prompt()\n    } else {\n        None\n    };\n\n    let room_config_prompt_override = room_config_context\n        .room_config\n        .settings\n        .text_generation\n        .prompt_override\n        .clone();\n    let global_config_prompt_override = room_config_context\n        .global_config\n        .fallback_room_settings\n        .text_generation\n        .prompt_override\n        .clone();\n\n    let (prompt, prompt_set_where) =\n        if let Some(room_config_prompt_override) = room_config_prompt_override {\n            (\n                room_config_prompt_override,\n                strings::cfg::status_badge_set_in_room_config(),\n            )\n        } else if let Some(global_config_prompt_override) = global_config_prompt_override {\n            (\n                global_config_prompt_override,\n                strings::cfg::status_badge_set_in_global_config(),\n            )\n        } else {\n            (\n                text_agent_prompt.unwrap_or(\"\".to_owned()),\n                strings::cfg::status_badge_set_in_agent_config(),\n            )\n        };\n\n    message.push_str(&strings::cfg::status_text_generation_entry_prompt(\n        &prompt,\n        prompt_set_where,\n    ));\n\n    // Temperature\n\n    let text_agent_temperature = if let Some(text_generation_agent) = &text_generation_agent {\n        text_generation_agent\n            .controller()\n            .text_generation_temperature()\n    } else {\n        None\n    };\n\n    let room_config_temperature_override = room_config_context\n        .room_config\n        .settings\n        .text_generation\n        .temperature_override;\n    let global_config_temperature_override = room_config_context\n        .global_config\n        .fallback_room_settings\n        .text_generation\n        .temperature_override;\n\n    let (effective_temperature, set_where) = if let Some(room_config_temperature_override) =\n        room_config_temperature_override\n    {\n        (\n            Some(room_config_temperature_override),\n            strings::cfg::status_badge_set_in_room_config(),\n        )\n    } else if let Some(global_config_temperature_override) = global_config_temperature_override {\n        (\n            Some(global_config_temperature_override),\n            strings::cfg::status_badge_set_in_global_config(),\n        )\n    } else {\n        (\n            text_agent_temperature,\n            strings::cfg::status_badge_set_in_agent_config(),\n        )\n    };\n\n    message.push_str(&strings::cfg::status_text_generation_entry_temperature(\n        effective_temperature,\n        set_where,\n    ));\n\n    message\n}\n\nasync fn generate_speech_to_text_section(\n    agent_manager: &AgentManager,\n    room_config_context: &RoomConfigContext,\n) -> String {\n    let mut message = String::new();\n\n    message.push_str(format!(\"## {}\\n\", strings::cfg::status_speech_to_text_heading()).as_str());\n\n    let speech_to_text_agent_info = get_effective_agent_for_purpose(\n        agent_manager,\n        room_config_context,\n        AgentPurpose::SpeechToText,\n    )\n    .await;\n\n    // Effective agent\n\n    match speech_to_text_agent_info {\n        Ok(speech_to_text_agent_info) => {\n            message.push_str(&strings::cfg::status_entry_effective_agent(\n                speech_to_text_agent_info.instance.identifier(),\n                speech_to_text_agent_info.configuration_source,\n            ));\n        }\n        Err(err) => {\n            tracing::error!(?err, \"Failed to determine speech-to-text agent\");\n            message.push_str(&strings::cfg::status_entry_effective_agent_error());\n        }\n    };\n\n    // Flow type\n\n    let effective_flow_type = room_config_context.speech_to_text_flow_type();\n    let room_config_flow_type = room_config_context\n        .room_config\n        .settings\n        .speech_to_text\n        .flow_type;\n    let global_config_flow_type = room_config_context\n        .global_config\n        .fallback_room_settings\n        .speech_to_text\n        .flow_type;\n\n    let flow_type_set_where = if room_config_flow_type.is_some() {\n        strings::cfg::status_badge_set_in_room_config()\n    } else if global_config_flow_type.is_some() {\n        strings::cfg::status_badge_set_in_global_config()\n    } else {\n        strings::cfg::status_badge_using_hardcoded_default()\n    };\n\n    message.push_str(&strings::cfg::status_speech_to_text_entry_flow_type(\n        effective_flow_type,\n        flow_type_set_where,\n    ));\n\n    // Msg Type For Non Threaded Only Transcribed Messages\n\n    let effective_msg_type_for_non_threaded_only_transcribed_messages =\n        room_config_context.speech_to_text_msg_type_for_non_threaded_only_transcribed_messages();\n    let room_config_msg_type_for_non_threaded_only_transcribed_messages = room_config_context\n        .room_config\n        .settings\n        .speech_to_text\n        .msg_type_for_non_threaded_only_transcribed_messages;\n    let global_config_msg_type_for_non_threaded_only_transcribed_messages = room_config_context\n        .global_config\n        .fallback_room_settings\n        .speech_to_text\n        .msg_type_for_non_threaded_only_transcribed_messages;\n\n    let msg_type_for_non_threaded_only_transcribed_messages_set_where =\n        if room_config_msg_type_for_non_threaded_only_transcribed_messages.is_some() {\n            strings::cfg::status_badge_set_in_room_config()\n        } else if global_config_msg_type_for_non_threaded_only_transcribed_messages.is_some() {\n            strings::cfg::status_badge_set_in_global_config()\n        } else {\n            strings::cfg::status_badge_using_hardcoded_default()\n        };\n\n    message.push_str(&strings::cfg::status_speech_to_text_entry_msg_type_for_non_threaded_only_transcribed_messages(\n        effective_msg_type_for_non_threaded_only_transcribed_messages,\n        msg_type_for_non_threaded_only_transcribed_messages_set_where,\n    ));\n\n    // Language\n\n    let effective_language = room_config_context.speech_to_text_language();\n    let room_config_language = &room_config_context\n        .room_config\n        .settings\n        .speech_to_text\n        .language;\n    let global_config_language = &room_config_context\n        .global_config\n        .fallback_room_settings\n        .speech_to_text\n        .language;\n\n    let language_set_where = if room_config_language.is_some() {\n        strings::cfg::status_badge_set_in_room_config()\n    } else if global_config_language.is_some() {\n        strings::cfg::status_badge_set_in_global_config()\n    } else {\n        strings::cfg::status_badge_using_hardcoded_default()\n    };\n\n    message.push_str(&strings::cfg::status_speech_to_text_entry_language(\n        effective_language,\n        language_set_where,\n    ));\n\n    message\n}\n\nasync fn generate_text_to_speech_section(\n    agent_manager: &AgentManager,\n    room_config_context: &RoomConfigContext,\n) -> String {\n    let mut message = String::new();\n\n    message.push_str(format!(\"## {}\\n\", strings::cfg::status_text_to_speech_heading()).as_str());\n\n    let text_to_speech_agent_info = get_effective_agent_for_purpose(\n        agent_manager,\n        room_config_context,\n        AgentPurpose::TextToSpeech,\n    )\n    .await;\n\n    // Effective agent\n\n    let text_to_speech_agent = match text_to_speech_agent_info {\n        Ok(text_to_speech_agent_info) => {\n            message.push_str(&strings::cfg::status_entry_effective_agent(\n                text_to_speech_agent_info.instance.identifier(),\n                text_to_speech_agent_info.configuration_source,\n            ));\n            Some(text_to_speech_agent_info.instance)\n        }\n        Err(err) => {\n            tracing::error!(?err, \"Failed to determine text-to-speech agent\");\n            message.push_str(&strings::cfg::status_entry_effective_agent_error());\n            None\n        }\n    };\n\n    // Bot messages flow type\n\n    let effective_bot_messages_tts_flow_type =\n        room_config_context.text_to_speech_bot_messages_flow_type();\n    let room_config_bot_messages_tts_flow_type = room_config_context\n        .room_config\n        .settings\n        .text_to_speech\n        .bot_msgs_flow_type;\n    let global_config_bot_messages_tts_flow_type = room_config_context\n        .global_config\n        .fallback_room_settings\n        .text_to_speech\n        .bot_msgs_flow_type;\n\n    let bot_messages_tts_flow_type_set_where = if room_config_bot_messages_tts_flow_type.is_some() {\n        strings::cfg::status_badge_set_in_room_config()\n    } else if global_config_bot_messages_tts_flow_type.is_some() {\n        strings::cfg::status_badge_set_in_global_config()\n    } else {\n        strings::cfg::status_badge_using_hardcoded_default()\n    };\n\n    message.push_str(\n        &strings::cfg::status_text_to_speech_entry_bot_msgs_flow_type(\n            effective_bot_messages_tts_flow_type,\n            bot_messages_tts_flow_type_set_where,\n        ),\n    );\n\n    // User messages flow type\n\n    let effective_user_messages_tts_flow_type =\n        room_config_context.text_to_speech_user_messages_flow_type();\n    let room_config_user_messages_tts_flow_type = room_config_context\n        .room_config\n        .settings\n        .text_to_speech\n        .user_msgs_flow_type;\n    let global_config_user_messages_tts_flow_type = room_config_context\n        .global_config\n        .fallback_room_settings\n        .text_to_speech\n        .user_msgs_flow_type;\n\n    let user_messages_tts_flow_type_set_where = if room_config_user_messages_tts_flow_type.is_some()\n    {\n        strings::cfg::status_badge_set_in_room_config()\n    } else if global_config_user_messages_tts_flow_type.is_some() {\n        strings::cfg::status_badge_set_in_global_config()\n    } else {\n        strings::cfg::status_badge_using_hardcoded_default()\n    };\n\n    message.push_str(\n        &strings::cfg::status_text_to_speech_entry_user_msgs_flow_type(\n            effective_user_messages_tts_flow_type,\n            user_messages_tts_flow_type_set_where,\n        ),\n    );\n\n    // Speed\n\n    let agent_speed = if let Some(text_to_speech_agent) = &text_to_speech_agent {\n        text_to_speech_agent.controller().text_to_speech_speed()\n    } else {\n        None\n    };\n\n    let room_config_speed_override = room_config_context\n        .room_config\n        .settings\n        .text_to_speech\n        .speed_override;\n    let global_config_speed_override = room_config_context\n        .global_config\n        .fallback_room_settings\n        .text_to_speech\n        .speed_override;\n\n    let (effective_speed, set_where) =\n        if let Some(room_config_speed_override) = room_config_speed_override {\n            (\n                Some(room_config_speed_override),\n                strings::cfg::status_badge_set_in_room_config(),\n            )\n        } else if let Some(global_config_speed_override) = global_config_speed_override {\n            (\n                Some(global_config_speed_override),\n                strings::cfg::status_badge_set_in_global_config(),\n            )\n        } else if agent_speed.is_some() {\n            (\n                agent_speed,\n                strings::cfg::status_badge_set_in_agent_config(),\n            )\n        } else {\n            (None, strings::cfg::status_badge_using_hardcoded_default())\n        };\n\n    message.push_str(&strings::cfg::status_text_to_speech_entry_speed(\n        effective_speed,\n        set_where,\n    ));\n\n    // Voice\n\n    let agent_voice = if let Some(text_to_speech_agent) = &text_to_speech_agent {\n        text_to_speech_agent.controller().text_to_speech_voice()\n    } else {\n        None\n    };\n\n    let room_config_voice_override = room_config_context\n        .room_config\n        .settings\n        .text_to_speech\n        .voice_override\n        .clone();\n    let global_config_voice_override = room_config_context\n        .global_config\n        .fallback_room_settings\n        .text_to_speech\n        .voice_override\n        .clone();\n\n    let (effective_voice, set_where) =\n        if let Some(room_config_voice_override) = room_config_voice_override {\n            (\n                Some(room_config_voice_override),\n                strings::cfg::status_badge_set_in_room_config(),\n            )\n        } else if let Some(global_config_voice_override) = global_config_voice_override {\n            (\n                Some(global_config_voice_override),\n                strings::cfg::status_badge_set_in_global_config(),\n            )\n        } else if agent_voice.is_some() {\n            (\n                agent_voice,\n                strings::cfg::status_badge_set_in_agent_config(),\n            )\n        } else {\n            (None, strings::cfg::status_badge_using_hardcoded_default())\n        };\n\n    message.push_str(&strings::cfg::status_text_to_speech_entry_voice(\n        effective_voice,\n        set_where,\n    ));\n\n    message\n}\n\nasync fn generate_image_generation_section(\n    agent_manager: &AgentManager,\n    room_config_context: &RoomConfigContext,\n) -> String {\n    let mut message = String::new();\n\n    message.push_str(format!(\"## {}\\n\", strings::cfg::status_image_generation_heading()).as_str());\n\n    let image_generation_agent_info = get_effective_agent_for_purpose(\n        agent_manager,\n        room_config_context,\n        AgentPurpose::ImageGeneration,\n    )\n    .await;\n\n    // Effective agent\n\n    let _image_generation_agent = match image_generation_agent_info {\n        Ok(image_generation_agent_info) => {\n            message.push_str(&strings::cfg::status_entry_effective_agent(\n                image_generation_agent_info.instance.identifier(),\n                image_generation_agent_info.configuration_source,\n            ));\n            Some(image_generation_agent_info.instance)\n        }\n        Err(err) => {\n            tracing::error!(?err, \"Failed to determine image generation agent\");\n            message.push_str(&strings::cfg::status_entry_effective_agent_error());\n            None\n        }\n    };\n\n    message\n}\n"
  },
  {
    "path": "src/controller/chat_completion/mod.rs",
    "content": "use mxlink::matrix_sdk::ruma::OwnedEventId;\nuse mxlink::matrix_sdk::ruma::events::room::message::AudioMessageEventContent;\nuse mxlink::{MatrixLink, MessageResponseType};\n\nuse tracing::Instrument;\n\nuse crate::agent::AgentInstance;\nuse crate::agent::AgentPurpose;\nuse crate::agent::ControllerTrait;\nuse crate::agent::provider::{\n    SpeechToTextParams, TextGenerationParams, TextGenerationPromptVariables,\n};\nuse crate::controller::utils::agent::get_effective_agent_for_purpose_or_complain;\nuse crate::conversation::matrix::MatrixMessageProcessingParams;\nuse crate::entity::MessagePayload;\nuse crate::entity::roomconfig::{\n    SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,\n    TextGenerationSenderContextMode, TextToSpeechBotMessagesFlowType,\n    TextToSpeechUserMessagesFlowType,\n};\nuse crate::strings;\nuse crate::utils::text_to_speech::create_transcribed_message_text;\nuse crate::{\n    Bot,\n    conversation::{\n        create_llm_conversation_for_matrix_reply_chain, create_llm_conversation_for_matrix_thread,\n        llm::{Author, Conversation, MessageContent},\n        matrix::create_list_of_bot_user_prefixes_to_strip,\n    },\n    entity::MessageContext,\n};\n\n#[derive(Debug, PartialEq)]\npub enum ChatCompletionControllerType {\n    // Invoked via a command prefix (e.g. `!bai Hello!`)\n    TextCommand,\n    // Invoked via a mention (e.g. `@baibot Hello!`)\n    TextMention,\n    // Invoked via a direct message (e.g. `Hello!`)\n    TextDirect,\n\n    Audio,\n\n    Image,\n\n    File,\n\n    ThreadMention,\n    ReplyMention,\n}\n\nstruct TextToSpeechEligiblePayload {\n    text: String,\n    event_id: OwnedEventId,\n}\n\nenum TextToSpeechParams {\n    Perform(TextToSpeechEligiblePayload, MessageResponseType),\n    Offer(TextToSpeechEligiblePayload, MessageResponseType),\n}\n\npub async fn handle(\n    bot: &Bot,\n    matrix_link: MatrixLink,\n    message_context: &MessageContext,\n    controller_type: &ChatCompletionControllerType,\n) -> anyhow::Result<()> {\n    let mut original_message_is_audio = false;\n\n    let mut _typing_notice_guard: Option<mxlink::TypingNoticeGuard> = None;\n\n    let speech_to_text_flow_type = message_context\n        .room_config_context()\n        .speech_to_text_flow_type();\n\n    let mut speech_to_text_created_event_id: Option<OwnedEventId> = None;\n\n    if let MessagePayload::Audio(audio_content) = &message_context.payload() {\n        original_message_is_audio = true;\n\n        let (response_type, msg_type) = match speech_to_text_flow_type {\n            SpeechToTextFlowType::Ignore => {\n                tracing::debug!(\"Intentionally ignoring audio message\");\n                return Ok(());\n            }\n            SpeechToTextFlowType::TranscribeAndGenerateText => {\n                tracing::debug!(\"Will be transcribing and possibly generating text..\");\n                (\n                    MessageResponseType::InThread(message_context.thread_info().clone()),\n                    SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages::Notice,\n                )\n            }\n            SpeechToTextFlowType::OnlyTranscribe => {\n                tracing::debug!(\"Will only be transcribing audio to text..\");\n                if message_context.thread_info().is_thread_root_only() {\n                    let msg_type = message_context\n                        .room_config_context()\n                        .speech_to_text_msg_type_for_non_threaded_only_transcribed_messages();\n                    (\n                        MessageResponseType::Reply(\n                            message_context.thread_info().root_event_id.clone(),\n                        ),\n                        msg_type,\n                    )\n                } else {\n                    (\n                        MessageResponseType::InThread(message_context.thread_info().clone()),\n                        SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages::Notice,\n                    )\n                }\n            }\n        };\n\n        if _typing_notice_guard.is_none() {\n            _typing_notice_guard = Some(bot.start_typing_notice(message_context.room()).await);\n        }\n\n        let Some(speech_to_text_created_event_id_result) = handle_stage_speech_to_text(\n            bot,\n            message_context,\n            audio_content,\n            response_type,\n            msg_type,\n        )\n        .await\n        else {\n            return Ok(());\n        };\n\n        speech_to_text_created_event_id = Some(speech_to_text_created_event_id_result);\n\n        if speech_to_text_flow_type == SpeechToTextFlowType::OnlyTranscribe {\n            tracing::debug!(\n                \"Intentionally not continuing with text generation after transcription\"\n            );\n            return Ok(());\n        }\n\n        // We've pushed a transcription to the room.\n        // Let's proceed below where we potentially handle text-generation.\n    }\n\n    let text_to_speech_stage_params: Option<TextToSpeechParams>;\n\n    if message_context\n        .room_config_context()\n        .should_auto_text_generate(original_message_is_audio)\n    {\n        if _typing_notice_guard.is_none() {\n            _typing_notice_guard = Some(bot.start_typing_notice(message_context.room()).await);\n        }\n\n        let speech_to_text_created_event_id_reaction_event_id =\n            if let Some(speech_to_text_created_event_id) = speech_to_text_created_event_id {\n                let reaction_event_response = bot\n                    .reacting()\n                    .react_no_fail(\n                        message_context.room(),\n                        speech_to_text_created_event_id.clone(),\n                        strings::PROGRESS_INDICATOR_EMOJI.to_owned(),\n                    )\n                    .await;\n\n                reaction_event_response\n                    .map(|reaction_event_response| reaction_event_response.event_id)\n            } else {\n                None\n            };\n\n        let response_type = match controller_type {\n            // When we're triggered via a reply mention, we reply to the message that triggered us.\n            ChatCompletionControllerType::ReplyMention => {\n                MessageResponseType::Reply(message_context.thread_info().last_event_id.clone())\n            }\n\n            // In all other cases, we're dealing with a threaded conversation, so we reply in the thread.\n            _ => MessageResponseType::InThread(message_context.thread_info().clone()),\n        };\n\n        let text_to_speech_eligible_payload = handle_stage_text_generation(\n            bot,\n            matrix_link.clone(),\n            message_context,\n            controller_type,\n            response_type.clone(),\n        )\n        .await;\n\n        if let Some(speech_to_text_created_event_id_reaction_event_id) =\n            speech_to_text_created_event_id_reaction_event_id\n        {\n            bot.messaging()\n                .redact_event_no_fail(\n                    message_context.room(),\n                    speech_to_text_created_event_id_reaction_event_id,\n                    Some(\"Done\".to_owned()),\n                )\n                .await;\n        }\n\n        // If no text was generated (due to some issue), there's no point in continuing.\n        let Some(text_to_speech_eligible_payload) = text_to_speech_eligible_payload else {\n            return Ok(());\n        };\n\n        text_to_speech_stage_params = match message_context\n            .room_config_context()\n            .text_to_speech_bot_messages_flow_type()\n        {\n            TextToSpeechBotMessagesFlowType::Never => None,\n            TextToSpeechBotMessagesFlowType::OnDemandAlways => Some(TextToSpeechParams::Offer(\n                text_to_speech_eligible_payload,\n                response_type,\n            )),\n            TextToSpeechBotMessagesFlowType::OnDemandForVoice => {\n                if original_message_is_audio {\n                    Some(TextToSpeechParams::Offer(\n                        text_to_speech_eligible_payload,\n                        response_type,\n                    ))\n                } else {\n                    None\n                }\n            }\n            TextToSpeechBotMessagesFlowType::OnlyForVoice => {\n                if original_message_is_audio {\n                    Some(TextToSpeechParams::Perform(\n                        text_to_speech_eligible_payload,\n                        response_type,\n                    ))\n                } else {\n                    None\n                }\n            }\n            TextToSpeechBotMessagesFlowType::Always => Some(TextToSpeechParams::Perform(\n                text_to_speech_eligible_payload,\n                response_type,\n            )),\n        };\n    } else {\n        tracing::debug!(\"Not generating text due to auto-usage configuration\");\n\n        let response_type = MessageResponseType::Reply(message_context.event_id().clone());\n\n        // If we got text from the user, perhaps it's eligible for text-to-speech.\n\n        let MessagePayload::Text(text_payload) = &message_context.payload() else {\n            // Audio message, or a notice or something else.\n            // We don't wish to proceed with potential TTS for non-text messages.\n            return Ok(());\n        };\n\n        let text_to_speech_eligible_payload = TextToSpeechEligiblePayload {\n            text: text_payload.body.clone(),\n            event_id: message_context.event_id().clone(),\n        };\n\n        text_to_speech_stage_params = match message_context\n            .room_config_context()\n            .text_to_speech_user_messages_flow_type()\n        {\n            TextToSpeechUserMessagesFlowType::Never => None,\n            TextToSpeechUserMessagesFlowType::OnDemand => Some(TextToSpeechParams::Offer(\n                text_to_speech_eligible_payload,\n                response_type,\n            )),\n            TextToSpeechUserMessagesFlowType::Always => Some(TextToSpeechParams::Perform(\n                text_to_speech_eligible_payload,\n                response_type,\n            )),\n        };\n    }\n\n    // We're potentially dealing with some text in text_to_speech_eligible_payload - either coming directly from the user or generated by an agent.\n\n    match text_to_speech_stage_params {\n        Some(TextToSpeechParams::Perform(text_to_speech_eligible_payload, response_type)) => {\n            if _typing_notice_guard.is_none() {\n                _typing_notice_guard = Some(bot.start_typing_notice(message_context.room()).await);\n            }\n\n            let _tts_result = generate_and_send_tts_for_message(\n                bot,\n                matrix_link.clone(),\n                message_context,\n                response_type,\n                text_to_speech_eligible_payload.event_id,\n                &text_to_speech_eligible_payload.text,\n            )\n            .await;\n        }\n        Some(TextToSpeechParams::Offer(text_to_speech_eligible_payload, response_type)) => {\n            send_tts_offer_for_message(\n                bot,\n                message_context,\n                response_type,\n                text_to_speech_eligible_payload.event_id,\n            )\n            .await;\n        }\n        None => {}\n    }\n\n    Ok(())\n}\n\nasync fn handle_stage_speech_to_text(\n    bot: &Bot,\n    message_context: &MessageContext,\n    audio_content: &AudioMessageEventContent,\n    response_type: MessageResponseType,\n    msg_type: SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,\n) -> Option<OwnedEventId> {\n    let agent = get_effective_agent_for_purpose_or_complain(\n        bot,\n        message_context,\n        AgentPurpose::SpeechToText,\n        response_type.clone(),\n        true,\n    )\n    .await?;\n\n    tracing::debug!(\n        agent_id = agent.identifier().as_string(),\n        \"Handling speech-to-text\",\n    );\n\n    let reaction_event_response = bot\n        .reacting()\n        .react_no_fail(\n            message_context.room(),\n            message_context.event_id().clone(),\n            strings::PROGRESS_INDICATOR_EMOJI.to_owned(),\n        )\n        .await;\n\n    let speech_to_text_created_event_id = handle_stage_speech_to_text_actual_transcribing(\n        bot,\n        message_context,\n        &agent,\n        audio_content,\n        response_type.clone(),\n        msg_type,\n    )\n    .await;\n\n    if let Some(reaction_event_response) = reaction_event_response {\n        let redaction_reason = if speech_to_text_created_event_id.is_ok() {\n            strings::speech_to_text::redaction_reason_done()\n        } else {\n            strings::speech_to_text::redaction_reason_failed()\n        };\n\n        bot.messaging()\n            .redact_event_no_fail(\n                message_context.room(),\n                reaction_event_response.event_id,\n                Some(redaction_reason.to_owned()),\n            )\n            .await;\n    }\n\n    let speech_to_text_created_event_id = match speech_to_text_created_event_id {\n        Ok(event_id) => event_id,\n        Err(err) => {\n            tracing::warn!(\n                \"Error in room {} while trying to transcribe via agent {}: {:?}\",\n                message_context.room_id(),\n                agent.identifier(),\n                err,\n            );\n\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::agent::error_while_serving_purpose(\n                        agent.identifier(),\n                        &AgentPurpose::SpeechToText,\n                        &err,\n                    ),\n                    response_type,\n                )\n                .await;\n\n            return None;\n        }\n    };\n\n    Some(speech_to_text_created_event_id)\n}\n\nasync fn handle_stage_text_generation(\n    bot: &Bot,\n    matrix_link: MatrixLink,\n    message_context: &MessageContext,\n    controller_type: &ChatCompletionControllerType,\n    response_type: MessageResponseType,\n) -> Option<TextToSpeechEligiblePayload> {\n    let agent = get_effective_agent_for_purpose_or_complain(\n        bot,\n        message_context,\n        AgentPurpose::TextGeneration,\n        response_type.clone(),\n        true,\n    )\n    .await?;\n\n    // We only strip text from the first message if we're invoked via a command prefix.\n    // Otherwise, we do bot-user mentions stripping on all messages below.\n    let first_message_prefixes_to_strip = match controller_type {\n        ChatCompletionControllerType::TextCommand => vec![bot.command_prefix().to_owned()],\n        _ => vec![],\n    };\n\n    let bot_user_prefixes_to_strip = create_list_of_bot_user_prefixes_to_strip(\n        bot.user_id(),\n        message_context.bot_display_name(),\n    );\n\n    let allowed_users = match controller_type {\n        // Regular chat completion only operates on messages from allowed users.\n        ChatCompletionControllerType::TextCommand\n        | ChatCompletionControllerType::TextMention\n        | ChatCompletionControllerType::TextDirect\n        | ChatCompletionControllerType::Audio\n        | ChatCompletionControllerType::Image\n        | ChatCompletionControllerType::File => {\n            Some(message_context.combined_admin_and_user_regexes())\n        }\n\n        // When we're triggered via an explicit mention (thread or reply), we wish to operate against the mention's whole context\n        // (the whole thread or the whole reply chain upward of the message that triggered us).\n        //\n        // This is to allow admins and users to trigger text-generation for other users' messages.\n        // When we're dragged into a conversation by a known (to us) user, we'd like to process all messages in the conversation,\n        // not just those from allowed users.\n        ChatCompletionControllerType::ThreadMention\n        | ChatCompletionControllerType::ReplyMention => None,\n    };\n\n    let params = MatrixMessageProcessingParams::new(bot.user_id().to_owned(), allowed_users)\n        .with_first_message_prefixes_to_strip(first_message_prefixes_to_strip)\n        .with_bot_user_prefixes_to_strip(bot_user_prefixes_to_strip);\n\n    let conversation = match controller_type {\n        // When we're triggered via a reply mention, the context is the whole reply chain upward of the message that triggered us.\n        ChatCompletionControllerType::ReplyMention => {\n            create_llm_conversation_for_matrix_reply_chain(\n                &matrix_link,\n                &bot.room_event_fetcher().clone(),\n                message_context.room(),\n                message_context.thread_info().last_event_id.clone(),\n                &params,\n            )\n            .await\n        }\n\n        // Everything else is happening in a thread, so the context is the whole thread.\n        _ => {\n            create_llm_conversation_for_matrix_thread(\n                &matrix_link,\n                message_context.room(),\n                message_context.thread_info().root_event_id.clone(),\n                &params,\n            )\n            .await\n        }\n    };\n\n    let conversation = match conversation {\n        Ok(conversation) => conversation,\n        Err(err) => {\n            tracing::warn!(?err, \"Error while trying to create conversation\");\n\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::agent::error_while_serving_purpose(\n                        agent.identifier(),\n                        &AgentPurpose::TextGeneration,\n                        &err,\n                    ),\n                    response_type,\n                )\n                .await;\n\n            return None;\n        }\n    };\n\n    let conversation = inject_sender_context(\n        conversation,\n        message_context\n            .room_config_context()\n            .text_generation_sender_context_mode(),\n    );\n\n    tracing::debug!(\n        agent_id = agent.identifier().as_string(),\n        provider = format!(\"{}\", agent.definition().provider.clone()),\n        \"Invoking LLM for text generation with conversation..\"\n    );\n\n    let span = tracing::debug_span!(\n        \"text_generation\",\n        agent_id = agent.identifier().as_string(),\n        provider = format!(\"{}\", agent.definition().provider.clone()),\n    );\n\n    let start_time = std::time::Instant::now();\n\n    let controller = agent.controller();\n\n    let prompt_variables = TextGenerationPromptVariables::new(\n        bot.name(),\n        &controller\n            .text_generation_model_id()\n            .unwrap_or(\"unknown-model\".to_owned()),\n        chrono::Utc::now(),\n        conversation.start_time(),\n    );\n\n    let params = TextGenerationParams {\n        context_management_enabled: message_context\n            .room_config_context()\n            .text_generation_context_management_enabled(),\n\n        prompt_override: message_context\n            .room_config_context()\n            .text_generation_prompt_override(),\n\n        temperature_override: message_context\n            .room_config_context()\n            .text_generation_temperature_override(),\n\n        prompt_variables,\n    };\n\n    let result = controller\n        .generate_text(conversation, params)\n        .instrument(span)\n        .await;\n\n    let duration = std::time::Instant::now().duration_since(start_time);\n\n    tracing::debug!(\n        agent_id = agent.identifier().as_string(),\n        provider = format!(\"{}\", agent.definition().provider.clone()),\n        ?duration,\n        \"Done with LLM text generation\"\n    );\n\n    let result = match result {\n        Ok(result) => result,\n        Err(err) => {\n            tracing::warn!(\n                \"Error in room {} while trying to generate text via agent {}: {:?}\",\n                message_context.room_id(),\n                agent.identifier(),\n                err,\n            );\n\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::agent::error_while_serving_purpose(\n                        agent.identifier(),\n                        &AgentPurpose::TextGeneration,\n                        &err,\n                    ),\n                    response_type,\n                )\n                .await;\n\n            return None;\n        }\n    };\n\n    let text = result.text.clone().trim().to_owned();\n    if text.is_empty() {\n        tracing::warn!(\n            agent_id = agent.identifier().as_string(),\n            \"Agent returned empty text\",\n        );\n\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                &strings::agent::empty_response_returned(agent.identifier()),\n                response_type,\n            )\n            .await;\n\n        return None;\n    }\n\n    let send_message_response = bot\n        .messaging()\n        .send_text_markdown_no_fail(message_context.room(), text.clone(), response_type)\n        .await?;\n\n    Some(TextToSpeechEligiblePayload {\n        text,\n        event_id: send_message_response.event_id,\n    })\n}\n\nasync fn handle_stage_speech_to_text_actual_transcribing(\n    bot: &Bot,\n    message_context: &MessageContext,\n    agent: &AgentInstance,\n    audio_content: &AudioMessageEventContent,\n    response_type: MessageResponseType,\n    msg_type: SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,\n) -> anyhow::Result<OwnedEventId> {\n    let src = &audio_content.source;\n\n    let media_request = mxlink::matrix_sdk::media::MediaRequestParameters {\n        source: src.to_owned(),\n        format: mxlink::matrix_sdk::media::MediaFormat::File,\n    };\n\n    let media = message_context\n        .room()\n        .client()\n        .media()\n        .get_media_content(&media_request, true)\n        .await?;\n\n    let span = tracing::debug_span!(\n        \"speech_to_text_generation\",\n        agent_id = agent.identifier().as_string()\n    );\n\n    let mime_type = audio_content\n        .info\n        .as_ref()\n        .and_then(|info| info.mimetype.clone())\n        .unwrap_or_else(|| \"audio/ogg\".to_string())\n        .parse::<mxlink::mime::Mime>()\n        .map_err(|err| anyhow::anyhow!(\"Invalid MIME type: {}\", err))?;\n\n    let params = SpeechToTextParams {\n        language_override: message_context\n            .room_config_context()\n            .speech_to_text_language(),\n    };\n\n    let speech_to_text_result = agent\n        .controller()\n        .speech_to_text(&mime_type, media, params)\n        .instrument(span)\n        .await?;\n\n    // Only use the `> 🦻 Transcribed text` format if we're posting in a thread.\n    //\n    // If we're dealing with a regular reply (which would be the case in \"Transcribe-only mode\" = speech-to-text/flow-type=only_transcribe),\n    // we don't want to use the `> 🦻 Transcribed text` format for 2 reasons:\n    //\n    // 1. This kind of blockquote-formatting can be confused by clients for a fallback-for-rich-replies\n    //    (see https://spec.matrix.org/v1.11/client-server-api/#fallbacks-for-rich-replies).\n    //    It makes certain clients render our messages incorrectly.\n    //\n    // 2. Transcribe-only mode is typically used for memos. Sticking to a plain-text format\n    //    allows people to copy-paste the text or forward it to another room more easily (without having to strip formatting, etc.)\n    //\n    // When sending a bare reply, we'd better annotate the message with a 🦻 reaction instead,\n    // to make it clear to users that it's a transcription.\n    let (transcribed_text, annotate_message_with_reaction) =\n        if let MessageResponseType::InThread(_) = response_type {\n            (\n                create_transcribed_message_text(&speech_to_text_result.text),\n                false,\n            )\n        } else {\n            (speech_to_text_result.text, true)\n        };\n\n    let result = match msg_type {\n        SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages::Text => {\n            bot.messaging()\n                .send_text_markdown_no_fail(message_context.room(), transcribed_text, response_type)\n                .await\n        }\n        SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages::Notice => {\n            bot.messaging()\n                .send_notice_markdown_no_fail(\n                    message_context.room(),\n                    transcribed_text,\n                    response_type,\n                )\n                .await\n        }\n    };\n\n    let event_id = result\n        .map(|result| result.event_id)\n        .ok_or_else(|| anyhow::anyhow!(\"Failed to send transcribed text\"))?;\n\n    if annotate_message_with_reaction {\n        bot.reacting()\n            .react_no_fail(\n                message_context.room(),\n                event_id.clone(),\n                AgentPurpose::SpeechToText.emoji().to_owned(),\n            )\n            .await;\n    }\n\n    Ok(event_id)\n}\n\nasync fn send_tts_offer_for_message(\n    bot: &Bot,\n    message_context: &MessageContext,\n    response_type: MessageResponseType,\n    event_id: OwnedEventId,\n) {\n    // Offers may be enabled, but there's no guarantee that whatever agent is configured can actually do TTS.\n    // So.. do not complain if there's no agent available. Just silently ignore it.\n    let speech_agent = get_effective_agent_for_purpose_or_complain(\n        bot,\n        message_context,\n        AgentPurpose::TextToSpeech,\n        response_type,\n        false,\n    )\n    .await;\n\n    if speech_agent.is_some() {\n        bot.reacting()\n            .react_no_fail(\n                message_context.room(),\n                event_id,\n                AgentPurpose::TextToSpeech.emoji().to_owned(),\n            )\n            .await;\n    }\n}\n\nasync fn generate_and_send_tts_for_message(\n    bot: &Bot,\n    matrix_link: MatrixLink,\n    message_context: &MessageContext,\n    response_type: MessageResponseType,\n    event_id: OwnedEventId,\n    text: &str,\n) -> bool {\n    let speech_agent = get_effective_agent_for_purpose_or_complain(\n        bot,\n        message_context,\n        AgentPurpose::TextToSpeech,\n        response_type.clone(),\n        true,\n    )\n    .await;\n\n    let Some(speech_agent) = speech_agent else {\n        return false;\n    };\n\n    crate::controller::utils::text_to_speech::generate_and_send_tts_for_message(\n        bot,\n        matrix_link,\n        message_context,\n        response_type,\n        &speech_agent,\n        &event_id,\n        text,\n    )\n    .await\n}\n\nfn inject_sender_context(\n    conversation: Conversation,\n    sender_context_mode: TextGenerationSenderContextMode,\n) -> Conversation {\n    if sender_context_mode == TextGenerationSenderContextMode::Disabled {\n        return conversation;\n    }\n\n    let include_timestamp =\n        sender_context_mode == TextGenerationSenderContextMode::MatrixUserIdAndTimestamp;\n\n    let messages = conversation\n        .messages\n        .into_iter()\n        .map(|mut message| {\n            if message.author == Author::Prompt {\n                return message;\n            }\n\n            let Some(sender_id) = &message.sender_id else {\n                return message;\n            };\n\n            if let MessageContent::Text(ref mut text) = message.content {\n                *text = if include_timestamp {\n                    let timestamp = message.timestamp.format(\"%Y-%m-%dT%H:%M:%SZ\");\n                    format!(\"[sender={} sent_at={}] {}\", sender_id, timestamp, text)\n                } else {\n                    format!(\"[sender={}] {}\", sender_id, text)\n                };\n            }\n\n            message\n        })\n        .collect();\n\n    Conversation { messages }\n}\n\n#[cfg(test)]\nmod sender_context_tests {\n    use super::inject_sender_context;\n    use crate::conversation::llm::{Author, Conversation, ImageDetails, Message, MessageContent};\n    use crate::entity::roomconfig::TextGenerationSenderContextMode;\n    use chrono::{TimeZone, Utc};\n    use mxlink::matrix_sdk::ruma::events::room::message::ImageMessageEventContent;\n    use mxlink::matrix_sdk::ruma::{OwnedMxcUri, OwnedUserId};\n    use mxlink::mime;\n\n    #[test]\n    fn test_inject_sender_context_prefixes_text_messages() {\n        let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();\n        let user_id = OwnedUserId::try_from(\"@alice:example.com\").unwrap();\n\n        let conversation = Conversation {\n            messages: vec![Message {\n                author: Author::User,\n                sender_id: Some(user_id),\n                timestamp,\n                content: MessageContent::Text(\"Hello bot\".to_string()),\n            }],\n        };\n\n        let result = inject_sender_context(\n            conversation,\n            TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,\n        );\n\n        assert_eq!(result.messages.len(), 1);\n        assert_eq!(\n            result.messages[0].content,\n            MessageContent::Text(\n                \"[sender=@alice:example.com sent_at=2026-03-23T14:30:00Z] Hello bot\".to_string()\n            )\n        );\n    }\n\n    #[test]\n    fn test_inject_sender_context_can_prefix_without_timestamp() {\n        let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();\n        let user_id = OwnedUserId::try_from(\"@alice:example.com\").unwrap();\n\n        let conversation = Conversation {\n            messages: vec![Message {\n                author: Author::User,\n                sender_id: Some(user_id),\n                timestamp,\n                content: MessageContent::Text(\"Hello bot\".to_string()),\n            }],\n        };\n\n        let result =\n            inject_sender_context(conversation, TextGenerationSenderContextMode::MatrixUserId);\n\n        assert_eq!(result.messages.len(), 1);\n        assert_eq!(\n            result.messages[0].content,\n            MessageContent::Text(\"[sender=@alice:example.com] Hello bot\".to_string())\n        );\n    }\n\n    #[test]\n    fn test_inject_sender_context_prefixes_assistant_messages() {\n        let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();\n        let user_id = OwnedUserId::try_from(\"@baibot:example.com\").unwrap();\n\n        let conversation = Conversation {\n            messages: vec![Message {\n                author: Author::Assistant,\n                sender_id: Some(user_id),\n                timestamp,\n                content: MessageContent::Text(\"Hello human\".to_string()),\n            }],\n        };\n\n        let result = inject_sender_context(\n            conversation,\n            TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,\n        );\n\n        assert_eq!(result.messages.len(), 1);\n        assert_eq!(\n            result.messages[0].content,\n            MessageContent::Text(\n                \"[sender=@baibot:example.com sent_at=2026-03-23T14:30:00Z] Hello human\".to_string()\n            )\n        );\n    }\n\n    #[test]\n    fn test_inject_sender_context_skips_prompt_messages() {\n        let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();\n\n        let conversation = Conversation {\n            messages: vec![Message {\n                author: Author::Prompt,\n                sender_id: None,\n                timestamp,\n                content: MessageContent::Text(\"You are a bot\".to_string()),\n            }],\n        };\n\n        let result =\n            inject_sender_context(conversation, TextGenerationSenderContextMode::MatrixUserId);\n\n        assert_eq!(\n            result.messages[0].content,\n            MessageContent::Text(\"You are a bot\".to_string())\n        );\n    }\n\n    #[test]\n    fn test_inject_sender_context_skips_messages_without_sender_id() {\n        let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();\n\n        let conversation = Conversation {\n            messages: vec![Message {\n                author: Author::User,\n                sender_id: None,\n                timestamp,\n                content: MessageContent::Text(\"Transcribed text\".to_string()),\n            }],\n        };\n\n        let result = inject_sender_context(\n            conversation,\n            TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,\n        );\n\n        assert_eq!(\n            result.messages[0].content,\n            MessageContent::Text(\"Transcribed text\".to_string())\n        );\n    }\n\n    #[test]\n    fn test_inject_sender_context_leaves_non_text_content_unchanged() {\n        let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();\n        let user_id = OwnedUserId::try_from(\"@alice:example.com\").unwrap();\n        let image_event_content = ImageMessageEventContent::plain(\n            \"image.png\".to_string(),\n            OwnedMxcUri::from(\"mxc://example.com/1234567890\"),\n        );\n\n        let conversation = Conversation {\n            messages: vec![Message {\n                author: Author::User,\n                sender_id: Some(user_id),\n                timestamp,\n                content: MessageContent::Image(ImageDetails::new(\n                    image_event_content.clone(),\n                    mime::IMAGE_PNG,\n                    vec![],\n                )),\n            }],\n        };\n\n        let result = inject_sender_context(\n            conversation,\n            TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,\n        );\n\n        assert_eq!(\n            result.messages[0].content,\n            MessageContent::Image(ImageDetails::new(\n                image_event_content,\n                mime::IMAGE_PNG,\n                vec![]\n            ))\n        );\n    }\n\n    #[test]\n    fn test_inject_sender_context_none_leaves_text_unchanged() {\n        let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();\n        let user_id = OwnedUserId::try_from(\"@alice:example.com\").unwrap();\n\n        let conversation = Conversation {\n            messages: vec![Message {\n                author: Author::User,\n                sender_id: Some(user_id),\n                timestamp,\n                content: MessageContent::Text(\"Hello bot\".to_string()),\n            }],\n        };\n\n        let result = inject_sender_context(conversation, TextGenerationSenderContextMode::Disabled);\n\n        assert_eq!(\n            result.messages[0].content,\n            MessageContent::Text(\"Hello bot\".to_string())\n        );\n    }\n}\n"
  },
  {
    "path": "src/controller/controller_type.rs",
    "content": "#[derive(Debug, PartialEq)]\npub enum ControllerType {\n    // Denotes that the message is to be ignored.\n    Ignore,\n\n    Help,\n\n    UsageHelp,\n\n    Unknown,\n\n    Error(String),\n    ErrorInThread(String, mxlink::ThreadInfo),\n\n    ProviderHelp,\n\n    Access(super::access::AccessControllerType),\n\n    Agent(super::agent::AgentControllerType),\n\n    Config(super::cfg::ConfigControllerType),\n\n    ChatCompletion(super::chat_completion::ChatCompletionControllerType),\n\n    ImageGeneration(String),\n    ImageEdit(String),\n    StickerGeneration(String),\n}\n"
  },
  {
    "path": "src/controller/determination/mod.rs",
    "content": "#[cfg(test)]\nmod tests;\n\nuse super::chat_completion::ChatCompletionControllerType;\nuse crate::{\n    entity::{\n        InteractionTrigger, MessageContext, MessagePayload,\n        roomconfig::TextGenerationPrefixRequirementType,\n    },\n    strings,\n};\n\nuse super::ControllerType;\n\npub fn determine_controller(\n    command_prefix: &str,\n    first_thread_message: &InteractionTrigger,\n    message_context: &MessageContext,\n) -> ControllerType {\n    match &first_thread_message.payload {\n        MessagePayload::SynthethicChatCompletionTriggerInThread => {\n            ControllerType::ChatCompletion(ChatCompletionControllerType::ThreadMention)\n        }\n        MessagePayload::SynthethicChatCompletionTriggerForReply => {\n            ControllerType::ChatCompletion(ChatCompletionControllerType::ReplyMention)\n        }\n        MessagePayload::Text(text_message_content) => {\n            let prefix_requirement_type = message_context\n                .room_config_context()\n                .text_generation_prefix_requirement_type();\n\n            determine_text_controller(\n                command_prefix,\n                &text_message_content.body,\n                prefix_requirement_type,\n                first_thread_message.is_mentioning_bot,\n            )\n        }\n        MessagePayload::Image(_image_message_content) => {\n            let prefix_requirement_type = message_context\n                .room_config_context()\n                .text_generation_prefix_requirement_type();\n\n            match prefix_requirement_type {\n                TextGenerationPrefixRequirementType::CommandPrefix => ControllerType::Ignore,\n                TextGenerationPrefixRequirementType::No => {\n                    ControllerType::ChatCompletion(ChatCompletionControllerType::Image)\n                }\n            }\n        }\n        MessagePayload::Encrypted(thread_info) => {\n            if thread_info.is_thread_root_only() {\n                ControllerType::Error(strings::error::message_is_encrypted().to_owned())\n            } else {\n                ControllerType::ErrorInThread(\n                    strings::error::first_message_in_thread_is_encrypted().to_owned(),\n                    thread_info.clone(),\n                )\n            }\n        }\n        MessagePayload::File(_file_message_content) => {\n            let prefix_requirement_type = message_context\n                .room_config_context()\n                .text_generation_prefix_requirement_type();\n\n            match prefix_requirement_type {\n                TextGenerationPrefixRequirementType::CommandPrefix => ControllerType::Ignore,\n                TextGenerationPrefixRequirementType::No => {\n                    ControllerType::ChatCompletion(ChatCompletionControllerType::File)\n                }\n            }\n        }\n        MessagePayload::Audio(_) => {\n            ControllerType::ChatCompletion(ChatCompletionControllerType::Audio)\n        }\n        MessagePayload::Reaction { .. } => {\n            panic!(\"Handling reaction as first message in thread does not make sense\")\n        }\n    }\n}\n\nfn determine_text_controller(\n    command_prefix: &str,\n    text: &str,\n    room_text_generation_prefix_requirement_type: TextGenerationPrefixRequirementType,\n    is_mentioning_bot: bool,\n) -> ControllerType {\n    let text = text.trim();\n\n    if text.starts_with(&format!(\"{command_prefix} help\")) || text == command_prefix {\n        return ControllerType::Help;\n    }\n\n    if let Some(remaining) = text.strip_prefix(&format!(\"{command_prefix} access\")) {\n        return super::access::determine_controller(remaining.trim());\n    }\n\n    if let Some(remaining) = text.strip_prefix(&format!(\"{command_prefix} provider\")) {\n        return super::provider::determine_controller(remaining.trim());\n    }\n\n    if let Some(remaining) = text.strip_prefix(&format!(\"{command_prefix} agent\")) {\n        return super::agent::determine_controller(command_prefix, remaining.trim());\n    }\n\n    if let Some(remaining) = text.strip_prefix(&format!(\"{command_prefix} config\")) {\n        return super::cfg::determine_controller(remaining.trim());\n    }\n\n    if let Some(prompt) = text.strip_prefix(&format!(\"{command_prefix} image\")) {\n        return super::image::determine_controller(prompt.trim());\n    }\n\n    if let Some(prompt) = text.strip_prefix(&format!(\"{command_prefix} sticker\")) {\n        return ControllerType::StickerGeneration(prompt.trim().to_owned());\n    }\n\n    if let Some(remaining) = text.strip_prefix(&format!(\"{command_prefix} usage\")) {\n        return super::usage::determine_controller(remaining.trim());\n    }\n\n    // Regular text message that does not match any command.\n    // If it mentions the bot, it's a chat completion.\n    // Otherwise, it depends on the prefix requirement for text generation - it may be routed for chat completion or ignored.\n\n    if is_mentioning_bot {\n        return ControllerType::ChatCompletion(ChatCompletionControllerType::TextMention);\n    }\n\n    // Regardless of what the prefix requirement is, if we encounter a command prefix, we'll consider it a chat completion via command prefix invokation.\n    // This is to correctly indicate to the chat completion controller that a command prefix was used,\n    // so that it can be stripped from the beginning of the message.\n    if text.starts_with(command_prefix) {\n        return ControllerType::ChatCompletion(ChatCompletionControllerType::TextCommand);\n    }\n\n    // We're dealing with a regular message that does not start with a command prefix.\n\n    match room_text_generation_prefix_requirement_type {\n        TextGenerationPrefixRequirementType::CommandPrefix => {\n            // A prefix is required, but we've already checked (above) that the message does not start with a command prefix.\n            // It's to be ignored.\n            ControllerType::Ignore\n        }\n        TextGenerationPrefixRequirementType::No => {\n            ControllerType::ChatCompletion(ChatCompletionControllerType::TextDirect)\n        }\n    }\n}\n"
  },
  {
    "path": "src/controller/determination/tests.rs",
    "content": "#[test]\nfn determine_text_controller() {\n    use super::super::chat_completion::ChatCompletionControllerType;\n    use super::ControllerType;\n    use crate::controller;\n\n    let command_prefix = \"!bai\";\n\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        is_mentioning_bot: bool,\n        expected: ControllerType,\n        // This value only matters for some of the tests.\n        // We default to using the No variant for most tests where it's irrelevant.\n        room_text_generation_prefix_requirement_type: super::TextGenerationPrefixRequirementType,\n    }\n\n    // We only have top-level test cases here.\n    // Each submodule defines its own test cases.\n    let test_cases = vec![\n        TestCase {\n            name: \"Help\",\n            input: \"!bai help\",\n            is_mentioning_bot: false,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::No,\n            expected: ControllerType::Help,\n        },\n        TestCase {\n            name: \"Prefix only leads to help\",\n            input: \"!bai\",\n            is_mentioning_bot: false,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::No,\n            expected: ControllerType::Help,\n        },\n        TestCase {\n            name: \"Prefix and unknown command leads to chat completion\",\n            input: \"!bai something-else\",\n            is_mentioning_bot: false,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::No,\n            expected: ControllerType::ChatCompletion(ChatCompletionControllerType::TextCommand),\n        },\n        TestCase {\n            name: \"Access top-level\",\n            input: \"!bai access\",\n            is_mentioning_bot: false,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::No,\n            expected: ControllerType::Access(controller::access::AccessControllerType::Help),\n        },\n        TestCase {\n            name: \"Provider\",\n            input: \"!bai provider\",\n            is_mentioning_bot: false,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::No,\n            expected: ControllerType::ProviderHelp,\n        },\n        TestCase {\n            name: \"Usage\",\n            input: \"!bai usage\",\n            is_mentioning_bot: false,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::No,\n            expected: ControllerType::UsageHelp,\n        },\n        TestCase {\n            name: \"Agent top-level\",\n            input: \"!bai agent\",\n            is_mentioning_bot: false,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::No,\n            expected: ControllerType::Agent(controller::agent::AgentControllerType::Help),\n        },\n        TestCase {\n            name: \"Config top-level\",\n            input: \"!bai config\",\n            is_mentioning_bot: false,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::No,\n            expected: ControllerType::Config(controller::cfg::ConfigControllerType::Help),\n        },\n        TestCase {\n            name: \"Generic image command causes usage help\",\n            input: \"!bai image Draw a cat!\",\n            is_mentioning_bot: false,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::No,\n            expected: ControllerType::UsageHelp,\n        },\n        TestCase {\n            name: \"Image generation\",\n            input: \"!bai image create Draw a cat!\",\n            is_mentioning_bot: false,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::No,\n            expected: ControllerType::ImageGeneration(\"Draw a cat!\".to_owned()),\n        },\n        TestCase {\n            name: \"Sticker generation\",\n            input: \"!bai sticker A surprised cat\",\n            is_mentioning_bot: false,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::No,\n            expected: ControllerType::StickerGeneration(\"A surprised cat\".to_owned()),\n        },\n        TestCase {\n            name: \"Regular text triggers completion when prefix not required\",\n            input: \"Regular text goes here\",\n            is_mentioning_bot: false,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::No,\n            expected: ControllerType::ChatCompletion(ChatCompletionControllerType::TextDirect),\n        },\n        TestCase {\n            name: \"Regular text is ignored when prefix is required\",\n            input: \"Regular text goes here\",\n            is_mentioning_bot: false,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::CommandPrefix,\n            expected: ControllerType::Ignore,\n        },\n        TestCase {\n            name: \"Command-prefixed text triggers completion when prefix is required\",\n            input: \"!bai Regular text goes here\",\n            is_mentioning_bot: false,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::CommandPrefix,\n            expected: ControllerType::ChatCompletion(ChatCompletionControllerType::TextCommand),\n        },\n        TestCase {\n            name: \"Command-prefixed text triggers completion even when prefix is not required\",\n            input: \"!bai Regular text goes here\",\n            is_mentioning_bot: false,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::No,\n            expected: ControllerType::ChatCompletion(ChatCompletionControllerType::TextCommand),\n        },\n        TestCase {\n            name: \"Regular message with bot mention triggers completion (no prefix requirement)\",\n            input: \"Regular text goes here\",\n            is_mentioning_bot: true,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::No,\n            expected: ControllerType::ChatCompletion(ChatCompletionControllerType::TextMention),\n        },\n        // This test case is the same as the one above, just with a different prefix requirement setting.\n        // We expect the same result.\n        TestCase {\n            name: \"Regular message with bot mention triggers completion (command prefix requirement)\",\n            input: \"Regular text goes here\",\n            is_mentioning_bot: true,\n            room_text_generation_prefix_requirement_type:\n                super::TextGenerationPrefixRequirementType::CommandPrefix,\n            expected: ControllerType::ChatCompletion(ChatCompletionControllerType::TextMention),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine_text_controller(\n            command_prefix,\n            test_case.input,\n            test_case.room_text_generation_prefix_requirement_type,\n            test_case.is_mentioning_bot,\n        );\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n"
  },
  {
    "path": "src/controller/dispatching.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{Bot, entity::MessageContext, strings};\n\nuse super::ControllerType;\n\npub async fn dispatch_controller(\n    controller_type: &ControllerType,\n    message_context: &MessageContext,\n    bot: &Bot,\n) {\n    let result = match controller_type {\n        ControllerType::Access(controller_type) => {\n            super::access::dispatch_controller(controller_type, message_context, bot).await\n        }\n        ControllerType::Agent(controller_type) => {\n            super::agent::dispatch_controller(controller_type, message_context, bot).await\n        }\n        ControllerType::Config(controller_type) => {\n            super::cfg::dispatch_controller(controller_type, message_context, bot).await\n        }\n        ControllerType::Help => super::help::handle(bot, message_context).await,\n        ControllerType::Unknown => {\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::error::unknown_command_see_help(bot.command_prefix()),\n                    MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n                )\n                .await;\n\n            Ok(())\n        }\n        ControllerType::ProviderHelp => super::provider::handle_help(message_context, bot).await,\n        ControllerType::UsageHelp => super::usage::handle_help(message_context, bot).await,\n        ControllerType::ChatCompletion(controller_type) => {\n            super::chat_completion::handle(\n                bot,\n                bot.matrix_link().clone(),\n                message_context,\n                controller_type,\n            )\n            .await\n        }\n        ControllerType::Error(message) => {\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    message,\n                    MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n                )\n                .await;\n\n            Ok(())\n        }\n        ControllerType::ErrorInThread(message, thread_info) => {\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    message,\n                    MessageResponseType::InThread(thread_info.clone()),\n                )\n                .await;\n\n            Ok(())\n        }\n        ControllerType::Ignore => {\n            tracing::trace!(\"Ignoring text message\");\n            Ok(())\n        }\n        ControllerType::ImageGeneration(prompt) => {\n            super::image::generation::handle_image(\n                bot,\n                bot.matrix_link().clone(),\n                message_context,\n                prompt,\n            )\n            .await\n        }\n        ControllerType::ImageEdit(prompt) => {\n            super::image::edit::handle(bot, bot.matrix_link().clone(), message_context, prompt)\n                .await\n        }\n        ControllerType::StickerGeneration(prompt) => {\n            super::image::generation::handle_sticker(\n                bot,\n                bot.matrix_link().clone(),\n                message_context,\n                prompt,\n            )\n            .await\n        }\n    };\n\n    if let Err(e) = result {\n        tracing::error!(\n            \"Error handling message {} from sender {} in room {}: {:?}\",\n            message_context.event_id(),\n            message_context.sender_id(),\n            message_context.room_id(),\n            e,\n        );\n\n        bot.messaging()\n            .send_error_markdown_no_fail(\n                message_context.room(),\n                strings::error::error_while_processing_message(),\n                MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n            )\n            .await;\n    }\n}\n"
  },
  {
    "path": "src/controller/help/mod.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{Bot, entity::MessageContext, strings};\n\npub async fn handle(bot: &Bot, message_context: &MessageContext) -> anyhow::Result<()> {\n    let sender_can_manage_global_config = message_context.sender_can_manage_global_config();\n    let sender_can_manage_room_local_agents =\n        message_context.sender_can_manage_room_local_agents()?;\n\n    let mut message = String::from(\"\");\n    message.push_str(&format!(\"## {}\\n\\n\", strings::help::heading_introduction()));\n    message.push_str(&strings::introduction::create_short_introduction(\n        bot.name(),\n    ));\n\n    message.push_str(\"\\n\\n\");\n\n    // Agents\n    message.push_str(&format!(\"## {}\", strings::help::agent::heading()));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::agent::intro(bot.command_prefix()));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::agent::intro_handler_relation(\n        bot.command_prefix(),\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::learn_more_send_a_command(\n        bot.command_prefix(),\n        \"agent\",\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // Providers\n    if sender_can_manage_room_local_agents || sender_can_manage_global_config {\n        message.push_str(&format!(\"## {}\", strings::help::provider::heading()));\n        message.push_str(\"\\n\\n\");\n        message.push_str(&strings::help::provider::intro());\n        message.push_str(\"\\n\\n\");\n        message.push_str(&strings::help::learn_more_send_a_command(\n            bot.command_prefix(),\n            \"provider\",\n        ));\n        message.push_str(\"\\n\\n\");\n    }\n\n    // Access\n    message.push_str(&format!(\"## {}\", strings::help::access::heading()));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::access::intro());\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::learn_more_send_a_command(\n        bot.command_prefix(),\n        \"access\",\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // Configuration\n    message.push_str(&format!(\"## {}\", strings::help::cfg::heading()));\n    message.push_str(\"\\n\\n\");\n    message.push_str(strings::help::cfg::intro_short());\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::learn_more_send_a_command(\n        bot.command_prefix(),\n        \"config\",\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // Usage\n    message.push_str(&format!(\"## {}\", strings::help::usage::heading()));\n    message.push_str(\"\\n\\n\");\n    message.push_str(strings::help::usage::intro());\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::learn_more_send_a_command(\n        bot.command_prefix(),\n        \"usage\",\n    ));\n\n    bot.messaging()\n        .send_text_markdown_no_fail(\n            message_context.room(),\n            message,\n            MessageResponseType::Reply(message_context.thread_info().last_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/image/determination/mod.rs",
    "content": "use crate::controller::ControllerType;\nmod tests;\n\npub fn determine_controller(text: &str) -> ControllerType {\n    let text = text.trim();\n\n    if let Some(prompt) = text.strip_prefix(\"create\") {\n        return ControllerType::ImageGeneration(prompt.trim().to_owned());\n    }\n\n    if let Some(prompt) = text.strip_prefix(\"edit\") {\n        return ControllerType::ImageEdit(prompt.trim().to_owned());\n    }\n\n    ControllerType::UsageHelp\n}\n"
  },
  {
    "path": "src/controller/image/determination/tests.rs",
    "content": "#[test]\nfn determine_controller() {\n    struct TestCase {\n        name: &'static str,\n        input: &'static str,\n        expected: super::ControllerType,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"Top-level is usage help\",\n            input: \"\",\n            expected: super::ControllerType::UsageHelp,\n        },\n        TestCase {\n            name: \"Top-level with some text is usage help\",\n            input: \"Some text\",\n            expected: super::ControllerType::UsageHelp,\n        },\n        TestCase {\n            name: \"Image generation triggered by create prefix\",\n            input: \"create Some prompt\",\n            expected: super::ControllerType::ImageGeneration(\"Some prompt\".to_owned()),\n        },\n        TestCase {\n            name: \"Image edit triggered by edit prefix\",\n            input: \"edit Turn this into an anime-style image\",\n            expected: super::ControllerType::ImageEdit(\n                \"Turn this into an anime-style image\".to_owned(),\n            ),\n        },\n    ];\n\n    for test_case in test_cases {\n        let result = super::determine_controller(test_case.input);\n        assert_eq!(result, test_case.expected, \"Test case: {}\", test_case.name);\n    }\n}\n"
  },
  {
    "path": "src/controller/image/edit.rs",
    "content": "use mxlink::{MatrixLink, MessageResponseType};\n\nuse tracing::Instrument;\n\nuse crate::agent::AgentPurpose;\nuse crate::agent::ControllerTrait;\nuse crate::agent::provider::ImageEditParams;\nuse crate::agent::provider::ImageSource;\nuse crate::controller::utils::agent::get_effective_agent_for_purpose_or_complain;\nuse crate::conversation::create_llm_conversation_for_matrix_thread;\nuse crate::conversation::matrix::MatrixMessageProcessingParams;\nuse crate::strings;\nuse crate::utils::mime::get_file_extension;\nuse crate::{Bot, entity::MessageContext};\n\npub async fn handle(\n    bot: &Bot,\n    matrix_link: MatrixLink,\n    message_context: &MessageContext,\n    original_prompt: &str,\n) -> anyhow::Result<()> {\n    let response_type = MessageResponseType::InThread(message_context.thread_info().clone());\n\n    let Some(agent) = get_effective_agent_for_purpose_or_complain(\n        bot,\n        message_context,\n        AgentPurpose::ImageGeneration,\n        response_type.clone(),\n        true,\n    )\n    .await\n    else {\n        return Ok(());\n    };\n\n    if message_context.thread_info().is_thread_root_only() {\n        return send_guide(bot, message_context).await;\n    }\n\n    let _typing_notice_guard = bot.start_typing_notice(message_context.room()).await;\n\n    let params = MatrixMessageProcessingParams::new(\n        bot.user_id().to_owned(),\n        Some(message_context.combined_admin_and_user_regexes()),\n    );\n\n    let conversation = create_llm_conversation_for_matrix_thread(\n        &matrix_link,\n        message_context.room(),\n        message_context.thread_info().root_event_id.clone(),\n        &params,\n    )\n    .await?;\n\n    let prompt = if conversation.messages.len() >= 2 {\n        // Skip the first message, which contains the original prompt (which we already have)\n        let other_messages = conversation.messages.iter().skip(1).cloned().collect();\n\n        super::prompt::build(original_prompt, other_messages)\n    } else {\n        original_prompt.to_owned()\n    };\n\n    let got_go_signal = conversation.messages.iter().any(|message| {\n        if let crate::conversation::llm::MessageContent::Text(text) = &message.content {\n            text.to_lowercase() == \"go\"\n        } else {\n            false\n        }\n    });\n\n    let image_sources: Vec<ImageSource> = conversation\n        .messages\n        .iter()\n        .filter_map(|message| {\n            if let crate::conversation::llm::MessageContent::Image(image_content) = &message.content\n            {\n                Some(image_content.clone().into())\n            } else {\n                None\n            }\n        })\n        .collect();\n\n    if !got_go_signal || image_sources.is_empty() {\n        // We don't send the guide again here to avoid being annoying.\n        return Ok(());\n    }\n\n    let span = tracing::debug_span!(\"image_edit\", agent_id = agent.identifier().as_string());\n\n    let result = agent\n        .controller()\n        .create_image_edit(&prompt, image_sources, ImageEditParams::default())\n        .instrument(span)\n        .await;\n\n    let response = match result {\n        Ok(response) => response,\n        Err(err) => {\n            tracing::warn!(\n                \"Error in room {} while trying to generate image edit via agent {}: {:?}\",\n                message_context.room_id(),\n                agent.identifier(),\n                err,\n            );\n\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::agent::error_while_serving_purpose(\n                        agent.identifier(),\n                        &AgentPurpose::ImageGeneration,\n                        &err,\n                    ),\n                    response_type,\n                )\n                .await;\n\n            return Ok(());\n        }\n    };\n\n    let attachment_body_text = format!(\n        \"generated-image-edit.{}\",\n        get_file_extension(&response.mime_type)\n    );\n\n    let mut event_content = matrix_link\n        .media()\n        .upload_and_prepare_event_content(\n            message_context.room(),\n            &response.mime_type,\n            response.bytes,\n            &attachment_body_text,\n        )\n        .await\n        .map_err(|e| anyhow::anyhow!(\"Failed to upload and prepare event: {}\", e))?;\n\n    matrix_link\n        .messaging()\n        .send_event(\n            message_context.room(),\n            &mut event_content,\n            response_type.clone(),\n        )\n        .await?;\n\n    Ok(())\n}\n\nasync fn send_guide(bot: &Bot, message_context: &MessageContext) -> anyhow::Result<()> {\n    bot.messaging()\n        .send_text_markdown_no_fail(\n            message_context.room(),\n            strings::image_edit::guide_how_to_proceed(),\n            MessageResponseType::InThread(message_context.thread_info().clone()),\n        )\n        .await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/image/generation.rs",
    "content": "use mxlink::{MatrixLink, MessageResponseType};\n\nuse tracing::Instrument;\n\nuse crate::agent::AgentPurpose;\nuse crate::agent::ControllerTrait;\nuse crate::agent::provider::ImageGenerationParams;\nuse crate::controller::utils::agent::get_effective_agent_for_purpose_or_complain;\nuse crate::conversation::create_llm_conversation_for_matrix_thread;\nuse crate::conversation::matrix::MatrixMessageProcessingParams;\nuse crate::strings;\nuse crate::utils::mime::get_file_extension;\nuse crate::{Bot, entity::MessageContext};\n\npub async fn handle_image(\n    bot: &Bot,\n    matrix_link: MatrixLink,\n    message_context: &MessageContext,\n    original_prompt: &str,\n) -> anyhow::Result<()> {\n    let response_type = MessageResponseType::InThread(message_context.thread_info().clone());\n\n    let Some(agent) = get_effective_agent_for_purpose_or_complain(\n        bot,\n        message_context,\n        AgentPurpose::ImageGeneration,\n        response_type.clone(),\n        true,\n    )\n    .await\n    else {\n        return Ok(());\n    };\n\n    let _typing_notice_guard = bot.start_typing_notice(message_context.room()).await;\n\n    let params = MatrixMessageProcessingParams::new(\n        bot.user_id().to_owned(),\n        Some(message_context.combined_admin_and_user_regexes()),\n    );\n\n    let conversation = create_llm_conversation_for_matrix_thread(\n        &matrix_link,\n        message_context.room(),\n        message_context.thread_info().root_event_id.clone(),\n        &params,\n    )\n    .await?;\n\n    let prompt = if conversation.messages.len() >= 2 {\n        // Skip the first message, which contains the original prompt (which we already have)\n        let other_messages = conversation.messages.iter().skip(1).cloned().collect();\n\n        super::prompt::build(original_prompt, other_messages)\n    } else {\n        original_prompt.to_owned()\n    };\n\n    let span = tracing::debug_span!(\n        \"image_generation\",\n        agent_id = agent.identifier().as_string()\n    );\n\n    let result = agent\n        .controller()\n        .generate_image(&prompt, ImageGenerationParams::default())\n        .instrument(span)\n        .await;\n\n    let response = match result {\n        Ok(response) => response,\n        Err(err) => {\n            tracing::warn!(\n                \"Error in room {} while trying to generate image via agent {}: {:?}\",\n                message_context.room_id(),\n                agent.identifier(),\n                err,\n            );\n\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::agent::error_while_serving_purpose(\n                        agent.identifier(),\n                        &AgentPurpose::ImageGeneration,\n                        &err,\n                    ),\n                    response_type,\n                )\n                .await;\n\n            return Ok(());\n        }\n    };\n\n    let actual_prompt = response.revised_prompt.as_deref().unwrap_or(&prompt);\n\n    if *actual_prompt.trim() != *prompt.trim() {\n        bot.messaging()\n            .send_notice_markdown_no_fail(\n                message_context.room(),\n                strings::image_generation::revised_prompt(actual_prompt),\n                response_type.clone(),\n            )\n            .await;\n    }\n\n    let attachment_body_text = format!(\n        \"generated-image.{}\",\n        get_file_extension(&response.mime_type)\n    );\n\n    let mut event_content = matrix_link\n        .media()\n        .upload_and_prepare_event_content(\n            message_context.room(),\n            &response.mime_type,\n            response.bytes,\n            &attachment_body_text,\n        )\n        .await\n        .map_err(|e| anyhow::anyhow!(\"Failed to upload and prepare event: {}\", e))?;\n\n    matrix_link\n        .messaging()\n        .send_event(\n            message_context.room(),\n            &mut event_content,\n            response_type.clone(),\n        )\n        .await?;\n\n    if conversation.messages.len() == 1 {\n        // If this is the beginning of the thread, send helpful instructions\n        bot.messaging()\n            .send_notice_markdown_no_fail(\n                message_context.room(),\n                strings::image_generation::guide_how_to_proceed(),\n                response_type.clone(),\n            )\n            .await;\n    }\n\n    Ok(())\n}\n\npub async fn handle_sticker(\n    bot: &Bot,\n    matrix_link: MatrixLink,\n    message_context: &MessageContext,\n    original_prompt: &str,\n) -> anyhow::Result<()> {\n    // Stickers are always sent directly to the room - no threading.\n    let response_type =\n        MessageResponseType::Reply(message_context.thread_info().root_event_id.clone());\n\n    let Some(agent) = get_effective_agent_for_purpose_or_complain(\n        bot,\n        message_context,\n        AgentPurpose::ImageGeneration,\n        response_type.clone(),\n        true,\n    )\n    .await\n    else {\n        return Ok(());\n    };\n\n    let _typing_notice_guard = bot.start_typing_notice(message_context.room()).await;\n\n    let span = tracing::debug_span!(\n        \"sticker_generation\",\n        agent_id = agent.identifier().as_string()\n    );\n\n    let params = ImageGenerationParams::default()\n        .with_smallest_size_possible(true)\n        .with_cheaper_model_switching_allowed(true)\n        .with_cheaper_quality_switching_allowed(true);\n\n    let result = agent\n        .controller()\n        .generate_image(original_prompt, params)\n        .instrument(span)\n        .await;\n\n    let response = match result {\n        Ok(response) => response,\n        Err(err) => {\n            tracing::warn!(\n                \"Error in room {} while trying to generate sticker via agent {}: {:?}\",\n                message_context.room_id(),\n                agent.identifier(),\n                err,\n            );\n\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::agent::error_while_serving_purpose(\n                        agent.identifier(),\n                        &AgentPurpose::ImageGeneration,\n                        &err,\n                    ),\n                    response_type,\n                )\n                .await;\n\n            return Ok(());\n        }\n    };\n\n    let attachment_body_text = format!(\n        \"generated-sticker.{}\",\n        get_file_extension(&response.mime_type)\n    );\n\n    let mut event_content = matrix_link\n        .media()\n        .upload_and_prepare_event_content(\n            message_context.room(),\n            &response.mime_type,\n            response.bytes,\n            &attachment_body_text,\n        )\n        .await\n        .map_err(|e| anyhow::anyhow!(\"Failed to upload and prepare event: {}\", e))?;\n\n    matrix_link\n        .messaging()\n        .send_event(message_context.room(), &mut event_content, response_type)\n        .await?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/image/mod.rs",
    "content": "mod determination;\npub mod edit;\npub mod generation;\nmod prompt;\n\npub use determination::determine_controller;\n"
  },
  {
    "path": "src/controller/image/prompt.rs",
    "content": "use crate::conversation::llm::{Author, Message, MessageContent};\n\n/// Builds a prompt from the original prompt and other messages in the conversation.\n///\n/// Only messages authored by the user are considered.\n///\n/// Messages that say \"Again\" or \"Go\" (regardless of casing) are ignored. They are considered special messages\n/// which trigger re-generation and \"start\" respectively, and do not need to be included in the prompt criteria.\npub fn build(original_prompt: &str, other_messages: Vec<Message>) -> String {\n    let mut prompt = original_prompt.to_owned();\n\n    // Make a new messages vector that only contains messages we care about\n    let other_messages: Vec<Message> = other_messages\n        .into_iter()\n        .filter(|message| {\n            if let Author::User = message.author {\n                if let MessageContent::Text(text) = &message.content {\n                    text.to_lowercase() != \"again\" && text.to_lowercase() != \"go\"\n                } else {\n                    false\n                }\n            } else {\n                false\n            }\n        })\n        .collect();\n\n    if !other_messages.is_empty() {\n        prompt.push_str(\"\\nOther criteria:\");\n        for message in other_messages {\n            if let MessageContent::Text(text) = &message.content {\n                prompt.push_str(format!(\"\\n- {}\", text.replace(\"\\n\", \". \").as_str()).as_str());\n            }\n        }\n    }\n\n    prompt\n}\n\n#[cfg(test)]\nmod tests {\n    use super::build;\n    use super::{Author, Message, MessageContent};\n\n    struct TestCase {\n        original_prompt: &'static str,\n        messages: Vec<Message>,\n        expected_prompt: &'static str,\n    }\n\n    #[test]\n    fn test_build_prompt() {\n        let timestamp = chrono::Utc::now();\n\n        let test_cases = vec![\n            // Simple case\n            TestCase {\n                original_prompt: \"Generate a picture of a cat\",\n                messages: vec![],\n                expected_prompt: \"Generate a picture of a cat\",\n            },\n            // Only a single user message\n            TestCase {\n                original_prompt: \"Generate a picture of a dog\",\n                messages: vec![Message {\n                    author: Author::User,\n                    sender_id: None,\n                    content: MessageContent::Text(\"Must be blue\".to_owned()),\n                    timestamp,\n                }],\n                expected_prompt: \"Generate a picture of a dog\\nOther criteria:\\n- Must be blue\",\n            },\n            // Multiple complex user messages dispersed with assistant messages\n            TestCase {\n                original_prompt: \"Generate a picture of an elephant\",\n                messages: vec![\n                    Message {\n                        author: Author::User,\n                        sender_id: None,\n                        content: MessageContent::Text(\"Must be blue\".to_owned()),\n                        timestamp,\n                    },\n                    Message {\n                        author: Author::Assistant,\n                        sender_id: None,\n                        content: MessageContent::Text(\"Whatever\".to_owned()),\n                        timestamp,\n                    },\n                    Message {\n                        author: Author::User,\n                        sender_id: None,\n                        content: MessageContent::Text(\n                            \"Must be 3-legged.\\nMust be flying.\".to_owned(),\n                        ),\n                        timestamp,\n                    },\n                ],\n                expected_prompt: \"Generate a picture of an elephant\\nOther criteria:\\n- Must be blue\\n- Must be 3-legged.. Must be flying.\",\n            },\n            // \"Again\" is ignored.\n            TestCase {\n                original_prompt: \"Generate a picture of a grizzly bear\",\n                messages: vec![\n                    Message {\n                        author: Author::User,\n                        sender_id: None,\n                        content: MessageContent::Text(\"Must be blue\".to_owned()),\n                        timestamp,\n                    },\n                    Message {\n                        author: Author::Assistant,\n                        sender_id: None,\n                        content: MessageContent::Text(\"Whatever\".to_owned()),\n                        timestamp,\n                    },\n                    Message {\n                        author: Author::User,\n                        sender_id: None,\n                        content: MessageContent::Text(\"Again\".to_owned()),\n                        timestamp,\n                    },\n                    Message {\n                        author: Author::User,\n                        sender_id: None,\n                        content: MessageContent::Text(\"again\".to_owned()),\n                        timestamp,\n                    },\n                ],\n                expected_prompt: \"Generate a picture of a grizzly bear\\nOther criteria:\\n- Must be blue\",\n            },\n        ];\n\n        for test_case in test_cases {\n            let actual_prompt = build(test_case.original_prompt, test_case.messages);\n\n            assert_eq!(actual_prompt, test_case.expected_prompt);\n        }\n    }\n}\n"
  },
  {
    "path": "src/controller/join/mod.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::entity::RoomConfigContext;\nuse crate::{Bot, strings};\n\npub async fn handle(\n    bot: &Bot,\n    room: &mxlink::matrix_sdk::Room,\n    room_config_context: &RoomConfigContext,\n) -> anyhow::Result<()> {\n    if !bot.post_join_self_introduction_enabled() {\n        tracing::debug!(\n            \"Post-join self-introduction is disabled - not sending introduction message\"\n        );\n\n        return Ok(());\n    }\n\n    let agent_manager = bot.agent_manager();\n\n    bot.messaging()\n        .send_text_markdown_no_fail(\n            room,\n            strings::introduction::create_on_join_introduction(\n                bot.name(),\n                bot.command_prefix(),\n                agent_manager,\n                room_config_context,\n            )\n            .await,\n            MessageResponseType::InRoom,\n        )\n        .await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/mod.rs",
    "content": "mod controller_type;\n\npub mod access;\npub mod agent;\npub mod cfg;\npub mod chat_completion;\nmod determination;\nmod dispatching;\npub mod help;\npub mod image;\npub mod join;\npub mod provider;\npub mod reaction;\npub mod usage;\nmod utils;\n\npub use controller_type::ControllerType;\npub use determination::determine_controller;\npub use dispatching::dispatch_controller;\n"
  },
  {
    "path": "src/controller/provider/mod.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{Bot, agent::AgentProvider, entity::MessageContext, strings};\n\nuse super::ControllerType;\n\npub fn determine_controller(_text: &str) -> ControllerType {\n    ControllerType::ProviderHelp\n}\n\npub async fn handle_help(message_context: &MessageContext, bot: &Bot) -> anyhow::Result<()> {\n    let can_create_global_agents = message_context.sender_can_manage_global_config();\n    let can_create_room_local_agents = message_context.sender_can_manage_room_local_agents()?;\n\n    let mut message = String::new();\n    message.push_str(&format!(\"## {}\", strings::help::provider::heading()));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::help::provider::intro());\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::provider::providers_list_intro());\n    message.push_str(\"\\n\\n\");\n\n    // How to choose\n    message.push_str(&format!(\n        \"### {}\",\n        strings::provider::help_how_to_choose_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::provider::help_how_to_choose_description(\n        bot.command_prefix(),\n    ));\n    message.push_str(\"\\n\\n\");\n\n    // How to use\n    message.push_str(&format!(\n        \"### {}\",\n        strings::provider::help_how_to_use_heading()\n    ));\n    message.push_str(\"\\n\\n\");\n    message.push_str(&strings::provider::help_how_to_use_description(\n        bot.command_prefix(),\n    ));\n    message.push_str(\"\\n\\n\");\n\n    for provider in AgentProvider::choices() {\n        let provider_info = provider.info();\n\n        message.push_str(&format!(\n            \"### {}\",\n            strings::provider::help_provider_heading(\n                provider_info.name,\n                &provider_info.homepage_url.as_ref().map(|s| s.to_string())\n            )\n        ));\n\n        message.push_str(\"\\n\\n\");\n\n        message.push_str(&strings::provider::help_provider_details(\n            provider.to_static_str(),\n            &provider_info,\n        ));\n\n        // We always show a \"Quick start\" section (even to unprivileged users),\n        // because we're talking about it in a previous message.\n        message.push_str(\"- 🗲 Quick start:\");\n        if can_create_room_local_agents {\n            message.push_str(&format!(\n                \"\\n\\t- create a room-local agent: `{command_prefix} agent create-room-local {provider_id} my-{provider_id}-agent`\",\n                command_prefix = bot.command_prefix(),\n                provider_id = provider.to_static_str(),\n            ));\n        }\n        if can_create_global_agents {\n            message.push_str(&format!(\n                \"\\n\\t- create a global agent: `{command_prefix} agent create-global {provider_id} my-{provider_id}-agent`\",\n                command_prefix = bot.command_prefix(),\n                provider_id = provider.to_static_str(),\n            ));\n        }\n        if !can_create_room_local_agents && !can_create_global_agents {\n            message.push_str(\" ask an administrator to create an agent for you (you lack permissions to do so yourself)\");\n        }\n\n        message.push_str(\"\\n\\n\");\n    }\n\n    bot.messaging()\n        .send_text_markdown_no_fail(\n            message_context.room(),\n            message,\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/reaction/mod.rs",
    "content": "use std::ops::Deref;\n\nuse mxlink::MatrixLink;\n\nuse crate::{\n    Bot,\n    agent::AgentPurpose,\n    entity::{MessageContext, MessagePayload},\n};\n\nmod text_to_speech;\n\npub async fn handle(\n    bot: &Bot,\n    matrix_link: MatrixLink,\n    message_context: &MessageContext,\n) -> anyhow::Result<()> {\n    match &message_context.payload() {\n        MessagePayload::Reaction {\n            key,\n            reacted_to_event_payload,\n            reacted_to_event_id,\n            reacted_to_event_sender_id,\n        } => {\n            if key == AgentPurpose::TextToSpeech.emoji() {\n                if let MessagePayload::Text(text_content) = reacted_to_event_payload.deref() {\n                    return text_to_speech::handle(\n                        bot,\n                        matrix_link,\n                        message_context,\n                        reacted_to_event_id,\n                        reacted_to_event_sender_id,\n                        text_content,\n                    )\n                    .await;\n                }\n\n                tracing::debug!(\"Ignoring text-to-speech reaction to non-text message\");\n                return Ok(());\n            }\n\n            tracing::debug!(\"Ignoring unknown reaction\");\n\n            Ok(())\n        }\n        _ => Err(anyhow::anyhow!(\n            \"Reaction controller called with a non-reaction message\"\n        )),\n    }\n}\n"
  },
  {
    "path": "src/controller/reaction/text_to_speech.rs",
    "content": "use mxlink::{MatrixLink, MessageResponseType};\n\nuse mxlink::matrix_sdk::ruma::{\n    OwnedEventId, OwnedUserId, events::room::message::TextMessageEventContent,\n};\n\nuse crate::entity::roomconfig::{\n    TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,\n};\n\nuse crate::{\n    Bot, agent::AgentPurpose,\n    controller::utils::agent::get_effective_agent_for_purpose_or_complain, entity::MessageContext,\n};\n\npub(super) async fn handle(\n    bot: &Bot,\n    matrix_link: MatrixLink,\n    message_context: &MessageContext,\n    reacted_to_event_id: &OwnedEventId,\n    reacted_to_event_sender_id: &OwnedUserId,\n    text_content: &TextMessageEventContent,\n) -> anyhow::Result<()> {\n    // If we're in a thread, we're likely dealing with a bot message, so we should start in the thread.\n    // Otherwise, we're likely operating in \"TTS user messages\" mode, so we should reply to the reacted-to message and avoid threads.\n    let response_type = if message_context.thread_info().is_thread_root_only() {\n        MessageResponseType::Reply(reacted_to_event_id.clone())\n    } else {\n        MessageResponseType::InThread(message_context.thread_info().clone())\n    };\n\n    if !is_allowed_to_tts_for_event(\n        message_context,\n        reacted_to_event_sender_id,\n        matrix_link.user_id(),\n    ) {\n        tracing::debug!(\n            \"Ignoring request for on-demand text-to-speech (via reaction) due to room configuration\"\n        );\n        return Ok(());\n    }\n\n    let speech_agent = get_effective_agent_for_purpose_or_complain(\n        bot,\n        message_context,\n        AgentPurpose::TextToSpeech,\n        response_type.clone(),\n        true,\n    )\n    .await;\n\n    let Some(speech_agent) = speech_agent else {\n        // We've already complained about this in get_effective_agent_or_complain\n        return Ok(());\n    };\n\n    let _typing_notice_guard = bot.start_typing_notice(message_context.room()).await;\n\n    crate::controller::utils::text_to_speech::generate_and_send_tts_for_message(\n        bot,\n        matrix_link,\n        message_context,\n        response_type,\n        &speech_agent,\n        reacted_to_event_id,\n        &text_content.body,\n    )\n    .await;\n\n    Ok(())\n}\n\nfn is_allowed_to_tts_for_event(\n    message_context: &MessageContext,\n    sender_id: &OwnedUserId,\n    bot_user_id: &OwnedUserId,\n) -> bool {\n    // Whether we're allowed depends on who the original message sender is (the bot or some user).\n    //\n    // The user may be an allowed bot user or someone else.\n    // Regardless, we've been invoked by an allowed user, so if the user wants TTS for a foreign message, we should allow it.\n\n    if *sender_id == *bot_user_id {\n        match message_context\n            .room_config_context()\n            .text_to_speech_bot_messages_flow_type()\n        {\n            TextToSpeechBotMessagesFlowType::Never => false,\n            TextToSpeechBotMessagesFlowType::OnDemandAlways => true,\n            TextToSpeechBotMessagesFlowType::OnDemandForVoice => true,\n            TextToSpeechBotMessagesFlowType::OnlyForVoice => true,\n            TextToSpeechBotMessagesFlowType::Always => true,\n        }\n    } else {\n        match message_context\n            .room_config_context()\n            .text_to_speech_user_messages_flow_type()\n        {\n            TextToSpeechUserMessagesFlowType::Never => false,\n            TextToSpeechUserMessagesFlowType::OnDemand => true,\n            TextToSpeechUserMessagesFlowType::Always => true,\n        }\n    }\n}\n"
  },
  {
    "path": "src/controller/usage/mod.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{Bot, entity::MessageContext, strings};\n\nuse super::ControllerType;\n\npub fn determine_controller(_text: &str) -> ControllerType {\n    ControllerType::UsageHelp\n}\n\npub async fn handle_help(message_context: &MessageContext, bot: &Bot) -> anyhow::Result<()> {\n    bot.messaging()\n        .send_text_markdown_no_fail(\n            message_context.room(),\n            strings::usage::intro(bot.command_prefix()),\n            MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),\n        )\n        .await;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/controller/utils/agent.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{\n    Bot,\n    agent::{\n        AgentInstance, AgentPurpose,\n        utils::{AgentForPurposeDeterminationError, get_effective_agent_for_purpose},\n    },\n    entity::MessageContext,\n    strings,\n};\n\npub async fn get_effective_agent_for_purpose_or_complain(\n    bot: &Bot,\n    message_context: &MessageContext,\n    agent_purpose: AgentPurpose,\n    response_type: MessageResponseType,\n    complain_when_purpose_unsupported: bool,\n) -> Option<AgentInstance> {\n    let agent_info = get_effective_agent_for_purpose(\n        bot.agent_manager(),\n        message_context.room_config_context(),\n        agent_purpose,\n    )\n    .await;\n\n    match agent_info {\n        Ok(agent_info) => Some(agent_info.instance),\n        Err(err) => {\n            let error_message = match err {\n                AgentForPurposeDeterminationError::Unknown(err_string) => Some(err_string),\n                AgentForPurposeDeterminationError::NoneConfigured => None,\n                AgentForPurposeDeterminationError::ConfiguredButMissing(agent_identifier) => Some(\n                    strings::room_config::configures_agent_for_purpose_but_does_not_exist(\n                        &agent_identifier,\n                        agent_purpose,\n                    ),\n                ),\n                AgentForPurposeDeterminationError::ConfiguredButLacksSupport(agent_identifier) => {\n                    if complain_when_purpose_unsupported {\n                        Some(strings::room_config::configures_agent_for_purpose_but_agent_does_not_support_it(\n                            &agent_identifier,\n                            agent_purpose,\n                        ))\n                    } else {\n                        None\n                    }\n                }\n            };\n\n            if let Some(error_message) = error_message {\n                bot.messaging()\n                    .send_error_markdown_no_fail(\n                        message_context.room(),\n                        &error_message,\n                        response_type,\n                    )\n                    .await;\n            };\n\n            None\n        }\n    }\n}\n"
  },
  {
    "path": "src/controller/utils/mod.rs",
    "content": "use mxlink::MessageResponseType;\n\nuse crate::{\n    Bot,\n    entity::{MessageContext, MessagePayload},\n};\n\npub mod agent;\npub mod text_to_speech;\n\npub async fn get_text_body_or_complain<'a>(\n    bot: &Bot,\n    message_context: &'a MessageContext,\n) -> Option<&'a str> {\n    match &message_context.payload() {\n        MessagePayload::Text(text_message_content) => Some(&text_message_content.body),\n        _ => {\n            bot.messaging()\n                .send_text_markdown_no_fail(\n                    message_context.room(),\n                    \"This command only works with text messages.\".to_owned(),\n                    MessageResponseType::InThread(message_context.thread_info().clone()),\n                )\n                .await;\n\n            None\n        }\n    }\n}\n"
  },
  {
    "path": "src/controller/utils/text_to_speech.rs",
    "content": "use mxlink::matrix_sdk::ruma::OwnedEventId;\nuse mxlink::{MatrixLink, MessageResponseType};\n\nuse tracing::Instrument;\n\nuse crate::utils::mime::get_file_extension;\nuse crate::{\n    Bot,\n    agent::{AgentInstance, AgentPurpose, ControllerTrait, provider::TextToSpeechParams},\n    entity::MessageContext,\n    strings,\n};\n\npub async fn generate_and_send_tts_for_message(\n    bot: &Bot,\n    matrix_link: MatrixLink,\n    message_context: &MessageContext,\n    response_type: MessageResponseType,\n    speech_agent: &AgentInstance,\n    text_message_event_id: &OwnedEventId,\n    text_content: &str,\n) -> bool {\n    let reaction_event_response = bot\n        .reacting()\n        .react_no_fail(\n            message_context.room(),\n            text_message_event_id.clone(),\n            strings::PROGRESS_INDICATOR_EMOJI.to_owned(),\n        )\n        .await;\n\n    let result = do_generate_and_send_tts_for_message(\n        bot,\n        matrix_link,\n        message_context,\n        response_type,\n        speech_agent,\n        text_content,\n    )\n    .await;\n\n    if let Some(reaction_event_response) = reaction_event_response {\n        let redaction_reason = if result {\n            strings::text_to_speech::redaction_reason_done()\n        } else {\n            strings::text_to_speech::redaction_reason_failed()\n        };\n\n        bot.messaging()\n            .redact_event_no_fail(\n                message_context.room(),\n                reaction_event_response.event_id,\n                Some(redaction_reason.to_owned()),\n            )\n            .await;\n    }\n\n    result\n}\n\nasync fn do_generate_and_send_tts_for_message(\n    bot: &Bot,\n    matrix_link: MatrixLink,\n    message_context: &MessageContext,\n    response_type: MessageResponseType,\n    speech_agent: &AgentInstance,\n    text_content: &str,\n) -> bool {\n    let params = TextToSpeechParams {\n        speed_override: message_context\n            .room_config_context()\n            .text_to_speech_speed_override(),\n\n        voice_override: message_context\n            .room_config_context()\n            .text_to_speech_voice_override(),\n    };\n\n    let text_content = if let Some(text_content) = text_content.strip_prefix(bot.command_prefix()) {\n        text_content.trim()\n    } else {\n        text_content\n    };\n\n    let span = tracing::debug_span!(\n        \"text_to_speech_generation\",\n        agent_id = speech_agent.identifier().as_string()\n    );\n\n    let text_to_speech_result = speech_agent\n        .controller()\n        .text_to_speech(text_content, params)\n        .instrument(span)\n        .await;\n\n    let text_to_speech_result = match text_to_speech_result {\n        Ok(text_to_speech_result) => text_to_speech_result,\n        Err(err) => {\n            tracing::warn!(\n                \"Error in room {} while trying to generate TTS via agent {}: {:?}\",\n                message_context.room_id(),\n                speech_agent.identifier(),\n                err,\n            );\n\n            bot.messaging()\n                .send_error_markdown_no_fail(\n                    message_context.room(),\n                    &strings::agent::error_while_serving_purpose(\n                        speech_agent.identifier(),\n                        &AgentPurpose::SpeechToText,\n                        &err,\n                    ),\n                    response_type,\n                )\n                .await;\n\n            return false;\n        }\n    };\n\n    let attachment_body_text = format!(\n        \"generated-speech.{}\",\n        get_file_extension(&text_to_speech_result.mime_type)\n    );\n\n    let event_content = matrix_link\n        .media()\n        .upload_and_prepare_event_content(\n            message_context.room(),\n            &text_to_speech_result.mime_type,\n            text_to_speech_result.bytes,\n            &attachment_body_text,\n        )\n        .await;\n\n    let mut event_content = match event_content {\n        Ok(event_content) => event_content,\n        Err(err) => {\n            tracing::error!(\n                ?err,\n                \"Error in room {} while trying to upload TTS via agent {}\",\n                message_context.room_id(),\n                speech_agent.identifier(),\n            );\n\n            return false;\n        }\n    };\n\n    let result = matrix_link\n        .messaging()\n        .send_event(\n            message_context.room(),\n            &mut event_content,\n            response_type.clone(),\n        )\n        .await;\n\n    let Err(err) = result else {\n        return true;\n    };\n\n    tracing::error!(\n        ?err,\n        \"Error in room {} while trying to send TTS payload\",\n        message_context.room_id(),\n    );\n\n    false\n}\n"
  },
  {
    "path": "src/conversation/llm/entity.rs",
    "content": "use chrono::{DateTime, Utc};\nuse mxlink::matrix_sdk::ruma::OwnedUserId;\nuse mxlink::matrix_sdk::ruma::events::room::message::{\n    FileMessageEventContent, ImageMessageEventContent,\n};\nuse mxlink::mime::Mime;\n\nuse crate::agent::provider::ImageSource;\n\n#[derive(Debug, Clone, PartialEq)]\npub enum Author {\n    Prompt,\n    Assistant,\n    User,\n}\n\n#[derive(Debug, Clone)]\npub struct Message {\n    pub author: Author,\n    pub sender_id: Option<OwnedUserId>,\n    pub timestamp: DateTime<Utc>,\n    pub content: MessageContent,\n}\n\n#[derive(Debug, Clone)]\npub struct ImageDetails {\n    pub event_content: ImageMessageEventContent,\n    pub mime: Mime,\n    pub data: Vec<u8>,\n}\n\nimpl ImageDetails {\n    pub fn new(event_content: ImageMessageEventContent, mime: Mime, data: Vec<u8>) -> Self {\n        Self {\n            event_content,\n            mime,\n            data,\n        }\n    }\n\n    pub fn filename(&self) -> String {\n        self.event_content\n            .filename\n            .clone()\n            .unwrap_or(self.event_content.body.clone())\n    }\n}\n\nimpl From<ImageDetails> for ImageSource {\n    fn from(value: ImageDetails) -> Self {\n        ImageSource::new(value.filename(), value.data.clone(), value.mime.clone())\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct FileDetails {\n    pub event_content: FileMessageEventContent,\n    pub mime: Mime,\n    pub data: Vec<u8>,\n}\n\nimpl FileDetails {\n    pub fn new(event_content: FileMessageEventContent, mime: Mime, data: Vec<u8>) -> Self {\n        Self {\n            event_content,\n            mime,\n            data,\n        }\n    }\n\n    pub fn filename(&self) -> String {\n        self.event_content\n            .filename\n            .clone()\n            .unwrap_or(self.event_content.body.clone())\n    }\n}\n\n#[derive(Debug, Clone)]\npub enum MessageContent {\n    Text(String),\n    Image(ImageDetails),\n    File(FileDetails),\n}\n\nimpl PartialEq for MessageContent {\n    fn eq(&self, other: &Self) -> bool {\n        match (self, other) {\n            (MessageContent::Text(a), MessageContent::Text(b)) => a == b,\n            (MessageContent::Image(a), MessageContent::Image(b)) => {\n                // We can probably do better than this by inspecting `.event_conten1t.source`, but for now this is good enough.\n                a.filename() == b.filename()\n            }\n            (MessageContent::File(a), MessageContent::File(b)) => a.filename() == b.filename(),\n            _ => false,\n        }\n    }\n}\n#[derive(Debug)]\npub struct Conversation {\n    pub messages: Vec<Message>,\n}\n\nimpl Conversation {\n    /// Combine consecutive messages by the same author into a single message.\n    ///\n    /// Certain models (like Anthropic) cannot tolerate consecutive messages by the same author,\n    /// so combining them helps avoid issues.\n    ///\n    /// When multiple text messages by the same author are merged, the resulting message keeps a\n    /// `sender_id` only if all merged messages came from the same sender. Mixed-sender merges are\n    /// possible for user turns in multi-user rooms, so `sender_id` is cleared in that case to\n    /// avoid incorrectly attributing the whole merged turn to the first sender.\n    /// See: https://github.com/etkecc/baibot/issues/13\n    pub fn combine_consecutive_messages(&self) -> Conversation {\n        // We'll likely get fewer messages, but let's reserve the maximum we expect.\n        let mut new_messages = Vec::with_capacity(self.messages.len());\n        let mut last_seen_text_from_author: Option<Author> = None;\n\n        for message in &self.messages {\n            let MessageContent::Text(message_text_content) = &message.content else {\n                last_seen_text_from_author = None;\n                new_messages.push(message.clone());\n                continue;\n            };\n\n            let Some(last_seen_author_clone) = last_seen_text_from_author.clone() else {\n                last_seen_text_from_author = Some(message.author.clone());\n                new_messages.push(message.clone());\n                continue;\n            };\n\n            if message.author != last_seen_author_clone {\n                last_seen_text_from_author = Some(message.author.clone());\n                new_messages.push(message.clone());\n                continue;\n            }\n\n            let last_message = new_messages.last_mut().unwrap();\n            if let MessageContent::Text(ref mut text) = last_message.content {\n                text.push('\\n');\n                text.push_str(message_text_content);\n            }\n\n            if last_message.sender_id != message.sender_id {\n                last_message.sender_id = None;\n            }\n        }\n\n        Conversation {\n            messages: new_messages,\n        }\n    }\n\n    pub fn start_time(&self) -> Option<DateTime<Utc>> {\n        self.messages.first().map(|message| message.timestamp)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use chrono::{TimeZone, Utc};\n    use mxlink::matrix_sdk::ruma::{OwnedMxcUri, OwnedUserId};\n    use mxlink::mime;\n\n    #[test]\n    fn combine_consecutive_messages() {\n        let timestamp_1 = Utc.with_ymd_and_hms(2024, 9, 20, 18, 34, 15).unwrap();\n\n        let timestamp_2 = Utc.with_ymd_and_hms(2024, 9, 21, 18, 34, 16).unwrap();\n\n        let timestamp_3 = Utc.with_ymd_and_hms(2024, 9, 22, 18, 34, 17).unwrap();\n\n        let timestamp_4 = Utc.with_ymd_and_hms(2024, 9, 23, 18, 34, 18).unwrap();\n\n        let image_event_content = ImageMessageEventContent::plain(\n            \"image.png\".to_string(),\n            OwnedMxcUri::from(\"mxc://example.com/1234567890\"),\n        );\n\n        let conversation = Conversation {\n            messages: vec![\n                // User's turn\n                Message {\n                    author: Author::User,\n                    sender_id: None,\n                    content: MessageContent::Text(\"Hello\".to_string()),\n                    timestamp: timestamp_1,\n                },\n                Message {\n                    author: Author::User,\n                    sender_id: None,\n                    content: MessageContent::Text(\"How are you?\".to_string()),\n                    timestamp: timestamp_2,\n                },\n                Message {\n                    author: Author::User,\n                    sender_id: None,\n                    content: MessageContent::Text(\"I'm OK, btw.\".to_string()),\n                    timestamp: timestamp_3,\n                },\n                Message {\n                    author: Author::User,\n                    sender_id: None,\n                    content: MessageContent::Image(ImageDetails::new(\n                        image_event_content.clone(),\n                        mime::IMAGE_PNG,\n                        vec![],\n                    )),\n                    timestamp: timestamp_4,\n                },\n                Message {\n                    author: Author::User,\n                    sender_id: None,\n                    content: MessageContent::Text(\"Above is an image.\".to_string()),\n                    timestamp: timestamp_4,\n                },\n                Message {\n                    author: Author::User,\n                    sender_id: None,\n                    content: MessageContent::Text(\"Would you take a look at it?\".to_string()),\n                    timestamp: timestamp_4,\n                },\n                // Assistant's turn\n                Message {\n                    author: Author::Assistant,\n                    sender_id: None,\n                    content: MessageContent::Text(\"Hi there!\".to_string()),\n                    timestamp: timestamp_2,\n                },\n                Message {\n                    author: Author::Assistant,\n                    sender_id: None,\n                    content: MessageContent::Text(\"I'm doing well, thank you.\".to_string()),\n                    timestamp: timestamp_3,\n                },\n                // User's turn\n                Message {\n                    author: Author::User,\n                    sender_id: None,\n                    content: MessageContent::Text(\"That's great!\".to_string()),\n                    timestamp: timestamp_3,\n                },\n            ],\n        };\n\n        let conversation = conversation.combine_consecutive_messages();\n\n        assert_eq!(conversation.messages.len(), 5);\n\n        assert_eq!(conversation.messages[0].author, Author::User);\n        assert_eq!(\n            conversation.messages[0].content,\n            MessageContent::Text(\"Hello\\nHow are you?\\nI'm OK, btw.\".to_string())\n        );\n        assert_eq!(conversation.messages[0].timestamp, timestamp_1);\n\n        assert_eq!(conversation.messages[1].author, Author::User);\n        assert_eq!(\n            conversation.messages[1].content,\n            MessageContent::Image(ImageDetails::new(\n                image_event_content.clone(),\n                mime::IMAGE_PNG,\n                vec![],\n            ))\n        );\n\n        assert_eq!(conversation.messages[2].author, Author::User);\n        assert_eq!(\n            conversation.messages[2].content,\n            MessageContent::Text(\"Above is an image.\\nWould you take a look at it?\".to_string())\n        );\n        assert_eq!(conversation.messages[2].timestamp, timestamp_4);\n\n        assert_eq!(conversation.messages[3].author, Author::Assistant);\n        assert_eq!(\n            conversation.messages[3].content,\n            MessageContent::Text(\"Hi there!\\nI'm doing well, thank you.\".to_string())\n        );\n        assert_eq!(conversation.messages[3].timestamp, timestamp_2);\n\n        assert_eq!(conversation.messages[4].author, Author::User);\n        assert_eq!(\n            conversation.messages[4].content,\n            MessageContent::Text(\"That's great!\".to_string())\n        );\n        assert_eq!(conversation.messages[4].timestamp, timestamp_3);\n    }\n\n    #[test]\n    fn combine_consecutive_messages_clears_sender_id_for_mixed_sender_turns() {\n        let timestamp_1 = Utc.with_ymd_and_hms(2024, 9, 20, 18, 34, 15).unwrap();\n        let timestamp_2 = Utc.with_ymd_and_hms(2024, 9, 20, 18, 34, 16).unwrap();\n        let sender_1 = OwnedUserId::try_from(\"@alice:example.com\").unwrap();\n        let sender_2 = OwnedUserId::try_from(\"@bob:example.com\").unwrap();\n\n        let conversation = Conversation {\n            messages: vec![\n                Message {\n                    author: Author::User,\n                    sender_id: Some(sender_1),\n                    content: MessageContent::Text(\"Hello\".to_string()),\n                    timestamp: timestamp_1,\n                },\n                Message {\n                    author: Author::User,\n                    sender_id: Some(sender_2),\n                    content: MessageContent::Text(\"Hi there\".to_string()),\n                    timestamp: timestamp_2,\n                },\n            ],\n        };\n\n        let conversation = conversation.combine_consecutive_messages();\n\n        assert_eq!(conversation.messages.len(), 1);\n        assert_eq!(conversation.messages[0].sender_id, None);\n        assert_eq!(\n            conversation.messages[0].content,\n            MessageContent::Text(\"Hello\\nHi there\".to_string())\n        );\n        assert_eq!(conversation.messages[0].timestamp, timestamp_1);\n    }\n}\n"
  },
  {
    "path": "src/conversation/llm/mod.rs",
    "content": "mod entity;\nmod tokenization;\nmod utils;\n\n#[cfg(test)]\nmod tests;\n\npub use entity::*;\npub use tokenization::shorten_messages_list_to_context_size;\npub use utils::*;\n"
  },
  {
    "path": "src/conversation/llm/tests.rs",
    "content": "use mxlink::matrix_sdk::ruma::OwnedUserId;\n\nuse crate::utils::status::create_error_message_text;\nuse crate::utils::text_to_speech::create_transcribed_message_text;\n\nuse super::*;\n\n#[test]\nfn test_messages_by_the_bot_are_identified_correctly() {\n    let bot_user_id =\n        OwnedUserId::try_from(\"@bot:example.com\").expect(\"Failed to parse bot user ID\");\n\n    let matrix_message = super::super::matrix::MatrixMessage {\n        sender_id: bot_user_id.to_owned(),\n        content: super::super::matrix::MatrixMessageContent::Text(\"Hello!\".to_owned()),\n        mentioned_users: vec![],\n        timestamp: chrono::Utc::now(),\n    };\n\n    let llm_message = convert_matrix_message_to_llm_message(&matrix_message, &bot_user_id).unwrap();\n\n    assert_eq!(llm_message.author, Author::Assistant);\n    assert_eq!(llm_message.sender_id, Some(bot_user_id.clone()));\n    assert_eq!(\n        llm_message.content,\n        MessageContent::Text(\"Hello!\".to_string())\n    );\n}\n\n#[test]\nfn test_notice_messages_by_bot_with_speech_to_text_prefix_are_cleaned_up_and_considered_sent_by_user()\n {\n    let bot_user_id =\n        OwnedUserId::try_from(\"@bot:example.com\").expect(\"Failed to parse bot user ID\");\n\n    let source_message_text = \"Hello!\";\n    let message_text = create_transcribed_message_text(source_message_text);\n\n    assert_ne!(source_message_text, message_text);\n\n    let matrix_message = super::super::matrix::MatrixMessage {\n        sender_id: bot_user_id.to_owned(),\n        content: super::super::matrix::MatrixMessageContent::Notice(message_text),\n        mentioned_users: vec![],\n        timestamp: chrono::Utc::now(),\n    };\n\n    let llm_message = convert_matrix_message_to_llm_message(&matrix_message, &bot_user_id).unwrap();\n\n    assert_eq!(llm_message.author, Author::User);\n    assert_eq!(llm_message.sender_id, None);\n    assert_eq!(\n        llm_message.content,\n        MessageContent::Text(source_message_text.to_string())\n    );\n}\n\n#[test]\nfn test_notice_error_messages_by_bot_are_ignored() {\n    let bot_user_id =\n        OwnedUserId::try_from(\"@bot:example.com\").expect(\"Failed to parse bot user ID\");\n\n    let source_message_text = \"Some error happened\";\n    let message_text = create_error_message_text(source_message_text);\n\n    assert_ne!(source_message_text, message_text);\n\n    let matrix_message = super::super::matrix::MatrixMessage {\n        sender_id: bot_user_id.to_owned(),\n        content: super::super::matrix::MatrixMessageContent::Notice(message_text),\n        mentioned_users: vec![],\n        timestamp: chrono::Utc::now(),\n    };\n\n    let llm_message = convert_matrix_message_to_llm_message(&matrix_message, &bot_user_id);\n\n    assert!(llm_message.is_none());\n}\n\n#[test]\nfn test_user_messages_preserve_sender_id() {\n    let bot_user_id =\n        OwnedUserId::try_from(\"@bot:example.com\").expect(\"Failed to parse bot user ID\");\n\n    let user_id = OwnedUserId::try_from(\"@alice:example.com\").expect(\"Failed to parse user ID\");\n\n    let matrix_message = super::super::matrix::MatrixMessage {\n        sender_id: user_id.clone(),\n        content: super::super::matrix::MatrixMessageContent::Text(\"Hello!\".to_owned()),\n        mentioned_users: vec![],\n        timestamp: chrono::Utc::now(),\n    };\n\n    let llm_message = convert_matrix_message_to_llm_message(&matrix_message, &bot_user_id).unwrap();\n\n    assert_eq!(llm_message.author, Author::User);\n    assert_eq!(llm_message.sender_id, Some(user_id));\n    assert_eq!(\n        llm_message.content,\n        MessageContent::Text(\"Hello!\".to_string())\n    );\n}\n\n#[test]\nfn test_other_notice_messages_by_the_bot_are_ignored() {\n    // Also see `test_notice_error_messages_by_bot_are_ignored()`.\n    // That one passes accidentally, because we ignore all messages by the bot that are notices\n    // (except for speech-to-text-created transcriptions - see `test_notice_messages_by_bot_with_speech_to_text_prefix_are_cleaned_up_and_considered_sent_by_user()`).\n    // This test is to make sure that we don't accidentally start accepting other notice messages.\n\n    let bot_user_id =\n        OwnedUserId::try_from(\"@bot:example.com\").expect(\"Failed to parse bot user ID\");\n\n    let message_text = \"Something something\";\n\n    let matrix_message = super::super::matrix::MatrixMessage {\n        sender_id: bot_user_id.to_owned(),\n        content: super::super::matrix::MatrixMessageContent::Notice(message_text.to_owned()),\n        mentioned_users: vec![],\n        timestamp: chrono::Utc::now(),\n    };\n\n    let llm_message = convert_matrix_message_to_llm_message(&matrix_message, &bot_user_id);\n\n    assert!(llm_message.is_none());\n}\n"
  },
  {
    "path": "src/conversation/llm/tokenization.rs",
    "content": "use tiktoken_rs::CoreBPE;\nuse tiktoken_rs::bpe_for_tokenizer;\nuse tiktoken_rs::tokenizer;\n\nuse super::{Author, Message, MessageContent};\n\nfn get_bpe_for_model(model: &str) -> &'static CoreBPE {\n    let tokenizer = tokenizer::get_tokenizer(model)\n        .or_else(|| tokenizer::get_tokenizer(\"gpt-4\"))\n        .unwrap();\n\n    bpe_for_tokenizer(tokenizer).unwrap()\n}\n\npub fn shorten_messages_list_to_context_size(\n    model: &str,\n    prompt_message: &Option<Message>,\n    mut messages: Vec<Message>,\n    max_response_tokens: Option<u32>,\n    max_context_tokens: u32,\n) -> Vec<Message> {\n    // Loading the tokenization data is an expensive process, so\n    // se construct the BPE instance once and then use it for all messages.\n    let bpe = get_bpe_for_model(model);\n\n    // We want to retain the prompt in all cases, so we always count it first.\n    // We also always reserve enough tokens for the maximum response we expect.\n    let mut current_context_length: u32 = if let Some(prompt_message) = prompt_message {\n        calculate_token_size_for_message(bpe, model, prompt_message)\n            + max_response_tokens.unwrap_or(0)\n    } else {\n        0\n    };\n\n    messages.reverse();\n\n    let mut messages_to_keep: Vec<Message> = Vec::new();\n\n    for message in messages {\n        let tokens_for_message = calculate_token_size_for_message(bpe, model, &message);\n\n        if current_context_length + tokens_for_message > max_context_tokens {\n            break;\n        }\n\n        current_context_length += tokens_for_message;\n\n        messages_to_keep.push(message);\n    }\n\n    messages_to_keep.reverse();\n\n    messages_to_keep\n}\n\n/// Calculate the token size of a message for a given model, with a preloaded CoreBPE object.\n/// Related to `calculate_token_size_for_model_message`.\nfn calculate_token_size_for_message(bpe: &CoreBPE, model: &str, message: &Message) -> u32 {\n    let (tokens_per_message, tokens_per_name) = if model.starts_with(\"gpt-3.5\") {\n        (\n            4,  // every message follows <im_start>{role/name}\\n{content}<im_end>\\n\n            -1, // if there's a name, the role is omitted\n        )\n    } else {\n        (3, 1)\n    };\n\n    let role_length = match message.author {\n        Author::Assistant => bpe.encode_with_special_tokens(\"assistant\").len() as i32,\n        Author::User => bpe.encode_with_special_tokens(\"user\").len() as i32,\n        Author::Prompt => bpe.encode_with_special_tokens(\"system\").len() as i32,\n    };\n\n    let text_length = match &message.content {\n        MessageContent::Text(text) => bpe.encode_with_special_tokens(text).len() as i32,\n        MessageContent::Image(..) => 0,\n        MessageContent::File(..) => 0,\n    };\n\n    (text_length + role_length + tokens_per_message + tokens_per_name) as u32\n}\n\npub mod test {\n    #[test]\n    fn message_size_counting_works() {\n        let model = \"gpt-4\";\n\n        let bpe = super::get_bpe_for_model(model);\n\n        let message = super::Message {\n            author: super::Author::User,\n            sender_id: None,\n            content: super::MessageContent::Text(\"Hello there!\".to_string()),\n            timestamp: chrono::Utc::now(),\n        };\n\n        let tokens = super::calculate_token_size_for_message(bpe, model, &message);\n\n        assert_eq!(8, tokens);\n    }\n\n    #[test]\n    fn shortening_works_with_english() {\n        let model = \"gpt-4\";\n\n        let bpe = super::get_bpe_for_model(model);\n\n        let max_response_tokens: Option<u32> = Some(5);\n\n        let prompt = super::Message {\n            author: super::Author::Prompt,\n            sender_id: None,\n            content: super::MessageContent::Text(\"You are a bot!\".to_string()),\n            timestamp: chrono::Utc::now(),\n        };\n        let prompt_length = 10;\n\n        assert_eq!(\n            prompt_length,\n            super::calculate_token_size_for_message(bpe, model, &prompt)\n        );\n\n        let mut conversation_messages = Vec::new();\n\n        let first = super::Message {\n            author: super::Author::User,\n            sender_id: None,\n            content: super::MessageContent::Text(\"Hello there!\".to_string()),\n            timestamp: chrono::Utc::now(),\n        };\n        let first_length = 8;\n\n        assert_eq!(\n            first_length,\n            super::calculate_token_size_for_message(bpe, model, &first)\n        );\n\n        conversation_messages.push(first);\n\n        let second = super::Message {\n            author: super::Author::Assistant,\n            sender_id: None,\n            content: super::MessageContent::Text(\"Hello!\".to_string()),\n            timestamp: chrono::Utc::now(),\n        };\n        let second_length = 7;\n\n        assert_eq!(\n            second_length,\n            super::calculate_token_size_for_message(bpe, model, &second)\n        );\n\n        conversation_messages.push(second);\n\n        let third = super::Message {\n            author: super::Author::User,\n            sender_id: None,\n            content: super::MessageContent::Text(\n                \"This is the 3rd message in this conversation. It shall be preserved.\".to_owned(),\n            ),\n            timestamp: chrono::Utc::now(),\n        };\n        let third_length = 21;\n\n        assert_eq!(\n            third_length,\n            super::calculate_token_size_for_message(bpe, model, &third)\n        );\n\n        conversation_messages.push(third.clone());\n\n        let forth = super::Message {\n            author: super::Author::Assistant,\n            sender_id: None,\n            content: super::MessageContent::Text(\n                \"This is yet another message that shall be preserved.\".to_owned(),\n            ),\n            timestamp: chrono::Utc::now(),\n        };\n        let forth_length = 15;\n\n        assert_eq!(\n            forth_length,\n            super::calculate_token_size_for_message(bpe, model, &forth)\n        );\n\n        conversation_messages.push(forth.clone());\n\n        assert_eq!(4, conversation_messages.len());\n\n        let new_conversation_messages = super::shorten_messages_list_to_context_size(\n            model,\n            &Some(prompt),\n            conversation_messages,\n            max_response_tokens,\n            prompt_length + max_response_tokens.unwrap_or(0) + forth_length + third_length,\n        );\n\n        assert_eq!(2, new_conversation_messages.len());\n\n        assert_eq!(\n            new_conversation_messages.first().unwrap().content,\n            third.content\n        );\n\n        assert_eq!(\n            new_conversation_messages.last().unwrap().content,\n            forth.content\n        );\n    }\n\n    #[test]\n    fn shortening_works_with_japanese() {\n        let model = \"gpt-4\";\n\n        let bpe = super::get_bpe_for_model(model);\n\n        let max_response_tokens: Option<u32> = Some(5);\n\n        let prompt = super::Message {\n            author: super::Author::User,\n            sender_id: None,\n            content: super::MessageContent::Text(\"あなたはボットです。\".to_string()),\n            timestamp: chrono::Utc::now(),\n        };\n        let prompt_length = 14;\n\n        assert_eq!(\n            prompt_length,\n            super::calculate_token_size_for_message(bpe, model, &prompt)\n        );\n\n        let mut conversation_messages = Vec::new();\n\n        let first = super::Message {\n            author: super::Author::User,\n            sender_id: None,\n            content: super::MessageContent::Text(\"こんにちは!\".to_string()),\n            timestamp: chrono::Utc::now(),\n        };\n        let first_length = 7;\n\n        assert_eq!(\n            first_length,\n            super::calculate_token_size_for_message(bpe, model, &first)\n        );\n\n        conversation_messages.push(first);\n\n        let second = super::Message {\n            author: super::Author::Assistant,\n            sender_id: None,\n            content: super::MessageContent::Text(\"こんにちは。今日は元気ですか。\".to_string()),\n            timestamp: chrono::Utc::now(),\n        };\n        let second_length = 15;\n\n        assert_eq!(\n            second_length,\n            super::calculate_token_size_for_message(bpe, model, &second)\n        );\n\n        conversation_messages.push(second);\n\n        let third = super::Message {\n            author: super::Author::User,\n            sender_id: None,\n            content: super::MessageContent::Text(\n                \"これは第3のメッセージなので、保存されます。\".to_string(),\n            ),\n            timestamp: chrono::Utc::now(),\n        };\n        let third_length = 22;\n\n        assert_eq!(\n            third_length,\n            super::calculate_token_size_for_message(bpe, model, &third)\n        );\n\n        conversation_messages.push(third.clone());\n\n        let forth = super::Message {\n            author: super::Author::Assistant,\n            sender_id: None,\n            content: super::MessageContent::Text(\n                \"これはもう一つの保存されますメッセージです。\".to_string(),\n            ),\n            timestamp: chrono::Utc::now(),\n        };\n        let forth_length = 21;\n\n        assert_eq!(\n            forth_length,\n            super::calculate_token_size_for_message(bpe, model, &forth)\n        );\n\n        conversation_messages.push(forth.clone());\n\n        assert_eq!(4, conversation_messages.len());\n\n        let new_conversation_messages = super::shorten_messages_list_to_context_size(\n            model,\n            &Some(prompt),\n            conversation_messages,\n            max_response_tokens,\n            prompt_length + max_response_tokens.unwrap_or(0) + forth_length + third_length,\n        );\n\n        assert_eq!(2, new_conversation_messages.len());\n\n        assert_eq!(\n            new_conversation_messages.first().unwrap().content,\n            third.content\n        );\n\n        assert_eq!(\n            new_conversation_messages.last().unwrap().content,\n            forth.content\n        );\n    }\n}\n"
  },
  {
    "path": "src/conversation/llm/utils.rs",
    "content": "use mxlink::matrix_sdk::ruma::OwnedUserId;\n\nuse super::entity::{Author, FileDetails, ImageDetails, Message, MessageContent};\nuse crate::conversation::matrix::{MatrixMessage, MatrixMessageContent};\nuse crate::utils::text_to_speech as text_to_speech_utils;\n\npub fn convert_matrix_message_to_llm_message(\n    matrix_message: &MatrixMessage,\n    bot_user_id: &OwnedUserId,\n) -> Option<Message> {\n    if matrix_message.sender_id == bot_user_id.as_str() {\n        return convert_bot_message(matrix_message);\n    }\n\n    convert_user_message(matrix_message)\n}\n\nfn convert_bot_message(matrix_message: &MatrixMessage) -> Option<Message> {\n    match &matrix_message.content {\n        MatrixMessageContent::Text(text) => convert_bot_text_message(\n            text,\n            &matrix_message.timestamp,\n            matrix_message.sender_id.clone(),\n        ),\n        MatrixMessageContent::Notice(text) => {\n            convert_bot_notice_message(text, &matrix_message.timestamp)\n        }\n        MatrixMessageContent::Image(image_content, mime_type, media_bytes) => Some(Message {\n            author: Author::Assistant,\n            sender_id: Some(matrix_message.sender_id.clone()),\n            content: MessageContent::Image(ImageDetails::new(\n                image_content.clone(),\n                mime_type.clone(),\n                media_bytes.clone(),\n            )),\n            timestamp: matrix_message.timestamp.to_owned(),\n        }),\n        MatrixMessageContent::File(file_content, mime_type, media_bytes) => Some(Message {\n            author: Author::Assistant,\n            sender_id: Some(matrix_message.sender_id.clone()),\n            content: MessageContent::File(FileDetails::new(\n                file_content.clone(),\n                mime_type.clone(),\n                media_bytes.clone(),\n            )),\n            timestamp: matrix_message.timestamp.to_owned(),\n        }),\n    }\n}\n\nfn convert_bot_text_message(\n    text: &str,\n    timestamp: &chrono::DateTime<chrono::Utc>,\n    sender_id: OwnedUserId,\n) -> Option<Message> {\n    Some(Message {\n        author: Author::Assistant,\n        sender_id: Some(sender_id),\n        content: MessageContent::Text(text.to_owned()),\n        timestamp: timestamp.to_owned(),\n    })\n}\n\nfn convert_bot_notice_message(\n    text: &str,\n    timestamp: &chrono::DateTime<chrono::Utc>,\n) -> Option<Message> {\n    // Notice messages sent by the bot are usually transcriptions of previous messages sent by the user.\n    // Such transcriptions are prefixed with an emoji and blockquoted.\n    // If we find a notice that doesn't match this pattern, we skip it.\n    //\n    // It should be noted that transcriptions are sometimes posted as regular notice (or even text) messages which do not include\n    // the `> 🦻` formatting. This function will not handle these properly.\n\n    if let Some(text) = text_to_speech_utils::parse_transcribed_message_text(text) {\n        // This is a transcription message. We remove the prefix and consider it as a message sent by the user.\n        // sender_id is None because the original speaker is unknown.\n        return Some(Message {\n            author: Author::User,\n            sender_id: None,\n            content: MessageContent::Text(text.to_owned()),\n            timestamp: timestamp.to_owned(),\n        });\n    }\n\n    None\n}\n\nfn convert_user_message(matrix_message: &MatrixMessage) -> Option<Message> {\n    match &matrix_message.content {\n        MatrixMessageContent::Text(text) => Some(Message {\n            author: Author::User,\n            sender_id: Some(matrix_message.sender_id.clone()),\n            content: MessageContent::Text(text.clone()),\n            timestamp: matrix_message.timestamp.to_owned(),\n        }),\n        MatrixMessageContent::Notice(text) => Some(Message {\n            author: Author::User,\n            sender_id: Some(matrix_message.sender_id.clone()),\n            content: MessageContent::Text(text.clone()),\n            timestamp: matrix_message.timestamp.to_owned(),\n        }),\n        MatrixMessageContent::Image(image_content, mime_type, media_bytes) => Some(Message {\n            author: Author::User,\n            sender_id: Some(matrix_message.sender_id.clone()),\n            content: MessageContent::Image(ImageDetails::new(\n                image_content.clone(),\n                mime_type.clone(),\n                media_bytes.clone(),\n            )),\n            timestamp: matrix_message.timestamp.to_owned(),\n        }),\n        MatrixMessageContent::File(file_content, mime_type, media_bytes) => Some(Message {\n            author: Author::User,\n            sender_id: Some(matrix_message.sender_id.clone()),\n            content: MessageContent::File(FileDetails::new(\n                file_content.clone(),\n                mime_type.clone(),\n                media_bytes.clone(),\n            )),\n            timestamp: matrix_message.timestamp.to_owned(),\n        }),\n    }\n}\n"
  },
  {
    "path": "src/conversation/matrix/entity.rs",
    "content": "use chrono::{DateTime, Utc};\nuse regex::Regex;\n\nuse mxlink::matrix_sdk::ruma::OwnedUserId;\nuse mxlink::matrix_sdk::ruma::events::room::message::{\n    FileMessageEventContent, ImageMessageEventContent,\n};\nuse mxlink::mime::Mime;\n\n#[derive(Clone)]\npub struct MatrixMessage {\n    pub sender_id: OwnedUserId,\n    pub content: MatrixMessageContent,\n    pub mentioned_users: Vec<OwnedUserId>,\n    pub timestamp: DateTime<Utc>,\n}\n\n#[derive(Clone)]\npub enum MatrixMessageContent {\n    Text(String),\n    Notice(String),\n    Image(ImageMessageEventContent, Mime, Vec<u8>),\n    File(FileMessageEventContent, Mime, Vec<u8>),\n}\n\n#[derive(Clone)]\npub struct MatrixMessageProcessingParams {\n    pub(crate) bot_user_id: OwnedUserId,\n\n    /// The prefixes that will be stripped when processing the messages in the context (thread or reply chain),\n    /// which are found to be mentioning the bot user (`bot_user_id`).\n    pub(crate) bot_user_prefixes_to_strip: Vec<String>,\n\n    /// The prefixes that will be stripped when processing the 1st message in the context (thread or reply chain).\n    pub(crate) first_message_prefixes_to_strip: Vec<String>,\n\n    /// A list of users whose messages are allowed.\n    /// If None, all messages are allowed.\n    /// If Some, only messages from the allowed users (and the bot itself, `bot_user_id`) are allowed.\n    pub(crate) allowed_users: Option<Vec<Regex>>,\n}\n\nimpl MatrixMessageProcessingParams {\n    pub fn new(bot_user_id: OwnedUserId, allowed_users: Option<Vec<Regex>>) -> Self {\n        Self {\n            bot_user_id,\n            bot_user_prefixes_to_strip: vec![],\n\n            first_message_prefixes_to_strip: vec![],\n\n            allowed_users,\n        }\n    }\n\n    pub fn with_bot_user_prefixes_to_strip(mut self, value: Vec<String>) -> Self {\n        self.bot_user_prefixes_to_strip = value;\n        self\n    }\n\n    pub fn with_first_message_prefixes_to_strip(mut self, value: Vec<String>) -> Self {\n        self.first_message_prefixes_to_strip = value;\n        self\n    }\n}\n"
  },
  {
    "path": "src/conversation/matrix/mod.rs",
    "content": "mod entity;\nmod room_display_name_fetcher;\nmod room_event_fetcher;\nmod utils;\n\npub(crate) use room_display_name_fetcher::RoomDisplayNameFetcher;\npub(crate) use room_event_fetcher::RoomEventFetcher;\n\npub(crate) use entity::{MatrixMessage, MatrixMessageContent, MatrixMessageProcessingParams};\n\npub(crate) use utils::*;\n"
  },
  {
    "path": "src/conversation/matrix/room_display_name_fetcher.rs",
    "content": "use mxlink::matrix_sdk::Room;\nuse mxlink::matrix_sdk::ruma::OwnedRoomId;\n\nuse mxlink::MatrixLink;\nuse quick_cache::sync::Cache;\n\npub struct RoomDisplayNameFetcher {\n    matrix_link: MatrixLink,\n    lru_cache: Option<Cache<OwnedRoomId, Option<String>>>,\n}\n\nimpl RoomDisplayNameFetcher {\n    pub fn new(matrix_link: MatrixLink, lru_cache_size: Option<usize>) -> Self {\n        let lru_cache = lru_cache_size.map(Cache::new);\n\n        Self {\n            matrix_link,\n            lru_cache,\n        }\n    }\n\n    #[tracing::instrument(skip_all, fields(room_id = room.room_id().as_str()))]\n    pub async fn own_display_name_in_room(\n        &self,\n        room: &Room,\n    ) -> mxlink::matrix_sdk::Result<Option<String>> {\n        let Some(lru_cache) = &self.lru_cache else {\n            return self.get_uncached_value(room).await;\n        };\n\n        let guard = lru_cache.get_value_or_guard_async(room.room_id()).await;\n\n        match guard {\n            Ok(value) => {\n                tracing::debug!(\"Returning existing cached display name..\");\n                return Ok(value);\n            }\n            Err(guard) => {\n                let value = self.get_uncached_value(room).await?;\n\n                let _ = guard.insert(value.clone());\n\n                tracing::debug!(\"Returning now-cached display name\");\n\n                return Ok(value);\n            }\n        }\n    }\n\n    async fn get_uncached_value(&self, room: &Room) -> mxlink::matrix_sdk::Result<Option<String>> {\n        self.matrix_link\n            .rooms()\n            .own_display_name_in_room(room)\n            .await\n    }\n}\n"
  },
  {
    "path": "src/conversation/matrix/room_event_fetcher.rs",
    "content": "use mxlink::matrix_sdk::Room;\nuse mxlink::matrix_sdk::deserialized_responses::TimelineEvent;\nuse mxlink::matrix_sdk::ruma::OwnedEventId;\n\nuse quick_cache::sync::Cache;\n\npub struct RoomEventFetcher {\n    lru_cache: Option<Cache<OwnedEventId, TimelineEvent>>,\n}\n\nimpl RoomEventFetcher {\n    pub fn new(lru_cache_size: Option<usize>) -> Self {\n        let lru_cache = lru_cache_size.map(Cache::new);\n\n        Self { lru_cache }\n    }\n\n    #[tracing::instrument(skip(self), fields(room_id = room.room_id().as_str(), event_id = event_id.as_str()))]\n    pub async fn fetch_event_in_room(\n        &self,\n        event_id: &OwnedEventId,\n        room: &Room,\n    ) -> mxlink::matrix_sdk::Result<TimelineEvent> {\n        let Some(lru_cache) = &self.lru_cache else {\n            return room.event(event_id, None).await;\n        };\n\n        let guard = lru_cache.get_value_or_guard_async(event_id).await;\n\n        match guard {\n            Ok(config) => {\n                tracing::trace!(\"Returning existing cached event..\");\n                return Ok(config);\n            }\n            Err(guard) => {\n                let event = room.event(event_id, None).await?;\n\n                let _ = guard.insert(event.clone());\n\n                tracing::trace!(\"Returning now-cached event\");\n\n                return Ok(event);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/conversation/matrix/utils/mod.rs",
    "content": "#[cfg(test)]\nmod tests;\n\nuse std::sync::Arc;\n\nuse mxlink::matrix_sdk::ruma::{OwnedEventId, OwnedUserId};\nuse mxlink::matrix_sdk::{\n    Room,\n    deserialized_responses::TimelineEvent,\n    ruma::events::{\n        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,\n        SyncMessageLikeEvent,\n        relation::Thread,\n        room::message::{\n            MessageType, OriginalSyncRoomMessageEvent, Relation, RoomMessageEventContent,\n            sanitize::remove_plain_reply_fallback,\n        },\n    },\n};\nuse mxlink::{MatrixLink, ThreadGetMessagesParams, ThreadInfo};\nuse tracing::Instrument;\n\nuse super::{MatrixMessage, MatrixMessageContent, MatrixMessageProcessingParams, RoomEventFetcher};\nuse crate::entity::{InteractionContext, InteractionTrigger, MessagePayload};\nuse crate::utils::mime::get_mime_type_from_file_name;\n\nstruct DetailedMessagePayload {\n    is_mentioning_bot: bool,\n    message_payload: MessagePayload,\n}\n\npub async fn get_matrix_messages_in_thread(\n    matrix_link: &MatrixLink,\n    room: &Room,\n    thread_id: OwnedEventId,\n) -> Result<Vec<MatrixMessage>, mxlink::matrix_sdk::Error> {\n    let messages_native = matrix_link\n        .threads()\n        .get_messages(room, thread_id, ThreadGetMessagesParams::default())\n        .await?;\n\n    let mut messages: Vec<MatrixMessage> = Vec::new();\n\n    for matrix_native_message in messages_native {\n        let message_result =\n            convert_matrix_native_event_to_matrix_message(matrix_link, &matrix_native_message)\n                .await?;\n\n        if let Some(message) = message_result {\n            messages.push(message);\n        }\n    }\n\n    Ok(messages)\n}\n\npub async fn get_matrix_messages_in_reply_chain(\n    matrix_link: &MatrixLink,\n    event_fetcher: &Arc<RoomEventFetcher>,\n    room: &Room,\n    event_id: OwnedEventId,\n) -> Result<Vec<MatrixMessage>, mxlink::matrix_sdk::Error> {\n    let messages_native =\n        get_matrix_messages_in_reply_chain_native(event_fetcher, room, event_id).await?;\n\n    let mut messages: Vec<MatrixMessage> = Vec::new();\n\n    for matrix_native_message in messages_native {\n        let message_result =\n            convert_matrix_native_event_to_matrix_message(matrix_link, &matrix_native_message)\n                .await?;\n\n        if let Some(message) = message_result {\n            messages.push(message);\n        }\n    }\n\n    Ok(messages)\n}\n\nasync fn get_matrix_messages_in_reply_chain_native(\n    event_fetcher: &Arc<RoomEventFetcher>,\n    room: &Room,\n    event_id: OwnedEventId,\n) -> Result<Vec<AnySyncMessageLikeEvent>, mxlink::matrix_sdk::Error> {\n    let mut next_event_id = Some(event_id.clone());\n\n    let mut messages: Vec<AnySyncMessageLikeEvent> = Vec::new();\n    let mut handled_event_ids: Vec<OwnedEventId> = Vec::new();\n\n    while let Some(next_event_id_in_loop) = next_event_id {\n        let event = event_fetcher\n            .fetch_event_in_room(&next_event_id_in_loop, room)\n            .await\n            .unwrap();\n\n        if handled_event_ids.contains(&next_event_id_in_loop) {\n            tracing::warn!(\n                \"Not following loop-causing event: {}\",\n                next_event_id_in_loop\n            );\n            break;\n        }\n\n        handled_event_ids.push(next_event_id_in_loop.clone());\n\n        let event_deserialized = event.raw().deserialize()?;\n\n        let AnySyncTimelineEvent::MessageLike(message_like_event) = event_deserialized else {\n            tracing::warn!(\n                \"Not proceeding past non-MessageLike event: {:?}\",\n                event_deserialized\n            );\n            break;\n        };\n\n        next_event_id = match message_like_event.clone() {\n            AnySyncMessageLikeEvent::RoomEncrypted(_) => None,\n            AnySyncMessageLikeEvent::RoomMessage(room_message) => {\n                if let SyncMessageLikeEvent::Original(room_message_original) = room_message {\n                    match room_message_original.content.relates_to {\n                        Some(Relation::Reply(reply)) => Some(reply.in_reply_to.event_id.clone()),\n                        _ => None,\n                    }\n                } else {\n                    None\n                }\n            }\n            _ => None,\n        };\n\n        messages.push(message_like_event);\n    }\n\n    messages.reverse();\n\n    Ok(messages)\n}\n\npub async fn process_matrix_messages(\n    messages: &[MatrixMessage],\n    params: &MatrixMessageProcessingParams,\n) -> Vec<MatrixMessage> {\n    let mut messages_filtered: Vec<MatrixMessage> = Vec::new();\n\n    for (i, message) in messages.iter().enumerate() {\n        if !is_message_from_allowed_sender(\n            message,\n            &params.bot_user_id,\n            params.allowed_users.as_deref(),\n        ) {\n            continue;\n        }\n\n        let mut message = message.clone();\n\n        if i == 0\n            && !params.first_message_prefixes_to_strip.is_empty()\n            && let MatrixMessageContent::Text(message_text) = &message.content\n        {\n            let mut message_text = message_text.clone();\n\n            for prefix in &params.first_message_prefixes_to_strip {\n                if let Some(message_text_stripped) = message_text.strip_prefix(prefix) {\n                    message_text = message_text_stripped.to_owned();\n                }\n            }\n\n            message.content = MatrixMessageContent::Text(message_text.trim().to_owned());\n        }\n\n        // We only strip `bot_user_prefixes_to_strip`-defined prefixes from messages that mention the bot user.\n        if !params.bot_user_prefixes_to_strip.is_empty()\n            && message.mentioned_users.contains(&params.bot_user_id)\n            && let MatrixMessageContent::Text(message_text) = &message.content\n        {\n            let mut message_text = message_text.clone();\n\n            for prefix in &params.bot_user_prefixes_to_strip {\n                if let Some(message_text_stripped) = message_text.strip_prefix(prefix) {\n                    message_text = message_text_stripped.to_owned();\n                }\n            }\n\n            message.content = MatrixMessageContent::Text(message_text.trim().to_owned());\n        }\n\n        messages_filtered.push(message);\n    }\n\n    messages_filtered\n}\n\n/// Tells if the given message is from an allowed sender.\n///\n/// If allowed_users is None, all messages are allowed.\n/// If allowed_users is Some, only messages from the allowed users (and the `bot_user_id`) are allowed.\nfn is_message_from_allowed_sender(\n    matrix_message: &MatrixMessage,\n    bot_user_id: &OwnedUserId,\n    allowed_users: Option<&[regex::Regex]>,\n) -> bool {\n    if matrix_message.sender_id == *bot_user_id {\n        return true;\n    }\n\n    if let Some(allowed_users) = allowed_users {\n        if mxidwc::match_user_id(matrix_message.sender_id.as_str(), allowed_users) {\n            return true;\n        }\n    } else {\n        // No allowed users configured, so all messages are allowed\n        return true;\n    }\n\n    false\n}\n\npub async fn convert_matrix_native_event_to_matrix_message(\n    matrix_link: &MatrixLink,\n    matrix_native_event: &AnySyncMessageLikeEvent,\n) -> Result<Option<MatrixMessage>, mxlink::matrix_sdk::Error> {\n    let Some(content) = matrix_native_event.original_content() else {\n        // Redacted message\n        return Ok(None);\n    };\n\n    let AnyMessageLikeEventContent::RoomMessage(room_message) = content else {\n        // Some state event, etc.\n        return Ok(None);\n    };\n\n    let (text, is_notice) = match &room_message.msgtype {\n        MessageType::Text(text_content) => (text_content.body.clone(), false),\n        MessageType::Notice(notice_content) => (notice_content.body.clone(), true),\n        MessageType::Image(image_content) => (image_content.body.clone(), false),\n        MessageType::File(file_content) => (file_content.body.clone(), false),\n        _ => return Ok(None),\n    };\n\n    let is_reply = matches!(room_message.relates_to, Some(Relation::Reply { .. }));\n\n    let text = if is_reply {\n        // For regular replies, we need to strip the fallback-for-rich replies part.\n        // See: https://spec.matrix.org/v1.11/client-server-api/#fallbacks-for-rich-replies\n        remove_plain_reply_fallback(&text).to_owned()\n    } else {\n        text\n    };\n\n    let timestamp = chrono::DateTime::<chrono::Utc>::from(\n        matrix_native_event\n            .origin_server_ts()\n            .to_system_time()\n            .unwrap_or_else(std::time::SystemTime::now),\n    );\n\n    let mentioned_users = room_message\n        .mentions\n        .map(|m| m.user_ids.iter().map(|u| u.to_owned()).collect())\n        .unwrap_or(vec![]);\n\n    if let MessageType::Image(image_content) = &room_message.msgtype {\n        let media_request = mxlink::matrix_sdk::media::MediaRequestParameters {\n            source: image_content.source.to_owned(),\n            format: mxlink::matrix_sdk::media::MediaFormat::File,\n        };\n\n        let file_name = image_content\n            .filename\n            .clone()\n            .unwrap_or(image_content.body.clone());\n\n        let mime_type = get_mime_type_from_file_name(&file_name);\n\n        tracing::debug!(\"Determined mime type {} for file {}\", mime_type, file_name);\n\n        let span = tracing::debug_span!(\"get_media_content\", file_name = %file_name, mime_type = %mime_type);\n\n        let media_bytes = matrix_link\n            .client()\n            .media()\n            .get_media_content(&media_request, true)\n            .instrument(span)\n            .await?;\n\n        return Ok(Some(MatrixMessage {\n            sender_id: matrix_native_event.sender().to_owned(),\n            content: MatrixMessageContent::Image(image_content.clone(), mime_type, media_bytes),\n            mentioned_users,\n            timestamp,\n        }));\n    }\n\n    if let MessageType::File(file_content) = &room_message.msgtype {\n        let media_request = mxlink::matrix_sdk::media::MediaRequestParameters {\n            source: file_content.source.to_owned(),\n            format: mxlink::matrix_sdk::media::MediaFormat::File,\n        };\n\n        let file_name = file_content\n            .filename\n            .clone()\n            .unwrap_or(file_content.body.clone());\n\n        let mime_type = file_content\n            .info\n            .as_ref()\n            .and_then(|info| info.mimetype.clone())\n            .and_then(|mimetype| mimetype.parse::<mxlink::mime::Mime>().ok())\n            .unwrap_or_else(|| get_mime_type_from_file_name(&file_name));\n\n        tracing::debug!(\"Determined mime type {} for file {}\", mime_type, file_name);\n\n        if mime_type == mxlink::mime::APPLICATION_OCTET_STREAM {\n            tracing::debug!(\n                \"Skipping file {} with unsupported MIME type {}. It will be represented as a text message.\",\n                file_name,\n                mime_type,\n            );\n\n            return Ok(Some(MatrixMessage {\n                sender_id: matrix_native_event.sender().to_owned(),\n                content: MatrixMessageContent::Text(format!(\n                    \"[A file ({}) was attached but skipped because its content type ({}) is not supported. Let the user know.]\",\n                    file_name, mime_type,\n                )),\n                mentioned_users,\n                timestamp,\n            }));\n        }\n\n        let span = tracing::debug_span!(\"get_media_content\", file_name = %file_name, mime_type = %mime_type);\n\n        let media_bytes = matrix_link\n            .client()\n            .media()\n            .get_media_content(&media_request, true)\n            .instrument(span)\n            .await?;\n\n        tracing::debug!(\n            \"Downloaded {} bytes for file {}\",\n            media_bytes.len(),\n            file_name\n        );\n\n        return Ok(Some(MatrixMessage {\n            sender_id: matrix_native_event.sender().to_owned(),\n            content: MatrixMessageContent::File(file_content.clone(), mime_type, media_bytes),\n            mentioned_users,\n            timestamp,\n        }));\n    }\n\n    Ok(Some(MatrixMessage {\n        sender_id: matrix_native_event.sender().to_owned(),\n        content: if is_notice {\n            MatrixMessageContent::Notice(text)\n        } else {\n            MatrixMessageContent::Text(text)\n        },\n        mentioned_users,\n        timestamp,\n    }))\n}\n\n/// Determines the interaction context for an incoming (new) room event.\n///\n/// This context is created based on the \"newest message\" (`current_event`), which is:\n/// - either a top-level message, which may or may not be mentioning the bot\n///     - this function will inspect the event and will likely start a new threaded conversation\n///\n/// - or a thread reply\n///     - this function will inspect the thread root event and will return the interaction context\n///     - if the bot only reacts to prefixed messsages (or mentions), this function may ignore the given thread reply, unless it mentions the bot (which causes a synthetic \"first message\" to be produced)\n///     - if the thread root event is not found, is redacted, or is of some unsupported MessagePayload type, this function will return `None`\n///\n/// - or an in-room (non-threaded) reply to a room message, which may or may not be mentioning the bot\n///     - replies that do not mention the bot cause this function to return `None`\n///     - other replies create a interaction context which points to a \"first message\" which is synthetic\n#[tracing::instrument(name = \"determine_interaction_context_for_room_event\", skip_all, fields(room_id = room.room_id().as_str(), event_id = current_event.event_id.as_str()))]\npub async fn determine_interaction_context_for_room_event(\n    bot_user_id: &OwnedUserId,\n    bot_display_name: &Option<String>,\n    room: &Room,\n    current_event: &OriginalSyncRoomMessageEvent,\n    current_event_payload: &MessagePayload,\n    event_fetcher: &Arc<RoomEventFetcher>,\n) -> anyhow::Result<Option<InteractionContext>> {\n    let current_event_is_mentioning_bot =\n        is_event_mentioning_bot(&current_event.content, bot_user_id, bot_display_name);\n\n    let Some(relation) = &current_event.content.relates_to else {\n        // This is a top-level message. We consider it the start of the thread.\n        let thread_info = ThreadInfo::new(\n            current_event.event_id.clone(),\n            current_event.event_id.clone(),\n        );\n\n        return Ok(Some(InteractionContext {\n            thread_info,\n            trigger: InteractionTrigger {\n                is_mentioning_bot: current_event_is_mentioning_bot,\n                payload: current_event_payload.clone(),\n            },\n        }));\n    };\n\n    match relation {\n        Relation::Thread(thread) => {\n            determine_interaction_context_for_room_event_related_to_thread(\n                bot_user_id,\n                bot_display_name,\n                room,\n                current_event,\n                event_fetcher,\n                current_event_is_mentioning_bot,\n                thread,\n            )\n            .await\n        }\n        Relation::Reply(reply) => {\n            determine_interaction_context_for_room_event_related_to_reply(\n                current_event,\n                current_event_is_mentioning_bot,\n                reply.in_reply_to.event_id.clone(),\n            )\n            .await\n        }\n\n        // This is a replacement or something else. It's not something we support.\n        _ => return Ok(None),\n    }\n}\n\nasync fn determine_interaction_context_for_room_event_related_to_thread(\n    bot_user_id: &OwnedUserId,\n    bot_display_name: &Option<String>,\n    room: &Room,\n    current_event: &OriginalSyncRoomMessageEvent,\n    event_fetcher: &Arc<RoomEventFetcher>,\n    current_event_is_mentioning_bot: bool,\n    thread: &Thread,\n) -> anyhow::Result<Option<InteractionContext>> {\n    let thread_info = ThreadInfo::new(thread.event_id.clone(), current_event.event_id.clone());\n\n    tracing::trace!(\n        ?current_event_is_mentioning_bot,\n        is_thread_root_only = thread_info.is_thread_root_only(),\n        \"Dealing with a thread reply\",\n    );\n\n    if current_event_is_mentioning_bot && !thread_info.is_thread_root_only() {\n        // If the current event is a thread reply and is mentioning the bot,\n        // it's probably someone trying to involve us in the threaded conversation.\n        // See: https://github.com/etkecc/baibot/issues/15\n        //\n        // In such cases, we don't care what the thread root event is like or what the current event is like,\n        // we want text-generation to be triggered for this whole thread regardless.\n        return Ok(Some(InteractionContext {\n            thread_info,\n            trigger: InteractionTrigger {\n                is_mentioning_bot: true,\n                payload: MessagePayload::SynthethicChatCompletionTriggerInThread,\n            },\n        }));\n    }\n\n    let start_time = std::time::Instant::now();\n\n    let thread_start_timeline_event = event_fetcher\n        .fetch_event_in_room(&thread.event_id, room)\n        .await;\n\n    let thread_start_timeline_event = match thread_start_timeline_event {\n        Ok(value) => value,\n        Err(err) => {\n            return Err(anyhow::format_err!(\n                \"Failed to fetch thread start event {}: {:?}\",\n                thread.event_id,\n                err\n            ));\n        }\n    };\n\n    let duration = start_time.elapsed();\n\n    tracing::trace!(\n        thread_id = thread.event_id.as_str(),\n        duration = ?duration,\n        \"Fetched thread start event\"\n    );\n\n    let thread_start_detailed_message_payload = timeline_event_to_detailed_message_payload(\n        &thread.event_id,\n        thread_start_timeline_event,\n        thread_info.clone(),\n        bot_user_id,\n        bot_display_name,\n    )?;\n\n    let Some(detailed_message_payload) = thread_start_detailed_message_payload else {\n        return Ok(None);\n    };\n\n    Ok(Some(InteractionContext {\n        thread_info,\n        trigger: InteractionTrigger {\n            is_mentioning_bot: detailed_message_payload.is_mentioning_bot,\n            payload: detailed_message_payload.message_payload,\n        },\n    }))\n}\n\nasync fn determine_interaction_context_for_room_event_related_to_reply(\n    current_event: &OriginalSyncRoomMessageEvent,\n    current_event_is_mentioning_bot: bool,\n    reply_to_event_id: OwnedEventId,\n) -> anyhow::Result<Option<InteractionContext>> {\n    tracing::trace!(?current_event_is_mentioning_bot, \"Dealing with a reply\");\n\n    if !current_event_is_mentioning_bot {\n        // If the current event is not mentioning the bot, we don't care about it.\n        tracing::trace!(\"Ignoring reply event which does not mention the bot\");\n        return Ok(None);\n    }\n\n    let thread_info = ThreadInfo::new(reply_to_event_id.clone(), current_event.event_id.clone());\n\n    Ok(Some(InteractionContext {\n        thread_info,\n        trigger: InteractionTrigger {\n            is_mentioning_bot: true,\n            payload: MessagePayload::SynthethicChatCompletionTriggerForReply,\n        },\n    }))\n}\n\nfn is_event_mentioning_bot(\n    event_content: &RoomMessageEventContent,\n    bot_user_id: &OwnedUserId,\n    bot_display_name: &Option<String>,\n) -> bool {\n    if let Some(mentions) = &event_content.mentions {\n        mentions\n            .user_ids\n            .iter()\n            .any(|user_id| user_id == bot_user_id)\n    } else {\n        // For compatibility with clients that do not support the new Mentions specification\n        // (see https://spec.matrix.org/latest/client-server-api/#user-and-room-mentions),\n        // we also do string matching here.\n        //\n        // As of 2024-10-03, at least Element iOS does not support the new Mentions specification\n        // and is still quite widespread.\n        //\n        // We may consider dropping this string-matching behavior altogether in the future,\n        // so improving this compatibility block is not a high priority.\n        if event_content.body().contains(bot_user_id.as_str()) {\n            return true;\n        }\n\n        if let Some(bot_display_name) = bot_display_name {\n            return event_content.body().contains(bot_display_name);\n        }\n\n        false\n    }\n}\n\nfn timeline_event_to_detailed_message_payload(\n    timeline_event_id: &OwnedEventId,\n    timeline_event: TimelineEvent,\n    thread_info: ThreadInfo,\n    bot_user_id: &OwnedUserId,\n    bot_display_name: &Option<String>,\n) -> anyhow::Result<Option<DetailedMessagePayload>> {\n    let timeline_event_deserialized = match timeline_event.raw().deserialize() {\n        Ok(value) => value,\n        Err(err) => {\n            return Err(anyhow::format_err!(\n                \"Failed to deserialize timeline event {}: {:?}\",\n                timeline_event_id,\n                err\n            ));\n        }\n    };\n\n    let AnySyncTimelineEvent::MessageLike(thread_start_message_like_event) =\n        timeline_event_deserialized\n    else {\n        tracing::trace!(\n            \"Ignoring non-MessageLike timeline event: {:?}\",\n            timeline_event_deserialized\n        );\n        return Ok(None);\n    };\n\n    let (is_mentioning_bot, message_payload) = match thread_start_message_like_event {\n        AnySyncMessageLikeEvent::RoomEncrypted(room_message) => {\n            tracing::warn!(\n                \"Could not inspect event {} because it failed to decrypt: {:?}\",\n                timeline_event_id.clone(),\n                room_message\n            );\n\n            // There's no way to know and it doesn't matter anyway.\n            let is_mentioning_bot = false;\n\n            (\n                is_mentioning_bot,\n                MessagePayload::Encrypted(thread_info.clone()),\n            )\n        }\n        AnySyncMessageLikeEvent::RoomMessage(room_message) => {\n            if let SyncMessageLikeEvent::Original(room_message_original) = room_message {\n                let room_message_payload: Result<MessagePayload, String> =\n                    room_message_original.content.msgtype.clone().try_into();\n\n                let Ok(room_message_payload) = room_message_payload else {\n                    tracing::debug!(\n                        msg_type = room_message_original.content.msgtype(),\n                        \"Ignoring event message of unknown type\",\n                    );\n                    return Ok(None);\n                };\n\n                let is_mentioning_bot = is_event_mentioning_bot(\n                    &room_message_original.content,\n                    bot_user_id,\n                    bot_display_name,\n                );\n\n                (is_mentioning_bot, room_message_payload)\n            } else {\n                tracing::error!(\"Ignoring event message which appears to be redacted\");\n\n                return Ok(None);\n            }\n        }\n        other => {\n            tracing::trace!(\"Ignoring unknown MessageLike event: {:?}\", other);\n            return Ok(None);\n        }\n    };\n\n    Ok(Some(DetailedMessagePayload {\n        is_mentioning_bot,\n        message_payload,\n    }))\n}\n\n/// Creates a list of prefixes to strip from the beginning of message texts that mention the bot user.\n///\n/// Different clients do mentions differently.\n/// The body text containing the mention usually contains one of:\n/// - the full user ID (includes a @ prefix by default)\n/// - the localpart (with a @ prefix)\n/// - the localpart (without a @ prefix)\n/// - the display name (with a @ prefix)\n/// - the display name (without a @ prefix)\n///\n/// Some add a `: ` suffix after the mention.\n///\n/// There's no guarantee that the mention is at the start even.\n/// It being there is most common and we try to strip it from there\n/// as best as we can.\npub fn create_list_of_bot_user_prefixes_to_strip(\n    bot_user_id: &OwnedUserId,\n    bot_display_name: &Option<String>,\n) -> Vec<String> {\n    let bot_user_id_localpart = bot_user_id.localpart();\n\n    let mut prefixes_to_strip = vec![\n        bot_user_id.as_str().to_owned(),\n        format!(\"@{}\", bot_user_id_localpart),\n        bot_user_id_localpart.to_owned(),\n    ];\n\n    if let Some(bot_display_name) = bot_display_name {\n        prefixes_to_strip.push(format!(\"@{}\", bot_display_name));\n        prefixes_to_strip.push(bot_display_name.to_owned());\n    }\n\n    prefixes_to_strip.push(\":\".to_owned());\n\n    prefixes_to_strip\n}\n"
  },
  {
    "path": "src/conversation/matrix/utils/tests.rs",
    "content": "use chrono::{TimeZone, Utc};\n\nuse mxlink::matrix_sdk::ruma::OwnedUserId;\n\nuse crate::conversation::matrix::{\n    MatrixMessage, MatrixMessageContent, MatrixMessageProcessingParams,\n};\n\n#[test]\nfn is_message_from_allowed_sender() {\n    let bot_user_id =\n        OwnedUserId::try_from(\"@bot:example.com\").expect(\"Failed to parse bot user ID\");\n    let allowed_user_id = OwnedUserId::try_from(\"@user.someone:example.com\").unwrap();\n    let unallowed_user_id = OwnedUserId::try_from(\"@another:example.com\").unwrap();\n\n    let timestamp = Utc.with_ymd_and_hms(2024, 9, 20, 18, 34, 15).unwrap();\n\n    let bot_message = MatrixMessage {\n        sender_id: bot_user_id.to_owned(),\n        content: MatrixMessageContent::Text(\"Hello!\".to_owned()),\n        mentioned_users: vec![],\n        timestamp,\n    };\n\n    let allowed_user_message = MatrixMessage {\n        sender_id: allowed_user_id.to_owned(),\n        content: MatrixMessageContent::Text(\"Hello!\".to_owned()),\n        mentioned_users: vec![],\n        timestamp,\n    };\n\n    let unallowed_user_message = MatrixMessage {\n        sender_id: unallowed_user_id.to_owned(),\n        content: MatrixMessageContent::Text(\"Hello!\".to_owned()),\n        mentioned_users: vec![],\n        timestamp,\n    };\n\n    let parsed_regex = match mxidwc::parse_pattern(\"@user.*:example.com\") {\n        Ok(value) => value,\n        Err(err) => {\n            panic!(\"Error parsing regex: {}\", err);\n        }\n    };\n\n    let allowed_users = vec![parsed_regex];\n\n    assert!(\n        super::is_message_from_allowed_sender(&bot_message, &bot_user_id, Some(&allowed_users)),\n        \"Bot message should be allowed\"\n    );\n\n    assert!(\n        super::is_message_from_allowed_sender(\n            &allowed_user_message,\n            &bot_user_id,\n            Some(&allowed_users)\n        ),\n        \"Allowed user message should be allowed\"\n    );\n\n    assert!(\n        !super::is_message_from_allowed_sender(\n            &unallowed_user_message,\n            &bot_user_id,\n            Some(&allowed_users),\n        ),\n        \"Unallowed user message should be ignored\"\n    );\n\n    assert!(\n        super::is_message_from_allowed_sender(&unallowed_user_message, &bot_user_id, None,),\n        \"An empty list of allowed users lets everyone through\"\n    );\n}\n\n#[tokio::test]\nasync fn process_matrix_messages() {\n    let bot_user_id =\n        OwnedUserId::try_from(\"@bot:example.com\").expect(\"Failed to parse bot user ID\");\n    let allowed_user_id = OwnedUserId::try_from(\"@user.someone:example.com\").unwrap();\n    let unallowed_user_id = OwnedUserId::try_from(\"@another:example.com\").unwrap();\n\n    let timestamp = Utc.with_ymd_and_hms(2024, 9, 20, 18, 34, 15).unwrap();\n\n    let allowed_user_message = MatrixMessage {\n        sender_id: allowed_user_id.to_owned(),\n        content: MatrixMessageContent::Text(\"Hello from the user!\".to_owned()),\n        mentioned_users: vec![],\n        timestamp,\n    };\n\n    let allowed_user_message_with_prefix = MatrixMessage {\n        sender_id: allowed_user_id.to_owned(),\n        content: MatrixMessageContent::Text(\"!bai Hello from the user!\".to_owned()),\n        mentioned_users: vec![],\n        timestamp,\n    };\n\n    let allowed_user_message_with_prefix_no_space = MatrixMessage {\n        sender_id: allowed_user_id.to_owned(),\n        content: MatrixMessageContent::Text(\"!baiHello from the user!\".to_owned()),\n        mentioned_users: vec![],\n        timestamp,\n    };\n\n    let allowed_user_message_with_prefix_full_width_space = MatrixMessage {\n        sender_id: allowed_user_id.to_owned(),\n        content: MatrixMessageContent::Text(\"!bai　Hello from the user!\".to_owned()),\n        mentioned_users: vec![],\n        timestamp,\n    };\n\n    let bot_message = MatrixMessage {\n        sender_id: bot_user_id.to_owned(),\n        content: MatrixMessageContent::Text(\"Hello from the bot!\".to_owned()),\n        mentioned_users: vec![],\n        timestamp,\n    };\n\n    let allowed_user_message_with_bot_mention = MatrixMessage {\n        sender_id: allowed_user_id.to_owned(),\n        content: MatrixMessageContent::Text(\"@baibot: Hello from the user!\".to_owned()),\n        mentioned_users: vec![bot_user_id.to_owned()],\n        timestamp,\n    };\n\n    // The message text is the same as above - it mentions the bot, but the actually-mentioned user is another user.\n    let allowed_user_message_with_another_user_mention = MatrixMessage {\n        sender_id: allowed_user_id.to_owned(),\n        content: allowed_user_message_with_bot_mention.content.clone(),\n        mentioned_users: vec![allowed_user_id.to_owned()],\n        timestamp,\n    };\n\n    let unallowed_user_message = MatrixMessage {\n        sender_id: unallowed_user_id.to_owned(),\n        content: MatrixMessageContent::Text(\"Hello from an unallowed user!\".to_owned()),\n        mentioned_users: vec![],\n        timestamp,\n    };\n\n    let parsed_regex = match mxidwc::parse_pattern(\"@user.*:example.com\") {\n        Ok(value) => value,\n        Err(err) => {\n            panic!(\"Error parsing regex: {}\", err);\n        }\n    };\n\n    let allowed_users = vec![parsed_regex];\n\n    let message_processing_params_basic = super::MatrixMessageProcessingParams::new(\n        bot_user_id.to_owned(),\n        Some(allowed_users.clone()),\n    );\n\n    let message_processing_params_with_prefix_stripping =\n        super::MatrixMessageProcessingParams::new(\n            bot_user_id.to_owned(),\n            Some(allowed_users.clone()),\n        )\n        .with_first_message_prefixes_to_strip(vec![\"!bai\".to_owned()]);\n\n    let message_processing_params_with_bot_user_prefix_stripping =\n        super::MatrixMessageProcessingParams::new(\n            bot_user_id.to_owned(),\n            Some(allowed_users.clone()),\n        )\n        .with_bot_user_prefixes_to_strip(vec![\"@baibot: \".to_owned(), \"@baibot\".to_owned()]);\n\n    struct TestCase {\n        name: String,\n        messages: Vec<MatrixMessage>,\n        message_processing_params: MatrixMessageProcessingParams,\n        expected_message_texts: Vec<String>,\n    }\n\n    let test_cases = vec![\n        TestCase {\n            name: \"Messages by unallowed users are ignored\".to_owned(),\n            messages: vec![\n                allowed_user_message.clone(),\n                bot_message.clone(),\n                unallowed_user_message.clone(),\n            ],\n            message_processing_params: message_processing_params_basic.clone(),\n            expected_message_texts: vec![\n                \"Hello from the user!\".to_owned(),\n                \"Hello from the bot!\".to_owned(),\n            ],\n        },\n        TestCase {\n            name: \"The first message with a prefix gets stripped if params configure it (regular space)\".to_owned(),\n            messages: vec![\n                allowed_user_message_with_prefix.clone(),\n                bot_message.clone(),\n                allowed_user_message_with_prefix.clone(),\n                unallowed_user_message.clone(),\n            ],\n            message_processing_params: message_processing_params_with_prefix_stripping.clone(),\n            expected_message_texts: vec![\n                \"Hello from the user!\".to_owned(),\n                \"Hello from the bot!\".to_owned(),\n                \"!bai Hello from the user!\".to_owned(),\n            ],\n        },\n        TestCase {\n            name: \"The first message with a prefix gets stripped if params configure it (no space)\".to_owned(),\n            messages: vec![\n                allowed_user_message_with_prefix_no_space.clone(),\n                bot_message.clone(),\n                allowed_user_message_with_prefix_no_space.clone(),\n                unallowed_user_message.clone(),\n            ],\n            message_processing_params: message_processing_params_with_prefix_stripping.clone(),\n            expected_message_texts: vec![\n                \"Hello from the user!\".to_owned(),\n                \"Hello from the bot!\".to_owned(),\n                \"!baiHello from the user!\".to_owned(),\n            ],\n        },\n        TestCase {\n            name: \"The first message with a prefix gets stripped if params configure it (full-width-space)\".to_owned(),\n            messages: vec![\n                allowed_user_message_with_prefix_full_width_space.clone(),\n                bot_message.clone(),\n                allowed_user_message_with_prefix_full_width_space.clone(),\n                unallowed_user_message.clone(),\n            ],\n            message_processing_params: message_processing_params_with_prefix_stripping.clone(),\n            expected_message_texts: vec![\n                \"Hello from the user!\".to_owned(),\n                \"Hello from the bot!\".to_owned(),\n                \"!bai　Hello from the user!\".to_owned(),\n            ],\n        },\n        TestCase {\n            name: \"The first message with a prefix remains untouched if params leave it alone\"\n                .to_owned(),\n            messages: vec![\n                allowed_user_message_with_prefix.clone(),\n                bot_message.clone(),\n                allowed_user_message_with_prefix.clone(),\n                unallowed_user_message.clone(),\n            ],\n            message_processing_params: message_processing_params_basic.clone(),\n            expected_message_texts: vec![\n                \"!bai Hello from the user!\".to_owned(),\n                \"Hello from the bot!\".to_owned(),\n                \"!bai Hello from the user!\".to_owned(),\n            ],\n        },\n        TestCase {\n            name: \"Messages that mention the bot user get the bot user prefix stripped\"\n                .to_owned(),\n            messages: vec![\n                allowed_user_message_with_bot_mention.clone(),\n                allowed_user_message_with_another_user_mention.clone(),\n            ],\n            message_processing_params: message_processing_params_with_bot_user_prefix_stripping.clone(),\n            expected_message_texts: vec![\n                \"Hello from the user!\".to_owned(),\n                \"@baibot: Hello from the user!\".to_owned(),\n            ],\n        },\n    ];\n\n    for test_case in test_cases {\n        let processed_messages = super::process_matrix_messages(\n            &test_case.messages,\n            &test_case.message_processing_params,\n        )\n        .await;\n\n        let processed_message_texts = processed_messages\n            .iter()\n            .map(|message| match &message.content {\n                MatrixMessageContent::Text(text) => text.clone(),\n                _ => \"\".to_owned(),\n            })\n            .collect::<Vec<String>>();\n\n        assert_eq!(\n            processed_message_texts, test_case.expected_message_texts,\n            \"Test case {} failed\",\n            test_case.name,\n        );\n    }\n}\n\n#[test]\nfn create_list_of_bot_user_prefixes_to_strip() {\n    let bot_user_id =\n        OwnedUserId::try_from(\"@baibot:example.com\").expect(\"Failed to parse bot user ID\");\n\n    // Test case 1: Bot user with no display name\n    let bot_display_name = None;\n    let prefixes =\n        super::create_list_of_bot_user_prefixes_to_strip(&bot_user_id, &bot_display_name);\n\n    assert_eq!(\n        prefixes,\n        vec![\n            \"@baibot:example.com\".to_string(),\n            \"@baibot\".to_string(),\n            \"baibot\".to_string(),\n            \":\".to_string()\n        ]\n    );\n\n    // Test case 2: Bot user with display name\n    let bot_display_name = Some(\"Assistant\".to_string());\n    let prefixes =\n        super::create_list_of_bot_user_prefixes_to_strip(&bot_user_id, &bot_display_name);\n\n    assert_eq!(\n        prefixes,\n        vec![\n            \"@baibot:example.com\".to_string(),\n            \"@baibot\".to_string(),\n            \"baibot\".to_string(),\n            \"@Assistant\".to_string(),\n            \"Assistant\".to_string(),\n            \":\".to_string()\n        ]\n    );\n}\n"
  },
  {
    "path": "src/conversation/matrix_llm_bridge.rs",
    "content": "use std::sync::Arc;\n\nuse mxlink::MatrixLink;\nuse mxlink::matrix_sdk::ruma::OwnedEventId;\n\nuse crate::conversation::matrix::MatrixMessage;\n\nuse super::llm::{Conversation, Message, convert_matrix_message_to_llm_message};\nuse super::matrix::{\n    MatrixMessageProcessingParams, RoomEventFetcher, get_matrix_messages_in_reply_chain,\n    get_matrix_messages_in_thread, process_matrix_messages,\n};\n\npub async fn create_llm_conversation_for_matrix_thread(\n    matrix_link: &MatrixLink,\n    room: &mxlink::matrix_sdk::Room,\n    thread_id: OwnedEventId,\n    params: &MatrixMessageProcessingParams,\n) -> Result<Conversation, mxlink::matrix_sdk::Error> {\n    let messages = get_matrix_messages_in_thread(matrix_link, room, thread_id).await?;\n\n    let llm_messages = filter_messages_and_convert_to_llm_messages(messages, params).await;\n\n    Ok(Conversation {\n        messages: llm_messages,\n    })\n}\n\npub async fn create_llm_conversation_for_matrix_reply_chain(\n    matrix_link: &MatrixLink,\n    event_fetcher: &Arc<RoomEventFetcher>,\n    room: &mxlink::matrix_sdk::Room,\n    event_id: OwnedEventId,\n    params: &MatrixMessageProcessingParams,\n) -> Result<Conversation, mxlink::matrix_sdk::Error> {\n    let messages =\n        get_matrix_messages_in_reply_chain(matrix_link, event_fetcher, room, event_id).await?;\n\n    let llm_messages = filter_messages_and_convert_to_llm_messages(messages, params).await;\n\n    Ok(Conversation {\n        messages: llm_messages,\n    })\n}\n\nasync fn filter_messages_and_convert_to_llm_messages(\n    messages: Vec<MatrixMessage>,\n    params: &MatrixMessageProcessingParams,\n) -> Vec<Message> {\n    let messages_filtered = process_matrix_messages(&messages, params).await;\n\n    let mut llm_messages: Vec<Message> = Vec::new();\n\n    for matrix_message in messages_filtered {\n        let Some(llm_message) =\n            convert_matrix_message_to_llm_message(&matrix_message, &params.bot_user_id)\n        else {\n            continue;\n        };\n\n        llm_messages.push(llm_message);\n    }\n\n    llm_messages\n}\n"
  },
  {
    "path": "src/conversation/mod.rs",
    "content": "pub(crate) mod llm;\npub(crate) mod matrix;\nmod matrix_llm_bridge;\n\npub(crate) use matrix_llm_bridge::{\n    create_llm_conversation_for_matrix_reply_chain, create_llm_conversation_for_matrix_thread,\n};\n"
  },
  {
    "path": "src/entity/catch_up_marker/delayed_catch_up_marker_manager.rs",
    "content": "use std::sync::Arc;\n\nuse tokio::sync::Mutex;\nuse tokio::time::Duration;\n\nuse mxlink::helpers::account_data_config::ConfigError;\n\nuse super::CatchUpMarkerManager;\n\n/// A service that records roughly until when we're caught up on processing events.\n/// Roughly, because we account for potential federation delay and we don't persist the marker too often.\n///\n/// If the matrix-sdk's state-store is kept intact, we (usually) won't be given the same event twice.\n/// In such a happy path, we don't need to keep track of anything and there's no problem.\n///\n/// If the state-store is lost (a very rare, but possible event), we can recover our encryption keys, etc.,\n/// but the Matrix SDK would try to feed us the same events again.\n/// Responding to many old events again is annoying to users and can be a huge waste of resources.\n///\n/// In order to handle state-store-loss better, we need to record until when we're caught up in storage that won't get lost (such as account data for the user).\n/// Because state-store-loss is a very rare event, we don't need to be very exact about the specific timestamp we're caught up to.\n/// In fact, being behind is necessary, to allow for federation delay (see `federation_delay_tolerance_duration`).\npub struct DelayedCatchUpMarkerManager {\n    catch_up_marker_manager: Arc<Mutex<CatchUpMarkerManager>>,\n\n    /// `persist_interval_duration` affects how often we persist the catch-up marker to Account Data\n    /// A too small value means there's needless overhead.\n    /// The downside to a larger interval value (and a larger federation delay tolerance value) is that that a state-store loss will mean that\n    /// we will reprocess some of the same events.\n    /// Since this is a very rare event and the downside is not so bad, a large value is recommended.\n    persist_interval_duration: Duration,\n\n    /// `federation_delay_tolerance_duration` affects what federation delay we will tolerate.\n    /// A larger delay than this may mean we ignore events that are actually new to us.\n    /// This is necessary because the timestamp given to us (see `catch_up()`) is based on the \"origin server\" timestamp.\n    /// If federation is slow, we may actually receive old events later on - they'd still be new to us,\n    /// but we may ignore them if we've marked this \"origin server timestamp\" value as \"caught up\".\n    federation_delay_tolerance_duration: Duration,\n\n    /// Holds the timestamp to use for updating the catch-up marker's `caught_up_until_event_origin_server_ts_millis`.\n    /// A value of `0` is used to indicate that no update is scheduled and the next iteration should skip updating the marker.\n    next_catch_up_marker_event_origin_server_ts_millis: Arc<tokio::sync::Mutex<i64>>,\n}\n\nimpl DelayedCatchUpMarkerManager {\n    pub fn new(\n        catch_up_marker_manager: CatchUpMarkerManager,\n        persist_interval_duration: Duration,\n        federation_delay_tolerance_duration: Duration,\n    ) -> Self {\n        let next_catch_up_marker_event_origin_server_ts_millis =\n            Arc::new(tokio::sync::Mutex::new(0));\n\n        let catch_up_marker_manager = Arc::new(Mutex::new(catch_up_marker_manager));\n\n        Self {\n            catch_up_marker_manager,\n            persist_interval_duration,\n            federation_delay_tolerance_duration,\n\n            next_catch_up_marker_event_origin_server_ts_millis,\n        }\n    }\n\n    #[tracing::instrument(name = \"catch_up\", skip(self))]\n    pub async fn catch_up(&self, event_origin_server_ts_millis: i64) {\n        tracing::trace!(\"Locking to catch-up..\");\n\n        let mut next_catch_up_marker_event_origin_server_ts_millis_guard = self\n            .next_catch_up_marker_event_origin_server_ts_millis\n            .lock()\n            .await;\n\n        if *next_catch_up_marker_event_origin_server_ts_millis_guard > event_origin_server_ts_millis\n        {\n            tracing::trace!(\n                ?next_catch_up_marker_event_origin_server_ts_millis_guard,\n                \"Already have a more recent timestamp scheduled\",\n            );\n            return;\n        }\n\n        *next_catch_up_marker_event_origin_server_ts_millis_guard = event_origin_server_ts_millis;\n\n        tracing::info!(\"Configured catch-up timestamp for the next update\");\n    }\n\n    /// Tells if we're caught up until the given timestamp.\n    ///\n    /// This intentionally uses the latest (cached) data stored in catch_up_marker_manager (Account Data), not the `next_catch_up_marker_event_origin_server_ts_millis` value.\n    /// `next_catch_up_marker_event_origin_server_ts_millis` is used for scheduling the next update only.\n    /// The actual timestamp that will get persisted durign the update will actually be adjusted by `federation_delay_tolerance_duration`,\n    /// so comparing against `next_catch_up_marker_event_origin_server_ts_millis` in its raw form would be incorrect.\n    #[tracing::instrument(name = \"is_caught_up\", skip(self))]\n    pub(crate) async fn is_caught_up(\n        &self,\n        event_origin_ts_millis: i64,\n    ) -> Result<bool, ConfigError> {\n        tracing::trace!(\"Locking to check if caught up..\");\n\n        let mut manager = self.catch_up_marker_manager.lock().await;\n\n        let marker = manager.get_or_create().await?;\n\n        let is_caught_up =\n            marker.caught_up_until_event_origin_server_ts_millis >= event_origin_ts_millis;\n\n        tracing::debug!(\n            ?is_caught_up,\n            ?marker.caught_up_until_event_origin_server_ts_millis,\n            \"Determined caught-up status\"\n        );\n\n        Ok(is_caught_up)\n    }\n\n    pub async fn start(&self) {\n        let inner = Arc::clone(&self.catch_up_marker_manager);\n        let persist_interval_duration = self.persist_interval_duration;\n        let federation_delay_tolerance = self.federation_delay_tolerance_duration;\n        let next_catch_up_marker_event_origin_server_ts_millis =\n            Arc::clone(&self.next_catch_up_marker_event_origin_server_ts_millis);\n\n        tokio::spawn(async move {\n            let mut interval = tokio::time::interval(persist_interval_duration);\n\n            loop {\n                interval.tick().await;\n\n                tracing::trace!(\"Catch-up manager doing work..\");\n\n                let mut next_catch_up_marker_event_origin_server_ts_millis_guard =\n                    next_catch_up_marker_event_origin_server_ts_millis\n                        .lock()\n                        .await;\n\n                if *next_catch_up_marker_event_origin_server_ts_millis_guard == 0 {\n                    tracing::trace!(\"No scheduled updates to the catch-up marker\");\n                    continue;\n                }\n\n                let mut manager = inner.lock().await;\n\n                let marker = manager.get_or_create().await;\n                let mut marker = match marker {\n                    Ok(marker) => marker,\n                    Err(err) => {\n                        tracing::error!(?err, \"Failed to get or create catch-up marker\");\n                        continue;\n                    }\n                };\n\n                // To allow for some federation delay (specified in federation_delay_tolerance),\n                // we adjust the value we'll actually persist with that delay duration.\n                // For more information, see the documentation for `Self`.\n                let caught_up_until_event_origin_server_ts_millis =\n                    *next_catch_up_marker_event_origin_server_ts_millis_guard\n                        - (federation_delay_tolerance.as_millis() as i64);\n\n                marker.caught_up_until_event_origin_server_ts_millis =\n                    caught_up_until_event_origin_server_ts_millis;\n\n                tracing::debug!(\n                    ?caught_up_until_event_origin_server_ts_millis,\n                    next_catch_up_marker_event_origin_server_ts_millis = format!(\n                        \"{:?}\",\n                        next_catch_up_marker_event_origin_server_ts_millis_guard\n                    ),\n                    \"Updating catch-up marker..\",\n                );\n\n                let result = manager.persist(&marker).await;\n                if let Err(err) = result {\n                    tracing::error!(?err, \"Failed to persist catch-up marker\");\n                }\n\n                *next_catch_up_marker_event_origin_server_ts_millis_guard = 0;\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "src/entity/catch_up_marker/entity.rs",
    "content": "use mxlink::matrix_sdk::ruma::events::macros::EventContent;\n\nuse serde::{Deserialize, Serialize};\n\nuse mxlink::helpers::account_data_config::GlobalConfig;\nuse mxlink::helpers::account_data_config::GlobalConfigCarrierContent;\n\n#[derive(Clone, Debug, Default, Deserialize, Serialize, EventContent)]\n#[ruma_event(type = \"cc.etke.baibot.catch_up_marker\", kind = GlobalAccountData)]\npub struct CatchUpMarkerCarrierContent {\n    pub payload: String,\n}\n\nimpl GlobalConfigCarrierContent for CatchUpMarkerCarrierContent {\n    fn payload(&self) -> &str {\n        &self.payload\n    }\n\n    fn new(payload: String) -> Self {\n        Self { payload }\n    }\n}\n\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub struct CatchUpMarker {\n    pub caught_up_until_event_origin_server_ts_millis: i64,\n}\n\nimpl CatchUpMarker {\n    pub fn new(caught_up_until_event_origin_server_ts_millis: i64) -> Self {\n        Self {\n            caught_up_until_event_origin_server_ts_millis,\n        }\n    }\n}\n\nimpl GlobalConfig for CatchUpMarker {}\n"
  },
  {
    "path": "src/entity/catch_up_marker/mod.rs",
    "content": "mod delayed_catch_up_marker_manager;\nmod entity;\n\nuse mxlink::helpers::account_data_config::GlobalConfigManager as AccountDataGlobalConfigManager;\n\npub use entity::{CatchUpMarker, CatchUpMarkerCarrierContent};\n\npub type CatchUpMarkerManager =\n    AccountDataGlobalConfigManager<CatchUpMarker, CatchUpMarkerCarrierContent>;\n\npub use delayed_catch_up_marker_manager::DelayedCatchUpMarkerManager;\n"
  },
  {
    "path": "src/entity/cfg/config.rs",
    "content": "use std::path::PathBuf;\n\nuse mxlink::helpers::encryption::EncryptionKey;\nuse mxlink::matrix_sdk::ruma::{OwnedDeviceId, OwnedUserId};\nuse serde::{Deserialize, Deserializer, Serialize};\n\nuse crate::{\n    agent::{AgentDefinition, AgentPurpose, PublicIdentifier},\n    entity::{globalconfig::GlobalConfig, roomconfig::RoomSettingsHandler},\n};\n\n#[derive(Debug, Deserialize)]\npub struct Config {\n    pub homeserver: ConfigHomeserver,\n\n    pub user: ConfigUser,\n\n    pub persistence: PersistenceConfig,\n\n    #[serde(default = \"super::defaults::command_prefix\")]\n    pub command_prefix: String,\n\n    #[serde(default)]\n    pub room: ConfigRoom,\n\n    pub access: ConfigAccess,\n\n    pub agents: ConfigAgents,\n\n    // Contains the initial global configuration values.\n    // Not all properties of the object make sense to be configured statically,\n    // so not all of them will be reflected onto the actual global configuration.\n    pub initial_global_config: ConfigInitialGlobalConfig,\n\n    #[serde(default = \"super::defaults::logging\")]\n    pub logging: String,\n}\n\nimpl Config {\n    pub fn validate(&self) -> anyhow::Result<()> {\n        self.homeserver.validate()?;\n        self.user.validate(&self.homeserver.server_name)?;\n        self.persistence.validate()?;\n        self.room.validate()?;\n        self.access.validate()?;\n\n        if self.command_prefix.is_empty() {\n            return Err(anyhow::anyhow!(\n                \"The command_prefix ({}) configuration must be set\",\n                super::env::BAIBOT_COMMAND_PREFIX\n            ));\n        }\n\n        self.agents.validate()?;\n        self.initial_global_config.clone().validate()?;\n\n        Ok(())\n    }\n}\n\n#[derive(Debug)]\npub enum ConfigUserAuth {\n    UserPassword {\n        username: String,\n        password: String,\n    },\n    AccessToken {\n        user_id: OwnedUserId,\n        device_id: OwnedDeviceId,\n        access_token: String,\n    },\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct ConfigHomeserver {\n    pub server_name: String,\n    pub url: String,\n}\n\nimpl ConfigHomeserver {\n    pub fn validate(&self) -> anyhow::Result<()> {\n        if self.server_name.is_empty() {\n            return Err(anyhow::anyhow!(\n                \"The homeserver.server_name ({}) configuration must be set\",\n                super::env::BAIBOT_HOMESERVER_SERVER_NAME\n            ));\n        }\n\n        if self.url.is_empty() {\n            return Err(anyhow::anyhow!(\n                \"The homeserver.url ({}) configuration must be set\",\n                super::env::BAIBOT_HOMESERVER_URL\n            ));\n        }\n\n        Ok(())\n    }\n}\n\n/// Configuration for the bot's avatar.\n///\n/// - `Default`: Use the built-in default avatar (null, empty string, or missing in config)\n/// - `Keep`: Don't touch the avatar, keep whatever is already set (\"keep\" in config)\n/// - `Custom(String)`: Use a custom avatar from the specified file path\n#[derive(Debug, Clone, Default, PartialEq, Serialize)]\npub enum Avatar {\n    /// Use the built-in default avatar\n    #[default]\n    Default,\n    /// Keep the current avatar, don't change it\n    Keep,\n    /// Use a custom avatar from the specified file path\n    Custom(String),\n}\n\nimpl<'de> Deserialize<'de> for Avatar {\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: Deserializer<'de>,\n    {\n        let value: Option<String> = Option::deserialize(deserializer)?;\n        Ok(match value {\n            None => Avatar::Default,\n            Some(s) => Avatar::from_string(s),\n        })\n    }\n}\n\nimpl Avatar {\n    pub fn from_string(value: String) -> Self {\n        if value.is_empty() {\n            Avatar::Default\n        } else if value.eq_ignore_ascii_case(\"keep\") {\n            Avatar::Keep\n        } else {\n            Avatar::Custom(value)\n        }\n    }\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct ConfigUser {\n    pub mxid_localpart: String,\n\n    #[serde(default)]\n    pub password: Option<String>,\n\n    #[serde(default)]\n    pub access_token: Option<String>,\n\n    #[serde(default)]\n    pub device_id: Option<String>,\n\n    #[serde(default = \"super::defaults::name\")]\n    pub name: String,\n\n    #[serde(default)]\n    pub encryption: ConfigUserEncryption,\n\n    #[serde(default)]\n    pub avatar: Avatar,\n}\n\nimpl ConfigUser {\n    pub fn validate(&self, homeserver_server_name: &str) -> anyhow::Result<()> {\n        if self.mxid_localpart.is_empty() {\n            return Err(anyhow::anyhow!(\n                \"The user.mxid_localpart ({}) configuration must be set\",\n                super::env::BAIBOT_USER_MXID_LOCALPART\n            ));\n        }\n\n        self.auth_config(homeserver_server_name)?;\n\n        if self.name.is_empty() {\n            return Err(anyhow::anyhow!(\n                \"The name ({}) configuration must be set\",\n                super::env::BAIBOT_USER_NAME\n            ));\n        }\n\n        self.encryption.validate()?;\n\n        Ok(())\n    }\n\n    pub fn auth_config(&self, homeserver_server_name: &str) -> anyhow::Result<ConfigUserAuth> {\n        let password = self.password.as_deref().filter(|value| !value.is_empty());\n        let access_token = self\n            .access_token\n            .as_deref()\n            .filter(|value| !value.is_empty());\n\n        match (password, access_token) {\n            (Some(_), Some(_)) => Err(anyhow::anyhow!(\n                \"Set exactly one authentication method: either user.password ({}) OR user.access_token ({}) + user.device_id ({})\",\n                super::env::BAIBOT_USER_PASSWORD,\n                super::env::BAIBOT_USER_ACCESS_TOKEN,\n                super::env::BAIBOT_USER_DEVICE_ID\n            )),\n            (None, None) => Err(anyhow::anyhow!(\n                \"Set one authentication method: either user.password ({}) OR user.access_token ({}) + user.device_id ({})\",\n                super::env::BAIBOT_USER_PASSWORD,\n                super::env::BAIBOT_USER_ACCESS_TOKEN,\n                super::env::BAIBOT_USER_DEVICE_ID\n            )),\n            (Some(password), None) => Ok(ConfigUserAuth::UserPassword {\n                username: self.mxid_localpart.to_owned(),\n                password: password.to_owned(),\n            }),\n            (None, Some(access_token)) => {\n                let device_id = self\n                    .device_id\n                    .as_deref()\n                    .filter(|value| !value.is_empty())\n                    .ok_or_else(|| {\n                        anyhow::anyhow!(\n                            \"user.device_id ({}) must be set when using access token authentication\",\n                            super::env::BAIBOT_USER_DEVICE_ID\n                        )\n                    })?;\n\n                let user_id = OwnedUserId::try_from(format!(\n                    \"@{}:{}\",\n                    self.mxid_localpart, homeserver_server_name\n                ))\n                .map_err(|e| anyhow::anyhow!(\"Invalid user ID: {e}\"))?;\n\n                Ok(ConfigUserAuth::AccessToken {\n                    user_id,\n                    device_id: OwnedDeviceId::from(device_id),\n                    access_token: access_token.to_owned(),\n                })\n            }\n        }\n    }\n}\n\n#[derive(Debug, Default, Serialize, Deserialize)]\npub struct ConfigUserEncryption {\n    pub recovery_passphrase: Option<String>,\n    pub recovery_reset_allowed: bool,\n}\n\nimpl ConfigUserEncryption {\n    pub fn validate(&self) -> anyhow::Result<()> {\n        if let Some(passphrase) = &self.recovery_passphrase\n            && passphrase.is_empty()\n        {\n            return Err(anyhow::anyhow!(\n                \"The user.encryption.recovery_passphrase ({}) configuration must either be null or set to a non-empty passphrase\",\n                super::env::BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE\n            ));\n        }\n\n        Ok(())\n    }\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct PersistenceConfig {\n    #[serde(default = \"super::defaults::persistence_data_dir_path\")]\n    pub data_dir_path: Option<String>,\n\n    #[serde(default = \"super::defaults::persistence_session_file_name\")]\n    session_file_name: String,\n\n    #[serde(default = \"super::defaults::persistence_db_dir_name\")]\n    db_dir_name: String,\n\n    pub session_encryption_key: Option<String>,\n\n    pub config_encryption_key: Option<String>,\n}\n\nimpl PersistenceConfig {\n    pub fn validate(&self) -> anyhow::Result<()> {\n        if let Some(data_dir_path) = &self.data_dir_path {\n            let path = PathBuf::from(data_dir_path);\n            if !path.exists() {\n                return Err(anyhow::anyhow!(\n                    \"The persistence.data_dir_path ({}) directory ({}) must exist\",\n                    super::env::BAIBOT_PERSISTENCE_DATA_DIR_PATH,\n                    data_dir_path,\n                ));\n            }\n        }\n\n        self.config_encryption_key()\n            .map_err(|e| anyhow::anyhow!(e))?;\n\n        Ok(())\n    }\n\n    pub fn session_file_path(&self) -> anyhow::Result<PathBuf> {\n        let Some(data_dir_path) = &self.data_dir_path else {\n            return Err(anyhow::anyhow!(\n                \"The persistence.data_dir_path ({}) directory must be set\",\n                super::env::BAIBOT_PERSISTENCE_DATA_DIR_PATH\n            ));\n        };\n\n        let mut path = PathBuf::from(data_dir_path);\n        path.push(&self.session_file_name);\n\n        Ok(path)\n    }\n\n    pub fn db_dir_path(&self) -> anyhow::Result<PathBuf> {\n        let Some(data_dir_path) = &self.data_dir_path else {\n            return Err(anyhow::anyhow!(\n                \"The persistence.data_dir_path ({}) directory must be set\",\n                super::env::BAIBOT_PERSISTENCE_DATA_DIR_PATH\n            ));\n        };\n\n        let mut path = PathBuf::from(data_dir_path);\n        path.push(&self.db_dir_name);\n\n        Ok(path)\n    }\n\n    pub fn session_encryption_key(&self) -> anyhow::Result<Option<EncryptionKey>> {\n        self.parse_encryption_key(&self.session_encryption_key).map_err(|err| {\n            anyhow::anyhow!(\n                \"Encryption key specified in persistence.session_encryption_key ({}) is not valid: {}\",\n                super::env::BAIBOT_PERSISTENCE_SESSION_ENCRYPTION_KEY,\n                err\n            )\n        })\n    }\n\n    pub fn config_encryption_key(&self) -> anyhow::Result<Option<EncryptionKey>> {\n        self.parse_encryption_key(&self.config_encryption_key).map_err(|err| {\n            anyhow::anyhow!(\n                \"Encryption key specified in persistence.config_encryption_key ({}) is not valid: {}\",\n                super::env::BAIBOT_PERSISTENCE_CONFIG_ENCRYPTION_KEY,\n                err\n            )\n        })\n    }\n\n    fn parse_encryption_key(\n        &self,\n        value: &Option<String>,\n    ) -> anyhow::Result<Option<EncryptionKey>, String> {\n        let key = match value {\n            Some(key) => {\n                if key.is_empty() {\n                    None\n                } else {\n                    Some(EncryptionKey::from_hex_str(key)?)\n                }\n            }\n            None => None,\n        };\n\n        Ok(key)\n    }\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct ConfigRoom {\n    #[serde(default = \"super::defaults::room_post_join_self_introduction_enabled\")]\n    pub post_join_self_introduction_enabled: bool,\n}\n\nimpl ConfigRoom {\n    pub fn validate(&self) -> anyhow::Result<()> {\n        Ok(())\n    }\n}\n\nimpl Default for ConfigRoom {\n    fn default() -> Self {\n        Self {\n            post_join_self_introduction_enabled:\n                super::defaults::room_post_join_self_introduction_enabled(),\n        }\n    }\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct ConfigAccess {\n    // Contains the admin whitelist patterns before parsing into regex.\n    // Example: `[\"@*:example.com\"]`\n    pub admin_patterns: Vec<String>,\n}\n\nimpl ConfigAccess {\n    // Returns the the mxidwc-parsed regexes for the admin whitelist.\n    // Example: `[\"^@\\.*:example\\.com$\"]`\n    pub fn admin_pattern_regexes(&self) -> anyhow::Result<Vec<regex::Regex>> {\n        mxidwc::parse_patterns_vector(&self.admin_patterns).map_err(|e| {\n            anyhow::anyhow!(\n                \"Failed parsing access.admin_patterns ({}): {:?}\",\n                super::env::BAIBOT_ACCESS_ADMIN_PATTERNS,\n                e\n            )\n        })\n    }\n\n    pub fn validate(&self) -> anyhow::Result<()> {\n        if self.admin_patterns.is_empty() {\n            return Err(anyhow::anyhow!(\n                \"The access.admin_patterns ({}) configuration must contain at least one pattern\",\n                super::env::BAIBOT_ACCESS_ADMIN_PATTERNS\n            ));\n        }\n\n        self.admin_pattern_regexes()?;\n\n        Ok(())\n    }\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct ConfigAgents {\n    pub static_definitions: Vec<AgentDefinition>,\n}\n\nimpl ConfigAgents {\n    pub fn validate(&self) -> anyhow::Result<()> {\n        Ok(())\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ConfigInitialGlobalConfig {\n    #[serde(default)]\n    pub handler: RoomSettingsHandler,\n\n    pub user_patterns: Option<Vec<String>>,\n}\n\nimpl ConfigInitialGlobalConfig {\n    fn user_pattern_regexes(&self) -> anyhow::Result<Option<Vec<regex::Regex>>> {\n        match &self.user_patterns {\n            Some(user_patterns) => {\n                let user_patterns = mxidwc::parse_patterns_vector(user_patterns).map_err(|e| {\n                    anyhow::anyhow!(\n                        \"Failed parsing initial_global_config.user_patterns ({}): {}\",\n                        super::env::BAIBOT_INITIAL_GLOBAL_CONFIG_USER_PATTERNS,\n                        e\n                    )\n                })?;\n\n                Ok(Some(user_patterns))\n            }\n            None => Ok(None),\n        }\n    }\n\n    pub fn validate(self) -> anyhow::Result<()> {\n        self.user_pattern_regexes()?;\n\n        for purpose in AgentPurpose::choices() {\n            let agent_id = self.handler.get_by_purpose(*purpose);\n\n            let Some(agent_id) = agent_id else {\n                // None is OK\n                continue;\n            };\n\n            let config_key = format!(\n                \"initial_global_config.handler.{}\",\n                purpose.as_str().replace(\"-\", \"_\")\n            );\n\n            if agent_id.is_empty() {\n                return Err(anyhow::anyhow!(\n                    \"The {} configuration key must be pointing to a valid agent id or be set to null\",\n                    config_key,\n                ));\n            }\n\n            let agent_identifier = PublicIdentifier::from_str(&agent_id);\n\n            let Some(agent_identifier) = agent_identifier else {\n                return Err(anyhow::anyhow!(\n                    \"The {} configuration key specifies an agent id (`{}`) that cannot be parsed. {}\",\n                    config_key,\n                    agent_id,\n                    crate::strings::agent::invalid_id_generic()\n                ));\n            };\n\n            // We only allow statically-defined agents for now, although DynamicGlobal may make sense too.\n            let PublicIdentifier::Static(_) = agent_identifier else {\n                return Err(anyhow::anyhow!(\n                    \"The {} configuration key specifies an agent id (`{}`) which does not refer to a static agent.\",\n                    config_key,\n                    agent_id,\n                ));\n            };\n        }\n\n        let _: GlobalConfig = self.try_into()?;\n\n        Ok(())\n    }\n}\n\nimpl TryInto<GlobalConfig> for ConfigInitialGlobalConfig {\n    type Error = anyhow::Error;\n\n    fn try_into(self) -> anyhow::Result<GlobalConfig> {\n        let mut entity = GlobalConfig::default();\n\n        if let Some(user_patterns) = self.user_patterns {\n            // We'd rather fail parsing this during startup than at runtime\n            let _ = mxidwc::parse_patterns_vector(&user_patterns).map_err(|err| {\n                anyhow::anyhow!(\n                    \"Bad initial_global_config.user_patterns ({}): {}\",\n                    super::env::BAIBOT_INITIAL_GLOBAL_CONFIG_USER_PATTERNS,\n                    err\n                )\n            })?;\n\n            entity.access.user_patterns = if user_patterns.is_empty() {\n                None\n            } else {\n                Some(user_patterns)\n            };\n        }\n\n        for purpose in AgentPurpose::choices() {\n            let agent_id = self.handler.get_by_purpose(*purpose);\n\n            entity\n                .fallback_room_settings\n                .handler\n                .set_by_purpose(*purpose, agent_id);\n        }\n\n        Ok(entity)\n    }\n}\n\n#[cfg(test)]\n#[path = \"config_tests.rs\"]\nmod config_tests;\n"
  },
  {
    "path": "src/entity/cfg/config_tests.rs",
    "content": "use super::{Avatar, ConfigUser, ConfigUserAuth, ConfigUserEncryption};\nuse crate::entity::cfg::env;\n\nfn base_user() -> ConfigUser {\n    ConfigUser {\n        mxid_localpart: \"baibot\".to_owned(),\n        password: None,\n        access_token: None,\n        device_id: None,\n        name: \"baibot\".to_owned(),\n        encryption: ConfigUserEncryption {\n            recovery_passphrase: None,\n            recovery_reset_allowed: false,\n        },\n        avatar: Avatar::Default,\n    }\n}\n\n#[test]\nfn auth_config_uses_password_mode() {\n    let mut user = base_user();\n    user.password = Some(\"secret\".to_owned());\n\n    let auth = user\n        .auth_config(\"example.com\")\n        .expect(\"password auth should be valid\");\n\n    match auth {\n        ConfigUserAuth::UserPassword { username, password } => {\n            assert_eq!(username, \"baibot\");\n            assert_eq!(password, \"secret\");\n        }\n        ConfigUserAuth::AccessToken { .. } => {\n            panic!(\"expected password auth mode\");\n        }\n    }\n}\n\n#[test]\nfn auth_config_uses_access_token_mode() {\n    let mut user = base_user();\n    user.access_token = Some(\"token123\".to_owned());\n    user.device_id = Some(\"DEVICE1\".to_owned());\n\n    let auth = user\n        .auth_config(\"example.com\")\n        .expect(\"access token auth should be valid\");\n\n    match auth {\n        ConfigUserAuth::AccessToken {\n            user_id,\n            device_id,\n            access_token,\n        } => {\n            assert_eq!(user_id.as_str(), \"@baibot:example.com\");\n            assert_eq!(device_id.as_str(), \"DEVICE1\");\n            assert_eq!(access_token, \"token123\");\n        }\n        ConfigUserAuth::UserPassword { .. } => {\n            panic!(\"expected access token auth mode\");\n        }\n    }\n}\n\n#[test]\nfn auth_config_rejects_both_auth_methods() {\n    let mut user = base_user();\n    user.password = Some(\"secret\".to_owned());\n    user.access_token = Some(\"token123\".to_owned());\n    user.device_id = Some(\"DEVICE1\".to_owned());\n\n    let err = user\n        .auth_config(\"example.com\")\n        .expect_err(\"both auth methods should be rejected\");\n\n    assert!(\n        err.to_string()\n            .contains(\"exactly one authentication method\")\n    );\n}\n\n#[test]\nfn auth_config_rejects_missing_auth() {\n    let user = base_user();\n\n    let err = user\n        .auth_config(\"example.com\")\n        .expect_err(\"missing auth should be rejected\");\n\n    assert!(err.to_string().contains(\"Set one authentication method\"));\n}\n\n#[test]\nfn auth_config_rejects_access_token_without_device_id() {\n    let mut user = base_user();\n    user.access_token = Some(\"token123\".to_owned());\n\n    let err = user\n        .auth_config(\"example.com\")\n        .expect_err(\"access token mode without device_id should be rejected\");\n\n    assert!(err.to_string().contains(env::BAIBOT_USER_DEVICE_ID));\n}\n\n#[test]\nfn auth_config_treats_empty_strings_as_unset() {\n    let mut user = base_user();\n    user.password = Some(String::new());\n    user.access_token = Some(String::new());\n    user.device_id = Some(String::new());\n\n    let err = user\n        .auth_config(\"example.com\")\n        .expect_err(\"empty auth values should be treated as unset\");\n\n    assert!(err.to_string().contains(\"Set one authentication method\"));\n}\n"
  },
  {
    "path": "src/entity/cfg/defaults.rs",
    "content": "const CONFIG_FILE_PATH: &str = \"config.yml\";\n\nconst NAME: &str = \"baibot\";\nconst COMMAND_PREFIX: &str = \"!bai\";\n\nconst PERSISTENCE_SESSION_FILE_NAME: &str = \"session.json\";\nconst PERSISTENCE_DB_DIR_NAME: &str = \"db\";\n\npub(crate) fn name() -> String {\n    NAME.to_owned()\n}\n\npub(crate) fn config_file_path() -> String {\n    CONFIG_FILE_PATH.to_owned()\n}\n\npub(super) fn command_prefix() -> String {\n    COMMAND_PREFIX.to_owned()\n}\n\npub(super) fn room_post_join_self_introduction_enabled() -> bool {\n    true\n}\n\npub(super) fn persistence_data_dir_path() -> Option<String> {\n    None\n}\n\npub(super) fn persistence_session_file_name() -> String {\n    PERSISTENCE_SESSION_FILE_NAME.to_owned()\n}\n\npub(super) fn persistence_db_dir_name() -> String {\n    PERSISTENCE_DB_DIR_NAME.to_owned()\n}\n\npub(super) fn logging() -> String {\n    \"warn,mxlink=debug,baibot=debug\".to_owned()\n}\n"
  },
  {
    "path": "src/entity/cfg/env.rs",
    "content": "pub const BAIBOT_CONFIG_FILE_PATH: &str = \"BAIBOT_CONFIG_FILE_PATH\";\n\npub const BAIBOT_HOMESERVER_SERVER_NAME: &str = \"BAIBOT_HOMESERVER_SERVER_NAME\";\npub const BAIBOT_HOMESERVER_URL: &str = \"BAIBOT_HOMESERVER_URL\";\n\npub const BAIBOT_USER_MXID_LOCALPART: &str = \"BAIBOT_USER_MXID_LOCALPART\";\npub const BAIBOT_USER_PASSWORD: &str = \"BAIBOT_USER_PASSWORD\";\npub const BAIBOT_USER_ACCESS_TOKEN: &str = \"BAIBOT_USER_ACCESS_TOKEN\";\npub const BAIBOT_USER_DEVICE_ID: &str = \"BAIBOT_USER_DEVICE_ID\";\npub const BAIBOT_USER_NAME: &str = \"BAIBOT_USER_NAME\";\npub const BAIBOT_USER_AVATAR: &str = \"BAIBOT_USER_AVATAR\";\npub const BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE: &str =\n    \"BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE\";\npub const BAIBOT_USER_ENCRYPTION_RECOVERY_RESET_ALLOWED: &str =\n    \"BAIBOT_USER_ENCRYPTION_RECOVERY_RESET_ALLOWED\";\n\npub const BAIBOT_COMMAND_PREFIX: &str = \"BAIBOT_COMMAND_PREFIX\";\n\npub const BAIBOT_ROOM_POST_JOIN_SELF_INTRODUCTION_ENABLED: &str =\n    \"BAIBOT_ROOM_POST_JOIN_SELF_INTRODUCTION_ENABLED\";\n\npub const BAIBOT_LOGGING: &str = \"BAIBOT_LOGGING\";\n\npub const BAIBOT_ACCESS_ADMIN_PATTERNS: &str = \"BAIBOT_ACCESS_ADMIN_PATTERNS\";\n\npub const BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_CATCH_ALL: &str =\n    \"BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_CATCH_ALL\";\npub const BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_TEXT_GENERATION: &str =\n    \"BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_TEXT_GENERATION\";\npub const BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_TEXT_TO_SPEECH: &str =\n    \"BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_TEXT_TO_SPEECH\";\npub const BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_SPEECH_TO_TEXT: &str =\n    \"BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_SPEECH_TO_TEXT\";\npub const BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_IMAGE_GENERATION: &str =\n    \"BAIBOT_INITIAL_GLOBAL_CONFIG_HANDLER_IMAGE_GENERATION\";\n\npub const BAIBOT_INITIAL_GLOBAL_CONFIG_USER_PATTERNS: &str =\n    \"BAIBOT_INITIAL_GLOBAL_CONFIG_USER_PATTERNS\";\n\npub const BAIBOT_PERSISTENCE_DATA_DIR_PATH: &str = \"BAIBOT_PERSISTENCE_DATA_DIR_PATH\";\n\npub const BAIBOT_PERSISTENCE_SESSION_ENCRYPTION_KEY: &str =\n    \"BAIBOT_PERSISTENCE_SESSION_ENCRYPTION_KEY\";\n\npub const BAIBOT_PERSISTENCE_CONFIG_ENCRYPTION_KEY: &str =\n    \"BAIBOT_PERSISTENCE_CONFIG_ENCRYPTION_KEY\";\n"
  },
  {
    "path": "src/entity/cfg/mod.rs",
    "content": "mod config;\npub mod defaults;\npub mod env;\n\npub use config::{Avatar, Config, ConfigUserAuth};\n"
  },
  {
    "path": "src/entity/globalconfig/entity.rs",
    "content": "use mxlink::matrix_sdk::ruma::events::macros::EventContent;\n\nuse serde::{Deserialize, Serialize};\n\nuse mxlink::helpers::account_data_config::GlobalConfig as GlobalConfigTrait;\nuse mxlink::helpers::account_data_config::GlobalConfigCarrierContent as GlobalConfigCarrierContentTrait;\n\nuse crate::agent::AgentDefinition;\nuse crate::entity::roomconfig::RoomSettings;\n\n#[derive(Clone, Debug, Default, Deserialize, Serialize, EventContent)]\n#[ruma_event(type = \"cc.etke.baibot.global_config\", kind = GlobalAccountData)]\npub struct GlobalConfigCarrierContent {\n    pub payload: String,\n}\n\nimpl GlobalConfigCarrierContentTrait for GlobalConfigCarrierContent {\n    fn payload(&self) -> &str {\n        &self.payload\n    }\n\n    fn new(payload: String) -> Self {\n        Self { payload }\n    }\n}\n\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub struct GlobalConfig {\n    pub fallback_room_settings: RoomSettings,\n\n    pub access: GlobalConfigAccess,\n\n    pub agents: Vec<AgentDefinition>,\n}\n\nimpl GlobalConfig {\n    pub fn new(user_patterns: Option<Vec<String>>) -> Self {\n        Self {\n            fallback_room_settings: RoomSettings::default(),\n\n            access: GlobalConfigAccess {\n                user_patterns,\n                room_local_agent_manager_patterns: None,\n            },\n\n            agents: vec![],\n        }\n    }\n}\n\nimpl GlobalConfigTrait for GlobalConfig {}\n\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub struct GlobalConfigAccess {\n    // Contains a list of patterns that will be used to specify the \"allowed bot users\".\n    // These remain as patterns and are turned into regex and made use of on demand.\n    // Example: `[\"@*:example.com\"]`\n    pub user_patterns: Option<Vec<String>>,\n\n    // Contains a list of patterns that will be used to specify \"allowed room-local agent managers\".\n    // These remain as patterns and are turned into regex and made use of on demand.\n    // Example: `[\"@*:example.com\"]`\n    pub room_local_agent_manager_patterns: Option<Vec<String>>,\n}\n"
  },
  {
    "path": "src/entity/globalconfig/mod.rs",
    "content": "mod entity;\n\nuse mxlink::helpers::account_data_config::GlobalConfigManager as AccountDataGlobalConfigManager;\n\npub use entity::{GlobalConfig, GlobalConfigCarrierContent};\n\npub type GlobalConfigurationManager =\n    AccountDataGlobalConfigManager<GlobalConfig, GlobalConfigCarrierContent>;\n"
  },
  {
    "path": "src/entity/interaction_context.rs",
    "content": "use mxlink::ThreadInfo;\n\nuse super::MessagePayload;\n\npub struct InteractionContext {\n    pub thread_info: ThreadInfo,\n    pub trigger: InteractionTrigger,\n}\n\npub struct InteractionTrigger {\n    pub is_mentioning_bot: bool,\n    pub payload: MessagePayload,\n}\n"
  },
  {
    "path": "src/entity/message_context.rs",
    "content": "use mxlink::matrix_sdk::Room;\nuse mxlink::matrix_sdk::ruma::{OwnedEventId, OwnedUserId, RoomId};\n\nuse mxlink::ThreadInfo;\n\nuse super::{\n    MessagePayload, RoomConfigContext, TriggerEventInfo, globalconfig::GlobalConfig,\n    roomconfig::RoomConfig,\n};\n\n#[derive(Debug)]\npub struct MessageContext {\n    room: Room,\n    room_config_context: RoomConfigContext,\n    admin_whitelist_regexes: Vec<regex::Regex>,\n    trigger_event_info: TriggerEventInfo,\n    thread_info: ThreadInfo,\n\n    bot_display_name: Option<String>,\n}\n\nimpl MessageContext {\n    pub fn new(\n        room: Room,\n        room_config_context: RoomConfigContext,\n        admin_whitelist_regexes: Vec<regex::Regex>,\n        trigger_event_info: TriggerEventInfo,\n        thread_info: ThreadInfo,\n    ) -> Self {\n        Self {\n            room,\n            room_config_context,\n            admin_whitelist_regexes,\n            trigger_event_info,\n            thread_info,\n\n            bot_display_name: None,\n        }\n    }\n\n    pub fn with_bot_display_name(mut self, value: Option<String>) -> Self {\n        self.bot_display_name = value;\n        self\n    }\n\n    pub fn bot_display_name(&self) -> &Option<String> {\n        &self.bot_display_name\n    }\n\n    pub fn room(&self) -> &Room {\n        &self.room\n    }\n\n    pub fn room_id(&self) -> &RoomId {\n        self.room.room_id()\n    }\n\n    pub fn global_config(&self) -> &GlobalConfig {\n        &self.room_config_context.global_config\n    }\n\n    pub fn room_config(&self) -> &RoomConfig {\n        &self.room_config_context.room_config\n    }\n\n    pub fn room_config_context(&self) -> &RoomConfigContext {\n        &self.room_config_context\n    }\n\n    pub fn event_id(&self) -> &OwnedEventId {\n        &self.trigger_event_info.event_id\n    }\n\n    pub fn sender_id(&self) -> &OwnedUserId {\n        &self.trigger_event_info.sender\n    }\n\n    pub fn payload(&self) -> &MessagePayload {\n        &self.trigger_event_info.payload\n    }\n\n    pub fn thread_info(&self) -> &ThreadInfo {\n        &self.thread_info\n    }\n\n    pub fn sender_can_manage_global_config(&self) -> bool {\n        self.trigger_event_info.sender_is_admin\n    }\n\n    pub fn sender_can_manage_room_local_agents(&self) -> mxidwc::Result<bool> {\n        Ok(self.sender_can_manage_global_config()\n            || self.sender_is_allowed_room_local_agent_manager()?)\n    }\n\n    pub fn combined_admin_and_user_regexes(&self) -> Vec<regex::Regex> {\n        let mut combined = self.admin_whitelist_regexes.clone();\n\n        if let Some(user_patterns) = &self.global_config().access.user_patterns {\n            let user_regexes = mxidwc::parse_patterns_vector(user_patterns);\n\n            match user_regexes {\n                Ok(user_regexes) => {\n                    combined.extend(user_regexes);\n                }\n                Err(err) => {\n                    tracing::warn!(\n                        \"Error parsing user patterns for room {}: {:?}\",\n                        self.room.room_id(),\n                        err\n                    );\n                }\n            }\n        }\n\n        combined\n    }\n\n    fn sender_is_allowed_room_local_agent_manager(&self) -> mxidwc::Result<bool> {\n        self.room_config_context()\n            .is_user_allowed_room_local_agent_manager(self.sender_id().clone())\n    }\n}\n"
  },
  {
    "path": "src/entity/message_payload.rs",
    "content": "use mxlink::matrix_sdk::ruma::events::room::message::{\n    AudioMessageEventContent, FileMessageEventContent, ImageMessageEventContent, MessageType,\n    TextMessageEventContent,\n};\nuse mxlink::matrix_sdk::ruma::{OwnedEventId, OwnedUserId};\n\nuse mxlink::ThreadInfo;\n\n/// MessagePayload is like matrix-sdk's MessageType, but represents only message types that the bot deals with and payloads are massaged a bit.\n///\n/// This also includes a few synthetic events.\n#[derive(Debug, Clone)]\npub enum MessagePayload {\n    /// A synthetic message payload that indicates that the bot should produce a reply inside a thread.\n    /// This does not represent an actual message event, it's just a way to trigger a chat completion.\n    ///\n    /// When this is invoked, the ThreadInfo contains the full thread details (which represents our context).\n    ///\n    /// See: https://github.com/etkecc/baibot/issues/15\n    SynthethicChatCompletionTriggerInThread,\n\n    /// A synthetic message payload that indicates that the bot should produce a reply to a specific message.\n    /// This does not represent an actual message event, it's just a way to trigger a chat completion.\n    ///\n    /// When this is invoked, the ThreadInfo would refer to the reply-message that triggered us.\n    /// We can follow the chain upward from it to get the full context.\n    ///\n    /// See: https://github.com/etkecc/baibot/issues/15\n    SynthethicChatCompletionTriggerForReply,\n\n    Text(TextMessageEventContent),\n    Audio(AudioMessageEventContent),\n    Image(ImageMessageEventContent),\n    File(FileMessageEventContent),\n\n    Reaction {\n        key: String,\n        reacted_to_event_payload: Box<Self>,\n        reacted_to_event_id: OwnedEventId,\n        reacted_to_event_sender_id: OwnedUserId,\n    },\n\n    /// Represents an encrypted message\n    Encrypted(ThreadInfo),\n}\n\nimpl TryInto<MessagePayload> for MessageType {\n    type Error = String;\n\n    fn try_into(self) -> Result<MessagePayload, Self::Error> {\n        let payload = match self {\n            MessageType::Text(text_content) => MessagePayload::Text(text_content),\n            MessageType::Audio(audio_content) => {\n                // We can consider inspecting `audio_content.voice.is_some()` and ignoring audio which is not a voice message.\n                //\n                // However, at the time of this writing (2024-09-10), certain popular clients (Element iOS) send voice messages\n                // as regular audio messages, without voice annotation as per MSC3245.\n                // For this reason, we handle all audio.\n                MessagePayload::Audio(audio_content)\n            }\n            MessageType::Image(image_content) => MessagePayload::Image(image_content),\n            MessageType::File(file_content) => MessagePayload::File(file_content),\n            other => {\n                return Err(format!(\"Unsupported message type: {:?}\", other));\n            }\n        };\n\n        Ok(payload)\n    }\n}\n"
  },
  {
    "path": "src/entity/mod.rs",
    "content": "pub mod catch_up_marker;\npub mod cfg;\npub mod globalconfig;\nmod interaction_context;\nmod message_context;\nmod message_payload;\nmod room_config_context;\npub mod roomconfig;\nmod trigger_event_info;\n\npub use interaction_context::{InteractionContext, InteractionTrigger};\npub use message_context::MessageContext;\npub use message_payload::MessagePayload;\npub use room_config_context::RoomConfigContext;\npub use trigger_event_info::TriggerEventInfo;\n"
  },
  {
    "path": "src/entity/room_config_context.rs",
    "content": "use mxlink::matrix_sdk::ruma::OwnedUserId;\n\nuse super::globalconfig::GlobalConfig;\nuse super::roomconfig::RoomConfig;\n\nuse crate::entity::roomconfig::{\n    SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,\n    TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextGenerationSenderContextMode,\n    TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,\n    defaults as roomconfig_defaults,\n};\n\n#[derive(Debug)]\npub struct RoomConfigContext {\n    pub(crate) global_config: GlobalConfig,\n    pub(crate) room_config: RoomConfig,\n}\n\nimpl RoomConfigContext {\n    pub fn new(global_config: GlobalConfig, room_config: RoomConfig) -> RoomConfigContext {\n        Self {\n            global_config,\n            room_config,\n        }\n    }\n\n    pub fn speech_to_text_flow_type(&self) -> SpeechToTextFlowType {\n        self.room_config\n            .settings\n            .speech_to_text\n            .flow_type\n            .or({\n                self.global_config\n                    .fallback_room_settings\n                    .speech_to_text\n                    .flow_type\n            })\n            .unwrap_or(roomconfig_defaults::SPEECH_TO_TEXT_FLOW_TYPE)\n    }\n\n    pub fn speech_to_text_msg_type_for_non_threaded_only_transcribed_messages(\n        &self,\n    ) -> SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages {\n        self.room_config\n            .settings\n            .speech_to_text\n            .msg_type_for_non_threaded_only_transcribed_messages\n            .or({\n                self.global_config\n                    .fallback_room_settings\n                    .speech_to_text\n                    .msg_type_for_non_threaded_only_transcribed_messages\n            })\n            .unwrap_or(\n                roomconfig_defaults::SPEECH_TO_TEXT_ONLY_TRANSCRIBE_NON_THREADED_MESSAGE_TYPE,\n            )\n    }\n\n    pub fn speech_to_text_language(&self) -> Option<String> {\n        self.room_config\n            .settings\n            .speech_to_text\n            .language\n            .clone()\n            .or({\n                self.global_config\n                    .fallback_room_settings\n                    .speech_to_text\n                    .language\n                    .clone()\n            })\n    }\n\n    pub fn auto_text_generation_usage(&self) -> TextGenerationAutoUsage {\n        self.room_config\n            .settings\n            .text_generation\n            .auto_usage\n            .or({\n                self.global_config\n                    .fallback_room_settings\n                    .text_generation\n                    .auto_usage\n            })\n            .unwrap_or(roomconfig_defaults::TEXT_GENERATION_AUTO_USAGE)\n    }\n\n    pub fn should_auto_text_generate(&self, original_message_is_audio: bool) -> bool {\n        match self.auto_text_generation_usage() {\n            TextGenerationAutoUsage::Never => false,\n            TextGenerationAutoUsage::Always => true,\n            TextGenerationAutoUsage::OnlyForVoice => original_message_is_audio,\n            TextGenerationAutoUsage::OnlyForText => !original_message_is_audio,\n        }\n    }\n\n    pub fn text_generation_prompt_override(&self) -> Option<String> {\n        self.room_config\n            .settings\n            .text_generation\n            .prompt_override\n            .clone()\n            .or_else(|| {\n                self.global_config\n                    .fallback_room_settings\n                    .text_generation\n                    .prompt_override\n                    .clone()\n            })\n    }\n\n    pub fn text_generation_temperature_override(&self) -> Option<f32> {\n        self.room_config\n            .settings\n            .text_generation\n            .temperature_override\n            .or({\n                self.global_config\n                    .fallback_room_settings\n                    .text_generation\n                    .temperature_override\n            })\n    }\n\n    pub fn text_generation_context_management_enabled(&self) -> bool {\n        self.room_config\n            .settings\n            .text_generation\n            .context_management_enabled\n            .or({\n                self.global_config\n                    .fallback_room_settings\n                    .text_generation\n                    .context_management_enabled\n            })\n            .unwrap_or(false)\n    }\n\n    pub fn text_generation_sender_context_mode(&self) -> TextGenerationSenderContextMode {\n        self.room_config\n            .settings\n            .text_generation\n            .sender_context_mode\n            .or({\n                self.global_config\n                    .fallback_room_settings\n                    .text_generation\n                    .sender_context_mode\n            })\n            .unwrap_or(roomconfig_defaults::TEXT_GENERATION_SENDER_CONTEXT_MODE)\n    }\n\n    pub fn text_generation_prefix_requirement_type(&self) -> TextGenerationPrefixRequirementType {\n        self.room_config\n            .settings\n            .text_generation\n            .prefix_requirement_type\n            .or({\n                self.global_config\n                    .fallback_room_settings\n                    .text_generation\n                    .prefix_requirement_type\n            })\n            .unwrap_or(roomconfig_defaults::TEXT_GENERATION_PREFIX_REQUIREMENT_TYPE)\n    }\n\n    pub fn text_to_speech_bot_messages_flow_type(&self) -> TextToSpeechBotMessagesFlowType {\n        self.room_config\n            .settings\n            .text_to_speech\n            .bot_msgs_flow_type\n            .or({\n                self.global_config\n                    .fallback_room_settings\n                    .text_to_speech\n                    .bot_msgs_flow_type\n            })\n            .unwrap_or(roomconfig_defaults::TEXT_TO_SPEECH_BOT_MESSAGES_FLOW_TYPE)\n    }\n\n    pub fn text_to_speech_user_messages_flow_type(&self) -> TextToSpeechUserMessagesFlowType {\n        self.room_config\n            .settings\n            .text_to_speech\n            .user_msgs_flow_type\n            .or({\n                self.global_config\n                    .fallback_room_settings\n                    .text_to_speech\n                    .user_msgs_flow_type\n            })\n            .unwrap_or(roomconfig_defaults::TEXT_TO_SPEECH_USER_MESSAGES_FLOW_TYPE)\n    }\n\n    pub fn text_to_speech_speed_override(&self) -> Option<f32> {\n        self.room_config.settings.text_to_speech.speed_override.or({\n            self.global_config\n                .fallback_room_settings\n                .text_to_speech\n                .speed_override\n        })\n    }\n\n    pub fn text_to_speech_voice_override(&self) -> Option<String> {\n        self.room_config\n            .settings\n            .text_to_speech\n            .voice_override\n            .clone()\n            .or_else(|| {\n                self.global_config\n                    .fallback_room_settings\n                    .text_to_speech\n                    .voice_override\n                    .clone()\n            })\n    }\n\n    pub fn is_user_allowed_room_local_agent_manager(\n        &self,\n        user_id: OwnedUserId,\n    ) -> mxidwc::Result<bool> {\n        match &self.global_config.access.room_local_agent_manager_patterns {\n            None => Ok(false),\n            Some(patterns) => {\n                let allowed_regexes = mxidwc::parse_patterns_vector(patterns)?;\n\n                Ok(mxidwc::match_user_id(user_id.as_str(), &allowed_regexes))\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/entity/roomconfig/defaults.rs",
    "content": "use super::{SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages};\nuse super::{\n    TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextGenerationSenderContextMode,\n};\nuse super::{TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType};\n\npub const TEXT_GENERATION_PREFIX_REQUIREMENT_TYPE: TextGenerationPrefixRequirementType =\n    TextGenerationPrefixRequirementType::No;\n\npub const TEXT_GENERATION_AUTO_USAGE: TextGenerationAutoUsage = TextGenerationAutoUsage::Always;\n\npub const TEXT_GENERATION_SENDER_CONTEXT_MODE: TextGenerationSenderContextMode =\n    TextGenerationSenderContextMode::Disabled;\n\npub const TEXT_TO_SPEECH_BOT_MESSAGES_FLOW_TYPE: TextToSpeechBotMessagesFlowType =\n    TextToSpeechBotMessagesFlowType::OnDemandForVoice;\n\npub const TEXT_TO_SPEECH_USER_MESSAGES_FLOW_TYPE: TextToSpeechUserMessagesFlowType =\n    TextToSpeechUserMessagesFlowType::OnDemand;\n\npub const SPEECH_TO_TEXT_FLOW_TYPE: SpeechToTextFlowType =\n    SpeechToTextFlowType::TranscribeAndGenerateText;\n\n// While notice messages may be less desirable with other bots in the room,\n// it's probably a better default for most people who enable \"transcribe-only\" mode.\npub const SPEECH_TO_TEXT_ONLY_TRANSCRIBE_NON_THREADED_MESSAGE_TYPE:\n    SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages =\n    SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages::Text;\n"
  },
  {
    "path": "src/entity/roomconfig/entity/handler.rs",
    "content": "use serde::{Deserialize, Serialize};\n\nuse crate::agent::AgentPurpose;\n\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub struct RoomSettingsHandler {\n    /// The agent used for any of the tasks which do not have a dedicated agent for them\n    catch_all: Option<String>,\n\n    /// The agent used for text generation\n    text_generation: Option<String>,\n\n    /// The agent used for transcribing audio (voice) to text\n    speech_to_text: Option<String>,\n\n    /// The agent used for converting text to audio (voice)\n    text_to_speech: Option<String>,\n\n    /// The agent used for generating images\n    image_generation: Option<String>,\n}\n\nimpl RoomSettingsHandler {\n    pub fn get_by_purpose(&self, purpose: AgentPurpose) -> Option<String> {\n        match purpose {\n            AgentPurpose::CatchAll => self.catch_all.clone(),\n            AgentPurpose::TextGeneration => self.text_generation.clone(),\n            AgentPurpose::SpeechToText => self.speech_to_text.clone(),\n            AgentPurpose::TextToSpeech => self.text_to_speech.clone(),\n            AgentPurpose::ImageGeneration => self.image_generation.clone(),\n        }\n    }\n\n    pub fn get_by_purpose_with_catch_all_fallback(&self, purpose: AgentPurpose) -> Option<String> {\n        match self.get_by_purpose(purpose) {\n            Some(agent_id) => Some(agent_id),\n            None => self.catch_all.clone(),\n        }\n    }\n\n    pub fn set_by_purpose(&mut self, purpose: AgentPurpose, agent_id: Option<String>) {\n        match purpose {\n            AgentPurpose::CatchAll => {\n                self.catch_all = agent_id;\n            }\n            AgentPurpose::TextGeneration => {\n                self.text_generation = agent_id;\n            }\n            AgentPurpose::SpeechToText => {\n                self.speech_to_text = agent_id;\n            }\n            AgentPurpose::TextToSpeech => {\n                self.text_to_speech = agent_id;\n            }\n            AgentPurpose::ImageGeneration => {\n                self.image_generation = agent_id;\n            }\n        };\n    }\n}\n"
  },
  {
    "path": "src/entity/roomconfig/entity/mod.rs",
    "content": "use mxlink::helpers::account_data_config::RoomConfig as RoomConfigTrait;\nuse mxlink::helpers::account_data_config::RoomConfigCarrierContent as RoomConfigCarrierContentTrait;\nuse mxlink::matrix_sdk::ruma::events::macros::EventContent;\nuse mxlink::matrix_sdk::{Room, RoomMemberships};\n\nuse serde::{Deserialize, Serialize};\n\nuse crate::agent::AgentDefinition;\n\nmod handler;\nmod speech_to_text;\nmod text_generation;\nmod text_to_speech;\n\npub use handler::RoomSettingsHandler;\npub use speech_to_text::{\n    SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,\n};\npub use text_generation::{\n    TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextGenerationSenderContextMode,\n};\npub use text_to_speech::{TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType};\n\n#[derive(Clone, Debug, Deserialize, Serialize, EventContent)]\n#[ruma_event(type = \"cc.etke.baibot.room_config\", kind = RoomAccountData)]\npub struct RoomConfigCarrierContent {\n    pub payload: String,\n}\n\nimpl RoomConfigCarrierContentTrait for RoomConfigCarrierContent {\n    fn payload(&self) -> &str {\n        &self.payload\n    }\n\n    fn new(payload: String) -> Self {\n        Self { payload }\n    }\n}\n\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub struct RoomConfig {\n    pub settings: RoomSettings,\n\n    pub agents: Vec<AgentDefinition>,\n}\n\nimpl RoomConfigTrait for RoomConfig {}\n\nimpl RoomConfig {\n    pub async fn with_room(mut self, room: Room) -> Self {\n        tracing::trace!(\n            \"Determining room members count to decide on a suitable text-generation/prefix-requirement-type default\"\n        );\n\n        let members = room.members(RoomMemberships::ACTIVE).await;\n\n        let prefix_requirement_type = match members {\n            Ok(members) => {\n                let members_count = members.len();\n\n                let prefix_requirement_type = if members.len() > 2 {\n                    text_generation::TextGenerationPrefixRequirementType::CommandPrefix\n                } else {\n                    text_generation::TextGenerationPrefixRequirementType::No\n                };\n\n                tracing::info!(\n                    ?members_count,\n                    ?prefix_requirement_type,\n                    \"Determined text-generation/prefix-requirement-type based on room members count\"\n                );\n\n                prefix_requirement_type\n            }\n            Err(err) => {\n                tracing::error!(\n                    ?err,\n                    \"Failed to get members of room - will default text-generation/prefix-requirement-type to No\"\n                );\n                text_generation::TextGenerationPrefixRequirementType::No\n            }\n        };\n\n        self.settings.text_generation.prefix_requirement_type = Some(prefix_requirement_type);\n\n        self\n    }\n}\n\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub struct RoomSettings {\n    pub handler: handler::RoomSettingsHandler,\n\n    #[serde(default)]\n    pub text_generation: text_generation::RoomSettingsTextGeneration,\n\n    #[serde(default)]\n    pub speech_to_text: speech_to_text::RoomSettingsSpeechToText,\n\n    #[serde(default)]\n    pub text_to_speech: text_to_speech::RoomSettingsTextToSpeech,\n}\n"
  },
  {
    "path": "src/entity/roomconfig/entity/speech_to_text.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub struct RoomSettingsSpeechToText {\n    pub flow_type: Option<SpeechToTextFlowType>,\n\n    /// Controls how the transcribed message is posted when dealing with:\n    /// - messages that only get transcribed (and do not trigger text-generation).\n    ///   See `flow_type` and `SpeechToTextFlowType::OnlyTranscribe` for more details.\n    /// - incoming voice messages that are not part of a thread.\n    ///   For messages that are part of a thread, we need to reply within the thread in a way (with a notice message)\n    ///   that won't confuse the bot later, so we have no choice but to use a notice message.\n    ///\n    /// Text-generation may happen either as a direct result of the incoming voice message or as part of a threaded conversation.\n    /// Transcribed messages should not be attributed to the bot for the purposes of text-generation,\n    /// so any time there's a chance of text-generation happening, we should use `SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages::Notice`\n    /// and optionally prefix the message with `> 🦻`.\n    pub msg_type_for_non_threaded_only_transcribed_messages:\n        Option<SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages>,\n\n    /// The language of the input audio.\n    /// Supplying the input language in [ISO-639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) format will improve accuracy and latency.\n    pub language: Option<String>,\n}\n\n#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]\npub enum SpeechToTextFlowType {\n    /// Voice messages are to be ignored.\n    #[serde(rename = \"ignore\")]\n    Ignore,\n\n    /// Voice messages are to trigger text-generation.\n    /// This may potentially trigger speech-to-text, but that's not what we care about here.\n    #[serde(rename = \"transcribe_and_generate_text\")]\n    TranscribeAndGenerateText,\n\n    // Voices messages are to trigger transcription.\n    #[serde(rename = \"only_transcribe\")]\n    OnlyTranscribe,\n}\n\nimpl SpeechToTextFlowType {\n    pub fn choices() -> Vec<Self> {\n        vec![\n            Self::Ignore,\n            Self::TranscribeAndGenerateText,\n            Self::OnlyTranscribe,\n        ]\n    }\n\n    pub fn from_str(s: &str) -> Option<Self> {\n        match s {\n            \"ignore\" => Some(Self::Ignore),\n            \"transcribe_and_generate_text\" => Some(Self::TranscribeAndGenerateText),\n            \"only_transcribe\" => Some(Self::OnlyTranscribe),\n            _ => None,\n        }\n    }\n}\n\nimpl std::fmt::Display for SpeechToTextFlowType {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            SpeechToTextFlowType::Ignore => {\n                write!(f, \"ignore\")\n            }\n            SpeechToTextFlowType::TranscribeAndGenerateText => {\n                write!(f, \"transcribe_and_generate_text\")\n            }\n            SpeechToTextFlowType::OnlyTranscribe => write!(f, \"only_transcribe\"),\n        }\n    }\n}\n\n#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]\npub enum SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages {\n    /// Send the transcribed message text as a regular message\n    #[serde(rename = \"text\")]\n    Text,\n\n    /// Send the transcribed message text as a notice message\n    #[serde(rename = \"notice\")]\n    Notice,\n}\n\nimpl SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages {\n    pub fn choices() -> Vec<Self> {\n        vec![Self::Text, Self::Notice]\n    }\n\n    pub fn from_str(s: &str) -> Option<Self> {\n        match s {\n            \"text\" => Some(Self::Text),\n            \"notice\" => Some(Self::Notice),\n            _ => None,\n        }\n    }\n}\n\nimpl std::fmt::Display for SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages::Text => write!(f, \"text\"),\n            SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages::Notice => {\n                write!(f, \"notice\")\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/entity/roomconfig/entity/text_generation.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub struct RoomSettingsTextGeneration {\n    /// Controls whether initial text messages require a prefix to trigger text generation.\n    /// This could have been a bool, using an enum allows us to add more options (e.g. CustomPrefix) in the future.\n    /// Even if set to \"required\", prefixless-triggering could still happen via an initial voice message (see auto_usage).\n    pub prefix_requirement_type: Option<TextGenerationPrefixRequirementType>,\n\n    /// Controls whether text generation is automatically triggered (depending on message type).\n    pub auto_usage: Option<TextGenerationAutoUsage>,\n\n    /// Controls whether conversation context management is enabled.\n    /// When enabled, the bot will automatically tokenize messages and try to shorten the message context intelligently.\n    pub context_management_enabled: Option<bool>,\n\n    /// Controls how each message in the conversation context is annotated with sender metadata.\n    pub sender_context_mode: Option<TextGenerationSenderContextMode>,\n\n    /// Allows customizing the system prompt that the agent would use\n    pub prompt_override: Option<String>,\n\n    /// Allows customizing the temperature that the agent would use\n    pub temperature_override: Option<f32>,\n}\n\n#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]\npub enum TextGenerationPrefixRequirementType {\n    /// Text Generation is to be triggered for any text message\n    #[serde(rename = \"no\")]\n    No,\n\n    /// Text Generation is to be triggered only for messages that are prefixed with the command prefix\n    #[serde(rename = \"command_prefix\")]\n    CommandPrefix,\n}\n\nimpl TextGenerationPrefixRequirementType {\n    pub fn choices() -> Vec<Self> {\n        vec![Self::No, Self::CommandPrefix]\n    }\n\n    pub fn from_str(s: &str) -> Option<Self> {\n        match s {\n            \"no\" => Some(Self::No),\n            \"command_prefix\" => Some(Self::CommandPrefix),\n            _ => None,\n        }\n    }\n}\n\nimpl std::fmt::Display for TextGenerationPrefixRequirementType {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            TextGenerationPrefixRequirementType::No => write!(f, \"no\"),\n            TextGenerationPrefixRequirementType::CommandPrefix => {\n                write!(f, \"command_prefix\")\n            }\n        }\n    }\n}\n\n#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]\npub enum TextGenerationAutoUsage {\n    /// Text Generation is to never be performed\n    #[serde(rename = \"never\")]\n    Never,\n\n    /// Text Generation is to always be performed\n    #[serde(rename = \"always\")]\n    Always,\n\n    /// Text Generation is to be performed when the original message was sent as audio (voice).\n    /// The voice message would be transcribed to text (subject to other configuration)\n    /// and text generation would be triggered.\n    ///\n    /// Also see `SpeechToTextFlowType`.\n    #[serde(rename = \"only_for_voice\")]\n    OnlyForVoice,\n\n    /// Text Generation is to be performed when the original message was sent as text\n    #[serde(rename = \"only_for_text\")]\n    OnlyForText,\n}\n\nimpl TextGenerationAutoUsage {\n    pub fn choices() -> Vec<Self> {\n        vec![\n            Self::Never,\n            Self::Always,\n            Self::OnlyForVoice,\n            Self::OnlyForText,\n        ]\n    }\n\n    pub fn from_str(s: &str) -> Option<Self> {\n        match s {\n            \"never\" => Some(Self::Never),\n            \"always\" => Some(Self::Always),\n            \"only_for_voice\" => Some(Self::OnlyForVoice),\n            \"only_for_text\" => Some(Self::OnlyForText),\n            _ => None,\n        }\n    }\n}\n\nimpl std::fmt::Display for TextGenerationAutoUsage {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            TextGenerationAutoUsage::Never => write!(f, \"never\"),\n            TextGenerationAutoUsage::Always => write!(f, \"always\"),\n            TextGenerationAutoUsage::OnlyForVoice => write!(f, \"only_for_voice\"),\n            TextGenerationAutoUsage::OnlyForText => write!(f, \"only_for_text\"),\n        }\n    }\n}\n\n#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]\npub enum TextGenerationSenderContextMode {\n    #[serde(rename = \"disabled\")]\n    Disabled,\n\n    #[serde(rename = \"matrix_user_id\")]\n    MatrixUserId,\n\n    #[serde(rename = \"matrix_user_id_and_timestamp\")]\n    MatrixUserIdAndTimestamp,\n}\n\nimpl TextGenerationSenderContextMode {\n    pub fn choices() -> Vec<Self> {\n        vec![\n            Self::Disabled,\n            Self::MatrixUserId,\n            Self::MatrixUserIdAndTimestamp,\n        ]\n    }\n\n    pub fn from_str(s: &str) -> Option<Self> {\n        match s {\n            \"disabled\" => Some(Self::Disabled),\n            \"matrix_user_id\" => Some(Self::MatrixUserId),\n            \"matrix_user_id_and_timestamp\" => Some(Self::MatrixUserIdAndTimestamp),\n            _ => None,\n        }\n    }\n}\n\nimpl std::fmt::Display for TextGenerationSenderContextMode {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            TextGenerationSenderContextMode::Disabled => write!(f, \"disabled\"),\n            TextGenerationSenderContextMode::MatrixUserId => write!(f, \"matrix_user_id\"),\n            TextGenerationSenderContextMode::MatrixUserIdAndTimestamp => {\n                write!(f, \"matrix_user_id_and_timestamp\")\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/entity/roomconfig/entity/text_to_speech.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub struct RoomSettingsTextToSpeech {\n    pub bot_msgs_flow_type: Option<TextToSpeechBotMessagesFlowType>,\n\n    pub user_msgs_flow_type: Option<TextToSpeechUserMessagesFlowType>,\n\n    pub speed_override: Option<f32>,\n\n    pub voice_override: Option<String>,\n}\n\n#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]\npub enum TextToSpeechBotMessagesFlowType {\n    /// Never do text-to-speech for bot messages automatically and don't offer it\n    #[serde(rename = \"never\")]\n    Never,\n\n    /// Never do text-to-speech for bot messages automatically, but offer it via an emoji reaction for all messages\n    #[serde(rename = \"on_demand_always\")]\n    OnDemandAlways,\n\n    /// Never do text-to-speech for bot messages automatically, but offer it via an emoji reaction if the user message that prompted the bot message was audio (voice)\n    #[serde(rename = \"on_demand_for_voice\")]\n    OnDemandForVoice,\n\n    /// Convert all bot text messages to audio (voice) automatically if the user message that prompted the bot message was audio (voice)\n    #[serde(rename = \"only_for_voice\")]\n    OnlyForVoice,\n\n    /// Convert all bot text messages to audio (voice) automatically\n    #[serde(rename = \"always\")]\n    Always,\n}\n\nimpl TextToSpeechBotMessagesFlowType {\n    pub fn choices() -> Vec<Self> {\n        vec![\n            Self::Never,\n            Self::OnDemandAlways,\n            Self::OnDemandForVoice,\n            Self::Always,\n            Self::OnlyForVoice,\n        ]\n    }\n\n    pub fn from_str(s: &str) -> Option<Self> {\n        match s {\n            \"never\" => Some(Self::Never),\n            \"on_demand_always\" => Some(Self::OnDemandAlways),\n            \"on_demand_for_voice\" => Some(Self::OnDemandForVoice),\n            \"only_for_voice\" => Some(Self::OnlyForVoice),\n            \"always\" => Some(Self::Always),\n            _ => None,\n        }\n    }\n}\n\nimpl std::fmt::Display for TextToSpeechBotMessagesFlowType {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            TextToSpeechBotMessagesFlowType::Never => write!(f, \"never\"),\n            TextToSpeechBotMessagesFlowType::OnDemandAlways => write!(f, \"on_demand_always\"),\n            TextToSpeechBotMessagesFlowType::OnDemandForVoice => write!(f, \"on_demand_for_voice\"),\n            TextToSpeechBotMessagesFlowType::Always => write!(f, \"always\"),\n            TextToSpeechBotMessagesFlowType::OnlyForVoice => write!(f, \"only_for_voice\"),\n        }\n    }\n}\n\n#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]\npub enum TextToSpeechUserMessagesFlowType {\n    /// Never do text-to-speech for user messages automatically and don't offer it\n    #[serde(rename = \"never\")]\n    Never,\n\n    /// Never do text-to-speech for user messages automatically, but offer it via an emoji reaction\n    #[serde(rename = \"on_demand\")]\n    OnDemand,\n\n    /// Convert all user text messages to audio (voice) automatically\n    #[serde(rename = \"always\")]\n    Always,\n}\n\nimpl TextToSpeechUserMessagesFlowType {\n    pub fn choices() -> Vec<Self> {\n        vec![Self::Never, Self::OnDemand, Self::Always]\n    }\n\n    pub fn from_str(s: &str) -> Option<Self> {\n        match s {\n            \"never\" => Some(Self::Never),\n            \"on_demand\" => Some(Self::OnDemand),\n            \"always\" => Some(Self::Always),\n            _ => None,\n        }\n    }\n}\n\nimpl std::fmt::Display for TextToSpeechUserMessagesFlowType {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            TextToSpeechUserMessagesFlowType::Never => write!(f, \"never\"),\n            TextToSpeechUserMessagesFlowType::OnDemand => write!(f, \"on_demand\"),\n            TextToSpeechUserMessagesFlowType::Always => write!(f, \"always\"),\n        }\n    }\n}\n"
  },
  {
    "path": "src/entity/roomconfig/mod.rs",
    "content": "pub mod defaults;\nmod entity;\n\nuse mxlink::helpers::account_data_config::RoomConfigManager as AccountDataRoomConfigManager;\n\npub use entity::{RoomConfig, RoomConfigCarrierContent, RoomSettings, RoomSettingsHandler};\npub use entity::{\n    SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,\n    TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextGenerationSenderContextMode,\n    TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,\n};\n\npub type RoomConfigurationManager =\n    AccountDataRoomConfigManager<RoomConfig, RoomConfigCarrierContent>;\n"
  },
  {
    "path": "src/entity/trigger_event_info.rs",
    "content": "use mxlink::matrix_sdk::ruma::{OwnedEventId, OwnedUserId};\n\nuse super::MessagePayload;\n\n#[derive(Debug)]\npub struct TriggerEventInfo {\n    pub event_id: OwnedEventId,\n    pub sender: OwnedUserId,\n    pub payload: MessagePayload,\n    pub sender_is_admin: bool,\n}\n\nimpl TriggerEventInfo {\n    pub fn new(\n        event_id: OwnedEventId,\n        sender: OwnedUserId,\n        payload: MessagePayload,\n        sender_is_admin: bool,\n    ) -> Self {\n        Self {\n            event_id,\n            sender,\n            payload,\n            sender_is_admin,\n        }\n    }\n}\n"
  },
  {
    "path": "src/lib.rs",
    "content": "// rustc 1.94+ trips a query-depth overflow when computing async layouts in\n// the matrix-sdk timeline future graph. matrix-rust-sdk PR #6489 raises the\n// limit, but `recursion_limit` is per-crate and applies to the crate currently\n// being compiled — so the consumer has to repeat it.\n#![recursion_limit = \"256\"]\n\nmod agent;\nmod bot;\nmod controller;\nmod conversation;\nmod entity;\nmod strings;\nmod utils;\n\npub use bot::{Bot, load_config};\npub use entity::cfg::Config;\n"
  },
  {
    "path": "src/main.rs",
    "content": "use tracing_subscriber::EnvFilter;\nuse tracing_subscriber::fmt::format::FmtSpan;\n\nuse baibot::{Bot, Config, load_config};\n\n#[tokio::main]\nasync fn main() -> anyhow::Result<()> {\n    match load_config() {\n        Ok(config) => run_with_config(config).await,\n        Err(err) => Err(anyhow::anyhow!(\"Failed loading configuration: {}\", err)),\n    }\n}\n\nasync fn run_with_config(config: Config) -> anyhow::Result<()> {\n    let subscriber = tracing_subscriber::fmt()\n        .with_file(true)\n        .with_line_number(true)\n        .with_thread_ids(true)\n        .with_target(true)\n        .with_span_events(FmtSpan::NONE)\n        .with_env_filter(EnvFilter::new(config.logging.clone()))\n        .finish();\n\n    tracing::subscriber::set_global_default(subscriber).expect(\"Failed setting global subscriber\");\n\n    let bot = Bot::new(config).await?;\n\n    bot.start().await?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/strings/access.rs",
    "content": "pub fn users_no_patterns() -> String {\n    \"No user patterns are configured, so the bot can only be used by administrators.\".to_owned()\n}\n\npub fn users_now_match_patterns(patterns: &[String]) -> String {\n    format!(\n        \"The bot can be used by users with a [Matrix user id](https://spec.matrix.org/v1.11/#users) matching the following patterns: `{}`\",\n        patterns.join(\" \"),\n    )\n}\n\npub fn room_local_agent_managers_no_patterns() -> String {\n    \"No room-local agent manager patterns are configured, so new agents can only be created by administrators.\".to_owned()\n}\n\npub fn room_local_agent_managers_now_match_patterns(patterns: &[String]) -> String {\n    format!(\n        \"The bot allows users with a [Matrix user id](https://spec.matrix.org/v1.11/#users) matching the following patterns to manage agents: `{}`\",\n        patterns.join(\" \"),\n    )\n}\n\npub fn failed_to_parse_patterns(err: &str) -> String {\n    format!(\"Failed to parse patterns: {}\", err)\n}\n"
  },
  {
    "path": "src/strings/agent.rs",
    "content": "use crate::{\n    agent::{AgentInstance, AgentProvider, AgentPurpose, ControllerTrait, PublicIdentifier},\n    utils::text::block_quote,\n};\n\npub fn invalid_id_generic() -> String {\n    \"The provided agent ID is not valid.\\n\\nIt must have a prefix (like `static/`, `global/` or `room-local/`) followed by a unique identifier which does not contain `/` or spaces.\".to_string()\n}\n\npub fn invalid_id_validation_error(validation_error: String) -> String {\n    format!(\"The provided agent ID is not valid. {}\", validation_error)\n}\n\npub fn agent_with_given_identifier_missing(agent_identifier: &PublicIdentifier) -> String {\n    format!(\"There is no agent with an ID of `{}`.\", agent_identifier)\n}\n\npub fn already_exists_see_help(agent_id: &str, command_prefix: &str) -> String {\n    format!(\n        \"An agent with the ID `{}` already exists. Send a `{} help` command to the room (**not in this thread**) for more information.\",\n        agent_id, command_prefix\n    )\n}\n\npub fn incorrect_creation_invocation(command_prefix: &str) -> String {\n    format!(\n        \"Incorrect command invocation. This command expects a provider ID and an agent ID. See `{command_prefix} agent` for help.\"\n    )\n}\n\npub fn incorrect_invocation_expects_agent_id_arg(command_prefix: &str) -> String {\n    format!(\n        \"Incorrect command invocation. This command expects an agent ID. See `{command_prefix} agent` for help.\"\n    )\n}\n\npub fn not_allowed_to_manage_room_local_agents_in_room() -> String {\n    \"You are not allowed to manage room-local agents in this room.\".to_string()\n}\n\npub fn not_allowed_to_manage_static_agents() -> String {\n    \"Statically defined agents cannot be managed via commands to the bot. Consider editing the bot configuration.\".to_string()\n}\n\npub fn configuration_does_not_result_in_a_working_agent(err: anyhow::Error) -> String {\n    format!(\n        \"The provided configuration does not result in a working agent. The following error was encountered when trying to talk to the agent API:\\n```\\n{}\\n```\",\n        err,\n    )\n}\n\npub fn configuration_agent_will_ping() -> &'static str {\n    \"Checking this agent's API. Please wait..\"\n}\n\npub fn configuration_agent_ping_inconclusive() -> String {\n    \"Basic check results are inconclusive - this agent may or may not work.\".to_string()\n}\n\npub fn configuration_agent_ping_ok() -> String {\n    \"Basic checks succeeded.\".to_string()\n}\n\npub fn created(agent_identifier: &PublicIdentifier) -> String {\n    format!(\"Agent `{}` created.\", agent_identifier)\n}\n\npub fn post_creation_helpful_commands(\n    agent_identifier: &PublicIdentifier,\n    agent_instance: &AgentInstance,\n    command_prefix: &str,\n) -> String {\n    let mut message = String::new();\n\n    message.push_str(&format!(\n        \"To make use of the new agent, set it as a handler for a given purpose ({}, {}, etc.) either globally or in this room.\",\n        AgentPurpose::TextGeneration,\n        AgentPurpose::SpeechToText,\n    ));\n    message.push_str(\"\\n\\n\");\n\n    let supported_purposes: Vec<&AgentPurpose> = AgentPurpose::choices()\n        .into_iter()\n        .filter(|&p| {\n            if *p == AgentPurpose::CatchAll {\n                true\n            } else {\n                agent_instance.controller().supports_purpose(*p)\n            }\n        })\n        .collect();\n\n    if !supported_purposes.is_empty() {\n        message.push_str(\n            \"Choose and send to the room (**not in this thread**) one or a few of these commands:\",\n        );\n        message.push('\\n');\n\n        let is_room_local = matches!(agent_identifier, PublicIdentifier::DynamicRoomLocal(_));\n\n        for purpose in supported_purposes {\n            message.push_str(&format!(\n                \"\\n- {}\",\n                &set_as_purpose_handler_in_room(agent_identifier, purpose, command_prefix,)\n            ));\n\n            if !is_room_local {\n                message.push_str(&format!(\n                    \"\\n- {}\",\n                    &set_as_purpose_handler_globally(agent_identifier, purpose, command_prefix,)\n                ));\n            }\n        }\n    } else {\n        message.push_str(\n            \"This agent does not support any handler purposes and cannot really be made use of.\",\n        );\n    }\n\n    message.push_str(\"\\n\\n\");\n\n    message.push_str(&format!(\n        \"For more information about configuring handlers, see `{command_prefix} config`\\n\",\n    ));\n\n    message\n}\n\nfn set_as_purpose_handler_in_room(\n    agent_identifier: &PublicIdentifier,\n    purpose: &AgentPurpose,\n    command_prefix: &str,\n) -> String {\n    let purpose_emoji = purpose.emoji();\n\n    format!(\n        \"{purpose_emoji} Set as **{purpose}** handler in **this room**: `{command_prefix} config room set-handler {purpose} {agent_identifier}`\",\n    )\n}\n\nfn set_as_purpose_handler_globally(\n    agent_identifier: &PublicIdentifier,\n    purpose: &AgentPurpose,\n    command_prefix: &str,\n) -> String {\n    let purpose_emoji = purpose.emoji();\n\n    format!(\n        \"{purpose_emoji} Set as fallback **{purpose}** handler **globally**: `{command_prefix} config global set-handler {purpose} {agent_identifier}`\",\n    )\n}\n\npub fn configuration_not_a_valid_yaml_hashmap(err: String) -> String {\n    format!(\n        \"The provided configuration is not a valid YAML hashmap:\\n```\\n{}\\n```\",\n        err\n    )\n}\n\npub fn creation_guide(\n    agent_identifier: &PublicIdentifier,\n    provider: &AgentProvider,\n    pretty_yaml: &str,\n) -> String {\n    let mut message = String::from(\"\");\n    message.push_str(creation_welcome(agent_identifier, provider).as_str());\n    message.push('\\n');\n    message.push_str(creation_example_config(pretty_yaml).as_str());\n\n    message.push_str(\"\\n\\n\");\n    message.push_str(creation_howto().as_str());\n    message.push_str(\"\\n\\n\");\n    message.push_str(creation_raw_or_codeblock_ok().as_str());\n\n    message\n}\n\nfn creation_welcome(agent_identifier: &PublicIdentifier, provider: &AgentProvider) -> String {\n    format!(\n        \"You're defining a new agent (`{}`) powered by the `{}` provider.\\n\\nSend [YAML](https://en.wikipedia.org/wiki/YAML) configuration that describes it.\",\n        agent_identifier,\n        provider.to_static_str(),\n    )\n}\n\nfn creation_example_config(pretty_yaml: &str) -> String {\n    format!(\"Below is an example:\\n```yml\\n{}\\n```\", pretty_yaml.trim())\n}\n\nfn creation_howto() -> String {\n    format!(\n        \"{}\\n\\n{}\",\n        \"Copy, modify (with your own values) and send back the configuration to this message thread.\",\n        \"You may omit certain configuration keys (or set them to `null`) - this signals to the bot that certain capabilities are not supported by your agent.\",\n    )\n}\n\nfn creation_raw_or_codeblock_ok() -> String {\n    \"You can send the configuration as-is in a plain-text message or optionally wrap it in a [Markdown codeblock](https://www.markdownguide.org/extended-syntax/#fenced-code-blocks).\".to_string()\n}\n\npub fn removed_room_local(agent_identifier: &PublicIdentifier, command_prefix: &str) -> String {\n    let mut message = String::new();\n\n    message.push_str(&removed(agent_identifier));\n    message.push_str(\"\\n\\n\");\n\n    message.push_str(\"This room may still have it configured as a handler. If so, handlers will fail with a friendly error message.\");\n    message.push_str(\"\\n\\n\");\n\n    message.push_str(&format!(\n        \"Use the `{command_prefix} config status` command to see the handlers for this room.\",\n    ));\n\n    message\n}\n\npub fn removed_global(agent_identifier: &PublicIdentifier, command_prefix: &str) -> String {\n    let mut message = String::new();\n\n    message.push_str(&removed(agent_identifier));\n    message.push_str(\"\\n\\n\");\n\n    message.push_str(\"Neither per-room, nor global handlers were adjusted. Some rooms may still try to use this now-removed agent for a given purpose. If so, handlers for them will fail with a friendly error message.\");\n    message.push_str(\"\\n\\n\");\n\n    message.push_str(&format!(\n        \"Use the `{command_prefix} config status` command to see the handlers for this room, as well as those configured as a global fallback.\"\n    ));\n    message.push('\\n');\n    message.push_str(\"This does not cover everything, but it's a start.\");\n\n    message\n}\n\nfn removed(agent_identifier: &PublicIdentifier) -> String {\n    format!(\"Agent `{}` removed.\", agent_identifier)\n}\n\npub fn purpose_unrecognized(purpose: &str) -> String {\n    format!(\"The `{}` purpose is unrecognized.\", purpose)\n}\n\npub fn purpose_howto(purpose: &AgentPurpose) -> &'static str {\n    match purpose {\n        AgentPurpose::CatchAll => \"used as a fallback, when no specific handler is configured\",\n        AgentPurpose::TextGeneration => \"communicating with you via text\",\n        AgentPurpose::SpeechToText => \"turning your voice messages into text\",\n        AgentPurpose::TextToSpeech => \"turning bot or users text messages into voice messages\",\n        AgentPurpose::ImageGeneration => \"generating images based on instructions\",\n    }\n}\n\npub fn agent_list_empty() -> String {\n    \"No agents are available.\".to_string()\n}\n\npub fn non_empty_agent_list_block(agents: &Vec<AgentInstance>) -> String {\n    let mut message = String::new();\n\n    message.push_str(&agent_list_intro());\n    message.push('\\n');\n\n    for agent in agents {\n        let provider_info = agent.definition().provider.info();\n\n        let provider_display = match provider_info.homepage_url {\n            Some(url) => format!(\"[{}]({})\", provider_info.name, url),\n            None => provider_info.name.to_string(),\n        };\n\n        message.push_str(&format!(\n            \"- `{}` ({}), powered by {}\\n\",\n            agent.identifier(),\n            create_support_badges_text(agent.controller()),\n            provider_display,\n        ));\n    }\n\n    message\n}\n\nfn agent_list_intro() -> String {\n    \"The following agents are available:\".to_string()\n}\n\npub fn agent_list_legend_intro() -> String {\n    \"Legend:\".to_string()\n}\n\npub fn error_while_serving_purpose(\n    agent_identifier: &PublicIdentifier,\n    purpose: &AgentPurpose,\n    err: impl std::fmt::Display,\n) -> String {\n    format!(\n        \"There was a problem performing {} via the `{}` agent:\\n\\n{}\",\n        purpose,\n        agent_identifier,\n        block_quote(&err.to_string())\n    )\n}\n\npub fn empty_response_returned(agent_identifier: &PublicIdentifier) -> String {\n    format!(\"The `{agent_identifier}` agent returned an empty response.\")\n}\n\npub fn no_configuration_for_purpose_so_cannot_be_used(purpose: &AgentPurpose) -> String {\n    format!(\n        \"This agent does not contain configuration for {} {}, so it cannot be used for that.\",\n        purpose.emoji(),\n        purpose\n    )\n}\n\npub fn no_configuration_for_purpose_after_conversion_so_cannot_be_used(\n    purpose: &AgentPurpose,\n) -> String {\n    format!(\n        \"This agent's configuration was converted to the OpenAI format, but conversion failed. There is no configuration for {} {}, so it cannot be used for that.\",\n        purpose.emoji(),\n        purpose\n    )\n}\n\npub fn create_support_badges_text(controller: &impl ControllerTrait) -> String {\n    let mut support_badges = vec![];\n\n    for purpose in AgentPurpose::choices() {\n        if *purpose == AgentPurpose::CatchAll {\n            // This is not a real purpose that users care about here\n            continue;\n        }\n\n        if controller.supports_purpose(*purpose) {\n            support_badges.push(purpose.emoji());\n        }\n    }\n\n    if support_badges.is_empty() {\n        return \"❌\".to_owned();\n    }\n\n    support_badges.join(\" \")\n}\n"
  },
  {
    "path": "src/strings/cfg.rs",
    "content": "use crate::{\n    agent::{\n        AgentInstance, AgentPurpose, PublicIdentifier,\n        utils::AgentForPurposeDeterminationInfoConfigurationSource,\n    },\n    entity::roomconfig::{\n        SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,\n        TextGenerationAutoUsage, TextGenerationPrefixRequirementType,\n        TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,\n    },\n    utils::text::block_quote,\n};\n\npub fn error_config_type_not_replaced() -> String {\n    \"The `CONFIG_TYPE` placeholder in the command was not replaced.\\n\\nIt should either be set to `room` (for room-specific configuration) or `global` (for global configuration).\".to_owned()\n}\n\npub fn create_display_text_for_value(value: impl std::fmt::Display) -> String {\n    let value = value.to_string();\n\n    if value.to_string().contains(\"\\n\") {\n        format!(\"\\n\\n{}\\n\", block_quote(&value))\n    } else {\n        format!(\" `{}`\", value)\n    }\n}\n\npub fn value_currently_set_to(value: impl std::fmt::Display) -> String {\n    format!(\n        \"This configuration value is currently set to:{}\",\n        create_display_text_for_value(value)\n    )\n}\n\npub fn value_currently_unset() -> String {\n    \"This configuration value is currently unset.\".to_owned()\n}\n\npub fn configuration_invocation_incorrect_more_values_expected() -> String {\n    \"The invocation for this command is incorrect. More values are expected in your command.\"\n        .to_string()\n}\n\npub fn configuration_getter_used_with_extra_text(\n    getter_name: &str,\n    remaining_text: &str,\n) -> String {\n    format!(\n        \"You're invoking a getter command (`{getter_name}`), but passing additional text (`{remaining_text}`) as if you're invoking a setter.\\n\\nPerhaps you meant to invoke `set-{getter_name}`?\"\n    )\n}\n\npub fn configuration_value_unrecognized(value: &str) -> String {\n    format!(\"The value `{}` is not a recognized choice.\", value)\n}\n\npub fn configuration_value_not_f32(value: &str) -> String {\n    format!(\n        \"The value `{}` could not be converted to a [floating point number](https://en.wikipedia.org/wiki/Floating-point_arithmetic).\",\n        value\n    )\n}\n\npub fn status_room_config_handlers_heading() -> &'static str {\n    \"📍 Room-specific handlers\"\n}\n\npub fn status_room_config_handlers_intro() -> &'static str {\n    \"This **room's configuration** specifies the following handlers (**taking priority** over global handlers):\"\n}\n\npub fn status_room_config_handlers_outro(command_prefix: &str) -> String {\n    let mut message = String::new();\n\n    message.push_str(&format!(\n        \"Use `{command_prefix} config room set-handler` commands (see how via `{command_prefix} config`) to configure the handlers for this room.\",\n    ));\n\n    message.push_str(\"\\n\\n\");\n\n    message.push_str(\n        \"If a particular handler is unset, the catch-all handler would be used. If the catch-all handler is also unset, the global configuration would be used.\",\n    );\n\n    message\n}\n\npub fn status_global_config_handlers_heading() -> &'static str {\n    \"🌐 Global handlers\"\n}\n\npub fn status_global_config_handlers_intro() -> &'static str {\n    \"The **global configuration** specifies the following handlers:\"\n}\n\npub fn status_global_config_handlers_outro(command_prefix: &str) -> String {\n    let mut message = String::new();\n\n    message.push_str(&format!(\n        \"Use `{command_prefix} config global set-handler` commands (see how via `{command_prefix} config`) to configure the default handlers globally.\",\n    ));\n\n    message.push_str(\"\\n\\n\");\n\n    message.push_str(\"If a particular handler is unset, the catch-all agent would be used.\");\n\n    message\n}\n\nfn status_agent_not_found() -> &'static str {\n    \"not found\"\n}\n\npub fn status_handler_line_agent_found(\n    purpose: &AgentPurpose,\n    agent_id: &str,\n    agent: Option<&AgentInstance>,\n) -> String {\n    let agent_status = match agent {\n        Some(agent) => super::agent::create_support_badges_text(agent.controller()),\n        None => status_agent_not_found().to_string(),\n    };\n\n    format!(\n        \"- {} {}: `{}` ({})\",\n        purpose.emoji(),\n        purpose,\n        agent_id,\n        agent_status,\n    )\n    .to_owned()\n}\n\npub fn status_handler_line_catch_all_agent_not_set_globally() -> String {\n    format!(\n        \"- {} {}: *not set*\",\n        AgentPurpose::CatchAll.emoji(),\n        AgentPurpose::CatchAll,\n    )\n    .to_owned()\n}\n\npub fn status_handler_line_catch_all_agent_not_set_in_room_default_to_global() -> String {\n    format!(\n        \"- {} {}: *not set, defaulting to global config*\",\n        AgentPurpose::CatchAll.emoji(),\n        AgentPurpose::CatchAll,\n    )\n    .to_owned()\n}\n\npub fn status_handler_line_non_catch_all_agent_not_set_globally(purpose: &AgentPurpose) -> String {\n    format!(\n        \"- {} {}: *not set, defaulting to {}*\",\n        purpose.emoji(),\n        purpose,\n        AgentPurpose::CatchAll,\n    )\n    .to_owned()\n}\n\npub fn status_handler_line_non_catch_all_agent_not_set_in_room_default_to_global(\n    purpose: &AgentPurpose,\n) -> String {\n    format!(\n        \"- {} {}: *not set, defaulting to {} or global config*\",\n        purpose.emoji(),\n        purpose,\n        AgentPurpose::CatchAll,\n    )\n    .to_owned()\n}\n\npub fn status_room_agents_heading() -> &'static str {\n    \"🤖 Room-specific agents\"\n}\n\npub fn status_room_agents_intro() -> &'static str {\n    \"The following agents have been defined in this room:\"\n}\n\npub fn status_room_agents_empty() -> &'static str {\n    \"No agents have been defined in this room.\"\n}\n\npub fn status_room_agents_outro(command_prefix: &str) -> String {\n    let mut message = String::new();\n\n    message.push_str(\n        format!(\"Use `{command_prefix} agent create-room-local` commands (see how via `{command_prefix} help`) to define agents in this room.\").as_str()\n    );\n\n    message.push_str(\"\\n\\n\");\n\n    message.push_str(format!(\n        \"You may also use any of the globally defined agents. See `{command_prefix} agent list` to see the full list of agents.\"\n    ).as_str());\n\n    message\n}\n\npub fn status_text_generation_heading() -> String {\n    format!(\n        \"{} {}\",\n        AgentPurpose::TextGeneration.emoji(),\n        AgentPurpose::TextGeneration.heading()\n    )\n}\n\npub fn status_speech_to_text_heading() -> String {\n    format!(\n        \"{} {}\",\n        AgentPurpose::SpeechToText.emoji(),\n        AgentPurpose::SpeechToText.heading()\n    )\n}\n\npub fn status_text_to_speech_heading() -> String {\n    format!(\n        \"{} {}\",\n        AgentPurpose::TextToSpeech.emoji(),\n        AgentPurpose::TextToSpeech.heading()\n    )\n}\n\npub fn status_image_generation_heading() -> String {\n    format!(\n        \"{} {}\",\n        AgentPurpose::ImageGeneration.emoji(),\n        AgentPurpose::ImageGeneration.heading()\n    )\n}\n\npub fn status_text_generation_entry_prefix_requirement_type(\n    value: TextGenerationPrefixRequirementType,\n    set_where: &str,\n) -> String {\n    format!(\"- 🗟 Prefix Requirement Type: `{}` ({})\\n\", value, set_where)\n}\n\npub fn status_text_generation_entry_auto_usage(\n    value: TextGenerationAutoUsage,\n    set_where: &str,\n) -> String {\n    format!(\"- 🪄 Auto usage: `{}` ({})\\n\", value, set_where)\n}\n\npub fn status_text_generation_entry_context_management(value: bool, set_where: &str) -> String {\n    format!(\"- ♻️ Context management: `{}` ({})\\n\", value, set_where)\n}\n\npub fn status_text_generation_entry_sender_context(\n    value: impl std::fmt::Display,\n    set_where: &str,\n) -> String {\n    format!(\"- 👤 Sender context mode: `{}` ({})\\n\", value, set_where)\n}\n\npub fn status_text_generation_entry_prompt(value: &str, set_where: &str) -> String {\n    let value = value.trim();\n\n    if value.is_empty() {\n        format!(\"- ⌨️ Prompt: not using a prompt ({})\\n\", set_where)\n    } else {\n        format!(\"- ⌨️ Prompt ({}):\\n\\n{}\\n\\n\", set_where, block_quote(value))\n    }\n}\n\npub fn status_text_generation_entry_temperature(value: Option<f32>, set_where: &str) -> String {\n    let formatted = match value {\n        Some(value) => format!(\"`{:.1}` ({})\", value, set_where),\n        None => \"not set\".to_string(),\n    };\n\n    format!(\"- 🌡️ Temperature: {}\\n\", formatted)\n}\n\npub fn status_speech_to_text_entry_flow_type(\n    value: SpeechToTextFlowType,\n    set_where: &str,\n) -> String {\n    format!(\"- 🪄 Flow type: `{}` ({})\\n\", value, set_where)\n}\n\npub fn status_speech_to_text_entry_msg_type_for_non_threaded_only_transcribed_messages(\n    value: SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,\n    set_where: &str,\n) -> String {\n    format!(\n        \"- 🪄 Message type for non-threaded only-transcribed messages: `{}` ({})\\n\",\n        value, set_where\n    )\n}\n\npub fn status_speech_to_text_entry_language(value: Option<String>, set_where: &str) -> String {\n    let formatted = match value {\n        Some(value) => format!(\"`{}` ({})\", value, set_where),\n        None => \"not set, using auto-detection\".to_string(),\n    };\n\n    format!(\"- 🔤 Language: {}\\n\", formatted)\n}\n\npub fn status_text_to_speech_entry_bot_msgs_flow_type(\n    value: TextToSpeechBotMessagesFlowType,\n    set_where: &str,\n) -> String {\n    format!(\n        \"- 🪄 Flow type for bot messages: `{}` ({})\\n\",\n        value, set_where\n    )\n}\n\npub fn status_text_to_speech_entry_user_msgs_flow_type(\n    value: TextToSpeechUserMessagesFlowType,\n    set_where: &str,\n) -> String {\n    format!(\n        \"- 🪄 Flow type for user messages: `{}` ({})\\n\",\n        value, set_where\n    )\n}\n\npub fn status_text_to_speech_entry_speed(value: Option<f32>, set_where: &str) -> String {\n    let formatted = match value {\n        Some(value) => format!(\"`{:.1}` ({})\", value, set_where),\n        None => \"not set\".to_string(),\n    };\n\n    format!(\"- ⚡ Speed: {}\\n\", formatted)\n}\n\npub fn status_text_to_speech_entry_voice(value: Option<String>, set_where: &str) -> String {\n    let formatted = match value {\n        Some(value) => format!(\"`{}` ({})\", value, set_where),\n        None => \"not set\".to_string(),\n    };\n\n    format!(\"- 👫 Voice: {}\\n\", formatted)\n}\n\npub fn status_entry_effective_agent_error() -> String {\n    \"- 🤖 Effective handler agent: error determining agent\\n\".to_string()\n}\n\npub fn status_entry_effective_agent(\n    value: &PublicIdentifier,\n    source: AgentForPurposeDeterminationInfoConfigurationSource,\n) -> String {\n    let set_where = match source {\n        AgentForPurposeDeterminationInfoConfigurationSource::Room => {\n            status_badge_set_in_room_config()\n        }\n        AgentForPurposeDeterminationInfoConfigurationSource::Global => {\n            status_badge_set_in_global_config()\n        }\n    };\n\n    format!(\n        \"- 🤖 Effective handler agent: `{}` ({})\\n\",\n        value, set_where\n    )\n}\n\npub fn status_badge_set_in_room_config() -> &'static str {\n    \"**📍 set in room**\"\n}\n\npub fn status_badge_set_in_global_config() -> &'static str {\n    \"**🌐 set globally**\"\n}\n\npub fn status_badge_using_hardcoded_default() -> &'static str {\n    \"📝 using hardcoded default\"\n}\n\npub fn status_badge_set_in_agent_config() -> &'static str {\n    \"🤖 set at the agent level\"\n}\n"
  },
  {
    "path": "src/strings/error.rs",
    "content": "pub fn unknown_command_see_help(command_prefix: &str) -> String {\n    format!(\"Unknown command. See help (`{command_prefix} help`).\")\n}\n\npub fn error_while_processing_message() -> &'static str {\n    \"An error occurred while processing your message. Please try again.\"\n}\n\npub fn message_is_encrypted() -> &'static str {\n    \"This message is encrypted and I cannot decrypt it right now, so I cannot properly serve you.\"\n}\n\npub fn first_message_in_thread_is_encrypted() -> &'static str {\n    \"The first message in this chat thread is encrypted and I cannot decrypt it right now, so I cannot properly serve you.\"\n}\n"
  },
  {
    "path": "src/strings/global_config.rs",
    "content": "use crate::agent::{AgentPurpose, PublicIdentifier};\n\npub fn no_permissions_to_administrate() -> &'static str {\n    \"You do not have permission to administrate the global config.\"\n}\n\npub fn not_allowed_to_use_agent_in_global_config(agent_identifier: &PublicIdentifier) -> String {\n    format!(\n        \"The agent `{}` is not allowed to be used in the global configuration.\",\n        agent_identifier\n    )\n}\n\npub fn global_config_lacks_specific_agent_for_purpose(purpose: AgentPurpose) -> String {\n    format!(\n        \"The global configuration does not specify any agent for the `{}` purpose.\",\n        purpose\n    )\n}\n\npub fn configured_to_use_agent_for_purpose(\n    agent_identifier: &PublicIdentifier,\n    purpose: AgentPurpose,\n) -> String {\n    format!(\n        \"The global configuration specifies that the `{}` agent is to be used for the `{}` purpose.\",\n        agent_identifier, purpose\n    )\n}\n\npub fn configures_agent_for_purpose_but_does_not_exist(\n    agent_identifier: &PublicIdentifier,\n    purpose: AgentPurpose,\n) -> String {\n    format!(\n        \"The global configuration specifies that the `{}` agent is to be used for the `{}` purpose, but such an agent does not exist.\",\n        agent_identifier, purpose\n    )\n}\n\npub fn reconfigured_to_use_agent_for_purpose(\n    agent_identifier: &PublicIdentifier,\n    purpose: AgentPurpose,\n) -> String {\n    format!(\n        \"The global configuration has been adjusted to use the `{}` agent for the `{}` purpose.\",\n        agent_identifier, purpose\n    )\n}\n\npub fn reconfigured_to_not_specify_agent_for_purpose(purpose: AgentPurpose) -> String {\n    format!(\n        \"The global configuration has been adjusted to not specify any agent for the `{}` purpose.\",\n        purpose\n    )\n}\n\npub fn value_was_set_to(value: impl std::fmt::Display) -> String {\n    format!(\n        \"This global configuration value was set to:{}\",\n        super::cfg::create_display_text_for_value(value)\n    )\n}\n\npub fn value_was_unset() -> String {\n    \"This global configuration value has been unset.\".to_owned()\n}\n"
  },
  {
    "path": "src/strings/help/access.rs",
    "content": "pub fn heading() -> String {\n    \"🔒 Access\".to_owned()\n}\n\npub fn intro() -> String {\n    \"This bot employs access control to decide who can use its services and manage its configuration.\".to_string()\n}\n\npub fn room_auto_join_heading() -> String {\n    \"👋 Joining rooms\".to_owned()\n}\n\npub fn room_auto_join_intro() -> String {\n    \"The bot automatically joins rooms when invited by someone considered a bot user (see below).\"\n        .to_string()\n}\n\npub fn users_heading() -> String {\n    \"👥 Users\".to_owned()\n}\n\npub fn users_intro() -> String {\n    \"The bot will ignore messages (and room invitations) from unallowed users.\".to_string()\n}\n\npub fn users_access() -> String {\n    \"Users can **use all the bot's features** (text-generation, speech-to-text, etc.), but cannot manage the bot's configuration.\".to_string()\n}\n\npub fn users_command_get(command_prefix: &str) -> String {\n    format!(\"- **Show** the currently allowed users: `{command_prefix} access users`\")\n}\n\npub fn users_command_set(command_prefix: &str) -> String {\n    format!(\n        \"- **Set** the list of allowed users: `{command_prefix} access set-users SPACE_SEPARATED_PATTERNS`\"\n    )\n}\n\npub fn example_user_patterns(own_server_name: &str) -> String {\n    format!(\"Example patterns: `@*:{own_server_name} @*:another.com @someone:company.org`\")\n}\n\npub fn administrators_heading() -> String {\n    \"👮‍♂️ Administrators\".to_owned()\n}\n\npub fn administrators_intro() -> String {\n    \"Administrators can **manage the bot's configuration and access control**.\".to_string()\n}\n\npub fn administrators_now_match_patterns(patterns: &[String]) -> String {\n    format!(\n        \"The bot can be administrated by users with a [Matrix user id](https://spec.matrix.org/v1.11/#users) matching the following patterns: `{}`\",\n        patterns.join(\" \"),\n    )\n}\n\npub fn administrators_outro() -> String {\n    \"Administrators cannot be changed without adjusting the bot's configuration on the server.\"\n        .to_string()\n}\n\npub fn room_local_agent_managers_heading() -> String {\n    \"💼 Room-local agent managers\".to_owned()\n}\n\npub fn room_local_agent_managers_intro(command_prefix: &str) -> String {\n    format!(\n        \"Room-local agent managers are users privileged to **create their own agents** (see `{command_prefix} agent`) in rooms.\"\n    )\n}\n\npub fn room_local_agent_managers_security_warning() -> String {\n    \"Letting regular users create agents which contact arbitrary network services **may be a security issue**.\".to_string()\n}\n\npub fn room_local_agent_managers_command_get(command_prefix: &str) -> String {\n    format!(\n        \"- **Show** the currently allowed users: `{command_prefix} access room-local-agent-managers`\"\n    )\n}\n\npub fn room_local_agent_managers_command_set(command_prefix: &str) -> String {\n    format!(\n        \"- **Set** the list of allowed users: `{command_prefix} access set-room-local-agent-managers SPACE_SEPARATED_PATTERNS`\"\n    )\n}\n"
  },
  {
    "path": "src/strings/help/agent.rs",
    "content": "pub fn heading() -> String {\n    \"🤖 Agents\".to_owned()\n}\n\npub fn intro(command_prefix: &str) -> String {\n    format!(\n        \"An agent is an instantiation and configuration of some **☁️ provider** (see `{command_prefix} provider`).\"\n    )\n}\n\npub fn intro_handler_relation(command_prefix: &str) -> String {\n    format!(\n        \"Agents can be set as **handlers for various purposes** (text-generation, speech-to-text, etc.) globally or in specific rooms. Send a `{command_prefix} config status` command to see the current configuration.\"\n    )\n}\n\npub fn intro_capabilities() -> String {\n    \"It can support different capabilities (text-generation, speech-to-text, etc.) depending on the provider used and on the configuration of the agent.\".to_string()\n}\n\npub fn no_permission_to_create_agents() -> &'static str {\n    \"⚠️ You are neither a bot administrator, nor a room-local agent manager, so **you cannot create new agents by yourself**.\"\n}\n\npub fn list_agents(command_prefix: &str) -> String {\n    format!(\"- **List** all available agents: `{command_prefix} agent list`\")\n}\n\npub fn show_agent_details(command_prefix: &str) -> String {\n    format!(\n        \"- **Show** full details for a given agent: `{command_prefix} agent details FULL_AGENT_IDENTIFIER`\"\n    )\n}\n\npub fn create_agent_intro() -> &'static str {\n    \"- **Create** a new agent:\"\n}\n\npub fn create_agent_room_local(command_prefix: &str) -> String {\n    format!(\n        \"\\t- (Accessible in **this room only**) `{command_prefix} agent create-room-local PROVIDER_ID AGENT_ID`\"\n    )\n}\n\npub fn create_agent_global(command_prefix: &str) -> String {\n    format!(\n        \"\\t- (Accessible in **all rooms**) `{command_prefix} agent create-global PROVIDER_ID AGENT_ID`\"\n    )\n}\n\npub fn create_agent_example(command_prefix: &str) -> String {\n    format!(\"\\t- Example: `{command_prefix} agent create-room-local openai my-openai-agent`\")\n}\n\npub fn delete_agent(command_prefix: &str) -> String {\n    format!(\"- **Delete** an agent: `{command_prefix} agent delete FULL_AGENT_IDENTIFIER`\")\n}\n\npub fn available_commands_outro_update_note() -> &'static str {\n    \"To **update** a given agent's configuration: show the agent's **details** (current configuration), then **delete** it and finally **re-create** it.\"\n}\n"
  },
  {
    "path": "src/strings/help/cfg.rs",
    "content": "use crate::agent::AgentPurpose;\n\npub fn heading() -> String {\n    \"🛠️ Configuration\".to_owned()\n}\n\npub fn intro_short() -> &'static str {\n    \"Various settings for this bot can be configured **📍 per-room** and **🌐 globally**.\"\n}\n\npub fn intro_long() -> String {\n    format!(\n        \"{}\\n\\n{}\\n{}\\n\\n{}\",\n        intro_short(),\n        \"Room-specific configuration values override the global configuration.\",\n        \"When no configuration values are set, the bot uses hardcoded defaults.\",\n        \"In commands below, **replace the `CONFIG_TYPE` value** with either `room` (for room-specific configuration) or `global` (for global configuration).\"\n    )\n}\n\npub fn status_heading() -> String {\n    \"📃 Status\".to_owned()\n}\n\npub fn status_intro(command_prefix: &str) -> String {\n    format!(\n        \"To **show a summary** of the configuration affecting the current room: `{command_prefix} config status`\"\n    )\n}\n\npub fn handlers_heading() -> String {\n    \"🤖 Handler Agents\".to_owned()\n}\n\npub fn handlers_intro_common() -> String {\n    format!(\n        \"{}\\n\\n{}\",\n        \"Different messages (text, audio, requests for image generation, etc.) in the room are handled differently and can potentially be served by different agents.\",\n        \"When no specific agent is configured for a given purpose, the catch-all agent would be used.\",\n    )\n}\n\npub fn handlers_intro_purposes() -> String {\n    let mut message = String::new();\n\n    message.push_str(\"The following purposes are available:\");\n    message.push('\\n');\n\n    for purpose in AgentPurpose::choices() {\n        message.push_str(&format!(\n            \"\\n- {} {}: {}\",\n            purpose.emoji(),\n            purpose.as_str(),\n            super::super::agent::purpose_howto(purpose),\n        ));\n    }\n\n    message\n}\n\npub fn handlers_show(command_prefix: &str) -> String {\n    format!(\n        \"**Show** the currently configured agent for the given purpose: `{command_prefix} config CONFIG_TYPE handler PURPOSE`\"\n    )\n}\n\npub fn handlers_set(command_prefix: &str) -> String {\n    format!(\n        \"**Set** the agent to be used for the given purpose: `{command_prefix} config CONFIG_TYPE set-handler PURPOSE AGENT_ID`\"\n    )\n}\n\npub fn handlers_unset(command_prefix: &str) -> String {\n    format!(\n        \"**Unset** the agent to be used for the given purpose: `{command_prefix} config CONFIG_TYPE set-handler PURPOSE`\"\n    )\n}\n\npub fn text_generation_heading() -> String {\n    format!(\n        \"{} {}\",\n        AgentPurpose::TextGeneration.emoji(),\n        AgentPurpose::TextGeneration.heading()\n    )\n}\n\npub fn text_generation_common() -> String {\n    let text_generation_description =\n        \"Text Generation is the bot's ability to generate text based on the input it receives.\";\n\n    let input_types = format!(\n        \"This input may be received directly as text, or as audio (a voice message) transcribed to text by the bot itself (see {} {}).\",\n        AgentPurpose::SpeechToText.emoji(),\n        AgentPurpose::SpeechToText.heading()\n    );\n\n    format!(\"{}\\n{}\", text_generation_description, input_types)\n}\n\npub fn text_generation_prefix_requirement_type_heading() -> &'static str {\n    \"🗟 Prefix Requirement Type\"\n}\n\npub fn text_generation_prefix_requirement_type_intro() -> String {\n    \"Controls whether all messages trigger text generation or just those prefixed in a certain way.\"\n        .to_owned()\n}\n\npub fn text_generation_prefix_requirement_type_outro(bot_username: &str) -> String {\n    format!(\n        \"Regardless of the setting, the bot will always respond to **direct mentions** (e.g. `@{bot_username}`).\"\n    )\n}\n\npub fn text_generation_auto_usage_heading() -> &'static str {\n    \"🪄 Auto usage\"\n}\n\npub fn text_generation_auto_usage_intro() -> String {\n    \"Controls how automatic text-generation functions.\".to_owned()\n}\n\npub fn text_generation_context_management_heading() -> &'static str {\n    \"♻️ Context Management\"\n}\n\npub fn text_generation_context_management_intro() -> String {\n    format!(\n        \"{}\\n{}\",\n        \"Controls the bot's ability to **intelligently drop old messages from the conversation context** when it gets too large.\",\n        \"This feature relies on [tokenization](https://en.wikipedia.org/wiki/Large_language_model#Tokenization) performed by the [tiktoken-rs](https://github.com/zurawiki/tiktoken-rs) library which is [poorly well-maintained](https://github.com/zurawiki/tiktoken-rs/issues/50) and only works well for [OpenAI](./providers.md#openai) models.\",\n    )\n}\n\npub fn text_generation_sender_context_heading() -> &'static str {\n    \"👤 Sender Context Mode\"\n}\n\npub fn text_generation_sender_context_intro() -> String {\n    format!(\n        \"{}\\n{}\",\n        \"Controls whether the bot attaches sender information to conversation messages before sending them to the model.\",\n        \"`disabled` leaves messages unchanged, `matrix_user_id` adds `[sender=@alice:example.com]`, and `matrix_user_id_and_timestamp` adds `[sender=@alice:example.com sent_at=2026-03-23T14:30:00Z]`. Enabling this sends Matrix user IDs, and optionally timestamps, to the model provider.\",\n    )\n}\n\npub fn text_generation_prompt_override_heading() -> &'static str {\n    \"⌨️ Prompt Override\"\n}\n\npub fn text_generation_prompt_override_intro() -> String {\n    \"Lets you override the [system prompt](https://huggingface.co/docs/transformers/en/tasks/prompting) parameter configured at the agent level.\".to_string()\n}\n\npub fn text_generation_temperature_override_heading() -> &'static str {\n    \"🌡️ Temperature Override\"\n}\n\npub fn text_generation_temperature_override_intro() -> String {\n    \"Lets you override the [temperature](https://blogs.novita.ai/what-are-large-language-model-settings-temperature-top-p-and-max-tokens/#what-is-llm-temperature) (randomness / creativity) parameter configured at the agent level.\".to_string()\n}\n\npub fn current_setting_show(command_prefix: &str, setting_path_parts: &str) -> String {\n    format!(\n        \"**Show** the current setting: `{command_prefix} config CONFIG_TYPE {setting_path_parts}`\"\n    )\n}\n\npub fn current_setting_set(command_prefix: &str, setting_path_parts: &str) -> String {\n    format!(\"**Set**: `{command_prefix} config CONFIG_TYPE {setting_path_parts}`\")\n}\n\npub fn current_setting_unset(command_prefix: &str, setting_path_parts: &str) -> String {\n    format!(\"**Unset**: `{command_prefix} config CONFIG_TYPE {setting_path_parts}`\")\n}\n\npub fn the_following_configuration_values_are_recognized(\n    values: Vec<impl std::fmt::Display>,\n) -> String {\n    let values_with_backticks = values\n        .iter()\n        .map(|v| format!(\"`{}`\", v))\n        .collect::<Vec<String>>();\n\n    format!(\n        \"The following configuration values are recognized: {}\",\n        values_with_backticks.join(\", \")\n    )\n}\n\npub fn speech_to_text_heading() -> String {\n    format!(\n        \"{} {}\",\n        AgentPurpose::SpeechToText.emoji(),\n        AgentPurpose::SpeechToText.heading()\n    )\n}\n\npub fn speech_to_text_common() -> String {\n    let intro = \"Speech-to-Text is the bot's ability to **turn audio (voice) messages into text**.\";\n\n    let text_gen = format!(\n        \"The generated text can be used for {} {}, or not (transcription only).\",\n        AgentPurpose::TextGeneration.emoji(),\n        AgentPurpose::TextGeneration.heading()\n    );\n\n    let text_to_speech = format!(\n        \"The bot may also turn the generated text response back into a voice message (see {} {}).\",\n        AgentPurpose::TextToSpeech.emoji(),\n        AgentPurpose::TextToSpeech.heading()\n    );\n\n    format!(\"{}\\n{}\\n{}\", intro, text_gen, text_to_speech)\n}\n\npub fn speech_to_text_flow_type_heading() -> &'static str {\n    \"🪄 Flow Type\"\n}\n\npub fn speech_to_text_flow_type_intro() -> &'static str {\n    \"Controls how voice messages are handled.\"\n}\n\npub fn speech_to_text_msg_type_for_non_threaded_only_transcribed_messages_heading() -> &'static str\n{\n    \"🪄 Message Type for non-threaded only-transcribed messages\"\n}\n\npub fn speech_to_text_msg_type_for_non_threaded_only_transcribed_messages_intro() -> &'static str {\n    \"Controls how the transcribed text of voice messages is sent to the chat when Flow Type = `only_transcribe`.\"\n}\n\npub fn speech_to_text_language_heading() -> &'static str {\n    \"🔤 Language\"\n}\n\npub fn speech_to_text_language_intro() -> &'static str {\n    \"Lets you specify the language of the input voice messages, to avoid using auto-detection.\\nSupplying the input language using a 2-letter code (e.g. `ja`) as per [ISO-639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) may improve accuracy & latency.\\n\\nIf different users are using different languages, do not specify a language.\"\n}\n\npub fn text_to_speech_heading() -> String {\n    format!(\n        \"{} {}\",\n        AgentPurpose::TextToSpeech.emoji(),\n        AgentPurpose::TextToSpeech.heading()\n    )\n}\n\npub fn text_to_speech_common() -> &'static str {\n    \"Text-to-Speech is the bot's ability to **turn text messages into voice messages**.\"\n}\n\npub fn text_to_speech_bot_msgs_flow_type_heading() -> &'static str {\n    \"🪄 Bot Messages Flow Type\"\n}\n\npub fn text_to_speech_bot_msgs_flow_type_intro() -> String {\n    \"Controls how automatic text-to-speech functions for **messages sent by the bot**.\".to_owned()\n}\n\npub fn text_to_speech_user_msgs_flow_type_heading() -> &'static str {\n    \"🪄 User Messages Flow Type\"\n}\n\npub fn text_to_speech_user_msgs_flow_type_intro() -> String {\n    \"Controls how automatic text-to-speech functions for **messages sent by users**.\\n**Only works when automatic text-generation is disabled** (see Text Generation / Auto usage).\".to_owned()\n}\n\npub fn text_to_speech_speed_override_heading() -> &'static str {\n    \"🗲 Speed override\"\n}\n\npub fn text_to_speech_speed_override_intro() -> String {\n    format!(\n        \"{}\\n{}\",\n        \"Lets you speed up/down speech relative to the default speed (`1.0` when unset).\",\n        \"Values typically range from `0.25` to `4.0`, but may vary depending on the selected model.\",\n    )\n}\n\npub fn text_to_speech_voice_override_heading() -> &'static str {\n    \"👫 Voice override\"\n}\n\npub fn text_to_speech_voice_override_intro() -> String {\n    format!(\n        \"{}\\n\\n{}\",\n        \"Lets you change the default voice configured in the agent configuration.\",\n        \"Possible values (e.g. `onyx`) depend on the model you're using. For example, for OpenAI's Whisper model, [these voices](https://platform.openai.com/docs/guides/text-to-speech/voice-options) are available.\",\n    )\n}\n\npub fn image_generation_heading() -> String {\n    format!(\n        \"{} {}\",\n        AgentPurpose::ImageGeneration.emoji(),\n        AgentPurpose::ImageGeneration.heading()\n    )\n}\n\npub fn image_generation_common() -> &'static str {\n    \"Image-generation is the bot's ability to **generate images** based on text prompts.\\n\\nThis feature is not configurable at the moment.\"\n}\n"
  },
  {
    "path": "src/strings/help/mod.rs",
    "content": "pub mod access;\npub mod agent;\npub mod cfg;\npub mod provider;\npub mod usage;\n\npub fn heading_introduction() -> String {\n    \"👋 Introduction\".to_owned()\n}\n\npub fn available_commands_intro() -> &'static str {\n    \"You can run the following commands:\"\n}\n\npub fn learn_more_send_a_command(command_prefix: &str, command_parts: &str) -> String {\n    format!(\"To learn more, send a `{command_prefix} {command_parts}` command.\")\n}\n"
  },
  {
    "path": "src/strings/help/provider.rs",
    "content": "pub fn heading() -> String {\n    \"☁️ Providers\".to_owned()\n}\n\npub fn intro() -> String {\n    \"Agents are powered by a provider. The provider could be powered by a local service or a cloud service.\".to_string()\n}\n"
  },
  {
    "path": "src/strings/help/usage.rs",
    "content": "pub fn heading() -> &'static str {\n    \"📖 Usage\"\n}\n\npub fn intro() -> &'static str {\n    \"The bot can perform various tasks, such as 💬 Text Generation, 🗣️ Text-to-Speech, 🦻 Speech-to-Text, 🖌️ Image Generation, and more.\"\n}\n"
  },
  {
    "path": "src/strings/image_edit.rs",
    "content": "pub fn guide_how_to_proceed() -> String {\n    let mut message = String::new();\n\n    message.push_str(\"💡 Respond in this thread (in any order) with:\\n\");\n    message.push_str(\"- one or more images: to use the given images for creating an edit\\n\");\n    message.push_str(\"- more messages: to expand on your original prompt\\n\");\n    message.push_str(\"- a message saying `go`: to generate an edit with the current prompt\\n\");\n    message.push_str(\n        \"- a message saying `again`: to generate one more image edit with the current prompt\\n\",\n    );\n\n    message\n}\n"
  },
  {
    "path": "src/strings/image_generation.rs",
    "content": "pub fn revised_prompt(prompt: &str) -> String {\n    format!(\"💭 Revised prompt to: {}\", prompt)\n}\n\npub fn guide_how_to_proceed() -> String {\n    let mut message = String::new();\n\n    message.push_str(\"💡 Respond in this thread with:\\n\");\n    message.push_str(\"- more messages: to expand on your original prompt\\n\");\n    message.push_str(\n        \"- a message saying `again`: to generate one more image with the current prompt\\n\",\n    );\n\n    message\n}\n"
  },
  {
    "path": "src/strings/introduction.rs",
    "content": "use crate::agent::utils::AgentForPurposeDeterminationError;\nuse crate::agent::utils::get_effective_agent_for_purpose;\nuse crate::agent::{AgentPurpose, Manager as AgentManager};\nuse crate::entity::RoomConfigContext;\nuse crate::entity::roomconfig::TextGenerationPrefixRequirementType;\n\nfn hello() -> &'static str {\n    \"Hello! 👋\"\n}\n\npub fn its_me(name: &str) -> String {\n    let mut message = format!(\n        \"I'm {name} - a bot exposing the power of [AI](https://en.wikipedia.org/wiki/Artificial_intelligence) ([Large Language Models](https://en.wikipedia.org/wiki/Large_language_model)) to you. 🤖\"\n    );\n\n    if name == crate::entity::cfg::defaults::name() {\n        message.push('\\n');\n        message.push_str(\"My name is pronounced 'bye' and is a play on [AI](https://en.wikipedia.org/wiki/Artificial_intelligence), referencing the fictional character [🇧🇬 Bai Ganyo](https://en.wikipedia.org/wiki/Bay_Ganyo).\");\n    }\n\n    message\n}\n\nfn purposes_intro() -> &'static str {\n    \"I can typically be used for the following purposes:\"\n}\n\npub async fn create_on_join_introduction(\n    name: &str,\n    command_prefix: &str,\n    agent_manager: &AgentManager,\n    room_config_context: &RoomConfigContext,\n) -> String {\n    let mut message = String::new();\n\n    message.push_str(hello());\n    message.push_str(\"\\n\\n\");\n    message.push_str(&create_short_introduction(name));\n    message.push_str(\"\\n\\n\");\n\n    let mut got_text_generation_agent = false;\n\n    message.push_str(purposes_intro());\n    for purpose in AgentPurpose::choices() {\n        if *purpose == AgentPurpose::CatchAll {\n            continue;\n        }\n\n        let mut purpose_intro_line = format!(\n            \"\\n- {} {}: {}\",\n            purpose.emoji(),\n            purpose.as_str(),\n            super::agent::purpose_howto(purpose),\n        );\n\n        let agent_info =\n            get_effective_agent_for_purpose(agent_manager, room_config_context, *purpose).await;\n\n        let current_status_text = match agent_info {\n            Ok(agent_info) => {\n                let agent_instance = agent_info.instance;\n                let provider_info = agent_instance.definition().provider.info();\n\n                if *purpose == AgentPurpose::TextGeneration {\n                    got_text_generation_agent = true;\n                }\n\n                let provider_display = match provider_info.homepage_url {\n                    Some(url) => format!(\"[{}]({})\", provider_info.name, url),\n                    None => provider_info.name.to_owned(),\n                };\n\n                format!(\n                    \"✅ enabled via the `{}` agent, powered by the {} provider\",\n                    agent_instance.identifier(),\n                    provider_display,\n                )\n            }\n            Err(err) => match err {\n                AgentForPurposeDeterminationError::Unknown(err) => {\n                    crate::utils::status::create_error_message_text(&err).to_owned()\n                }\n                AgentForPurposeDeterminationError::NoneConfigured => {\n                    \"❌ no agent configured\".to_string()\n                }\n                AgentForPurposeDeterminationError::ConfiguredButMissing(agent_identifier) => {\n                    format!(\"❌ configured via `{agent_identifier}`, but the agent is missing\")\n                }\n                AgentForPurposeDeterminationError::ConfiguredButLacksSupport(agent_identifier) => {\n                    format!(\"❌ configured via `{agent_identifier}`, but support is missing\")\n                }\n            },\n        };\n\n        purpose_intro_line.push_str(&format!(\" ({})\", current_status_text));\n\n        message.push_str(&purpose_intro_line);\n    }\n    message.push_str(\"\\n\\n\");\n\n    if got_text_generation_agent {\n        message.push_str(&make_use_of_me_simply_send_a_message(\n            command_prefix,\n            room_config_context.text_generation_prefix_requirement_type(),\n        ));\n    } else {\n        message.push_str(&make_use_of_me_agent_creation(\n            command_prefix,\n            room_config_context.text_generation_prefix_requirement_type(),\n        ));\n    }\n\n    message\n}\n\npub fn create_short_introduction(name: &str) -> String {\n    its_me(name)\n}\n\nfn make_use_of_me_simply_send_a_message(\n    command_prefix: &str,\n    prefix_requirement_type: TextGenerationPrefixRequirementType,\n) -> String {\n    let message = r#\"**To make use of me**:\n\n1. 👋 %send_a_message%\n2. 📖 %learn_more%\n\"#;\n\n    message\n        .replace(\"%command_prefix%\", command_prefix)\n        .replace(\n            \"%send_a_message%\",\n            &send_a_text_message(command_prefix, prefix_requirement_type),\n        )\n        .replace(\n            \"%learn_more%\",\n            &learn_more_from_usage_or_help(command_prefix),\n        )\n}\n\nfn make_use_of_me_agent_creation(\n    command_prefix: &str,\n    prefix_requirement_type: TextGenerationPrefixRequirementType,\n) -> String {\n    let message = r#\"**To make use of me**:\n\n1. ☁️ **Choose an agent provider** (e.g. OpenAI, Mistral, etc). Send a `%command_prefix% provider` command to see the list.\n2. 🤖 %create_one_or_more_agents%\n3. 🤝 %set_new_agent_as_handler%\n4. 👋 %send_a_message%\n5. 📖 %learn_more%\n\"#;\n\n    message\n        .replace(\"%command_prefix%\", command_prefix)\n        .replace(\n            \"%send_a_message%\",\n            &send_a_text_message(command_prefix, prefix_requirement_type),\n        )\n        .replace(\n            \"%learn_more%\",\n            &learn_more_from_usage_or_help(command_prefix),\n        )\n        .replace(\n            \"%create_one_or_more_agents%\",\n            &create_one_or_more_agents(command_prefix),\n        )\n        .replace(\n            \"%set_new_agent_as_handler%\",\n            &set_new_agent_as_handler(command_prefix),\n        )\n}\n\nfn send_a_text_message(\n    command_prefix: &str,\n    prefix_requirement_type: TextGenerationPrefixRequirementType,\n) -> String {\n    match prefix_requirement_type {\n        TextGenerationPrefixRequirementType::No => {\n            \"**Send a text message** in this room (e.g. `Hello!`) and see me reply.\".to_owned()\n        }\n        TextGenerationPrefixRequirementType::CommandPrefix => {\n            format!(\n                \"In this room, I'm configured to require the command prefix (`{command_prefix}`) for text messages. **Send a prefixed text message** (e.g. `{command_prefix} Hello!`) and see me reply.\"\n            )\n        }\n    }\n}\n\nfn learn_more_from_usage_or_help(command_prefix: &str) -> String {\n    format!(\n        \"**Learn more** by sending a `{command_prefix} usage` or `{command_prefix} help` command.\"\n    )\n}\n\npub fn create_one_or_more_agents(command_prefix: &str) -> String {\n    format!(\n        \"**Create one or more agents** in this room or globally. The provider help message will show you **🗲 Quick start** commands, but you may also send a `{command_prefix} agent` command to see the guide.\"\n    )\n}\n\npub fn set_new_agent_as_handler(command_prefix: &str) -> String {\n    format!(\n        \"**Set the new agent as a handler** for a given use-purpose like text-generation, image-generation, etc. The agent-creation wizard will tell you how, but you may also send a `{command_prefix} config` command to see the guide (in the 🤖 *Handler Agents* section).\"\n    )\n}\n"
  },
  {
    "path": "src/strings/mod.rs",
    "content": "pub mod access;\npub mod agent;\npub mod cfg;\npub mod error;\npub mod global_config;\npub mod help;\npub mod image_edit;\npub mod image_generation;\npub mod introduction;\npub mod provider;\npub mod room_config;\npub mod speech_to_text;\npub mod text_to_speech;\npub mod usage;\n\npub const PROGRESS_INDICATOR_EMOJI: &str = \"⏳\";\n\npub fn the_following_commands_are_available() -> &'static str {\n    \"The following commands are available:\"\n}\n"
  },
  {
    "path": "src/strings/provider.rs",
    "content": "use crate::agent::AgentInstantiationError;\nuse crate::agent::AgentProvider;\nuse crate::agent::AgentProviderInfo;\nuse crate::agent::AgentPurpose;\n\npub fn invalid(provider: &str) -> String {\n    let choices_string = AgentProvider::choices()\n        .iter()\n        .map(|choice| format!(\"`{}`\", choice.to_static_str(),))\n        .collect::<Vec<String>>()\n        .join(\", \");\n\n    format!(\n        \"`{}` is not a valid provider choice. Valid choices are: {}\",\n        provider, choices_string\n    )\n}\n\npub fn invalid_configuration_for_provider(\n    provider: &AgentProvider,\n    err: AgentInstantiationError,\n) -> String {\n    format!(\n        \"The provided configuration is not valid for the `{}` provider:\\n```\\n{:?}\\n```\",\n        provider, err\n    )\n}\n\npub fn providers_list_intro() -> String {\n    \"The list of supported providers is below.\".to_owned()\n}\n\npub fn help_how_to_choose_heading() -> String {\n    \"How to choose a provider\".to_string()\n}\n\npub fn help_how_to_choose_description(command_prefix: &str) -> String {\n    let str = r#\"\nIf you're not sure which provider to start with, **we recommend OpenAI** as it's the most popular and has the **widest range of capabilities**.\n\nYou don't need to choose just one though. The bot supports **mixing & matching models** (by setting different handlers for different types of messages - see `%command_prefix% config`), so you can use multiple providers at the same time.\n\"#;\n\n    str.replace(\"%command_prefix%\", command_prefix)\n        .trim()\n        .to_owned()\n}\n\npub fn help_how_to_use_heading() -> String {\n    \"How to use a provider\".to_string()\n}\n\npub fn help_how_to_use_description(command_prefix: &str) -> String {\n    let str = r#\"\n1. 📝 **Sign up for it**\n2. 🔑 **Obtain an API key**\n3. 🤖 %create_one_or_more_agents%\n4. 🤝 %set_new_agent_as_handler%\n\"#;\n\n    str.replace(\"%command_prefix%\", command_prefix)\n        .replace(\n            \"%create_one_or_more_agents%\",\n            &super::introduction::create_one_or_more_agents(command_prefix),\n        )\n        .replace(\n            \"%set_new_agent_as_handler%\",\n            &super::introduction::set_new_agent_as_handler(command_prefix),\n        )\n        .trim()\n        .to_owned()\n}\n\npub fn help_provider_heading(provider_name: &str, homepage_url: &Option<String>) -> String {\n    match homepage_url {\n        Some(url) => format!(\"[{}]({})\", provider_name, url),\n        None => provider_name.to_owned(),\n    }\n}\n\npub fn help_provider_details(id: &str, info: &AgentProviderInfo) -> String {\n    let mut message = String::new();\n\n    message.push_str(info.description.trim());\n    message.push_str(\"\\n\\n\");\n\n    message.push_str(&format!(\"- 🆔 Identifier: `{}`\\n\", id));\n\n    let mut links = Vec::new();\n    if let Some(url) = info.homepage_url {\n        links.push(format!(\"[🏠 Home page]({})\", url));\n    }\n    if let Some(url) = info.wiki_url {\n        links.push(format!(\"[🌐 Wiki]({})\", url));\n    }\n    if let Some(url) = info.sign_up_url {\n        links.push(format!(\"[👤 Sign up]({})\", url));\n    }\n    if let Some(url) = info.models_list_url {\n        links.push(format!(\"[📋 Models list]({})\", url));\n    }\n\n    if !links.is_empty() {\n        message.push_str(&format!(\"- 🔗 Links: {}\\n\", links.join(\", \")));\n    }\n\n    let mut capabilities = vec![];\n    for purpose in info.supported_purposes.iter() {\n        let mut purpose_line = format!(\"{} {}\", purpose.emoji(), purpose.as_str());\n\n        if let AgentPurpose::TextGeneration = purpose {\n            let mut extras = vec![];\n\n            if info.text_generation_supports_vision {\n                extras.push(\"incl. vision\");\n            } else {\n                extras.push(\"no vision\");\n            }\n\n            if info.text_generation_supports_tools {\n                extras.push(\"incl. tools\");\n            } else {\n                extras.push(\"no tools\");\n            }\n\n            purpose_line = format!(\"{} ({})\", purpose_line, extras.join(\", \"));\n        }\n\n        capabilities.push(purpose_line);\n    }\n\n    message.push_str(&format!(\"- 🌟 Capabilities: {}\\n\", capabilities.join(\", \")));\n\n    message\n}\n"
  },
  {
    "path": "src/strings/room_config.rs",
    "content": "use crate::agent::{AgentPurpose, PublicIdentifier};\n\npub fn room_not_configured_with_specific_agent_for_purpose(purpose: AgentPurpose) -> String {\n    format!(\n        \"This room is not configured to use any specific agent for the `{}` purpose.\",\n        purpose\n    )\n}\n\npub fn configured_to_use_agent_for_purpose(\n    agent_identifier: &PublicIdentifier,\n    purpose: AgentPurpose,\n) -> String {\n    format!(\n        \"This room is configured to use the `{agent_identifier}` agent for the `{purpose}` purpose.\",\n    )\n}\n\npub fn configures_agent_for_purpose_but_does_not_exist(\n    agent_identifier: &PublicIdentifier,\n    purpose: AgentPurpose,\n) -> String {\n    format!(\n        \"This room is configured to use the `{agent_identifier}` agent for the `{purpose}` purpose, but such an agent does not exist.\",\n    )\n}\n\npub fn configures_agent_for_purpose_but_agent_does_not_support_it(\n    agent_identifier: &PublicIdentifier,\n    purpose: AgentPurpose,\n) -> String {\n    format!(\n        \"This room is configured to use the `{}` agent for {} (either directly, or through a {} fallback), but this agent does not support being used for {}.\",\n        agent_identifier,\n        purpose,\n        AgentPurpose::CatchAll,\n        purpose,\n    )\n}\n\npub fn reconfigured_to_use_agent_for_purpose(\n    agent_identifier: &PublicIdentifier,\n    purpose: AgentPurpose,\n) -> String {\n    format!(\n        \"This room has been reconfigured to use the `{}` agent for the `{}` purpose.\",\n        agent_identifier, purpose\n    )\n}\n\npub fn reconfigured_to_not_specify_agent_for_purpose(purpose: AgentPurpose) -> String {\n    format!(\n        \"This room has been reconfigured to not specify any agent for the `{}` purpose.\",\n        purpose\n    )\n}\n\npub fn value_was_set_to(value: impl std::fmt::Display) -> String {\n    format!(\n        \"This room-specific configuration value was set to:{}\",\n        super::cfg::create_display_text_for_value(value)\n    )\n}\n\npub fn value_was_unset() -> String {\n    \"This room-specific configuration value has been unset.\".to_owned()\n}\n"
  },
  {
    "path": "src/strings/speech_to_text.rs",
    "content": "pub fn redaction_reason_done() -> &'static str {\n    \"Done transcribing\"\n}\n\npub fn redaction_reason_failed() -> &'static str {\n    \"Failed while transcribing\"\n}\n\npub fn language_code_invalid(value: &str) -> String {\n    format!(\n        \"The value `{}` is not a valid 2-letter language code as per [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes).\",\n        value\n    )\n}\n"
  },
  {
    "path": "src/strings/text_to_speech.rs",
    "content": "pub fn redaction_reason_done() -> &'static str {\n    \"Done with speech-to-text\"\n}\n\npub fn redaction_reason_failed() -> &'static str {\n    \"Failed while doing speech-to-text\"\n}\n"
  },
  {
    "path": "src/strings/usage.rs",
    "content": "pub fn intro(command_prefix: &str) -> String {\n    let message = r#\"\n## 📖 Usage\n\nTo get an **overview of the current configuration affecting this room**, send a `%command_prefix% config status` command.\n\nTo **adjust settings**, see `%command_prefix% config`.\n\n### 💬 Text Generation\n\nIf there's a text-generation handler agent configured (see `%command_prefix% config status`), the bot **may** respond to messages sent in the room.\n\nWhether the bot responds depends on the **💬 Text Generation / 🗟 Prefix Requirement** setting (see `%command_prefix% config status`).\nSometimes, a prefix (e.g. `%command_prefix%`) is required in front of messages sent to the room for the bot to respond.\nFor multi-user rooms, this setting defaults to \"required\".\n\nRoom messages start a threaded conversation where you can continue back-and-forth communication with the bot.\n\n### 🗣️ Text-to-Speech\n\nIf there's a text-to-speech handler agent configured (see `%command_prefix% config status`), the bot **may** convert text messages sent to the room to audio (voice).\n\nBy default, the bot will offer text-to-speech for its own messages which are a response to voice messages coming from you. Simply click the 🗣️ reaction on the bot's message and the bot will convert the text message to audio. This is configurable via the **🗣️ Text-to-Speech / 🪄 Bot Messages Flow Type** setting.\n\nThe bot may be configured to also turn your own text messages to audio (voice) via the **🗣️ Text-to-Speech / 🪄 User Messages Flow Type** setting.\n\n\n### 🦻 Speech-to-Text\n\nIf there's a speech-to-text handler agent configured (see `%command_prefix% config status`), the bot **may** transcribe voice messages sent to the room to text.\n\nBy default, the bot will also perform 💬 Text Generation on the text. This is configurable via the **🦻 Speech-to-Text / 🪄 Flow Type** setting.\n\nIf all your messages are in the same language, you can improve accuracy & latency by configuring the language via the **🦻 Speech-to-Text / 🔤 Language** setting.\n\n\n### Image Generation\n\n#### 🖌️ Creating images\n\nSimply send a command like `%command_prefix% image create A beautiful sunset over the ocean` and the bot will start a threaded conversation and post an image based on your prompt.\n\nYou can then respond in the same message thread with:\n\n- more messages, to add more criteria to your prompt.\n- a message saying `again`, to generate one more image with the current prompt.\n\n#### 🎨 Editing images\n\nSimply send a command like `%command_prefix% image edit Turn the following image into an anime-style drawing` and the bot will start a threaded conversation asking for more details.\n\nYou can then respond in the same message thread with:\n\n- more messages, to add more criteria to your prompt.\n- one or more images, to provide the images that the bot will operate on.\n- a message saying `go`, to start the image generation process.\n- a message saying `again`, to prompt the bot to generate one more image edit with the current prompt.\n\n#### 🫵 Creating stickers\n\nA variation of **creating images** is creating \"sticker images\".\n\nTo create a sticker, send a command like `%command_prefix% sticker A huge bowl of steaming ramen with a mountain of beansprouts on top`.\n\nThe difference from **creating images** is that the bot will:\n\n- create a smaller-resolution image (as small as the model allows) - smaller/quicker, but still good enough for a sticker\n- potentially switch to a different (cheaper or otherwise more suitable) model, if available\n- post the image directly to the room (as a reply to your message), without starting a threaded conversation\n\"#;\n\n    message.replace(\"%command_prefix%\", command_prefix)\n}\n"
  },
  {
    "path": "src/utils/base64.rs",
    "content": "use base64::{Engine as _, engine::general_purpose::STANDARD};\n\npub(crate) fn base64_decode(base64_string: &str) -> Result<Vec<u8>, base64::DecodeError> {\n    STANDARD.decode(base64_string)\n}\n\npub(crate) fn base64_encode(data: &[u8]) -> String {\n    STANDARD.encode(data)\n}\n"
  },
  {
    "path": "src/utils/mime.rs",
    "content": "use mxlink::mime;\n\npub fn get_file_extension(mime_type: &mime::Mime) -> String {\n    match (mime_type.type_(), mime_type.subtype()) {\n        (mime::AUDIO, mime::BASIC) => \"au\",\n        (mime::AUDIO, mime::MPEG) => \"mp3\",\n        (mime::AUDIO, mime::MP4) => \"m4a\",\n        (mime::AUDIO, mime::OGG) => \"ogg\",\n        (mime::IMAGE, mime::BMP) => \"bmp\",\n        (mime::IMAGE, mime::GIF) => \"gif\",\n        (mime::IMAGE, mime::JPEG) => \"jpg\",\n        (mime::IMAGE, mime::PNG) => \"png\",\n        (mime::IMAGE, mime::SVG) => \"svg\",\n        _ => \"bin\",\n    }\n    .to_string()\n}\n\npub fn get_mime_type_from_file_name(file_name: &str) -> mime::Mime {\n    mime_guess::from_path(file_name)\n        .first()\n        .unwrap_or(mime::APPLICATION_OCTET_STREAM)\n}\n"
  },
  {
    "path": "src/utils/mod.rs",
    "content": "pub(crate) mod base64;\npub(crate) mod mime;\npub mod status;\npub mod text;\npub mod text_to_speech;\n"
  },
  {
    "path": "src/utils/status.rs",
    "content": "pub fn create_error_message_text(text: &str) -> String {\n    format!(\"⚠️ Error: {}\", text)\n}\n\npub fn create_success_message_text(text: &str) -> String {\n    format!(\"✅ {}\", text)\n}\n\npub fn create_tooltip_message_text(text: &str) -> String {\n    format!(\"💡 {}\", text)\n}\n"
  },
  {
    "path": "src/utils/text.rs",
    "content": "pub fn block_quote(text: &str) -> String {\n    text.lines()\n        .map(|line| format!(\"> {}\", line))\n        .collect::<Vec<String>>()\n        .join(\"\\n\")\n}\n\npub fn block_unquote(text: &str) -> String {\n    text.lines()\n        .map(|line| {\n            if let Some(stripped) = line.strip_prefix(\"> \") {\n                stripped.to_string()\n            } else {\n                line.to_string()\n            }\n        })\n        .collect::<Vec<String>>()\n        .join(\"\\n\")\n}\n"
  },
  {
    "path": "src/utils/text_to_speech.rs",
    "content": "use crate::agent::AgentPurpose;\n\nuse super::text::{block_quote, block_unquote};\n\n/// Creates a text message which is based on transcribed audio.\n/// This text message is prefixed with an emoji and blockquoted, to indicate that it is a transcription.\n/// To reverse the process, use `parse_transcribed_message_text()`.\n///\n/// It should be noted that in certain cases (Transcribe-only mode), transcriptions are posted as regular notice messages which do not include\n/// the `> 🦻` prefixing. That is, not every transcribed message will pass through here (intentionally).\npub fn create_transcribed_message_text(text: &str) -> String {\n    block_quote(&format!(\"{} {}\", AgentPurpose::SpeechToText.emoji(), text))\n}\n\n/// Parses a transcribed message text, reversing the process done by `create_transcribed_message_text()`.\n/// If the provided text string does not match the expected format, None is returned.\n///\n/// It should be noted that in certain cases (Transcribe-only mode), transcriptions are posted as regular notice messages which do not include\n/// the `> 🦻` prefix. This function will not handle these properly.\npub fn parse_transcribed_message_text(text: &str) -> Option<String> {\n    if !text.starts_with(\"> \") {\n        return None;\n    }\n\n    let unquoted = block_unquote(text);\n\n    let emoji_prefix = format!(\"{} \", AgentPurpose::SpeechToText.emoji());\n\n    if let Some(original) = unquoted.strip_prefix(&emoji_prefix) {\n        return Some(original.to_string());\n    }\n\n    None\n}\n\npub mod test {\n    #[test]\n    fn test_transcribed_message_text_creation() {\n        let text = \"Hello there!\\nHow are you?\";\n        let expected = format!(\n            \"> {} Hello there!\\n> How are you?\",\n            crate::agent::AgentPurpose::SpeechToText.emoji()\n        );\n        assert_eq!(expected, super::create_transcribed_message_text(text));\n    }\n\n    #[test]\n    fn test_transcribed_message_text_parsing() {\n        // All good\n        let text = format!(\n            \"> {} Hello there!\\n> How are you?\",\n            crate::agent::AgentPurpose::SpeechToText.emoji()\n        );\n        let expected = \"Hello there!\\nHow are you?\";\n        assert_eq!(\n            Some(expected.to_owned()),\n            super::parse_transcribed_message_text(&text)\n        );\n\n        // No blockquote\n        let text = format!(\n            \"{} Hello there!\\nHow are you?\",\n            crate::agent::AgentPurpose::SpeechToText.emoji()\n        );\n        assert_eq!(None, super::parse_transcribed_message_text(&text));\n\n        // No emoji\n        let text = \"> Hello there!\\n> How are you?\";\n        assert_eq!(None, super::parse_transcribed_message_text(text));\n\n        // Different emoji\n        let text = \"> 🌸 Hello there!\\n> How are you?\";\n        assert_eq!(None, super::parse_transcribed_message_text(text));\n    }\n}\n"
  }
]