├── crates ├── bitpart │ ├── .gitignore │ ├── src │ │ ├── channels │ │ │ └── mod.rs │ │ ├── db │ │ │ ├── mod.rs │ │ │ ├── entities │ │ │ │ ├── mod.rs │ │ │ │ ├── prelude.rs │ │ │ │ ├── bot.rs │ │ │ │ ├── memory.rs │ │ │ │ ├── state.rs │ │ │ │ ├── channel.rs │ │ │ │ ├── conversation.rs │ │ │ │ ├── channel_state.rs │ │ │ │ └── message.rs │ │ │ ├── migration │ │ │ │ ├── mod.rs │ │ │ │ ├── m20240801_000006_create_channel.rs │ │ │ │ ├── m20240801_000001_create_bot.rs │ │ │ │ ├── m20240801_000007_create_channel_state.rs │ │ │ │ ├── m20240801_000005_create_state.rs │ │ │ │ ├── m20240801_000003_create_memory.rs │ │ │ │ ├── m20240801_000002_create_conversation.rs │ │ │ │ └── m20240801_000004_create_message.rs │ │ │ ├── message.rs │ │ │ ├── channel.rs │ │ │ ├── state.rs │ │ │ ├── memory.rs │ │ │ ├── conversation.rs │ │ │ └── bot.rs │ │ ├── csml │ │ │ ├── mod.rs │ │ │ ├── data.rs │ │ │ └── utils.rs │ │ ├── utils.rs │ │ ├── main.rs │ │ └── socket.rs │ └── Cargo.toml ├── bitpart-common │ ├── src │ │ ├── lib.rs │ │ ├── socket.rs │ │ ├── error.rs │ │ └── csml.rs │ └── Cargo.toml ├── presage-store-bitpart │ ├── src │ │ ├── db │ │ │ ├── entities │ │ │ │ ├── prelude.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── channel.rs │ │ │ │ └── channel_state.rs │ │ │ ├── mod.rs │ │ │ └── channel_state.rs │ │ ├── protobuf │ │ │ └── InternalSerialization.proto │ │ ├── error.rs │ │ ├── protobuf.rs │ │ ├── lib.rs │ │ └── content.rs │ ├── Cargo.toml │ └── build.rs └── bitpart-cli │ └── Cargo.toml ├── csml └── helloworld.csml ├── .rtx.toml ├── .cz.toml ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── test_build.yaml │ ├── run_checks.yaml │ └── release_build.yaml ├── Cargo.toml ├── .pre-commit-config.yaml ├── Dockerfile ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── README.md └── CONTRIBUTING.md /crates/bitpart/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite 2 | -------------------------------------------------------------------------------- /crates/bitpart/src/channels/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod signal; 2 | -------------------------------------------------------------------------------- /csml/helloworld.csml: -------------------------------------------------------------------------------- 1 | start: 2 | say "Hello world!" 3 | -------------------------------------------------------------------------------- /.rtx.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | rust = "stable" 3 | rust-analyzer = "2024-09-16" 4 | -------------------------------------------------------------------------------- /.cz.toml: -------------------------------------------------------------------------------- 1 | [tool.commitizen] 2 | version_scheme = "semver2" 3 | version_provider = "cargo" 4 | -------------------------------------------------------------------------------- /crates/bitpart-common/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod csml; 2 | pub mod error; 3 | pub mod socket; 4 | -------------------------------------------------------------------------------- /crates/presage-store-bitpart/src/db/entities/prelude.rs: -------------------------------------------------------------------------------- 1 | pub use super::channel_state::Entity as ChannelState; 2 | -------------------------------------------------------------------------------- /crates/presage-store-bitpart/src/db/entities/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod channel; 2 | pub mod channel_state; 3 | pub mod prelude; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | # Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | # Ignore any sqlite files used during testing 13 | **/*.sqlite 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context: Does this feature request require changes to the Bitpart dashboard or EMS?** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "crates/bitpart", 5 | "crates/bitpart-cli", 6 | "crates/bitpart-common", 7 | "crates/presage-store-bitpart", 8 | ] 9 | 10 | [workspace.package] 11 | version = "1.0.4" 12 | authors = ["Josh King "] 13 | edition = "2024" 14 | license = "AGPL-3.0-or-later" 15 | repository = "https://github.com/throneless-tech/bitpart" 16 | homepage = "https://bitp.art" 17 | keywords = ["signal", "chat", "bot"] 18 | 19 | [patch.crates-io] 20 | reqwest-websocket = { git = 'https://github.com/throneless-tech/reqwest-websocket' } 21 | curve25519-dalek = { git = 'https://github.com/signalapp/curve25519-dalek', tag = 'signal-curve25519-4.1.3' } 22 | 23 | [profile.dev] 24 | debug = true 25 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/mod.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | pub mod bot; 18 | pub mod channel; 19 | pub mod conversation; 20 | pub mod entities; 21 | pub mod memory; 22 | pub mod message; 23 | pub mod migration; 24 | pub mod state; 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: false 2 | default_stages: [pre-commit, pre-push] 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v5.0.0 7 | hooks: 8 | - id: trailing-whitespace 9 | - id: end-of-file-fixer 10 | 11 | - repo: https://github.com/commitizen-tools/commitizen 12 | rev: v4.4.1 13 | hooks: 14 | - id: commitizen 15 | - id: commitizen-branch 16 | stages: 17 | - push 18 | 19 | - repo: local 20 | hooks: 21 | - id: cargo-fmt 22 | name: cargo fmt 23 | entry: cargo fmt -- 24 | language: system 25 | types: [rust] 26 | pass_filenames: false # This makes it a lot faster 27 | 28 | - repo: local 29 | hooks: 30 | - id: cargo-clippy 31 | name: cargo clippy 32 | language: system 33 | types: [rust] 34 | pass_filenames: false 35 | entry: cargo clippy --all-targets --all-features -- -D warnings 36 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/entities/mod.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | pub mod prelude; 18 | 19 | pub mod bot; 20 | pub mod channel; 21 | pub mod channel_state; 22 | pub mod conversation; 23 | pub mod memory; 24 | pub mod message; 25 | pub mod state; 26 | -------------------------------------------------------------------------------- /crates/bitpart/src/csml/mod.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | // 4 | // This code is derived in part from code from the CSML project: 5 | // Copyright (C) 2020 CSML 6 | 7 | // This program is free software: you can redistribute it and/or modify 8 | // it under the terms of the GNU Affero General Public License as published by 9 | // the Free Software Foundation, either version 3 of the License, or 10 | // (at your option) any later version. 11 | 12 | // This program is distributed in the hope that it will be useful, 13 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | // GNU Affero General Public License for more details. 16 | 17 | // You should have received a copy of the GNU Affero General Public License 18 | // along with this program. If not, see . 19 | 20 | pub mod conversation; 21 | pub mod data; 22 | pub mod interpret; 23 | pub mod utils; 24 | -------------------------------------------------------------------------------- /crates/presage-store-bitpart/src/db/mod.rs: -------------------------------------------------------------------------------- 1 | // presage-store-bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | // 4 | // This code is derived in part from code from the Presage project: 5 | // Copyright (C) 2024 Gabriel Féron 6 | 7 | // This program is free software: you can redistribute it and/or modify 8 | // it under the terms of the GNU Affero General Public License as published by 9 | // the Free Software Foundation, either version 3 of the License, or 10 | // (at your option) any later version. 11 | 12 | // This program is distributed in the hope that it will be useful, 13 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | // GNU Affero General Public License for more details. 16 | 17 | // You should have received a copy of the GNU Affero General Public License 18 | // along with this program. If not, see . 19 | 20 | pub mod channel_state; 21 | pub mod entities; 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/entities/prelude.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | pub use super::bot::Entity as Bot; 18 | pub use super::channel::Entity as Channel; 19 | pub use super::channel_state::Entity as ChannelState; 20 | pub use super::conversation::Entity as Conversation; 21 | pub use super::memory::Entity as Memory; 22 | pub use super::message::Entity as Message; 23 | pub use super::state::Entity as State; 24 | -------------------------------------------------------------------------------- /crates/bitpart-common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bitpart-common" 3 | description = "Bitpart is a messaging tool that runs on top of Signal to support activists, journalists, and human rights defenders. (Common components)" 4 | version.workspace = true 5 | authors.workspace = true 6 | edition.workspace = true 7 | license.workspace = true 8 | repository.workspace = true 9 | homepage.workspace = true 10 | keywords.workspace = true 11 | 12 | [dependencies] 13 | anyhow = "1.0" 14 | base64 = "0.22.1" 15 | bincode = "1.3.3" 16 | csml_interpreter = { git = "https://github.com/throneless-tech/csml-engine", branch = "bitpart" } 17 | figment = "0.10.19" 18 | futures = "0.3.31" 19 | hex = "0.4.3" 20 | opentelemetry-otlp = "0.29.0" 21 | presage = { git = "https://github.com/whisperfish/presage", rev = "473c70db3048ec871f6cef1faf6a97d0fdb9c082" } 22 | presage-store-bitpart= { path = "../presage-store-bitpart" } 23 | prost = "0.13.5" 24 | sea-orm = "~1.0" 25 | serde = { version = "1.0.204", features = ["derive"] } 26 | serde_json = "1.0.132" 27 | thiserror = "2.0.12" 28 | thiserror-ext = "0.3.0" 29 | tokio = "1.44.2" 30 | uuid = "1.16.0" 31 | 32 | [dev-dependencies] 33 | -------------------------------------------------------------------------------- /crates/bitpart-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bitpart-cli" 3 | description = "Bitpart is a messaging tool that runs on top of Signal to support activists, journalists, and human rights defenders. (CLI component)" 4 | version.workspace = true 5 | authors.workspace = true 6 | edition.workspace = true 7 | license.workspace = true 8 | repository.workspace = true 9 | homepage.workspace = true 10 | keywords.workspace = true 11 | 12 | [dependencies] 13 | anyhow = "1.0.97" 14 | bitpart-common = { path = "../bitpart-common" } 15 | clap = { version = "4", features = ["derive"] } 16 | clap-verbosity-flag = "3.0.2" 17 | ctrlc = "3.4.5" 18 | futures-util = "0.3.31" 19 | http = "1.2.0" 20 | qr2term = "0.3.3" 21 | serde = { version = "1.0.204", features = ["derive"] } 22 | serde_json = "1.0.132" 23 | similar = "2.7.0" 24 | tokio = { version = "1.43.0", features = ["full"] } 25 | tokio-tungstenite = { version = "0.26.1", features = ["url"] } 26 | tracing = "0.1.41" 27 | tracing-log = "0.2.0" 28 | tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 29 | unescaper = "0.1.5" 30 | url = "2.5.4" 31 | uuid = { version = "1.16.0", features = ["v4"] } 32 | 33 | [dev-dependencies] 34 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/migration/mod.rs: -------------------------------------------------------------------------------- 1 | use bitpart_common::error::Result; 2 | use sea_orm::DatabaseConnection; 3 | pub use sea_orm_migration::prelude::*; 4 | 5 | mod m20240801_000001_create_bot; 6 | mod m20240801_000002_create_conversation; 7 | mod m20240801_000003_create_memory; 8 | mod m20240801_000004_create_message; 9 | mod m20240801_000005_create_state; 10 | mod m20240801_000006_create_channel; 11 | mod m20240801_000007_create_channel_state; 12 | 13 | pub struct Migrator; 14 | 15 | #[async_trait::async_trait] 16 | impl MigratorTrait for Migrator { 17 | fn migrations() -> Vec> { 18 | vec![ 19 | Box::new(m20240801_000001_create_bot::Migration), 20 | Box::new(m20240801_000002_create_conversation::Migration), 21 | Box::new(m20240801_000003_create_memory::Migration), 22 | Box::new(m20240801_000004_create_message::Migration), 23 | Box::new(m20240801_000005_create_state::Migration), 24 | Box::new(m20240801_000006_create_channel::Migration), 25 | Box::new(m20240801_000007_create_channel_state::Migration), 26 | ] 27 | } 28 | } 29 | 30 | pub async fn migrate(db: &DatabaseConnection) -> Result<()> { 31 | Migrator::up(db, None).await?; 32 | Ok(()) 33 | } 34 | -------------------------------------------------------------------------------- /crates/presage-store-bitpart/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "presage-store-bitpart" 3 | description = "Bitpart is a messaging tool that runs on top of Signal to support activists, journalists, and human rights defenders. (Signal message store)" 4 | version.workspace = true 5 | authors.workspace = true 6 | edition.workspace = true 7 | license.workspace = true 8 | repository.workspace = true 9 | homepage.workspace = true 10 | keywords.workspace = true 11 | 12 | [dependencies] 13 | async-trait = "0.1" 14 | base64 = "0.22" 15 | chrono = "0.4.35" 16 | fs_extra = "1.3" 17 | futures = "0.3.31" 18 | presage = { git = "https://github.com/whisperfish/presage", tag = "0.7.0" } 19 | prost = "0.13" 20 | quickcheck_macros = "1.0.0" 21 | sea-orm = { version = "~1.0", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros"] } 22 | serde = { version = "1.0", features = ["derive"] } 23 | serde_json = { version = "1.0", features = ["preserve_order"] } 24 | sha2 = "0.10" 25 | thiserror = "1.0" 26 | tokio = "1.35" 27 | tracing = "0.1" 28 | uuid = { version = "1.11.0", features = ["v4"] } 29 | 30 | [build-dependencies] 31 | prost-build = "0.13" 32 | 33 | [dev-dependencies] 34 | anyhow = "1.0" 35 | quickcheck = "1.0.3" 36 | quickcheck_async = "0.1" 37 | rand = "0.8" 38 | tokio = { version = "1.35", default-features = false, features = ["time"] } 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | #################################################################################################### 2 | ## Builder 3 | #################################################################################################### 4 | FROM rust:bookworm AS builder 5 | 6 | RUN apt update && apt install -y protobuf-compiler 7 | RUN update-ca-certificates 8 | 9 | # Create appuser 10 | ENV USER=bitpart 11 | ENV UID=10001 12 | 13 | RUN adduser \ 14 | --disabled-password \ 15 | --gecos "" \ 16 | --home "/nonexistent" \ 17 | --shell "/sbin/nologin" \ 18 | --no-create-home \ 19 | --uid "${UID}" \ 20 | "${USER}" 21 | 22 | 23 | WORKDIR /bitpart 24 | 25 | COPY ./ . 26 | 27 | # We no longer need to use the x86_64-unknown-linux-musl target 28 | RUN cargo build --release 29 | 30 | #################################################################################################### 31 | ## Final image 32 | #################################################################################################### 33 | FROM gcr.io/distroless/cc 34 | 35 | # Import from builder. 36 | COPY --from=builder /etc/passwd /etc/passwd 37 | COPY --from=builder /etc/group /etc/group 38 | 39 | WORKDIR /bitpart 40 | 41 | # Copy our build 42 | COPY --from=builder /bitpart/target/release/bitpart ./ 43 | 44 | # Use an unprivileged user. 45 | USER bitpart:bitpart 46 | 47 | CMD ["/bitpart/bitpart"] 48 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/entities/bot.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | use sea_orm::entity::prelude::*; 18 | use serde::Serialize; 19 | 20 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize)] 21 | #[sea_orm(table_name = "bot")] 22 | pub struct Model { 23 | #[sea_orm(primary_key, auto_increment = false)] 24 | pub id: String, 25 | pub bot_id: String, 26 | pub bot: String, 27 | pub engine_version: String, 28 | pub updated_at: String, 29 | pub created_at: String, 30 | } 31 | 32 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 33 | pub enum Relation {} 34 | 35 | impl ActiveModelBehavior for ActiveModel {} 36 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/entities/memory.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | use sea_orm::entity::prelude::*; 18 | use serde::Serialize; 19 | use serde_json::Value; 20 | 21 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize)] 22 | #[sea_orm(table_name = "memory")] 23 | pub struct Model { 24 | #[sea_orm(primary_key, auto_increment = false)] 25 | pub id: String, 26 | pub bot_id: String, 27 | pub channel_id: String, 28 | pub user_id: String, 29 | pub key: String, 30 | pub value: Value, 31 | pub created_at: String, 32 | pub updated_at: String, 33 | pub expires_at: Option, 34 | } 35 | 36 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 37 | pub enum Relation {} 38 | 39 | impl ActiveModelBehavior for ActiveModel {} 40 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/entities/state.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | use sea_orm::entity::prelude::*; 18 | use serde::Serialize; 19 | 20 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize)] 21 | #[sea_orm(table_name = "state")] 22 | pub struct Model { 23 | #[sea_orm(primary_key, auto_increment = false)] 24 | pub id: String, 25 | pub bot_id: String, 26 | pub channel_id: String, 27 | pub user_id: String, 28 | pub r#type: String, 29 | pub key: String, 30 | pub value: String, 31 | pub created_at: String, 32 | pub updated_at: String, 33 | pub expires_at: Option, 34 | } 35 | 36 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 37 | pub enum Relation {} 38 | 39 | impl ActiveModelBehavior for ActiveModel {} 40 | -------------------------------------------------------------------------------- /crates/bitpart-common/src/socket.rs: -------------------------------------------------------------------------------- 1 | use csml_interpreter::data::CsmlBot; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::csml::Request; 5 | 6 | #[derive(Debug, Serialize, Deserialize)] 7 | pub struct Paginate { 8 | pub limit: Option, 9 | pub offset: Option, 10 | } 11 | 12 | #[derive(Debug, Serialize, Deserialize)] 13 | pub struct Response { 14 | pub response_type: String, 15 | pub response: S, 16 | } 17 | 18 | #[derive(Debug, Serialize, Deserialize)] 19 | #[serde(tag = "message_type", content = "data")] 20 | pub enum SocketMessage { 21 | CreateBot(Box), 22 | ReadBot { 23 | id: String, 24 | }, 25 | BotVersions { 26 | id: String, 27 | options: Option, 28 | }, 29 | RollbackBot { 30 | id: String, 31 | version_id: String, 32 | }, 33 | DiffBot { 34 | version_a: String, 35 | version_b: String, 36 | }, 37 | DeleteBot { 38 | id: String, 39 | }, 40 | ListBots(Option), 41 | CreateChannel { 42 | id: String, 43 | bot_id: String, 44 | }, 45 | ReadChannel { 46 | id: String, 47 | bot_id: String, 48 | }, 49 | ListChannels(Option), 50 | DeleteChannel { 51 | id: String, 52 | bot_id: String, 53 | }, 54 | LinkChannel { 55 | id: String, 56 | bot_id: String, 57 | device_name: String, 58 | }, 59 | ChatRequest(Box), 60 | Response(Response), 61 | Error(Response), 62 | } 63 | -------------------------------------------------------------------------------- /.github/workflows/test_build.yaml: -------------------------------------------------------------------------------- 1 | name: Build test image 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Run checks"] 6 | branches: [develop] 7 | types: 8 | - completed 9 | 10 | env: 11 | IMAGE_NAME: bitpart 12 | BRANCH_NAME: ${{ github.head_ref || github.ref_name }} 13 | 14 | jobs: 15 | release_version: 16 | runs-on: ubuntu-latest 17 | name: "Build test image" 18 | steps: 19 | - name: Check out 20 | id: check_out 21 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 22 | with: 23 | ref: ${{ env.BRANCH_NAME }} 24 | - name: Set timestamp 25 | id: set_timestamp 26 | run: echo "TIMESTAMP=$(date +%s)" >> $GITHUB_ENV 27 | - name: Buildah Action 28 | id: build_image 29 | uses: redhat-actions/buildah-build@7a95fa7ee0f02d552a32753e7414641a04307056 30 | with: 31 | image: ${{ env.IMAGE_NAME }} 32 | tags: develop-${{ github.sha }}-${{ env.TIMESTAMP}} 33 | containerfiles: | 34 | ./Dockerfile 35 | - name: Push To GHCR 36 | uses: redhat-actions/push-to-registry@5ed88d269cf581ea9ef6dd6806d01562096bee9c 37 | id: push 38 | env: 39 | IMAGE_REGISTRY: ghcr.io/${{ github.repository_owner }} 40 | REGISTRY_USER: ${{ github.actor }} 41 | REGISTRY_PASSWORD: ${{ github.token }} 42 | with: 43 | image: ${{ steps.build_image.outputs.image }} 44 | tags: ${{ steps.build_image.outputs.tags }} 45 | registry: ${{ env.IMAGE_REGISTRY }} 46 | username: ${{ env.REGISTRY_USER }} 47 | password: ${{ env.REGISTRY_PASSWORD }} 48 | extra-args: | 49 | --disable-content-trust 50 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/entities/channel.rs: -------------------------------------------------------------------------------- 1 | // presage-store-bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | // 4 | // This code is derived in part from code from the Presage project: 5 | // Copyright (C) 2024 Gabriel Féron 6 | 7 | // This program is free software: you can redistribute it and/or modify 8 | // it under the terms of the GNU Affero General Public License as published by 9 | // the Free Software Foundation, either version 3 of the License, or 10 | // (at your option) any later version. 11 | 12 | // This program is distributed in the hope that it will be useful, 13 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | // GNU Affero General Public License for more details. 16 | 17 | // You should have received a copy of the GNU Affero General Public License 18 | // along with this program. If not, see . 19 | 20 | use sea_orm::entity::prelude::*; 21 | use serde::Serialize; 22 | 23 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize)] 24 | #[sea_orm(table_name = "channel")] 25 | pub struct Model { 26 | #[sea_orm(primary_key, auto_increment = false)] 27 | pub id: String, 28 | pub bot_id: String, 29 | pub channel_id: String, 30 | pub updated_at: String, 31 | pub created_at: String, 32 | } 33 | 34 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 35 | pub enum Relation { 36 | #[sea_orm(has_many = "super::channel_state::Entity")] 37 | ChannelState, 38 | } 39 | 40 | impl Related for Entity { 41 | fn to() -> RelationDef { 42 | Relation::ChannelState.def() 43 | } 44 | } 45 | 46 | impl ActiveModelBehavior for ActiveModel {} 47 | -------------------------------------------------------------------------------- /crates/presage-store-bitpart/src/db/entities/channel.rs: -------------------------------------------------------------------------------- 1 | // presage-store-bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | // 4 | // This code is derived in part from code from the Presage project: 5 | // Copyright (C) 2024 Gabriel Féron 6 | 7 | // This program is free software: you can redistribute it and/or modify 8 | // it under the terms of the GNU Affero General Public License as published by 9 | // the Free Software Foundation, either version 3 of the License, or 10 | // (at your option) any later version. 11 | 12 | // This program is distributed in the hope that it will be useful, 13 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | // GNU Affero General Public License for more details. 16 | 17 | // You should have received a copy of the GNU Affero General Public License 18 | // along with this program. If not, see . 19 | 20 | use sea_orm::entity::prelude::*; 21 | use serde::Serialize; 22 | 23 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize)] 24 | #[sea_orm(table_name = "channel")] 25 | pub struct Model { 26 | #[sea_orm(primary_key, auto_increment = false)] 27 | pub id: String, 28 | pub bot_id: String, 29 | pub channel_id: String, 30 | pub updated_at: String, 31 | pub created_at: String, 32 | } 33 | 34 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 35 | pub enum Relation { 36 | #[sea_orm(has_many = "super::channel_state::Entity")] 37 | ChannelState, 38 | } 39 | 40 | impl Related for Entity { 41 | fn to() -> RelationDef { 42 | Relation::ChannelState.def() 43 | } 44 | } 45 | 46 | impl ActiveModelBehavior for ActiveModel {} 47 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/entities/conversation.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | use sea_orm::entity::prelude::*; 18 | use serde::Serialize; 19 | 20 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize)] 21 | #[sea_orm(table_name = "conversation")] 22 | pub struct Model { 23 | #[sea_orm(primary_key, auto_increment = false)] 24 | pub id: String, 25 | pub bot_id: String, 26 | pub channel_id: String, 27 | pub user_id: String, 28 | pub flow_id: String, 29 | pub step_id: String, 30 | pub status: String, 31 | pub last_interaction_at: String, 32 | pub updated_at: String, 33 | pub created_at: String, 34 | pub expires_at: Option, 35 | } 36 | 37 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 38 | pub enum Relation { 39 | #[sea_orm(has_many = "super::message::Entity")] 40 | Message, 41 | } 42 | 43 | impl Related for Entity { 44 | fn to() -> RelationDef { 45 | Relation::Message.def() 46 | } 47 | } 48 | 49 | impl ActiveModelBehavior for ActiveModel {} 50 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/entities/channel_state.rs: -------------------------------------------------------------------------------- 1 | // presage-store-bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | // 4 | // This code is derived in part from code from the Presage project: 5 | // Copyright (C) 2024 Gabriel Féron 6 | 7 | // This program is free software: you can redistribute it and/or modify 8 | // it under the terms of the GNU Affero General Public License as published by 9 | // the Free Software Foundation, either version 3 of the License, or 10 | // (at your option) any later version. 11 | 12 | // This program is distributed in the hope that it will be useful, 13 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | // GNU Affero General Public License for more details. 16 | 17 | // You should have received a copy of the GNU Affero General Public License 18 | // along with this program. If not, see . 19 | 20 | use sea_orm::entity::prelude::*; 21 | use serde::Serialize; 22 | 23 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize)] 24 | #[sea_orm(table_name = "channel_state")] 25 | pub struct Model { 26 | #[sea_orm(primary_key, auto_increment = false)] 27 | pub id: String, 28 | pub channel_id: String, 29 | pub tree: String, 30 | pub key: String, 31 | pub value: String, 32 | pub updated_at: String, 33 | pub created_at: String, 34 | } 35 | 36 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 37 | pub enum Relation { 38 | #[sea_orm( 39 | belongs_to = "super::channel::Entity", 40 | from = "Column::ChannelId", 41 | to = "super::channel::Column::Id" 42 | )] 43 | Channel, 44 | } 45 | 46 | impl Related for Entity { 47 | fn to() -> RelationDef { 48 | Relation::Channel.def() 49 | } 50 | } 51 | 52 | impl ActiveModelBehavior for ActiveModel {} 53 | -------------------------------------------------------------------------------- /crates/presage-store-bitpart/src/db/entities/channel_state.rs: -------------------------------------------------------------------------------- 1 | // presage-store-bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | // 4 | // This code is derived in part from code from the Presage project: 5 | // Copyright (C) 2024 Gabriel Féron 6 | 7 | // This program is free software: you can redistribute it and/or modify 8 | // it under the terms of the GNU Affero General Public License as published by 9 | // the Free Software Foundation, either version 3 of the License, or 10 | // (at your option) any later version. 11 | 12 | // This program is distributed in the hope that it will be useful, 13 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | // GNU Affero General Public License for more details. 16 | 17 | // You should have received a copy of the GNU Affero General Public License 18 | // along with this program. If not, see . 19 | 20 | use sea_orm::entity::prelude::*; 21 | use serde::Serialize; 22 | 23 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize)] 24 | #[sea_orm(table_name = "channel_state")] 25 | pub struct Model { 26 | #[sea_orm(primary_key, auto_increment = false)] 27 | pub id: String, 28 | pub channel_id: String, 29 | pub tree: String, 30 | pub key: String, 31 | pub value: String, 32 | pub updated_at: String, 33 | pub created_at: String, 34 | } 35 | 36 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 37 | pub enum Relation { 38 | #[sea_orm( 39 | belongs_to = "super::channel::Entity", 40 | from = "Column::ChannelId", 41 | to = "super::channel::Column::Id" 42 | )] 43 | Channel, 44 | } 45 | 46 | impl Related for Entity { 47 | fn to() -> RelationDef { 48 | Relation::Channel.def() 49 | } 50 | } 51 | 52 | impl ActiveModelBehavior for ActiveModel {} 53 | -------------------------------------------------------------------------------- /crates/presage-store-bitpart/build.rs: -------------------------------------------------------------------------------- 1 | // presage-store-bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | // 4 | // This code is derived in part from code from the Presage project: 5 | // Copyright (C) 2024 Gabriel Féron 6 | 7 | // This program is free software: you can redistribute it and/or modify 8 | // it under the terms of the GNU Affero General Public License as published by 9 | // the Free Software Foundation, either version 3 of the License, or 10 | // (at your option) any later version. 11 | 12 | // This program is distributed in the hope that it will be useful, 13 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | // GNU Affero General Public License for more details. 16 | 17 | // You should have received a copy of the GNU Affero General Public License 18 | // along with this program. If not, see . 19 | 20 | use std::io::Result; 21 | use std::path::Path; 22 | 23 | fn main() -> Result<()> { 24 | let protobuf = Path::new("src/protobuf").to_owned(); 25 | 26 | // Build script does not automagically rerun when a new protobuf file is added. 27 | // Directories are checked against mtime, which is platform specific 28 | println!("cargo:rerun-if-changed=src/protobuf"); 29 | 30 | let input: Vec<_> = protobuf 31 | .read_dir() 32 | .expect("protobuf directory") 33 | .filter_map(|entry| { 34 | let entry = entry.expect("readable protobuf directory"); 35 | let path = entry.path(); 36 | if Some("proto") == path.extension().and_then(std::ffi::OsStr::to_str) { 37 | assert!(path.is_file()); 38 | println!("cargo:rerun-if-changed={}", path.to_str()?); 39 | Some(path) 40 | } else { 41 | None 42 | } 43 | }) 44 | .collect(); 45 | 46 | prost_build::compile_protos(&input, &[protobuf])?; 47 | 48 | Ok(()) 49 | } 50 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/entities/message.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | use sea_orm::entity::prelude::*; 18 | use serde::Serialize; 19 | 20 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize)] 21 | #[sea_orm(table_name = "message")] 22 | pub struct Model { 23 | #[sea_orm(primary_key, auto_increment = false)] 24 | pub id: String, 25 | pub conversation_id: String, 26 | pub flow_id: String, 27 | pub step_id: String, 28 | pub direction: String, 29 | pub payload: String, 30 | pub content_type: String, 31 | pub message_order: i32, 32 | pub interaction_order: i32, 33 | pub created_at: String, 34 | pub updated_at: String, 35 | pub expires_at: Option, 36 | } 37 | 38 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 39 | pub enum Relation { 40 | #[sea_orm( 41 | belongs_to = "super::conversation::Entity", 42 | from = "Column::ConversationId", 43 | to = "super::conversation::Column::Id", 44 | on_update = "NoAction", 45 | on_delete = "NoAction" 46 | )] 47 | Conversation, 48 | } 49 | 50 | impl Related for Entity { 51 | fn to() -> RelationDef { 52 | Relation::Conversation.def() 53 | } 54 | } 55 | 56 | impl ActiveModelBehavior for ActiveModel {} 57 | -------------------------------------------------------------------------------- /crates/presage-store-bitpart/src/protobuf/InternalSerialization.proto: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2019 Open Whisper Systems 3 | * 4 | * Licensed according to the LICENSE file in this repository. 5 | */ 6 | syntax = "proto2"; 7 | 8 | package textsecure; 9 | 10 | // Not needed 11 | // import "SignalService.proto"; 12 | 13 | option java_package = "org.whispersystems.signalservice.internal.serialize.protos"; 14 | option java_multiple_files = true; 15 | 16 | // Not needed 17 | // message SignalServiceContentProto { 18 | // optional AddressProto localAddress = 1; 19 | // optional MetadataProto metadata = 2; 20 | // oneof data { 21 | // signalservice.DataMessage legacyDataMessage = 3; 22 | // signalservice.Content content = 4; 23 | // } 24 | // } 25 | 26 | message SignalServiceEnvelopeProto { 27 | optional int32 type = 1; 28 | optional string sourceUuid = 2; 29 | optional string sourceE164 = 3; 30 | optional int32 deviceId = 4; 31 | optional bytes legacyMessage = 5; 32 | optional bytes content = 6; 33 | optional int64 timestamp = 7; 34 | optional int64 serverReceivedTimestamp = 8; 35 | optional int64 serverDeliveredTimestamp = 9; 36 | optional string serverGuid = 10; 37 | optional string destinationUuid = 11; 38 | optional bool urgent = 12 [default = true]; 39 | } 40 | 41 | message MetadataProto { 42 | optional AddressProto address = 1; 43 | optional int32 senderDevice = 2; 44 | optional int64 timestamp = 3; 45 | optional int64 serverReceivedTimestamp = 5; 46 | optional int64 serverDeliveredTimestamp = 6; 47 | optional bool needsReceipt = 4; 48 | optional string serverGuid = 7; 49 | optional bytes groupId = 8; 50 | optional string destinationUuid = 9; 51 | } 52 | 53 | message AddressProto { 54 | optional bytes uuid = 1; 55 | // optional string e164 = 2; 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/run_checks.yaml: -------------------------------------------------------------------------------- 1 | name: Run checks 2 | on: 3 | push: 4 | branches: 5 | - develop 6 | - main 7 | pull_request: 8 | branches: 9 | - develop 10 | 11 | env: 12 | BRANCH_NAME: ${{ github.head_ref || github.ref_name }} 13 | 14 | jobs: 15 | test: 16 | name: Tests 17 | runs-on: ubuntu-latest 18 | env: 19 | BRANCH_NAME: ${{ github.head_ref || github.ref_name }} 20 | steps: 21 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 22 | with: 23 | ref: ${{ env.BRANCH_NAME }} 24 | - uses: awalsh128/cache-apt-pkgs-action@2c09a5e66da6c8016428a2172bd76e5e4f14bb17 25 | with: 26 | packages: protobuf-compiler 27 | version: 1.0 28 | - uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 29 | - run: cargo test --all-features 30 | formatting: 31 | name: Formatting 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 35 | with: 36 | ref: ${{ env.BRANCH_NAME }} 37 | - uses: awalsh128/cache-apt-pkgs-action@2c09a5e66da6c8016428a2172bd76e5e4f14bb17 38 | with: 39 | packages: protobuf-compiler 40 | version: 1.0 41 | - uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 42 | with: 43 | components: rustfmt 44 | - name: Rustfmt Check 45 | uses: actions-rust-lang/rustfmt@559aa3035a47390ba96088dffa783b5d26da9326 46 | clippy: 47 | name: Clippy 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 51 | with: 52 | ref: ${{ env.BRANCH_NAME }} 53 | - uses: awalsh128/cache-apt-pkgs-action@2c09a5e66da6c8016428a2172bd76e5e4f14bb17 54 | with: 55 | packages: protobuf-compiler 56 | version: 1.0 57 | - uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 58 | with: 59 | components: clippy 60 | - uses: auguwu/clippy-action@94a9ff2f6920180b89e5c03d121d0af04a9d3e03 61 | with: 62 | token: ${{secrets.GITHUB_TOKEN}} 63 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/migration/m20240801_000006_create_channel.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[derive(DeriveMigrationName)] 4 | pub struct Migration; 5 | 6 | #[async_trait::async_trait] 7 | impl MigrationTrait for Migration { 8 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 | manager 10 | .create_table( 11 | Table::create() 12 | .table(Channel::Table) 13 | .if_not_exists() 14 | .col(ColumnDef::new(Channel::Id).uuid().not_null().primary_key()) 15 | .col(ColumnDef::new(Channel::BotId).string().not_null()) 16 | .col(ColumnDef::new(Channel::ChannelId).string().not_null()) 17 | .col( 18 | ColumnDef::new(Channel::CreatedAt) 19 | .date_time() 20 | .default(Expr::current_timestamp()) 21 | .not_null(), 22 | ) 23 | .col( 24 | ColumnDef::new(Channel::UpdatedAt) 25 | .date_time() 26 | .default(Expr::current_timestamp()) 27 | .not_null(), 28 | ) 29 | .to_owned(), 30 | ) 31 | .await?; 32 | 33 | let db = manager.get_connection(); 34 | 35 | db.execute_unprepared( 36 | "CREATE TRIGGER channel_updated_at 37 | AFTER UPDATE ON channel 38 | FOR EACH ROW 39 | BEGIN 40 | UPDATE channel 41 | SET updated_at = (datetime('now','localtime')) 42 | WHERE id = NEW.id; 43 | END;", 44 | ) 45 | .await?; 46 | 47 | Ok(()) 48 | } 49 | 50 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 51 | manager 52 | .drop_table(Table::drop().table(Channel::Table).to_owned()) 53 | .await 54 | } 55 | } 56 | 57 | #[allow(clippy::enum_variant_names)] 58 | #[derive(DeriveIden)] 59 | enum Channel { 60 | Table, 61 | Id, 62 | BotId, 63 | ChannelId, 64 | CreatedAt, 65 | UpdatedAt, 66 | } 67 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/migration/m20240801_000001_create_bot.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[derive(DeriveMigrationName)] 4 | pub struct Migration; 5 | 6 | #[async_trait::async_trait] 7 | impl MigrationTrait for Migration { 8 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 | manager 10 | .create_table( 11 | Table::create() 12 | .table(Bot::Table) 13 | .if_not_exists() 14 | .col(ColumnDef::new(Bot::Id).uuid().not_null().primary_key()) 15 | .col(ColumnDef::new(Bot::BotId).string().not_null()) 16 | .col(ColumnDef::new(Bot::Bot).string().not_null()) 17 | .col(ColumnDef::new(Bot::EngineVersion).string().not_null()) 18 | .col( 19 | ColumnDef::new(Bot::UpdatedAt) 20 | .date_time() 21 | .default(Expr::current_timestamp()) 22 | .not_null(), 23 | ) 24 | .col( 25 | ColumnDef::new(Bot::CreatedAt) 26 | .date_time() 27 | .default(Expr::current_timestamp()) 28 | .not_null(), 29 | ) 30 | .to_owned(), 31 | ) 32 | .await?; 33 | 34 | let db = manager.get_connection(); 35 | 36 | db.execute_unprepared( 37 | "CREATE TRIGGER bot_updated_at 38 | AFTER UPDATE ON bot 39 | FOR EACH ROW 40 | BEGIN 41 | UPDATE bot 42 | SET updated_at = (datetime('now','localtime')) 43 | WHERE id = NEW.id; 44 | END;", 45 | ) 46 | .await?; 47 | 48 | Ok(()) 49 | } 50 | 51 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 52 | manager 53 | .drop_table(Table::drop().table(Bot::Table).to_owned()) 54 | .await 55 | } 56 | } 57 | 58 | #[allow(clippy::enum_variant_names)] 59 | #[derive(DeriveIden)] 60 | enum Bot { 61 | Table, 62 | Id, 63 | BotId, 64 | Bot, 65 | EngineVersion, 66 | UpdatedAt, 67 | CreatedAt, 68 | } 69 | -------------------------------------------------------------------------------- /crates/presage-store-bitpart/src/error.rs: -------------------------------------------------------------------------------- 1 | // presage-store-bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | // 4 | // This code is derived in part from code from the Presage project: 5 | // Copyright (C) 2024 Gabriel Féron 6 | 7 | // This program is free software: you can redistribute it and/or modify 8 | // it under the terms of the GNU Affero General Public License as published by 9 | // the Free Software Foundation, either version 3 of the License, or 10 | // (at your option) any later version. 11 | 12 | // This program is distributed in the hope that it will be useful, 13 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | // GNU Affero General Public License for more details. 16 | 17 | // You should have received a copy of the GNU Affero General Public License 18 | // along with this program. If not, see . 19 | 20 | use presage::{libsignal_service::protocol::SignalProtocolError, store::StoreError}; 21 | use sea_orm::DbErr; 22 | use std::str; 23 | use tracing::error; 24 | 25 | #[derive(Debug, thiserror::Error)] 26 | pub enum BitpartStoreError { 27 | #[error("database migration is not supported")] 28 | MigrationConflict, 29 | #[error("database error: {0}")] 30 | Db(#[from] DbErr), 31 | #[error("data store error: {0}")] 32 | Store(String), 33 | #[error("JSON error: {0}")] 34 | Json(#[from] serde_json::Error), 35 | #[error("base64 decode error: {0}")] 36 | Base64Decode(#[from] base64::DecodeError), 37 | #[error("Prost error: {0}")] 38 | ProtobufDecode(#[from] prost::DecodeError), 39 | #[error("I/O error: {0}")] 40 | FsExtra(#[from] fs_extra::error::Error), 41 | #[error("group decryption error")] 42 | GroupDecryption, 43 | #[error("No UUID")] 44 | NoUuid, 45 | #[error("Unsupported message content")] 46 | UnsupportedContent, 47 | #[error("string encoding error: {0}")] 48 | Utf8(#[from] str::Utf8Error), 49 | #[error("Libsignal protocol error: `{0}`")] 50 | SignalProtocol(#[from] SignalProtocolError), 51 | } 52 | 53 | impl StoreError for BitpartStoreError {} 54 | 55 | impl From for SignalProtocolError { 56 | fn from(error: BitpartStoreError) -> Self { 57 | error!(%error, "presage store error"); 58 | Self::InvalidState("presage store error", error.to_string()) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /crates/bitpart/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bitpart" 3 | description = "Bitpart is a messaging tool that runs on top of Signal to support activists, journalists, and human rights defenders." 4 | version.workspace = true 5 | authors.workspace = true 6 | edition.workspace = true 7 | license.workspace = true 8 | repository.workspace = true 9 | homepage.workspace = true 10 | keywords.workspace = true 11 | 12 | [dependencies] 13 | anyhow = "1.0" 14 | async-recursion = "1.1.1" 15 | axum = { version = "0.8.0", features = ["http2", "macros", "ws"] } 16 | base64 = "0.22.1" 17 | bincode = "1.3.3" 18 | bitpart-common = { path = "../bitpart-common" } 19 | chrono = "0.4" 20 | clap = { version = "4", features = ["derive"] } 21 | clap-verbosity-flag = { git = "https://github.com/joshka/clap-verbosity-flag", branch = "jm/serde", features = ["serde"] } # TODO Revisit when PR is merged 22 | csml_interpreter = { git = "https://github.com/throneless-tech/csml-engine", branch = "bitpart" } 23 | directories = "6.0.0" 24 | figment = { version = "0.10.19", features = ["env", "toml"] } 25 | figment_file_provider_adapter = "0.1.1" 26 | futures = "0.3.31" 27 | hex = "0.4.3" 28 | libsqlite3-sys = { version = "0.26.0", features = ["bundled-sqlcipher"] } 29 | md-5 = "0.10.6" 30 | mime_guess = "2.0.5" 31 | opentelemetry = "0.29.1" 32 | opentelemetry-otlp = { version = "0.29.0", features = ["reqwest-rustls"] } 33 | opentelemetry_sdk = "0.29.0" 34 | presage = { git = "https://github.com/whisperfish/presage", tag = "0.7.0" } 35 | presage-store-bitpart= { path = "../presage-store-bitpart" } 36 | rand = "0.8.5" 37 | regex = "1.10.6" 38 | sanitise-file-name = "1.0.0" 39 | sea-orm = { version = "~1.0", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros"] } 40 | sea-orm-migration = "1.0.1" 41 | serde = { version = "1.0.204", features = ["derive"] } 42 | serde_json = "1.0.117" 43 | subtle = { version = "2.6.1", features = ["const-generics"] } 44 | tempfile = "3.13.0" 45 | thiserror = "1.0.61" 46 | tokio = { version = "1.38.0", features = ["full"] } 47 | tokio-util = { version = "0.7.16", features = ["rt"] } 48 | tracing = "0.1.40" 49 | tracing-log = "0.2.0" 50 | tracing-opentelemetry = "0.30.0" 51 | tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } 52 | ureq = "2.8" 53 | url = "2.5.3" 54 | uuid = { version = "1.10.0", features = ["v4", "fast-rng", "macro-diagnostics"]} 55 | 56 | [dev-dependencies] 57 | axum-test = { version = "17.2.0", features = ["ws"] } 58 | -------------------------------------------------------------------------------- /crates/bitpart/src/utils.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | #[cfg(test)] 18 | use crate::channels::signal; 19 | #[cfg(test)] 20 | use crate::db; 21 | #[cfg(test)] 22 | use crate::{api::ApiState, socket}; 23 | #[cfg(test)] 24 | use axum::{Router, routing::any}; 25 | #[cfg(test)] 26 | use axum_test::{TestServer, TestWebSocket}; 27 | #[cfg(test)] 28 | use sea_orm::Database; 29 | #[cfg(test)] 30 | use sea_orm_migration::MigratorTrait; 31 | #[cfg(test)] 32 | use std::collections::HashMap; 33 | #[cfg(test)] 34 | use std::net::SocketAddr; 35 | #[cfg(test)] 36 | use std::sync::Arc; 37 | #[cfg(test)] 38 | use tokio::sync::Mutex; 39 | #[cfg(test)] 40 | use tokio_util::{sync::CancellationToken, task::TaskTracker}; 41 | 42 | #[cfg(test)] 43 | pub async fn get_test_socket() -> TestWebSocket { 44 | let db = Database::connect("sqlite::memory:").await.unwrap(); 45 | db::migration::Migrator::refresh(&db).await.unwrap(); 46 | 47 | let token = CancellationToken::new(); 48 | let tracker = TaskTracker::new(); 49 | let tokens: HashMap<(String, String), CancellationToken> = HashMap::new(); 50 | let state = ApiState { 51 | db, 52 | parent_token: token.clone(), 53 | tokens: Arc::new(Mutex::new(tokens)), 54 | tracker: tracker.clone(), 55 | auth: "test".into(), 56 | attachments_dir: "/tmp".into(), 57 | manager: Box::new(signal::SignalManager::new()), 58 | }; 59 | 60 | let app = Router::new() 61 | .route("/ws", any(socket::handler)) 62 | .with_state(state); 63 | 64 | let server = TestServer::builder() 65 | .http_transport() 66 | .build(app.into_make_service_with_connect_info::()) 67 | .unwrap(); 68 | server.get_websocket("/ws").await.into_websocket().await 69 | } 70 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/migration/m20240801_000007_create_channel_state.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[derive(DeriveMigrationName)] 4 | pub struct Migration; 5 | 6 | #[async_trait::async_trait] 7 | impl MigrationTrait for Migration { 8 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 | manager 10 | .create_table( 11 | Table::create() 12 | .table(ChannelState::Table) 13 | .if_not_exists() 14 | .col( 15 | ColumnDef::new(ChannelState::Id) 16 | .uuid() 17 | .not_null() 18 | .primary_key(), 19 | ) 20 | .col(ColumnDef::new(ChannelState::ChannelId).string().not_null()) 21 | .col(ColumnDef::new(ChannelState::Tree).string().not_null()) 22 | .col(ColumnDef::new(ChannelState::Key).string().not_null()) 23 | .col(ColumnDef::new(ChannelState::Value).string().not_null()) 24 | .col( 25 | ColumnDef::new(ChannelState::CreatedAt) 26 | .date_time() 27 | .default(Expr::current_timestamp()) 28 | .not_null(), 29 | ) 30 | .col( 31 | ColumnDef::new(ChannelState::UpdatedAt) 32 | .date_time() 33 | .default(Expr::current_timestamp()) 34 | .not_null(), 35 | ) 36 | .to_owned(), 37 | ) 38 | .await?; 39 | 40 | let db = manager.get_connection(); 41 | 42 | db.execute_unprepared( 43 | "CREATE TRIGGER channel_state_updated_at 44 | AFTER UPDATE ON channel_state 45 | FOR EACH ROW 46 | BEGIN 47 | UPDATE channel_state 48 | SET updated_at = (datetime('now','localtime')) 49 | WHERE id = NEW.id; 50 | END;", 51 | ) 52 | .await?; 53 | 54 | Ok(()) 55 | } 56 | 57 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 58 | manager 59 | .drop_table(Table::drop().table(ChannelState::Table).to_owned()) 60 | .await 61 | } 62 | } 63 | 64 | #[derive(DeriveIden)] 65 | enum ChannelState { 66 | Table, 67 | Id, 68 | ChannelId, 69 | Tree, 70 | Key, 71 | Value, 72 | CreatedAt, 73 | UpdatedAt, 74 | } 75 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/migration/m20240801_000005_create_state.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[derive(DeriveMigrationName)] 4 | pub struct Migration; 5 | 6 | #[async_trait::async_trait] 7 | impl MigrationTrait for Migration { 8 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 | manager 10 | .create_table( 11 | Table::create() 12 | .table(State::Table) 13 | .if_not_exists() 14 | .col(ColumnDef::new(State::Id).uuid().not_null().primary_key()) 15 | .col(ColumnDef::new(State::BotId).string().not_null()) 16 | .col(ColumnDef::new(State::ChannelId).string().not_null()) 17 | .col(ColumnDef::new(State::UserId).string().not_null()) 18 | .col(ColumnDef::new(State::Type).string().not_null()) 19 | .col(ColumnDef::new(State::Key).string().not_null()) 20 | .col(ColumnDef::new(State::Value).string().not_null()) 21 | .col( 22 | ColumnDef::new(State::CreatedAt) 23 | .date_time() 24 | .default(Expr::current_timestamp()) 25 | .not_null(), 26 | ) 27 | .col( 28 | ColumnDef::new(State::UpdatedAt) 29 | .date_time() 30 | .default(Expr::current_timestamp()) 31 | .not_null(), 32 | ) 33 | .col(ColumnDef::new(State::ExpiresAt).date_time()) 34 | .to_owned(), 35 | ) 36 | .await?; 37 | 38 | let db = manager.get_connection(); 39 | 40 | db.execute_unprepared( 41 | "CREATE TRIGGER state_updated_at 42 | AFTER UPDATE ON state 43 | FOR EACH ROW 44 | BEGIN 45 | UPDATE state 46 | SET updated_at = (datetime('now','localtime')) 47 | WHERE id = NEW.id; 48 | END;", 49 | ) 50 | .await?; 51 | 52 | Ok(()) 53 | } 54 | 55 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 56 | manager 57 | .drop_table(Table::drop().table(State::Table).to_owned()) 58 | .await 59 | } 60 | } 61 | 62 | #[derive(DeriveIden)] 63 | enum State { 64 | Table, 65 | Id, 66 | BotId, 67 | ChannelId, 68 | UserId, 69 | Type, 70 | Key, 71 | Value, 72 | CreatedAt, 73 | UpdatedAt, 74 | ExpiresAt, 75 | } 76 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/migration/m20240801_000003_create_memory.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[derive(DeriveMigrationName)] 4 | pub struct Migration; 5 | 6 | #[async_trait::async_trait] 7 | impl MigrationTrait for Migration { 8 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 | manager 10 | .create_table( 11 | Table::create() 12 | .table(Memory::Table) 13 | .if_not_exists() 14 | .col(ColumnDef::new(Memory::Id).uuid().not_null().primary_key()) 15 | .col(ColumnDef::new(Memory::BotId).string().not_null()) 16 | .col(ColumnDef::new(Memory::ChannelId).string().not_null()) 17 | .col(ColumnDef::new(Memory::UserId).string().not_null()) 18 | .col(ColumnDef::new(Memory::Key).string().not_null()) 19 | .col(ColumnDef::new(Memory::Value).string().not_null()) 20 | .col( 21 | ColumnDef::new(Memory::CreatedAt) 22 | .date_time() 23 | .default(Expr::current_timestamp()) 24 | .not_null(), 25 | ) 26 | .col( 27 | ColumnDef::new(Memory::UpdatedAt) 28 | .date_time() 29 | .default(Expr::current_timestamp()) 30 | .not_null(), 31 | ) 32 | .col(ColumnDef::new(Memory::ExpiresAt).date_time()) 33 | .to_owned(), 34 | ) 35 | .await?; 36 | 37 | let db = manager.get_connection(); 38 | 39 | db.execute_unprepared( 40 | "CREATE TRIGGER memory_updated_at 41 | AFTER UPDATE ON memory 42 | FOR EACH ROW 43 | BEGIN 44 | UPDATE memory 45 | SET updated_at = (datetime('now','localtime')) 46 | WHERE id = NEW.id; 47 | END;", 48 | ) 49 | .await?; 50 | 51 | Ok(()) 52 | } 53 | 54 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 55 | // Replace the sample below with your own migration scripts 56 | // todo!(); 57 | 58 | manager 59 | .drop_table(Table::drop().table(Memory::Table).to_owned()) 60 | .await 61 | } 62 | } 63 | 64 | #[derive(DeriveIden)] 65 | enum Memory { 66 | Table, 67 | Id, 68 | BotId, 69 | ChannelId, 70 | UserId, 71 | Key, 72 | Value, 73 | CreatedAt, 74 | UpdatedAt, 75 | ExpiresAt, 76 | } 77 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.0.4 (2025-09-05) 2 | 3 | ### Fix 4 | 5 | - Add additional usage documentation to the README 6 | 7 | ## 1.0.3 (2025-09-04) 8 | 9 | ### Fix 10 | 11 | - Make delete commands return errors if channel/bot is not found. 12 | - Shutdown receive task when channel or its associated bot is deleted. 13 | - Send initial sync message after linking. 14 | - Be more resilient to disconnections or message errors. 15 | - Quick fix for attachment filename sanitization. 16 | 17 | ## 1.0.2 (2025-08-27) 18 | 19 | ### Fix 20 | 21 | - Compare authorization header in constant time. Shout out to @redshiftzero for catching this. 22 | 23 | ## 1.0.1 (2025-08-25) 24 | 25 | ### Fix 26 | 27 | - Properly filter whisper messages by bot_id. 28 | 29 | ## 1.0.0 (2025-08-14) 30 | 31 | ### Fix 32 | 33 | - Fmt fixes. 34 | - Blood for the Clippy god. 35 | - Box up errors. 36 | - Pin Github actions to hashes. Fixes #11 37 | - Update presage to 0.7.0, fixes #23 (hopefully) 38 | - Save new memory value for existing memory. Fixes #22 39 | - Double thread stack size. Fixes #21 40 | 41 | ## 1.0.0-alpha.12 (2025-05-16) 42 | 43 | ### Fix 44 | 45 | - Don't panic immediately when failing to send message to Signal 46 | 47 | ## 1.0.0-alpha.11 (2025-05-16) 48 | 49 | ### Fix 50 | 51 | - Delete memory when bot is deleted 52 | 53 | ## 1.0.0-alpha.10 (2025-05-16) 54 | 55 | ### Fix 56 | 57 | - Don't shout to yourself 58 | 59 | ## 1.0.0-alpha.9 (2025-05-16) 60 | 61 | ### Fix 62 | 63 | - Don't default events to 'secure' 64 | 65 | ## 1.0.0-alpha.8 (2025-05-13) 66 | 67 | ### Fix 68 | 69 | - properly serialize memories as their appropriate types instead of as strings 70 | - Remove extraneous unwrap()'s from bitpart and presage-store 71 | 72 | ## 1.0.0-alpha.7 (2025-05-09) 73 | 74 | ### Fix 75 | 76 | - allow building releases on all pushes to main branch 77 | - Keep duplicate memories from being saved to the database. 78 | 79 | ## 1.0.0-alpha.6 (2025-05-06) 80 | 81 | ## 1.0.0-alpha.5 (2025-05-06) 82 | 83 | ### Fix 84 | 85 | - Strip extra quotes from memory strings. 86 | - Fix inclusion of built-in functions in validation. 87 | - Improve CLI response handling. 88 | - Update Cargo.lock for newer package version 89 | 90 | ## 1.0.0-alpha.4 (2025-05-05) 91 | 92 | ### Fix 93 | 94 | - Change commitizen workflow to treat as alpha 95 | - Change commitizen version provider to Cargo. 96 | 97 | ## 1.0.0-alpha.3 (2025-05-04) 98 | 99 | ### Fix 100 | 101 | - Add Cargo.lock to repository. 102 | 103 | ## 1.0.0-alpha.2 (2025-05-03) 104 | 105 | ### Fix 106 | 107 | - Flip conditional on github release workflow. 108 | - **signal**: Automatically start newly-linked channels. 109 | - **signal**: Fix how session identifiers are deserialized when determining device list. 110 | - **signal**: Remove extra contacts sync job, it was causing connection problems. 111 | 112 | ## 1.0.0-alpha.1 (2025-04-29) 113 | 114 | ## 1.0.0 (2025-04-28) 115 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/migration/m20240801_000002_create_conversation.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[derive(DeriveMigrationName)] 4 | pub struct Migration; 5 | 6 | #[async_trait::async_trait] 7 | impl MigrationTrait for Migration { 8 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 | manager 10 | .create_table( 11 | Table::create() 12 | .table(Conversation::Table) 13 | .if_not_exists() 14 | .col( 15 | ColumnDef::new(Conversation::Id) 16 | .uuid() 17 | .not_null() 18 | .primary_key(), 19 | ) 20 | .col(ColumnDef::new(Conversation::BotId).string().not_null()) 21 | .col(ColumnDef::new(Conversation::ChannelId).string().not_null()) 22 | .col(ColumnDef::new(Conversation::UserId).string().not_null()) 23 | .col(ColumnDef::new(Conversation::FlowId).string().not_null()) 24 | .col(ColumnDef::new(Conversation::StepId).string().not_null()) 25 | .col(ColumnDef::new(Conversation::Status).string().not_null()) 26 | .col( 27 | ColumnDef::new(Conversation::LastInteractionAt) 28 | .date_time() 29 | .default(Expr::current_timestamp()) 30 | .not_null(), 31 | ) 32 | .col( 33 | ColumnDef::new(Conversation::UpdatedAt) 34 | .date_time() 35 | .default(Expr::current_timestamp()) 36 | .not_null(), 37 | ) 38 | .col( 39 | ColumnDef::new(Conversation::CreatedAt) 40 | .date_time() 41 | .default(Expr::current_timestamp()) 42 | .not_null(), 43 | ) 44 | .col(ColumnDef::new(Conversation::ExpiresAt).date_time()) 45 | .to_owned(), 46 | ) 47 | .await?; 48 | 49 | let db = manager.get_connection(); 50 | 51 | db.execute_unprepared( 52 | "CREATE TRIGGER conversation_updated_at 53 | AFTER UPDATE ON conversation 54 | FOR EACH ROW 55 | BEGIN 56 | UPDATE conversation 57 | SET updated_at = (datetime('now','localtime')) 58 | WHERE id = NEW.id; 59 | END;", 60 | ) 61 | .await?; 62 | 63 | Ok(()) 64 | } 65 | 66 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 67 | // Replace the sample below with your own migration scripts 68 | // todo!(); 69 | 70 | manager 71 | .drop_table(Table::drop().table(Conversation::Table).to_owned()) 72 | .await 73 | } 74 | } 75 | 76 | #[derive(DeriveIden)] 77 | pub enum Conversation { 78 | Table, 79 | Id, 80 | BotId, 81 | ChannelId, 82 | UserId, 83 | FlowId, 84 | StepId, 85 | Status, 86 | LastInteractionAt, 87 | UpdatedAt, 88 | CreatedAt, 89 | ExpiresAt, 90 | } 91 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct - Bitpart 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behaviour that contributes to a positive environment for our 15 | community include: 16 | 17 | * Demonstrating empathy and kindness toward other people 18 | * Being respectful of differing opinions, viewpoints, and experiences 19 | * Giving and gracefully accepting constructive feedback 20 | * Accepting responsibility and apologising to those affected by our mistakes, 21 | and learning from the experience 22 | * Focusing on what is best not just for us as individuals, but for the 23 | overall community 24 | 25 | Examples of unacceptable behaviour include: 26 | 27 | * The use of sexualised language or imagery, and sexual attention or advances 28 | * Trolling, insulting or derogatory comments, and personal or political attacks 29 | * Public or private harassment 30 | * Publishing others' private information, such as a physical or email 31 | address, without their explicit permission 32 | * Other conduct which could reasonably be considered inappropriate in a 33 | professional setting 34 | 35 | ## Our Responsibilities 36 | 37 | Project maintainers are responsible for clarifying and enforcing our standards of 38 | acceptable behaviour and will take appropriate and fair corrective action in 39 | response to any instances of unacceptable behaviour. 40 | 41 | Project maintainers have the right and responsibility to remove, edit, or reject 42 | comments, commits, code, wiki edits, issues, and other contributions that are 43 | not aligned to this Code of Conduct, or to ban 44 | temporarily or permanently any contributor for other behaviours that they deem 45 | inappropriate, threatening, offensive, or harmful. 46 | 47 | ## Scope 48 | 49 | This Code of Conduct applies within all community spaces, and also applies when 50 | an individual is officially representing the community in public spaces. 51 | Examples of representing our community include using an official e-mail address, 52 | posting via an official social media account, or acting as an appointed 53 | representative at an online or offline event. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behaviour may be 58 | reported to the community leaders responsible for enforcement at . 59 | All complaints will be reviewed and investigated promptly and fairly. 60 | 61 | All community leaders are obligated to respect the privacy and security of the 62 | reporter of any incident. 63 | 64 | ## Attribution 65 | 66 | This Code of Conduct is adapted from the [Contributor Covenant](https://contributor-covenant.org/), version 67 | [1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct/code_of_conduct.md) and 68 | [2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct/code_of_conduct.md), 69 | and was generated by [contributing.md](https://contributing.md/generator). 70 | -------------------------------------------------------------------------------- /crates/bitpart-common/src/error.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | use base64; 18 | use bincode; 19 | use figment; 20 | use futures; 21 | use hex; 22 | use opentelemetry_otlp; 23 | use presage; 24 | use presage_store_bitpart::BitpartStoreError; 25 | use prost; 26 | use sea_orm::DbErr; 27 | use serde_json::Error as SerdeError; 28 | use std::{array, io}; 29 | use thiserror::Error; 30 | use thiserror_ext::Box; 31 | use tokio; 32 | use uuid; 33 | 34 | #[derive(Debug, Error, Box)] 35 | #[thiserror_ext(newtype(name = BitpartError))] 36 | pub enum BitpartErrorKind { 37 | #[error("API error: `{0}`")] 38 | Api(String), 39 | #[error("Interpreter error: `{0}`")] 40 | Interpreter(String), 41 | #[error("Database error: `{0}`")] 42 | Db(#[from] DbErr), 43 | #[error("I/O error: `{0}`")] 44 | Io(#[from] io::Error), 45 | #[error("Directory error: `{0}`")] 46 | Directory(String), 47 | #[error("Figment error: `{0}`")] 48 | Figment(#[from] figment::Error), 49 | #[error("Channel Receive error: `{0}`")] 50 | ChannelRecv(#[from] tokio::sync::oneshot::error::RecvError), 51 | #[error("Presage store error")] 52 | PresageStore, 53 | #[error("Attachment error: `{0}`")] 54 | Attachment(#[from] presage::libsignal_service::sender::AttachmentUploadError), 55 | #[error("Serialization/deserialization error")] 56 | Serde(#[from] SerdeError), 57 | #[error("Signal error: `{0}`")] 58 | Signal(String), 59 | #[error("Decode base64 error: `{0}`")] 60 | DecodeBase64(#[from] base64::DecodeError), 61 | #[error("Decode hex error: `{0}`")] 62 | DecodeHex(#[from] hex::FromHexError), 63 | #[error("Signal error: `{0}`")] 64 | SignalManager(#[from] anyhow::Error), //TODO actually swap out the errors in the signal channel file 65 | #[error("Signal storage error: `{0}`")] 66 | SignalStore(#[from] BitpartStoreError), 67 | #[error("Websocket close")] 68 | WebsocketClose, 69 | #[error("Channel Canceled error: `{0}`")] 70 | ChannelCanceled(#[from] futures::channel::oneshot::Canceled), 71 | #[error("Signal Recipient error: `{0}`")] 72 | SignalRecipient(#[from] array::TryFromSliceError), 73 | #[error("Signal Message error: `{0}`")] 74 | SignalMessage(#[from] uuid::Error), 75 | #[error("OpenTelemetry build error: `{0}`")] 76 | OpenTelemetry(#[from] opentelemetry_otlp::ExporterBuildError), 77 | #[error("Protocol Buffers error: `{0}`")] 78 | ProtocolBuffers(#[from] prost::UnknownEnumValue), 79 | #[error("Bincode error: `{0}`")] 80 | Bincode(#[from] bincode::Error), 81 | } 82 | 83 | impl From> for BitpartErrorKind { 84 | fn from(_err: presage::Error) -> Self { 85 | Self::PresageStore 86 | } 87 | } 88 | 89 | pub type Result = std::result::Result; 90 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/message.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | use bitpart_common::error::Result; 18 | use chrono::NaiveDateTime; 19 | use csml_interpreter::data::Client; 20 | use sea_orm::*; 21 | use serde_json::Value; 22 | use uuid; 23 | 24 | use super::entities::{prelude::*, *}; 25 | use crate::csml::data::ConversationData; 26 | 27 | pub async fn create( 28 | data: &ConversationData, 29 | messages: &[Value], 30 | interaction_order: i32, 31 | direction: &str, 32 | expires_at: Option, 33 | db: &DatabaseConnection, 34 | ) -> Result<()> { 35 | if messages.is_empty() { 36 | return Ok(()); 37 | } 38 | 39 | let mut new_messages = vec![]; 40 | 41 | for (message_order, message) in messages.iter().enumerate() { 42 | let message = message::ActiveModel { 43 | id: ActiveValue::Set(uuid::Uuid::new_v4().to_string()), 44 | conversation_id: ActiveValue::Set(data.conversation_id.to_owned()), 45 | flow_id: ActiveValue::Set(data.context.flow.to_owned()), 46 | step_id: ActiveValue::Set(data.context.step.get_step_ref().to_owned()), 47 | direction: ActiveValue::Set(direction.to_owned()), 48 | payload: ActiveValue::Set(message.to_string()), 49 | content_type: ActiveValue::Set(message["content_type"].to_string()), 50 | message_order: ActiveValue::Set(message_order as i32), 51 | interaction_order: ActiveValue::Set(interaction_order), 52 | expires_at: ActiveValue::Set(expires_at.map(|e| e.to_string())), 53 | ..Default::default() 54 | }; 55 | 56 | new_messages.push(message); 57 | } 58 | 59 | Message::insert_many(new_messages).exec(db).await?; 60 | 61 | Ok(()) 62 | } 63 | 64 | pub async fn delete_by_client(client: &Client, db: &DatabaseConnection) -> Result<()> { 65 | let conversations = super::conversation::get_by_client(client, None, None, db).await?; 66 | for convo in conversations { 67 | Message::delete_many() 68 | .filter(message::Column::ConversationId.eq(convo.id.to_owned())) 69 | .exec(db) 70 | .await?; 71 | } 72 | Ok(()) 73 | } 74 | 75 | pub async fn get_by_client( 76 | client: &Client, 77 | limit: Option, 78 | offset: Option, 79 | db: &DatabaseConnection, 80 | ) -> Result> { 81 | let mut messages = vec![]; 82 | let conversations = super::conversation::get_by_client(client, limit, offset, db).await?; 83 | for convo in conversations { 84 | let entry = Message::find() 85 | .filter(message::Column::ConversationId.eq(convo.id.to_owned())) 86 | .limit(limit) 87 | .offset(offset) 88 | .all(db) 89 | .await?; 90 | messages.extend(entry); 91 | } 92 | 93 | Ok(messages) 94 | } 95 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/migration/m20240801_000004_create_message.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | use super::m20240801_000002_create_conversation::Conversation; 4 | 5 | #[derive(DeriveMigrationName)] 6 | pub struct Migration; 7 | 8 | #[async_trait::async_trait] 9 | impl MigrationTrait for Migration { 10 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 11 | manager 12 | .create_table( 13 | Table::create() 14 | .table(Message::Table) 15 | .if_not_exists() 16 | .col(ColumnDef::new(Message::Id).uuid().not_null().primary_key()) 17 | .foreign_key( 18 | ForeignKey::create() 19 | .name("fk-message-conversation_id") 20 | .from(Message::Table, Message::ConversationId) 21 | .to(Conversation::Table, Conversation::Id), 22 | ) 23 | .col(ColumnDef::new(Message::ConversationId).uuid().not_null()) 24 | .col(ColumnDef::new(Message::FlowId).string().not_null()) 25 | .col(ColumnDef::new(Message::StepId).string().not_null()) 26 | .col(ColumnDef::new(Message::Direction).string().not_null()) 27 | .col(ColumnDef::new(Message::Payload).string().not_null()) 28 | .col(ColumnDef::new(Message::ContentType).string().not_null()) 29 | .col(ColumnDef::new(Message::MessageOrder).integer().not_null()) 30 | .col( 31 | ColumnDef::new(Message::InteractionOrder) 32 | .integer() 33 | .not_null(), 34 | ) 35 | .col( 36 | ColumnDef::new(Message::CreatedAt) 37 | .date_time() 38 | .default(Expr::current_timestamp()) 39 | .not_null(), 40 | ) 41 | .col( 42 | ColumnDef::new(Message::UpdatedAt) 43 | .date_time() 44 | .default(Expr::current_timestamp()) 45 | .not_null(), 46 | ) 47 | .col(ColumnDef::new(Message::ExpiresAt).date_time()) 48 | .to_owned(), 49 | ) 50 | .await?; 51 | 52 | let db = manager.get_connection(); 53 | 54 | db.execute_unprepared( 55 | "CREATE TRIGGER message_updated_at 56 | AFTER UPDATE ON message 57 | FOR EACH ROW 58 | BEGIN 59 | UPDATE message 60 | SET updated_at = (datetime('now','localtime')) 61 | WHERE id = NEW.id; 62 | END;", 63 | ) 64 | .await?; 65 | 66 | Ok(()) 67 | } 68 | 69 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 70 | // Replace the sample below with your own migration scripts 71 | // todo!(); 72 | 73 | manager 74 | .drop_table(Table::drop().table(Message::Table).to_owned()) 75 | .await 76 | } 77 | } 78 | 79 | #[allow(clippy::enum_variant_names)] 80 | #[derive(DeriveIden)] 81 | enum Message { 82 | Table, 83 | Id, 84 | ConversationId, 85 | FlowId, 86 | StepId, 87 | Direction, 88 | Payload, 89 | ContentType, 90 | MessageOrder, 91 | InteractionOrder, 92 | CreatedAt, 93 | UpdatedAt, 94 | ExpiresAt, 95 | } 96 | -------------------------------------------------------------------------------- /crates/bitpart/src/csml/data.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | // 4 | // This code is derived in part from code from the CSML project: 5 | // Copyright (C) 2020 CSML 6 | 7 | // This program is free software: you can redistribute it and/or modify 8 | // it under the terms of the GNU Affero General Public License as published by 9 | // the Free Software Foundation, either version 3 of the License, or 10 | // (at your option) any later version. 11 | 12 | // This program is distributed in the hope that it will be useful, 13 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | // GNU Affero General Public License for more details. 16 | 17 | // You should have received a copy of the GNU Affero General Public License 18 | // along with this program. If not, see . 19 | 20 | use bitpart_common::{ 21 | csml::BotOpt, 22 | error::{BitpartErrorKind, Result}, 23 | }; 24 | use csml_interpreter::data::{Client, Context, CsmlBot, Message}; 25 | use sea_orm::DatabaseConnection; 26 | use serde::{Deserialize, Serialize}; 27 | 28 | use crate::db; 29 | 30 | #[derive(Debug, Clone)] 31 | pub struct SwitchBot { 32 | pub bot_id: String, 33 | pub version_id: Option, 34 | pub flow: Option, 35 | pub step: String, 36 | } 37 | 38 | #[derive(Serialize, Deserialize, Debug)] 39 | pub struct BotVersion { 40 | pub bot: CsmlBot, 41 | pub version_id: String, 42 | pub engine_version: String, 43 | } 44 | 45 | #[derive(Debug, Clone)] 46 | pub struct ConversationData { 47 | pub conversation_id: String, 48 | pub request_id: String, 49 | pub client: Client, 50 | pub callback_url: Option, 51 | pub context: Context, 52 | pub metadata: serde_json::Value, 53 | pub messages: Vec, 54 | pub ttl: Option, 55 | pub low_data: bool, 56 | } 57 | 58 | pub async fn search_bot(bot: &BotOpt, db: &DatabaseConnection) -> Result> { 59 | match bot { 60 | BotOpt::CsmlBot(csml_bot) => Ok(csml_bot.to_owned()), 61 | BotOpt::BotId { 62 | bot_id, 63 | apps_endpoint: _, 64 | multibot: _, 65 | } => { 66 | let bot_version = db::bot::get_latest_by_bot_id(bot_id, db).await?; 67 | 68 | match bot_version { 69 | Some(bot_version) => { 70 | // bot_version.bot.apps_endpoint = apps_endpoint.to_owned(); 71 | // bot_version.bot.multibot = multibot.to_owned(); 72 | Ok(Box::new(bot_version.bot)) 73 | } 74 | None => Err(BitpartErrorKind::Interpreter(format!( 75 | "bot ({}) not found in db", 76 | bot_id 77 | )) 78 | .into()), 79 | } 80 | } 81 | BotOpt::Id { 82 | version_id, 83 | bot_id: _, 84 | apps_endpoint: _, 85 | multibot: _, 86 | } => { 87 | let bot_version = db::bot::get_by_id(version_id, db).await?; 88 | 89 | match bot_version { 90 | Some(bot_version) => { 91 | // bot_version.bot.apps_endpoint = apps_endpoint.to_owned(); 92 | // bot_version.bot.multibot = multibot.to_owned(); 93 | Ok(Box::new(bot_version.bot)) 94 | } 95 | None => Err(BitpartErrorKind::Interpreter(format!( 96 | "bot version ({}) not found in db", 97 | version_id 98 | )) 99 | .into()), 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /.github/workflows/release_build.yaml: -------------------------------------------------------------------------------- 1 | name: Build release version 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Run checks"] 6 | branches: [main] 7 | types: 8 | - completed 9 | 10 | env: 11 | IMAGE_NAME: bitpart 12 | BRANCH_NAME: ${{ github.head_ref || github.ref_name }} 13 | 14 | jobs: 15 | bump_version: 16 | runs-on: ubuntu-latest 17 | name: "Bump version, create changelog" 18 | outputs: 19 | version: ${{ steps.cz.outputs.version }} 20 | body: ${{ steps.cz.outputs.body}} 21 | steps: 22 | - name: Check out 23 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 24 | with: 25 | fetch-depth: 0 26 | ref: ${{ env.BRANCH_NAME }} 27 | - id: cz 28 | name: Create bump and changelog 29 | uses: commitizen-tools/commitizen-action@5b0848cd060263e24602d1eba03710e056ef7711 30 | with: 31 | github_token: ${{ secrets.GITHUB_TOKEN }} 32 | changelog_increment_filename: body.md 33 | commitizen_version: '4.6.3' 34 | - name: Read body.md file 35 | id: get_body 36 | run: | 37 | echo 'body<> $GITHUB_OUTPUT 38 | echo "$(cat body.md)" >> $GITHUB_OUTPUT 39 | echo 'EOF' >> $GITHUB_OUTPUT 40 | - name: Print Version 41 | run: echo "Bumped to version ${{ steps.cz.outputs.version }}" 42 | build_release: 43 | runs-on: ubuntu-latest 44 | name: "Build release artifacts" 45 | needs: bump_version 46 | steps: 47 | - name: Check out 48 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 49 | with: 50 | ref: ${{ env.BRANCH_NAME }} 51 | - name: Install dependencies 52 | uses: awalsh128/cache-apt-pkgs-action@2c09a5e66da6c8016428a2172bd76e5e4f14bb17 53 | with: 54 | packages: protobuf-compiler 55 | version: 1.0 56 | - name: Setup Rust toolchain 57 | uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 58 | - name: Build 59 | run: cargo build --all --release && strip target/release/bitpart && strip target/release/bitpart-cli 60 | - name: Release 61 | uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 62 | with: 63 | body: ${{ needs.bump_version.outputs.body }} 64 | tag_name: ${{ needs.bump_version.outputs.version }} 65 | files: | 66 | target/release/bitpart 67 | target/release/bitpart-cli 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | build_image: 71 | runs-on: ubuntu-latest 72 | name: "Build release image" 73 | needs: bump_version 74 | steps: 75 | - name: Check out 76 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 77 | with: 78 | ref: ${{ env.BRANCH_NAME }} 79 | - name: Build image 80 | id: build_image 81 | uses: redhat-actions/buildah-build@7a95fa7ee0f02d552a32753e7414641a04307056 82 | with: 83 | image: ${{ env.IMAGE_NAME }} 84 | tags: ${{ needs.bump_version.outputs.version }} latest 85 | containerfiles: | 86 | ./Dockerfile 87 | - name: Push To GHCR 88 | uses: redhat-actions/push-to-registry@5ed88d269cf581ea9ef6dd6806d01562096bee9c 89 | id: push 90 | env: 91 | IMAGE_REGISTRY: ghcr.io/${{ github.repository_owner }} 92 | REGISTRY_USER: ${{ github.actor }} 93 | REGISTRY_PASSWORD: ${{ github.token }} 94 | with: 95 | image: ${{ steps.build_image.outputs.image }} 96 | tags: ${{ steps.build_image.outputs.tags }} 97 | registry: ${{ env.IMAGE_REGISTRY }} 98 | username: ${{ env.REGISTRY_USER }} 99 | password: ${{ env.REGISTRY_PASSWORD }} 100 | extra-args: | 101 | --disable-content-trust 102 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/channel.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | use bitpart_common::error::{BitpartErrorKind, Result}; 18 | use sea_orm::*; 19 | use uuid; 20 | 21 | use super::entities::{prelude::*, *}; 22 | 23 | pub async fn create(channel_id: &str, bot_id: &str, db: &DatabaseConnection) -> Result { 24 | let Some(existing) = Channel::find() 25 | .filter(channel::Column::BotId.eq(bot_id)) 26 | .filter(channel::Column::ChannelId.eq(channel_id)) 27 | .one(db) 28 | .await? 29 | else { 30 | let id = uuid::Uuid::new_v4().to_string(); 31 | let entry = channel::ActiveModel { 32 | id: ActiveValue::Set(id.clone()), 33 | bot_id: ActiveValue::Set(bot_id.to_owned()), 34 | channel_id: ActiveValue::Set(channel_id.to_owned()), 35 | ..Default::default() 36 | }; 37 | entry.insert(db).await?; 38 | return Ok(id); 39 | }; 40 | Ok(existing.id) 41 | } 42 | 43 | pub async fn list( 44 | limit: Option, 45 | offset: Option, 46 | db: &DatabaseConnection, 47 | ) -> Result> { 48 | let entries = Channel::find() 49 | .order_by(channel::Column::CreatedAt, Order::Desc) 50 | .limit(limit) 51 | .offset(offset) 52 | .all(db) 53 | .await?; 54 | 55 | Ok(entries) 56 | } 57 | 58 | pub async fn get( 59 | channel_id: &str, 60 | bot_id: &str, 61 | db: &DatabaseConnection, 62 | ) -> Result> { 63 | let entries = Channel::find() 64 | .filter(channel::Column::BotId.eq(bot_id)) 65 | .filter(channel::Column::ChannelId.eq(channel_id)) 66 | .one(db) 67 | .await?; 68 | 69 | Ok(entries) 70 | } 71 | 72 | pub async fn get_by_id(id: &str, db: &DatabaseConnection) -> Result> { 73 | let entries = Channel::find_by_id(id).one(db).await?; 74 | 75 | Ok(entries) 76 | } 77 | 78 | pub async fn get_by_bot_id(bot_id: &str, db: &DatabaseConnection) -> Result> { 79 | let entries = Channel::find() 80 | .filter(channel::Column::BotId.eq(bot_id.to_owned())) 81 | .all(db) 82 | .await?; 83 | 84 | Ok(entries) 85 | } 86 | 87 | pub async fn delete(channel_id: &str, bot_id: &str, db: &DatabaseConnection) -> Result<()> { 88 | let entry = Channel::find() 89 | .filter(channel::Column::BotId.eq(bot_id.to_owned())) 90 | .filter(channel::Column::ChannelId.eq(channel_id.to_owned())) 91 | .one(db) 92 | .await?; 93 | 94 | if let Some(e) = entry { 95 | e.delete(db).await?; 96 | Ok(()) 97 | } else { 98 | Err(BitpartErrorKind::Db(DbErr::RecordNotFound(bot_id.to_owned())).into()) 99 | } 100 | } 101 | 102 | pub async fn delete_by_bot_id(bot_id: &str, db: &DatabaseConnection) -> Result<()> { 103 | let entry = Channel::find() 104 | .filter(channel::Column::BotId.eq(bot_id.to_owned())) 105 | .one(db) 106 | .await?; 107 | 108 | if let Some(e) = entry { 109 | e.delete(db).await?; 110 | } 111 | 112 | Ok(()) 113 | } 114 | 115 | pub async fn delete_by_id(id: &str, db: &DatabaseConnection) -> Result<()> { 116 | Channel::delete_by_id(id).exec(db).await?; 117 | Ok(()) 118 | } 119 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/state.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | use bitpart_common::error::{BitpartErrorKind, Result}; 18 | use chrono::NaiveDateTime; 19 | use csml_interpreter::data::Client; 20 | use sea_orm::*; 21 | use serde_json::Value; 22 | 23 | use super::entities::{prelude::*, *}; 24 | 25 | pub async fn get( 26 | client: &Client, 27 | r#type: &str, 28 | key: &str, 29 | db: &DatabaseConnection, 30 | ) -> Result { 31 | let Some(entry) = State::find() 32 | .filter(state::Column::BotId.eq(&client.bot_id)) 33 | .filter(state::Column::ChannelId.eq(&client.channel_id)) 34 | .filter(state::Column::UserId.eq(&client.user_id)) 35 | .filter(state::Column::Type.eq(r#type)) 36 | .filter(state::Column::Key.eq(key)) 37 | .one(db) 38 | .await? 39 | else { 40 | return Err(BitpartErrorKind::Interpreter("No state found".to_owned()).into()); 41 | }; 42 | Ok(serde_json::from_str(&entry.value)?) 43 | } 44 | 45 | pub async fn get_by_client(client: &Client, db: &DatabaseConnection) -> Result> { 46 | let entries = State::find() 47 | .filter(state::Column::BotId.eq(&client.bot_id)) 48 | .filter(state::Column::ChannelId.eq(&client.channel_id)) 49 | .filter(state::Column::UserId.eq(&client.user_id)) 50 | .all(db) 51 | .await?; 52 | Ok(entries.into_iter().map(|e| e.value.into()).collect()) 53 | } 54 | 55 | pub async fn set( 56 | client: &Client, 57 | r#type: &str, 58 | key: &str, 59 | value: &Value, 60 | expires_at: Option, 61 | db: &DatabaseConnection, 62 | ) -> Result<()> { 63 | let Some(existing) = State::find() 64 | .filter(state::Column::BotId.eq(&client.bot_id)) 65 | .filter(state::Column::ChannelId.eq(&client.channel_id)) 66 | .filter(state::Column::UserId.eq(&client.user_id)) 67 | .filter(state::Column::Type.eq(r#type)) 68 | .filter(state::Column::Key.eq(key)) 69 | .one(db) 70 | .await? 71 | else { 72 | let entry = state::ActiveModel { 73 | id: ActiveValue::Set(uuid::Uuid::new_v4().to_string()), 74 | bot_id: ActiveValue::Set(client.bot_id.to_owned()), 75 | channel_id: ActiveValue::Set(client.channel_id.to_owned()), 76 | user_id: ActiveValue::Set(client.user_id.to_owned()), 77 | r#type: ActiveValue::Set(r#type.to_owned().to_owned()), 78 | key: ActiveValue::Set(key.to_owned()), 79 | value: ActiveValue::Set(value.to_string()), 80 | expires_at: ActiveValue::Set(expires_at.map(|e| e.to_string())), 81 | ..Default::default() 82 | }; 83 | entry.insert(db).await?; 84 | return Ok(()); 85 | }; 86 | 87 | let mut existing: state::ActiveModel = existing.into(); 88 | existing.value = ActiveValue::Set(value.to_string()); 89 | existing.expires_at = ActiveValue::Set(expires_at.map(|e| e.to_string())); 90 | existing.update(db).await?; 91 | Ok(()) 92 | } 93 | 94 | pub async fn delete( 95 | client: &Client, 96 | r#type: &str, 97 | key: &str, 98 | db: &DatabaseConnection, 99 | ) -> Result<()> { 100 | let entry = State::find() 101 | .filter(state::Column::BotId.eq(client.bot_id.to_owned())) 102 | .filter(state::Column::ChannelId.eq(client.channel_id.to_owned())) 103 | .filter(state::Column::UserId.eq(client.user_id.to_owned())) 104 | .filter(state::Column::Type.eq(r#type)) 105 | .filter(state::Column::Key.eq(key)) 106 | .one(db) 107 | .await?; 108 | 109 | if let Some(e) = entry { 110 | e.delete(db).await?; 111 | } 112 | 113 | Ok(()) 114 | } 115 | 116 | pub async fn delete_by_client(client: &Client, db: &DatabaseConnection) -> Result<()> { 117 | State::delete_many() 118 | .filter(state::Column::BotId.eq(client.bot_id.to_owned())) 119 | .filter(state::Column::ChannelId.eq(client.channel_id.to_owned())) 120 | .filter(state::Column::UserId.eq(client.user_id.to_owned())) 121 | .exec(db) 122 | .await?; 123 | Ok(()) 124 | } 125 | 126 | pub async fn delete_by_bot_id(bot_id: &str, db: &DatabaseConnection) -> Result<()> { 127 | State::delete_many() 128 | .filter(state::Column::BotId.eq(bot_id)) 129 | .exec(db) 130 | .await?; 131 | Ok(()) 132 | } 133 | -------------------------------------------------------------------------------- /crates/presage-store-bitpart/src/protobuf.rs: -------------------------------------------------------------------------------- 1 | // presage-store-bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | // 4 | // This code is derived in part from code from the Presage project: 5 | // Copyright (C) 2024 Gabriel Féron 6 | 7 | // This program is free software: you can redistribute it and/or modify 8 | // it under the terms of the GNU Affero General Public License as published by 9 | // the Free Software Foundation, either version 3 of the License, or 10 | // (at your option) any later version. 11 | 12 | // This program is distributed in the hope that it will be useful, 13 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | // GNU Affero General Public License for more details. 16 | 17 | // You should have received a copy of the GNU Affero General Public License 18 | // along with this program. If not, see . 19 | 20 | #[allow(clippy::derive_partial_eq_without_eq)] 21 | mod textsecure { 22 | include!(concat!(env!("OUT_DIR"), "/textsecure.rs")); 23 | } 24 | 25 | use std::str::FromStr; 26 | 27 | use presage::libsignal_service::content::Content; 28 | use presage::libsignal_service::content::ContentBody; 29 | use presage::libsignal_service::content::Metadata; 30 | use presage::libsignal_service::prelude::Uuid; 31 | use presage::libsignal_service::proto; 32 | use presage::libsignal_service::protocol::ServiceId; 33 | 34 | use crate::BitpartStoreError; 35 | 36 | use self::textsecure::AddressProto; 37 | use self::textsecure::MetadataProto; 38 | 39 | impl From for AddressProto { 40 | fn from(s: ServiceId) -> Self { 41 | AddressProto { 42 | uuid: Some(s.raw_uuid().as_bytes().to_vec()), 43 | } 44 | } 45 | } 46 | 47 | impl TryFrom for ServiceId { 48 | type Error = BitpartStoreError; 49 | 50 | fn try_from(address: AddressProto) -> Result { 51 | address 52 | .uuid 53 | .and_then(|bytes| Some(Uuid::from_bytes(bytes.try_into().ok()?))) 54 | .ok_or_else(|| BitpartStoreError::NoUuid) 55 | .map(|u| ServiceId::Aci(u.into())) 56 | } 57 | } 58 | 59 | impl From for MetadataProto { 60 | fn from(m: Metadata) -> Self { 61 | MetadataProto { 62 | address: Some(m.sender.into()), 63 | sender_device: m.sender_device.try_into().ok(), 64 | timestamp: m.timestamp.try_into().ok(), 65 | server_received_timestamp: None, 66 | server_delivered_timestamp: None, 67 | needs_receipt: Some(m.needs_receipt), 68 | server_guid: None, 69 | group_id: None, 70 | destination_uuid: Some(m.destination.raw_uuid().to_string()), 71 | } 72 | } 73 | } 74 | 75 | impl TryFrom for Metadata { 76 | type Error = BitpartStoreError; 77 | 78 | fn try_from(metadata: MetadataProto) -> Result { 79 | Ok(Metadata { 80 | sender: metadata 81 | .address 82 | .ok_or(BitpartStoreError::NoUuid)? 83 | .try_into()?, 84 | destination: ServiceId::Aci( 85 | match metadata.destination_uuid.as_deref() { 86 | Some(value) => value.parse().map_err(|_| BitpartStoreError::NoUuid), 87 | None => Ok(Uuid::nil()), 88 | }? 89 | .into(), 90 | ), 91 | sender_device: metadata 92 | .sender_device 93 | .and_then(|m| m.try_into().ok()) 94 | .unwrap_or_default(), 95 | server_guid: metadata 96 | .server_guid 97 | .and_then(|u| crate::Uuid::from_str(&u).ok()), 98 | timestamp: metadata 99 | .timestamp 100 | .and_then(|m| m.try_into().ok()) 101 | .unwrap_or_default(), 102 | needs_receipt: metadata.needs_receipt.unwrap_or_default(), 103 | unidentified_sender: false, 104 | was_plaintext: false, 105 | }) 106 | } 107 | } 108 | 109 | #[derive(Clone, PartialEq, ::prost::Message)] 110 | pub struct ContentProto { 111 | #[prost(message, required, tag = "1")] 112 | metadata: MetadataProto, 113 | #[prost(message, required, tag = "2")] 114 | content: proto::Content, 115 | } 116 | 117 | impl From for ContentProto { 118 | fn from(c: Content) -> Self { 119 | (c.metadata, c.body).into() 120 | } 121 | } 122 | 123 | impl From<(Metadata, ContentBody)> for ContentProto { 124 | fn from((metadata, content_body): (Metadata, ContentBody)) -> Self { 125 | ContentProto { 126 | metadata: metadata.into(), 127 | content: content_body.into_proto(), 128 | } 129 | } 130 | } 131 | 132 | impl TryInto for ContentProto { 133 | type Error = BitpartStoreError; 134 | 135 | fn try_into(self) -> Result { 136 | let metadata = self.metadata.try_into()?; 137 | Content::from_proto(self.content, metadata) 138 | .map_err(|_| BitpartStoreError::UnsupportedContent) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /crates/presage-store-bitpart/src/db/channel_state.rs: -------------------------------------------------------------------------------- 1 | // presage-store-bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | // 4 | // This code is derived in part from code from the Presage project: 5 | // Copyright (C) 2024 Gabriel Féron 6 | 7 | // This program is free software: you can redistribute it and/or modify 8 | // it under the terms of the GNU Affero General Public License as published by 9 | // the Free Software Foundation, either version 3 of the License, or 10 | // (at your option) any later version. 11 | 12 | // This program is distributed in the hope that it will be useful, 13 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | // GNU Affero General Public License for more details. 16 | 17 | // You should have received a copy of the GNU Affero General Public License 18 | // along with this program. If not, see . 19 | 20 | use super::entities::{prelude::*, *}; 21 | use sea_orm::*; 22 | use uuid; 23 | 24 | use crate::error::BitpartStoreError; 25 | 26 | pub async fn get( 27 | channel_id: &str, 28 | tree: &str, 29 | key: &str, 30 | db: &DatabaseConnection, 31 | ) -> Result, BitpartStoreError> { 32 | let existing = ChannelState::find() 33 | .filter(channel_state::Column::ChannelId.eq(channel_id)) 34 | .filter(channel_state::Column::Tree.eq(tree)) 35 | .filter(channel_state::Column::Key.eq(key)) 36 | .one(db) 37 | .await?; 38 | 39 | if let Some(entry) = existing { 40 | Ok(Some(entry.value)) 41 | } else { 42 | Ok(None) 43 | } 44 | } 45 | 46 | pub async fn get_all( 47 | channel_id: &str, 48 | tree: &str, 49 | db: &DatabaseConnection, 50 | ) -> Result, BitpartStoreError> { 51 | let existing = ChannelState::find() 52 | .select_only() 53 | .column(channel_state::Column::Key) 54 | .column(channel_state::Column::Value) 55 | .filter(channel_state::Column::ChannelId.eq(channel_id)) 56 | .filter(channel_state::Column::Tree.eq(tree)) 57 | // .order_by_asc(channel_state::Column::Key) 58 | .into_tuple() 59 | .all(db) 60 | .await?; 61 | 62 | Ok(existing) 63 | } 64 | 65 | pub async fn get_trees( 66 | channel_id: &str, 67 | db: &DatabaseConnection, 68 | ) -> Result, BitpartStoreError> { 69 | let existing = ChannelState::find() 70 | .select_only() 71 | .column(channel_state::Column::Tree) 72 | .filter(channel_state::Column::ChannelId.eq(channel_id)) 73 | .group_by(channel_state::Column::Tree) 74 | .into_tuple() 75 | .all(db) 76 | .await?; 77 | 78 | Ok(existing) 79 | } 80 | 81 | pub async fn set>( 82 | channel_id: &str, 83 | tree: &str, 84 | key: &str, 85 | value: V, 86 | db: &DatabaseConnection, 87 | ) -> Result { 88 | let existing = ChannelState::find() 89 | .filter(channel_state::Column::ChannelId.eq(channel_id)) 90 | .filter(channel_state::Column::Tree.eq(tree)) 91 | .filter(channel_state::Column::Key.eq(key)) 92 | .one(db) 93 | .await?; 94 | 95 | let existing = if let Some(entry) = existing { 96 | let mut to_update: channel_state::ActiveModel = entry.into(); 97 | to_update.value = ActiveValue::Set(value.into()); 98 | to_update.update(db).await?; 99 | true 100 | } else { 101 | let id = uuid::Uuid::new_v4().to_string(); 102 | let to_insert = channel_state::ActiveModel { 103 | id: ActiveValue::Set(id.clone()), 104 | channel_id: ActiveValue::Set(channel_id.to_owned()), 105 | tree: ActiveValue::Set(tree.to_owned()), 106 | key: ActiveValue::Set(key.to_owned()), 107 | value: ActiveValue::Set(value.into()), 108 | ..Default::default() 109 | }; 110 | to_insert.insert(db).await?; 111 | false 112 | }; 113 | 114 | Ok(existing) 115 | } 116 | 117 | pub async fn remove( 118 | channel_id: &str, 119 | tree: &str, 120 | key: &str, 121 | db: &DatabaseConnection, 122 | ) -> Result { 123 | let existing = ChannelState::find() 124 | .filter(channel_state::Column::ChannelId.eq(channel_id)) 125 | .filter(channel_state::Column::Tree.eq(tree)) 126 | .filter(channel_state::Column::Key.eq(key)) 127 | .one(db) 128 | .await?; 129 | 130 | if let Some(entry) = existing { 131 | Ok(entry.delete(db).await?.rows_affected) 132 | } else { 133 | Ok(0) 134 | } 135 | } 136 | 137 | pub async fn remove_all( 138 | channel_id: &str, 139 | tree: &str, 140 | db: &DatabaseConnection, 141 | ) -> Result { 142 | let existing = ChannelState::delete_many() 143 | .filter(channel_state::Column::ChannelId.eq(channel_id)) 144 | .filter(channel_state::Column::Tree.eq(tree)) 145 | .exec(db) 146 | .await?; 147 | 148 | Ok(existing.rows_affected) 149 | } 150 | 151 | pub async fn remove_like( 152 | channel_id: &str, 153 | tree: &str, 154 | key: &str, 155 | db: &DatabaseConnection, 156 | ) -> Result { 157 | let existing = ChannelState::delete_many() 158 | .filter(channel_state::Column::ChannelId.eq(channel_id)) 159 | .filter(channel_state::Column::Tree.eq(tree)) 160 | .filter(channel_state::Column::Key.like(key)) 161 | .exec(db) 162 | .await?; 163 | 164 | Ok(existing.rows_affected) 165 | } 166 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/memory.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | use bitpart_common::error::Result; 18 | use chrono::NaiveDateTime; 19 | use csml_interpreter::data::{Client, Memory as CsmlMemory}; 20 | use sea_orm::*; 21 | use std::collections::HashMap; 22 | use uuid; 23 | 24 | use super::entities::{prelude::*, *}; 25 | 26 | pub async fn create( 27 | client: &Client, 28 | key: &str, 29 | value: &serde_json::Value, 30 | expires_at: Option, 31 | db: &DatabaseConnection, 32 | ) -> Result<()> { 33 | let entry = memory::ActiveModel { 34 | id: ActiveValue::Set(uuid::Uuid::new_v4().to_string()), 35 | bot_id: ActiveValue::Set(client.bot_id.to_owned()), 36 | channel_id: ActiveValue::Set(client.channel_id.to_owned()), 37 | user_id: ActiveValue::Set(client.user_id.to_owned()), 38 | key: ActiveValue::Set(key.to_owned()), 39 | value: ActiveValue::Set(value.to_owned()), 40 | expires_at: ActiveValue::Set(expires_at.map(|e| e.to_string())), 41 | ..Default::default() 42 | }; 43 | Memory::insert(entry).exec(db).await?; 44 | Ok(()) 45 | } 46 | 47 | pub async fn create_many( 48 | client: &Client, 49 | memories: &HashMap, 50 | expires_at: Option, 51 | db: &DatabaseConnection, 52 | ) -> Result<()> { 53 | let mut new_memories = vec![]; 54 | 55 | for (key, value) in memories.iter() { 56 | match get(client, key, db).await { 57 | Ok(Some(existing)) => { 58 | let mut existing: memory::ActiveModel = existing.into(); 59 | existing.value = ActiveValue::Set(value.value.clone()); 60 | existing.update(db).await?; 61 | } 62 | Ok(None) => { 63 | let entry = memory::ActiveModel { 64 | id: ActiveValue::Set(uuid::Uuid::new_v4().to_string()), 65 | bot_id: ActiveValue::Set(client.bot_id.to_owned()), 66 | channel_id: ActiveValue::Set(client.channel_id.to_owned()), 67 | user_id: ActiveValue::Set(client.user_id.to_owned()), 68 | key: ActiveValue::Set(key.to_owned()), 69 | value: ActiveValue::Set(value.value.to_owned()), 70 | expires_at: ActiveValue::Set(expires_at.map(|e| e.to_string())), 71 | ..Default::default() 72 | }; 73 | new_memories.push(entry); 74 | } 75 | Err(e) => return Err(e), 76 | } 77 | } 78 | if !new_memories.is_empty() { 79 | Memory::insert_many(new_memories).exec(db).await?; 80 | } 81 | Ok(()) 82 | } 83 | 84 | pub async fn get( 85 | client: &Client, 86 | key: &str, 87 | db: &DatabaseConnection, 88 | ) -> Result> { 89 | let entry = Memory::find() 90 | .filter(memory::Column::BotId.eq(client.bot_id.to_owned())) 91 | .filter(memory::Column::ChannelId.eq(client.channel_id.to_owned())) 92 | .filter(memory::Column::UserId.eq(client.user_id.to_owned())) 93 | .filter(memory::Column::Key.eq(key)) 94 | .one(db) 95 | .await?; 96 | 97 | Ok(entry) 98 | } 99 | 100 | pub async fn get_by_client( 101 | client: &Client, 102 | limit: Option, 103 | offset: Option, 104 | db: &DatabaseConnection, 105 | ) -> Result> { 106 | let entry = Memory::find() 107 | .filter(memory::Column::BotId.eq(client.bot_id.to_owned())) 108 | .filter(memory::Column::ChannelId.eq(client.channel_id.to_owned())) 109 | .filter(memory::Column::UserId.eq(client.user_id.to_owned())) 110 | .limit(limit) 111 | .offset(offset) 112 | .all(db) 113 | .await?; 114 | 115 | Ok(entry) 116 | } 117 | 118 | pub async fn get_by_memory( 119 | key: &str, 120 | bot_id: &str, 121 | db: &DatabaseConnection, 122 | ) -> Result> { 123 | let entry = Memory::find() 124 | .filter(memory::Column::Key.eq(key)) 125 | .filter(memory::Column::BotId.eq(bot_id)) 126 | .all(db) 127 | .await?; 128 | 129 | Ok(entry) 130 | } 131 | 132 | pub async fn delete(client: &Client, key: &str, db: &DatabaseConnection) -> Result<()> { 133 | let entry = Memory::find() 134 | .filter(memory::Column::BotId.eq(client.bot_id.to_owned())) 135 | .filter(memory::Column::ChannelId.eq(client.channel_id.to_owned())) 136 | .filter(memory::Column::UserId.eq(client.user_id.to_owned())) 137 | .filter(memory::Column::Key.eq(key)) 138 | .one(db) 139 | .await?; 140 | 141 | if let Some(e) = entry { 142 | e.delete(db).await?; 143 | } 144 | 145 | Ok(()) 146 | } 147 | 148 | pub async fn delete_by_client(client: &Client, db: &DatabaseConnection) -> Result<()> { 149 | Memory::delete_many() 150 | .filter(memory::Column::BotId.eq(client.bot_id.to_owned())) 151 | .filter(memory::Column::ChannelId.eq(client.channel_id.to_owned())) 152 | .filter(memory::Column::UserId.eq(client.user_id.to_owned())) 153 | .exec(db) 154 | .await?; 155 | Ok(()) 156 | } 157 | 158 | pub async fn delete_by_bot_id(bot_id: &str, db: &DatabaseConnection) -> Result<()> { 159 | Memory::delete_many() 160 | .filter(memory::Column::BotId.eq(bot_id.to_owned())) 161 | .exec(db) 162 | .await?; 163 | Ok(()) 164 | } 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bitpart 🤖 2 | 3 | [![Run checks](https://github.com/throneless-tech/bitpart/actions/workflows/run_checks.yaml/badge.svg)](https://github.com/throneless-tech/bitpart/actions/workflows/run_checks.yaml) 4 | 5 | Bitpart is a messaging tool that runs on top of Signal to support activists, journalists, and human rights defenders. 6 | 7 | ## Building 8 | 9 | This repository contains three components: the Bitpart server, a command-line client for connecting to the Bitpart server, and a storage adapter used for storing Signal key information to Bitpart's database. In order to build all three components, make sure you have a _2024 edition of Rust_ and run: 10 | 11 | ``` 12 | cargo build 13 | ``` 14 | 15 | ## Installing 16 | 17 | Visit the [releases page](https://github.com/throneless-tech/bitpart/releases) to download the latest binaries for your operating system and architecture! 18 | 19 | ## Usage 20 | 21 | The Bitpart server expects certain configuration parameters. The following examples use the parameters defined below: 22 | 23 | - ``: the IP address and port that Bitpart listens on for client connections. For example, `127.0.0.1:3000` would mean that Bitpart is listening on port 3000 on localhost. This is only used by the command-line client and is not necessary to expose to the public internet. 24 | - ``: the token used to authenticate client connections on the above port. The token must match for both the server and the command-line client for them to be able to connect. 25 | - ``: the path to an SQLite database file where Bitpart stores its state. This file is created if it does not exist. 26 | - ``: the encryption key for the SQLite database. Bitpart uses an integrated copy of [SQLCipher](https://www.zetetic.net/sqlcipher/open-source/) to encrypt its database. If Bitpart creates a new database file, it will be initialized with this key. The key must be the same between different runs of Bitpart or otherwise it will not be able to decrypt its database. 27 | 28 | ### Bare metal 29 | 30 | Assuming the `bitpart` binary is in your path, you can view the inline help for the Bitpart server: 31 | 32 | ``` 33 | bitpart --help 34 | ``` 35 | 36 | Or run it as follows: 37 | 38 | ``` 39 | bitpart --bind --auth --database --key 40 | ``` 41 | 42 | Bitpart can also read configuration parameters from environment variables corresponding to its command-line parameters. For example, you could specify the encryption key via defining the environment variable `BITPART_KEY`. 43 | 44 | ### Container 45 | 46 | Bitpart is available in a Docker-compatible container. For example, to run Bitpart on port 3000 and mounting a database from the current directory: 47 | 48 | ``` 49 | docker run -d --name bitpart -p 3000:3000 -v ./bitpart.sqlite:/bitpart.sqlite -e BITPART_BIND=127.0.0.1:3000 -e BITPART_DATABASE=/bitpart.sqlite -e BITPART_AUTH=connect to the Bitpart server -e BITPART_KEY= ghcr.io/throneless-tech/bitpart:latest 50 | ``` 51 | 52 | ### Connecting with the client 53 | 54 | Assuming the `bitpart-cli` binary is in your path, you can print out the inline help for the command-line client via: 55 | 56 | ``` 57 | bitpart-cli --help 58 | ``` 59 | 60 | Or print out the help for a given subcommand via: 61 | 62 | ``` 63 | bitpart-cli help 64 | ``` 65 | 66 | For example, to list available bots on a Bitpart server listening at `` and with authorization token ``: 67 | 68 | ``` 69 | bitpart-cli --auth --connect list 70 | ``` 71 | 72 | ### Adding a bot and connecting it to Signal 73 | 74 | When you have the server running as described above, and you have `bitpart-cli` able to connect to it at location `` with authorization token ``, you can add a bot: 75 | 76 | ``` 77 | bitpart-cli --auth --connect add --id --name --default ./.csml 78 | ``` 79 | 80 | where `` is a unique name you choose for the bot, `` is the keyword the bot will respond to when in a group, and `` is the name of the default CSML script included in the bot (you can include multiple CSML scripts, but you must specify which one is used by default for new conversations). To find out more about CSML scripting, check out the example(s) in the `examples` directory in this repository. 81 | 82 | To link the bot you created to Signal so that it receives messages, you must open a _channel_ between the bot and a Signal account. **We recommend using a separate Signal account just for this purpose**, since Bitpart will also receive and respond to the Signal messages sent to this account. 83 | 84 | ``` 85 | bitpart-cli --auth --connect channel-link --id signal --bot-id --device-name 86 | ``` 87 | 88 | where `` is the id of the bot you're linking, and `` is the name of the device as it will appear in the list of linked devices on Signal (for example, `bitpart`). Currently the channel ID is always `signal`, because that's the only type of channel available. 89 | 90 | After you enter this command, a QR code will be displayed. In your Signal client, go to _Settings -> Linked Devices -> Link new device_ and take a picture of the QR code. After a few seconds, your bot will finish linking with Signal. From another Signal device, send a message to the number or username associated with the bot (**NOTE**: the bot will take on the profile information of the linked device) and it should reply! 91 | 92 | ## CSML 93 | 94 | Bitpart's conversation logic is defined by scripts written in the open-source Conversational Standard Meta Language, or CSML. Visit [the documentation from the CSML project](https://docs.csml.dev/) to learn how to write a CSML conversation flow. Each instance of Bitpart can run one or more bots, where each bot processes incoming messages according to one or more CSML flows. 95 | 96 | ## License 97 | 98 | [AGPLv3](https://www.gnu.org/licenses/agpl-3.0.html) 99 | 100 | Bitpart is a free software project licensed under the GNU Affero General Public License v3.0 (AGPLv3). Parts of it are derived from code from the [Presage](https://github.com/whisperfish/presage) and [CSML](https://csml.dev) projects, marked where appropriate. 101 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/conversation.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | use bitpart_common::error::Result; 18 | use chrono::NaiveDateTime; 19 | use csml_interpreter::data::Client; 20 | use sea_orm::*; 21 | use sea_query::Expr; 22 | use uuid; 23 | 24 | use super::entities::{prelude::*, *}; 25 | 26 | pub async fn create( 27 | flow_id: &str, 28 | step_id: &str, 29 | client: &Client, 30 | expires_at: Option, 31 | db: &DatabaseConnection, 32 | ) -> Result { 33 | let id = uuid::Uuid::new_v4().to_string(); 34 | let entry = conversation::ActiveModel { 35 | id: ActiveValue::Set(id.clone()), 36 | bot_id: ActiveValue::Set(client.bot_id.to_owned()), 37 | channel_id: ActiveValue::Set(client.channel_id.to_owned()), 38 | user_id: ActiveValue::Set(client.user_id.to_owned()), 39 | flow_id: ActiveValue::Set(flow_id.to_owned()), 40 | step_id: ActiveValue::Set(step_id.to_owned()), 41 | status: ActiveValue::Set("OPEN".to_owned()), 42 | expires_at: ActiveValue::Set(expires_at.map(|e| e.to_string())), 43 | ..Default::default() 44 | }; 45 | entry.insert(db).await?; 46 | Ok(id) 47 | } 48 | 49 | pub async fn set_status_by_id(id: &str, status: &str, db: &DatabaseConnection) -> Result<()> { 50 | let entry = Conversation::find_by_id(id).one(db).await?; 51 | match entry { 52 | Some(e) => { 53 | let mut e: conversation::ActiveModel = e.into(); 54 | e.status = ActiveValue::Set(status.to_owned()); 55 | e.update(db).await?; 56 | Ok(()) 57 | } 58 | None => Ok(()), 59 | } 60 | } 61 | 62 | pub async fn set_status_by_client( 63 | client: &Client, 64 | status: &str, 65 | db: &DatabaseConnection, 66 | ) -> Result<()> { 67 | Conversation::update_many() 68 | .col_expr(conversation::Column::Status, Expr::value(status.to_owned())) 69 | .filter(conversation::Column::BotId.eq(client.bot_id.to_owned())) 70 | .filter(conversation::Column::ChannelId.eq(client.channel_id.to_owned())) 71 | .filter(conversation::Column::UserId.eq(client.user_id.to_owned())) 72 | .exec(db) 73 | .await?; 74 | Ok(()) 75 | } 76 | 77 | pub async fn get_latest_open_by_client( 78 | client: &Client, 79 | db: &DatabaseConnection, 80 | ) -> Result> { 81 | let entry = Conversation::find() 82 | .filter(conversation::Column::BotId.eq(client.bot_id.to_owned())) 83 | .filter(conversation::Column::ChannelId.eq(client.channel_id.to_owned())) 84 | .filter(conversation::Column::UserId.eq(client.user_id.to_owned())) 85 | .filter(conversation::Column::Status.eq("OPEN".to_owned())) 86 | .order_by(conversation::Column::CreatedAt, Order::Desc) 87 | .one(db) 88 | .await?; 89 | 90 | Ok(entry) 91 | } 92 | 93 | pub async fn get_by_client( 94 | client: &Client, 95 | limit: Option, 96 | offset: Option, 97 | db: &DatabaseConnection, 98 | ) -> Result> { 99 | let entry = Conversation::find() 100 | .filter(conversation::Column::BotId.eq(client.bot_id.to_owned())) 101 | .filter(conversation::Column::ChannelId.eq(client.channel_id.to_owned())) 102 | .filter(conversation::Column::UserId.eq(client.user_id.to_owned())) 103 | .limit(limit) 104 | .offset(offset) 105 | .all(db) 106 | .await?; 107 | 108 | Ok(entry) 109 | } 110 | 111 | pub async fn get_open_by_bot_id( 112 | bot_id: &str, 113 | limit: Option, 114 | offset: Option, 115 | db: &DatabaseConnection, 116 | ) -> Result> { 117 | let entry = Conversation::find() 118 | .filter(conversation::Column::BotId.eq(bot_id.to_owned())) 119 | .filter(conversation::Column::Status.eq("OPEN".to_owned())) 120 | .limit(limit) 121 | .offset(offset) 122 | .all(db) 123 | .await?; 124 | 125 | Ok(entry) 126 | } 127 | 128 | pub async fn update( 129 | id: &str, 130 | flow_id: Option, 131 | step_id: Option, 132 | db: &DatabaseConnection, 133 | ) -> Result<()> { 134 | match (flow_id, step_id) { 135 | (Some(flow_id), Some(step_id)) => { 136 | if let Some(entry) = Conversation::find_by_id(id).one(db).await? { 137 | let mut entry: conversation::ActiveModel = entry.into(); 138 | entry.flow_id = ActiveValue::Set(flow_id.to_string()); 139 | entry.step_id = ActiveValue::Set(step_id.to_string()); 140 | entry.update(db).await?; 141 | } 142 | } 143 | (Some(flow_id), _) => { 144 | if let Some(entry) = Conversation::find_by_id(id).one(db).await? { 145 | let mut entry: conversation::ActiveModel = entry.into(); 146 | entry.flow_id = ActiveValue::Set(flow_id.to_string()); 147 | entry.update(db).await?; 148 | } 149 | } 150 | (_, Some(step_id)) => { 151 | if let Some(entry) = Conversation::find_by_id(id).one(db).await? { 152 | let mut entry: conversation::ActiveModel = entry.into(); 153 | entry.step_id = ActiveValue::Set(step_id.to_string()); 154 | entry.update(db).await?; 155 | } 156 | } 157 | _ => {} 158 | } 159 | Ok(()) 160 | } 161 | 162 | pub async fn delete_by_client(client: &Client, db: &DatabaseConnection) -> Result<()> { 163 | Conversation::delete_many() 164 | .filter(conversation::Column::BotId.eq(client.bot_id.to_owned())) 165 | .filter(conversation::Column::ChannelId.eq(client.channel_id.to_owned())) 166 | .filter(conversation::Column::UserId.eq(client.user_id.to_owned())) 167 | .exec(db) 168 | .await?; 169 | Ok(()) 170 | } 171 | 172 | pub async fn delete_by_bot_id(bot_id: &str, db: &DatabaseConnection) -> Result<()> { 173 | Conversation::delete_many() 174 | .filter(conversation::Column::BotId.eq(bot_id.to_owned())) 175 | .exec(db) 176 | .await?; 177 | Ok(()) 178 | } 179 | -------------------------------------------------------------------------------- /crates/bitpart/src/db/bot.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | use bitpart_common::error::{BitpartErrorKind, Result}; 18 | use csml_interpreter::data::{CsmlBot, CsmlFlow, Module, MultiBot}; 19 | use sea_orm::*; 20 | use serde::{Deserialize, Serialize}; 21 | use std::env; 22 | use uuid; 23 | 24 | use super::entities::{prelude::*, *}; 25 | use crate::csml::data::BotVersion; 26 | 27 | #[derive(Debug, Clone, Serialize, Deserialize)] 28 | struct SerializedCsmlBot { 29 | pub id: String, 30 | pub name: String, 31 | pub flows: Vec, 32 | pub native_components: Option, // serde_json::Map 33 | pub custom_components: Option, // serde_json::Value 34 | pub default_flow: String, 35 | pub no_interruption_delay: Option, 36 | pub env: Option, 37 | pub modules: Option>, 38 | pub apps_endpoint: Option, 39 | pub multibot: Option>, 40 | } 41 | 42 | impl From for CsmlBot { 43 | fn from(val: SerializedCsmlBot) -> Self { 44 | CsmlBot { 45 | id: val.id.to_owned(), 46 | name: val.name.to_owned(), 47 | apps_endpoint: val.apps_endpoint, 48 | flows: val.flows.to_owned(), 49 | native_components: { 50 | match val.native_components.to_owned() { 51 | Some(value) => match serde_json::from_str(&value) { 52 | Ok(serde_json::Value::Object(map)) => Some(map), 53 | _ => unreachable!(), 54 | }, 55 | None => None, 56 | } 57 | }, 58 | custom_components: { 59 | match val.custom_components.to_owned() { 60 | Some(value) => match serde_json::from_str(&value) { 61 | Ok(value) => Some(value), 62 | Err(_e) => unreachable!(), 63 | }, 64 | None => None, 65 | } 66 | }, 67 | default_flow: val.default_flow.to_owned(), 68 | bot_ast: None, 69 | no_interruption_delay: val.no_interruption_delay, 70 | env: val 71 | .env 72 | .as_ref() 73 | .map(|e| serde_json::from_str(e).unwrap_or(JsonValue::Null)), 74 | modules: val.modules.to_owned(), 75 | multibot: val.multibot, 76 | } 77 | } 78 | } 79 | 80 | pub async fn create(bot: CsmlBot, db: &DatabaseConnection) -> Result { 81 | let model = bot::ActiveModel { 82 | id: ActiveValue::Set(uuid::Uuid::new_v4().to_string()), 83 | bot_id: ActiveValue::Set(bot.id.to_owned()), 84 | bot: ActiveValue::Set(bot.to_json().to_string()), 85 | engine_version: ActiveValue::Set(env!["CARGO_PKG_VERSION"].to_owned()), 86 | ..Default::default() 87 | }; 88 | 89 | let entry = model.insert(db).await?; 90 | 91 | let bot: SerializedCsmlBot = serde_json::from_str(&entry.bot)?; 92 | 93 | Ok(BotVersion { 94 | bot: bot.into(), 95 | version_id: entry.id, 96 | engine_version: env!["CARGO_PKG_VERSION"].to_owned(), 97 | }) 98 | } 99 | 100 | pub async fn list( 101 | limit: Option, 102 | offset: Option, 103 | db: &DatabaseConnection, 104 | ) -> Result> { 105 | let entries = Bot::find() 106 | .column(bot::Column::BotId) 107 | .group_by(bot::Column::BotId) 108 | .order_by(bot::Column::CreatedAt, Order::Desc) 109 | .limit(limit) 110 | .offset(offset) 111 | .all(db) 112 | .await?; 113 | 114 | Ok(entries.into_iter().map(|e| e.bot_id.to_string()).collect()) 115 | } 116 | 117 | pub async fn get( 118 | bot_id: &str, 119 | limit: Option, 120 | offset: Option, 121 | db: &DatabaseConnection, 122 | ) -> Result> { 123 | let entries = Bot::find() 124 | .filter(bot::Column::BotId.eq(bot_id)) 125 | .order_by(bot::Column::UpdatedAt, Order::Desc) 126 | .limit(limit) 127 | .offset(offset) 128 | .all(db) 129 | .await?; 130 | 131 | Ok(entries 132 | .into_iter() 133 | .filter_map(|e| { 134 | let bot: SerializedCsmlBot = serde_json::from_str(&e.bot).ok()?; 135 | Some(BotVersion { 136 | version_id: e.id.to_string(), 137 | bot: bot.into(), 138 | engine_version: env!["CARGO_PKG_VERSION"].to_owned(), 139 | }) 140 | }) 141 | .collect()) 142 | } 143 | 144 | pub async fn get_by_id(id: &str, db: &DatabaseConnection) -> Result> { 145 | let entry = Bot::find_by_id(id).one(db).await?; 146 | match entry { 147 | Some(e) => { 148 | let bot: SerializedCsmlBot = serde_json::from_str(&e.bot)?; 149 | 150 | Ok(Some(BotVersion { 151 | version_id: bot.id.to_string(), 152 | bot: bot.into(), 153 | engine_version: env!["CARGO_PKG_VERSION"].to_owned(), 154 | })) 155 | } 156 | None => Ok(None), 157 | } 158 | } 159 | 160 | pub async fn touch( 161 | id: &str, 162 | version_id: &str, 163 | db: &DatabaseConnection, 164 | ) -> Result> { 165 | if let Some(entry) = Bot::find() 166 | .filter(bot::Column::Id.eq(version_id)) 167 | .filter(bot::Column::BotId.eq(id)) 168 | .one(db) 169 | .await? 170 | { 171 | let version_id = entry.id.clone(); 172 | let bot: SerializedCsmlBot = serde_json::from_str(&entry.bot)?; 173 | 174 | let entry: bot::ActiveModel = entry.into(); 175 | entry.update(db).await?; 176 | 177 | Ok(Some(BotVersion { 178 | version_id, 179 | bot: bot.into(), 180 | engine_version: env!["CARGO_PKG_VERSION"].to_owned(), 181 | })) 182 | } else { 183 | Ok(None) 184 | } 185 | } 186 | 187 | pub async fn get_latest_by_bot_id( 188 | bot_id: &str, 189 | db: &DatabaseConnection, 190 | ) -> Result> { 191 | let entry = Bot::find() 192 | .filter(bot::Column::BotId.eq(bot_id)) 193 | .order_by(bot::Column::UpdatedAt, Order::Desc) 194 | .one(db) 195 | .await?; 196 | 197 | match entry { 198 | Some(e) => { 199 | let bot: SerializedCsmlBot = serde_json::from_str(&e.bot)?; 200 | 201 | Ok(Some(BotVersion { 202 | version_id: bot.id.to_string(), 203 | bot: bot.into(), 204 | engine_version: env!["CARGO_PKG_VERSION"].to_owned(), 205 | })) 206 | } 207 | None => Ok(None), 208 | } 209 | } 210 | 211 | pub async fn delete_by_bot_id(bot_id: &str, db: &DatabaseConnection) -> Result<()> { 212 | let res = Bot::delete_many() 213 | .filter(bot::Column::BotId.eq(bot_id)) 214 | .exec(db) 215 | .await?; 216 | if res.rows_affected == 0 { 217 | Err(BitpartErrorKind::Db(DbErr::RecordNotFound(bot_id.to_owned())).into()) 218 | } else { 219 | Ok(()) 220 | } 221 | } 222 | 223 | pub async fn delete_by_id(id: &str, db: &DatabaseConnection) -> Result<()> { 224 | Bot::delete_by_id(id).exec(db).await?; 225 | Ok(()) 226 | } 227 | -------------------------------------------------------------------------------- /crates/bitpart-common/src/csml.rs: -------------------------------------------------------------------------------- 1 | use csml_interpreter::data::{Client, CsmlBot, Event, MultiBot}; 2 | use serde::{Deserialize, Serialize}; 3 | use serde_json::{Value, json}; 4 | 5 | use crate::error::{BitpartError, BitpartErrorKind}; 6 | 7 | #[derive(Debug, Serialize, Deserialize, Clone)] 8 | pub struct FlowTrigger { 9 | pub flow_id: String, 10 | pub step_id: Option, 11 | } 12 | 13 | #[derive(Debug, Serialize, Deserialize, Clone)] 14 | pub enum BotOpt { 15 | #[serde(rename = "bot")] 16 | CsmlBot(Box), 17 | #[serde(rename = "version_id")] 18 | Id { 19 | version_id: String, 20 | bot_id: String, 21 | apps_endpoint: Option, 22 | multibot: Option>, 23 | }, 24 | #[serde(rename = "bot_id")] 25 | BotId { 26 | bot_id: String, 27 | apps_endpoint: Option, 28 | multibot: Option>, 29 | }, 30 | } 31 | 32 | #[derive(Debug, Serialize, Deserialize, Clone)] 33 | pub struct Request { 34 | pub bot: Option, 35 | pub bot_id: Option, 36 | pub version_id: Option, 37 | #[serde(alias = "fn_endpoint")] 38 | pub apps_endpoint: Option, 39 | pub multibot: Option>, 40 | pub event: SerializedEvent, 41 | } 42 | 43 | impl TryInto for Request { 44 | type Error = BitpartError; 45 | 46 | fn try_into(self) -> Result { 47 | match self { 48 | // Bot 49 | Request { 50 | bot: Some(mut csml_bot), 51 | multibot, 52 | .. 53 | } => { 54 | csml_bot.multibot = multibot; 55 | 56 | Ok(BotOpt::CsmlBot(Box::new(csml_bot))) 57 | } 58 | 59 | // version id 60 | Request { 61 | version_id: Some(version_id), 62 | bot_id: Some(bot_id), 63 | apps_endpoint, 64 | multibot, 65 | .. 66 | } => Ok(BotOpt::Id { 67 | version_id, 68 | bot_id, 69 | apps_endpoint, 70 | multibot, 71 | }), 72 | 73 | // get bot by id will search for the last version id 74 | Request { 75 | bot_id: Some(bot_id), 76 | apps_endpoint, 77 | multibot, 78 | .. 79 | } => Ok(BotOpt::BotId { 80 | bot_id, 81 | apps_endpoint, 82 | multibot, 83 | }), 84 | _ => Err(BitpartErrorKind::Interpreter("Invalid bot_opt format".to_owned()).into()), 85 | } 86 | } 87 | } 88 | 89 | impl TryInto for &Request { 90 | type Error = BitpartError; 91 | 92 | fn try_into(self) -> Result { 93 | match self.clone() { 94 | // Bot 95 | Request { 96 | bot: Some(csml_bot), 97 | multibot, 98 | .. 99 | } => { 100 | let mut csml_bot = csml_bot.to_owned(); 101 | csml_bot.multibot = multibot.to_owned(); 102 | 103 | Ok(BotOpt::CsmlBot(Box::new(csml_bot))) 104 | } 105 | 106 | // version id 107 | Request { 108 | version_id: Some(version_id), 109 | bot_id: Some(bot_id), 110 | apps_endpoint, 111 | multibot, 112 | .. 113 | } => Ok(BotOpt::Id { 114 | version_id: version_id.to_owned(), 115 | bot_id: bot_id.to_owned(), 116 | apps_endpoint: apps_endpoint.to_owned(), 117 | multibot: multibot.to_owned(), 118 | }), 119 | 120 | // get bot by id will search for the last version id 121 | Request { 122 | bot_id: Some(bot_id), 123 | apps_endpoint, 124 | multibot, 125 | .. 126 | } => Ok(BotOpt::BotId { 127 | bot_id: bot_id.to_owned(), 128 | apps_endpoint: apps_endpoint.to_owned(), 129 | multibot: multibot.to_owned(), 130 | }), 131 | _ => Err(BitpartErrorKind::Interpreter("Invalid bot_opt format".to_owned()).into()), 132 | } 133 | } 134 | } 135 | 136 | fn get_event_content(content_type: &str, metadata: &Value) -> Result { 137 | match content_type { 138 | file if ["file", "audio", "video", "image", "url"].contains(&file) => { 139 | if let Some(val) = metadata["url"].as_str() { 140 | Ok(val.to_string()) 141 | } else { 142 | Err(BitpartErrorKind::Interpreter("no url content in event".to_owned()).into()) 143 | } 144 | } 145 | "payload" => { 146 | if let Some(val) = metadata["payload"].as_str() { 147 | Ok(val.to_string()) 148 | } else { 149 | Err(BitpartErrorKind::Interpreter("no payload content in event".to_owned()).into()) 150 | } 151 | } 152 | "text" => { 153 | if let Some(val) = metadata["text"].as_str() { 154 | Ok(val.to_string()) 155 | } else { 156 | Err(BitpartErrorKind::Interpreter("no text content in event".to_owned()).into()) 157 | } 158 | } 159 | "regex" => { 160 | if let Some(val) = metadata["payload"].as_str() { 161 | Ok(val.to_string()) 162 | } else { 163 | Err(BitpartErrorKind::Interpreter( 164 | "invalid payload for event type regex".to_owned(), 165 | ) 166 | .into()) 167 | } 168 | } 169 | "flow_trigger" => match serde_json::from_value::(metadata.clone()) { 170 | Ok(_) => Ok(metadata.to_string()), 171 | Err(_) => Err(BitpartErrorKind::Interpreter( 172 | "invalid content for event type flow_trigger: expect flow_id and optional step_id" 173 | .to_owned(), 174 | ) 175 | .into()), 176 | }, 177 | content_type => Err(BitpartErrorKind::Interpreter(format!( 178 | "{} is not a valid content_type", 179 | content_type 180 | )) 181 | .into()), 182 | } 183 | } 184 | 185 | fn request_to_event(request: &SerializedEvent) -> Result { 186 | let step_limit = request.step_limit; 187 | let json_event = json!(request); 188 | 189 | let content_type = match json_event["payload"]["content_type"].as_str() { 190 | Some(content_type) => content_type.to_string(), 191 | None => { 192 | return Err(BitpartErrorKind::Interpreter( 193 | "no content_type in event payload".to_owned(), 194 | ) 195 | .into()); 196 | } 197 | }; 198 | let content = json_event["payload"]["content"].to_owned(); 199 | 200 | let content_value = get_event_content(&content_type, &content)?; 201 | 202 | Ok(Event { 203 | content_type, 204 | content_value, 205 | content, 206 | ttl_duration: json_event["ttl_duration"].as_i64(), 207 | low_data_mode: json_event["low_data_mode"].as_bool(), 208 | step_limit, 209 | secure: json_event["payload"]["secure"].as_bool().unwrap_or(false), 210 | }) 211 | } 212 | 213 | #[derive(Debug, Clone, Serialize, Deserialize)] 214 | pub struct SerializedEvent { 215 | pub id: String, 216 | pub client: Client, 217 | pub metadata: serde_json::Value, 218 | pub payload: serde_json::Value, 219 | pub step_limit: Option, 220 | pub callback_url: Option, 221 | } 222 | 223 | impl TryFrom<&SerializedEvent> for Event { 224 | type Error = BitpartError; 225 | 226 | fn try_from(val: &SerializedEvent) -> Result { 227 | request_to_event(val) 228 | } 229 | } 230 | 231 | impl TryFrom for Event { 232 | type Error = BitpartError; 233 | 234 | fn try_from(val: SerializedEvent) -> Result { 235 | request_to_event(&val) 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /crates/bitpart/src/main.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | pub mod api; 18 | mod channels; 19 | mod csml; 20 | pub mod db; 21 | mod socket; 22 | mod utils; 23 | 24 | use axum::{ 25 | Router, 26 | extract::{Request, State}, 27 | http::{StatusCode, header}, 28 | middleware::{self, Next}, 29 | response::Response, 30 | routing::any, 31 | }; 32 | use bitpart_common::error::{BitpartErrorKind, Result}; 33 | use clap::Parser; 34 | use clap_verbosity_flag::Verbosity; 35 | use directories::ProjectDirs; 36 | use figment::{ 37 | Figment, 38 | providers::{Env, Format, Serialized, Toml}, 39 | }; 40 | use figment_file_provider_adapter::FileAdapter; 41 | use opentelemetry::trace::TracerProvider; 42 | use opentelemetry_sdk::{metrics::SdkMeterProvider, trace::SdkTracer}; 43 | use sea_orm::{ConnectOptions, Database}; 44 | use serde::{Deserialize, Serialize}; 45 | use std::collections::HashMap; 46 | use std::net::SocketAddr; 47 | use std::path::PathBuf; 48 | use std::sync::Arc; 49 | use subtle::ConstantTimeEq; 50 | use tokio::sync::Mutex; 51 | use tokio_util::{sync::CancellationToken, task::TaskTracker}; 52 | use tracing::info; 53 | use tracing_log::AsTrace; 54 | use tracing_opentelemetry::MetricsLayer; 55 | use tracing_subscriber::prelude::*; 56 | 57 | use api::ApiState; 58 | use channels::signal; 59 | use db::migration::migrate; 60 | 61 | /// Bitpart is a messaging tool that runs on top of Signal to support activists, journalists, and human rights defenders. 62 | #[derive(Debug, Parser, Serialize, Deserialize)] 63 | #[command(version, about, long_about = None)] 64 | struct Cli { 65 | /// Verbosity 66 | #[command(flatten)] 67 | verbose: Verbosity, 68 | 69 | /// API authentication token 70 | #[arg(short, long)] 71 | #[serde(skip_serializing_if = "::std::option::Option::is_none")] 72 | auth: Option, 73 | 74 | /// IP address and port to bind to 75 | #[arg(short, long)] 76 | #[serde(skip_serializing_if = "::std::option::Option::is_none")] 77 | bind: Option, 78 | 79 | /// Path to sqlcipher database file 80 | #[arg(short, long)] 81 | #[serde(skip_serializing_if = "::std::option::Option::is_none")] 82 | database: Option, 83 | 84 | /// Database encryption key 85 | #[arg(short, long)] 86 | #[serde(skip_serializing_if = "::std::option::Option::is_none")] 87 | key: Option, 88 | 89 | /// Enable Opentelemetry 90 | #[arg(short, long)] 91 | opentelemetry: bool, 92 | } 93 | 94 | #[derive(Debug, Serialize, Deserialize)] 95 | struct Config { 96 | /// Verbosity 97 | verbose: Verbosity, 98 | 99 | /// API authentication token 100 | auth: String, 101 | 102 | /// IP address and port to bind to 103 | bind: String, 104 | 105 | /// Path to sqlcipher database file 106 | database: String, 107 | 108 | /// Database encryption key 109 | key: String, 110 | 111 | /// Enable Opentelemetry 112 | opentelemetry: bool, 113 | } 114 | 115 | async fn authenticate( 116 | State(state): State, 117 | req: Request, 118 | next: Next, 119 | ) -> std::result::Result { 120 | let auth_header = req 121 | .headers() 122 | .get(header::AUTHORIZATION) 123 | .and_then(|header| header.to_str().ok()); 124 | 125 | match auth_header { 126 | Some(auth_header) if auth_header.as_bytes().ct_eq(state.auth.as_bytes()).into() => { 127 | Ok(next.run(req).await) 128 | } 129 | _ => Err(StatusCode::UNAUTHORIZED), 130 | } 131 | } 132 | 133 | fn telemetry_tracer_init() -> Result { 134 | let otlp_exporter = opentelemetry_otlp::SpanExporter::builder().with_http(); 135 | 136 | let tracer_provider = opentelemetry_sdk::trace::SdkTracerProvider::builder() 137 | .with_batch_exporter(otlp_exporter.build()?) 138 | .build(); 139 | 140 | Ok(tracer_provider.tracer("bitpart_tracer")) 141 | } 142 | 143 | fn telemetry_meter_init() -> Result { 144 | let metric_exporter = opentelemetry_otlp::MetricExporter::builder().with_http(); 145 | 146 | let meter_provider = opentelemetry_sdk::metrics::SdkMeterProvider::builder() 147 | .with_periodic_exporter(metric_exporter.build()?) 148 | .build(); 149 | 150 | Ok(meter_provider) 151 | } 152 | 153 | #[tokio::main] 154 | async fn main() -> Result<()> { 155 | // Set project directories 156 | let proj_dirs = ProjectDirs::from("tech", "throneless", "bitpart").ok_or( 157 | BitpartErrorKind::Directory("Failed to find project directories.".to_owned()), 158 | )?; 159 | 160 | // Merge the configuration from CLI, environment, files, container secrets 161 | let server: Config = Figment::new() 162 | .merge(FileAdapter::wrap(Toml::file( 163 | proj_dirs.config_dir().join("config.toml"), 164 | ))) 165 | .merge(FileAdapter::wrap(Env::prefixed("BITPART_"))) 166 | .merge(Serialized::defaults(Cli::parse())) 167 | .extract()?; 168 | 169 | // Setup logging and telemetry 170 | if server.opentelemetry { 171 | tracing_subscriber::registry() 172 | .with(server.verbose.log_level_filter().as_trace()) 173 | .with(tracing_subscriber::fmt::layer()) 174 | .with(tracing_opentelemetry::layer().with_tracer(telemetry_tracer_init()?)) 175 | .with(MetricsLayer::new(telemetry_meter_init()?)) 176 | .init(); 177 | } else { 178 | tracing_subscriber::registry() 179 | .with(server.verbose.log_level_filter().as_trace()) 180 | .with(tracing_subscriber::fmt::layer()) 181 | .init(); 182 | } 183 | 184 | // Initialize database 185 | let uri = format!("sqlite://{}?mode=rwc", server.database); 186 | let mut opts = ConnectOptions::new(&uri); 187 | opts.sqlcipher_key(server.key); 188 | let db = Database::connect(opts).await?; 189 | migrate(&db).await?; 190 | 191 | // Start incoming message channels 192 | let channels = db::channel::list(None, None, &db).await?; 193 | let token = CancellationToken::new(); 194 | let tracker = TaskTracker::new(); 195 | let tokens: HashMap<(String, String), CancellationToken> = HashMap::new(); 196 | let mut state = ApiState { 197 | db, 198 | auth: server.auth, 199 | parent_token: token.clone(), 200 | tokens: Arc::new(Mutex::new(tokens)), 201 | tracker: tracker.clone(), 202 | attachments_dir: proj_dirs.cache_dir().to_path_buf(), 203 | manager: Box::new(signal::SignalManager::new()), 204 | }; 205 | for channel in channels.iter() { 206 | let res = api::start_channel(&channel.id, &channel.bot_id, &mut state).await?; 207 | info!("Started channel: {}", res); 208 | } 209 | 210 | // Run client API 211 | let app = Router::new() 212 | .route("/ws", any(socket::handler)) 213 | .route_layer(middleware::from_fn_with_state(state.clone(), authenticate)) 214 | .with_state(state); 215 | 216 | println!("Server is running 🤖"); 217 | 218 | { 219 | let tracker = tracker.clone(); 220 | tokio::spawn(async move { 221 | tokio::signal::ctrl_c() 222 | .await 223 | .expect("Failed to listen for signal"); 224 | tracker.close(); 225 | token.cancel(); 226 | }); 227 | } 228 | 229 | if let Ok(addr) = server.bind.parse::() { 230 | let listener = tokio::net::TcpListener::bind(addr) 231 | .await 232 | .expect("Unable to bind to address"); 233 | axum::serve( 234 | listener, 235 | app.into_make_service_with_connect_info::(), 236 | ) 237 | .with_graceful_shutdown(async move { tracker.wait().await }) 238 | .await?; 239 | } else { 240 | let Ok(path) = server.bind.parse::(); 241 | let _ = tokio::fs::remove_file(&path).await; 242 | let listener = tokio::net::UnixListener::bind(path).expect("Unable to bind to address"); 243 | axum::serve(listener, app.into_make_service()) 244 | .with_graceful_shutdown(async move { tracker.wait().await }) 245 | .await?; 246 | }; 247 | 248 | Ok(()) 249 | } 250 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Contributing to Bitpart 4 | 5 | First off, thanks for taking the time to contribute! ❤️ 6 | 7 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 8 | 9 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: 10 | > 11 | > - Star the project 12 | > - Post about it 13 | > - Refer this project in your project's readme 14 | > - Mention the project at local meetups and tell your friends/colleagues 15 | 16 | 17 | 18 | ## Table of Contents 19 | 20 | - [Code of Conduct](#code-of-conduct) 21 | - [I Have a Question](#i-have-a-question) 22 | - [I Want To Contribute](#i-want-to-contribute) 23 | - [Reporting Bugs](#reporting-bugs) 24 | - [Suggesting Enhancements](#suggesting-enhancements) 25 | - [Your First Code Contribution](#your-first-code-contribution) 26 | - [Improving The Documentation](#improving-the-documentation) 27 | - [Styleguides](#styleguides) 28 | - [Commit Messages](#commit-messages) 29 | - [Join The Project Team](#join-the-project-team) 30 | 31 | ## Code of Conduct 32 | 33 | This project and everyone participating in it is governed by the 34 | [Bitpart Code of Conduct](https://github.com/throneless-tech/bitpart/blob//CODE_OF_CONDUCT.md). 35 | By participating, you are expected to uphold this code. Please report unacceptable behavior 36 | to . 37 | 38 | ## I Have a Question 39 | 40 | > If you want to ask a question, we assume that you have read the available [Documentation](https://docs.bitp.art). 41 | 42 | Before you ask a question, it is best to search for existing [Issues](https://github.com/throneless-tech/bitpart/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. 43 | 44 | If you then still feel the need to ask a question and need clarification, we recommend the following: 45 | 46 | - Open an [Issue](https://github.com/throneless-tech/bitpart/issues/new). 47 | - Provide as much context as you can about what you're running into. 48 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. 49 | 50 | We will then take care of the issue as soon as possible. 51 | 52 | 66 | 67 | ## I Want To Contribute 68 | 69 | > ### Legal Notice 70 | > 71 | > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project licence. 72 | 73 | ### Reporting Bugs 74 | 75 | 76 | 77 | #### Before Submitting a Bug Report 78 | 79 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. 80 | 81 | - Make sure that you are using the latest version. 82 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://docs.bitp.art). If you are looking for support, you might want to check [this section](#i-have-a-question)). 83 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/throneless-tech/bitpart/issues?q=label%3Abug). 84 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. 85 | - Collect information about the bug: 86 | - Stack trace (Traceback) 87 | - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) 88 | - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. 89 | - Possibly your input and the output 90 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions? 91 | 92 | 93 | 94 | #### How Do I Submit a Good Bug Report? 95 | 96 | > You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to . 97 | 98 | 99 | 100 | We use GitHub issues to track bugs and errors. If you run into an issue with the project: 101 | 102 | - Open an [Issue](https://github.com/throneless-tech/bitpart/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) 103 | - Explain the behavior you would expect and the actual behavior. 104 | - Please provide as much context as possible and describe the _reproduction steps_ that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. 105 | - Provide the information you collected in the previous section. 106 | 107 | Once it's filed: 108 | 109 | - The project team will label the issue accordingly. 110 | - A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. 111 | - If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). 112 | 113 | 114 | 115 | ### Suggesting Enhancements 116 | 117 | This section guides you through submitting an enhancement suggestion for Bitpart, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. 118 | 119 | 120 | 121 | #### Before Submitting an Enhancement 122 | 123 | - Make sure that you are using the latest version. 124 | - Read the [documentation](https://docs.bitp.art) carefully and find out if the functionality is already covered, maybe by an individual configuration. 125 | - Perform a [search](https://github.com/throneless-tech/bitpart/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 126 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. 127 | 128 | 129 | 130 | #### How Do I Submit a Good Enhancement Suggestion? 131 | 132 | Enhancement suggestions are tracked as [GitHub issues](https://github.com/throneless-tech/bitpart/issues). 133 | 134 | - Use a **clear and descriptive title** for the issue to identify the suggestion. 135 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. 136 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. 137 | - You may want to **include screenshots or screen recordings** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [LICEcap](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and the built-in [screen recorder in GNOME](https://help.gnome.org/users/gnome-help/stable/screen-shot-record.html.en) or [SimpleScreenRecorder](https://github.com/MaartenBaert/ssr) on Linux. 138 | - **Explain why this enhancement would be useful** to most Bitpart users. You may also want to point out the other projects that solved it better and which could serve as inspiration. 139 | 140 | 141 | 148 | 149 | 155 | 156 | ## Styleguides 157 | 158 | ### Lints 159 | 160 | All code must pass the `cargo fmt` and `cargo clippy` lints as configured in the repository in order to be included in Bitpart. 161 | 162 | ### Commit Messages 163 | 164 | All commit messages must adhere to the [Conventional Commits](https://www.conventionalcommits.org) standard in order to be accepted. 165 | 168 | 172 | 173 | 174 | 175 | ## Attribution 176 | 177 | This guide is based on the [contributing.md](https://contributing.md/generator)! 178 | -------------------------------------------------------------------------------- /crates/bitpart/src/socket.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with this program. If not, see . 16 | 17 | use axum::{ 18 | extract::ws::{Message, WebSocket, WebSocketUpgrade}, 19 | extract::{ConnectInfo, State}, 20 | response::IntoResponse, 21 | }; 22 | use bitpart_common::{ 23 | error::{BitpartErrorKind, Result}, 24 | socket::{Response, SocketMessage}, 25 | }; 26 | use serde::Serialize; 27 | use std::net::SocketAddr; 28 | use tracing::{debug, error}; 29 | 30 | use crate::api; 31 | use crate::api::ApiState; 32 | 33 | pub async fn handler( 34 | ws: WebSocketUpgrade, 35 | ConnectInfo(addr): ConnectInfo, 36 | State(state): State, 37 | ) -> impl IntoResponse { 38 | ws.on_upgrade(move |socket| handle_socket(socket, addr, state)) 39 | } 40 | 41 | async fn handle_socket(mut socket: WebSocket, who: SocketAddr, mut state: ApiState) { 42 | while let Some(msg) = socket.recv().await { 43 | let msg = if let Ok(msg) = msg { 44 | match process_message(msg, who, &mut state).await { 45 | Ok(Some(msg)) => msg, 46 | Ok(None) => { 47 | debug!("Websocket closed"); 48 | return; 49 | } 50 | Err(err) => { 51 | error!("Error parsing message from {who}: {}", err); 52 | return; 53 | } 54 | } 55 | } else { 56 | error!("Client {who} abruptly disconnected"); 57 | return; 58 | }; 59 | 60 | if socket.send(msg).await.is_err() { 61 | error!("Client {who} abruptly disconnected"); 62 | return; 63 | } 64 | } 65 | } 66 | 67 | fn wrap_error(response_type: &str, res: &S) -> Result> { 68 | Ok(Some(Message::Text( 69 | serde_json::to_string(&SocketMessage::Error(Response { 70 | response_type: response_type.to_owned(), 71 | response: res, 72 | }))? 73 | .into(), 74 | ))) 75 | } 76 | 77 | fn wrap_response(response_type: &str, res: &S) -> Result> { 78 | Ok(Some(Message::Text( 79 | serde_json::to_string(&SocketMessage::Response(Response { 80 | response_type: response_type.to_owned(), 81 | response: res, 82 | }))? 83 | .into(), 84 | ))) 85 | } 86 | 87 | async fn process_message( 88 | msg: Message, 89 | who: SocketAddr, 90 | state: &mut ApiState, 91 | ) -> Result> { 92 | match msg { 93 | Message::Text(t) => { 94 | debug!(">>> {who} sent str: {t:?}"); 95 | let contents: SocketMessage = serde_json::from_slice(t.as_bytes())?; 96 | match contents { 97 | SocketMessage::CreateBot(bot) => match api::create_bot(*bot, state).await { 98 | Ok(res) => wrap_response("CreateBot", &res), 99 | Err(err) => wrap_error("CreateBot", &err.to_string()), 100 | }, 101 | SocketMessage::ReadBot { id } => match api::read_bot(&id, state).await { 102 | Ok(res) => wrap_response("ReadBot", &res), 103 | Err(err) => wrap_error("ReadBot", &err.to_string()), 104 | }, 105 | SocketMessage::BotVersions { id, options } => { 106 | if let Some(paginate) = options { 107 | match api::get_bot_versions(&id, paginate.limit, paginate.offset, state) 108 | .await 109 | { 110 | Ok(res) => wrap_response("BotVersions", &res), 111 | Err(err) => wrap_error("BotVersions", &err.to_string()), 112 | } 113 | } else { 114 | match api::get_bot_versions(&id, None, None, state).await { 115 | Ok(res) => wrap_response("BotVersions", &res), 116 | Err(err) => wrap_error("BotVersions", &err.to_string()), 117 | } 118 | } 119 | } 120 | SocketMessage::RollbackBot { id, version_id } => { 121 | match api::touch_bot_version(&id, &version_id, state).await { 122 | Ok(res) => wrap_response("RollbackBot", &res), 123 | Err(err) => wrap_error("RollbackBot", &err.to_string()), 124 | } 125 | } 126 | SocketMessage::DiffBot { 127 | version_a, 128 | version_b, 129 | } => match api::get_bot_diff(&version_a, &version_b, state).await { 130 | Ok(res) => wrap_response("DiffBot", &res), 131 | Err(err) => wrap_error("DiffBot", &err.to_string()), 132 | }, 133 | SocketMessage::DeleteBot { id } => match api::delete_bot(&id, state).await { 134 | Ok(res) => wrap_response("DeleteBot", &res), 135 | Err(err) => wrap_error("DeleteBot", &err.to_string()), 136 | }, 137 | SocketMessage::ListBots(options) => { 138 | if let Some(paginate) = options { 139 | match api::list_bots(paginate.limit, paginate.offset, state).await { 140 | Ok(res) => wrap_response("ListBots", &res), 141 | Err(err) => wrap_error("ListBots", &err.to_string()), 142 | } 143 | } else { 144 | match api::list_bots(None, None, state).await { 145 | Ok(res) => wrap_response("ListBots", &res), 146 | Err(err) => wrap_error("ListBots", &err.to_string()), 147 | } 148 | } 149 | } 150 | SocketMessage::CreateChannel { id, bot_id } => { 151 | match api::create_channel(&id, &bot_id, state).await { 152 | Ok(res) => wrap_response("CreateChannel", &res), 153 | Err(err) => wrap_error("CreateChannel", &err.to_string()), 154 | } 155 | } 156 | SocketMessage::ReadChannel { id, bot_id } => { 157 | match api::read_channel(&id, &bot_id, state).await { 158 | Ok(res) => wrap_response("ReadChannel", &res), 159 | Err(err) => wrap_error("ReadChannel", &err.to_string()), 160 | } 161 | } 162 | SocketMessage::ListChannels(options) => { 163 | if let Some(paginate) = options { 164 | match api::list_channels(paginate.limit, paginate.offset, state).await { 165 | Ok(res) => wrap_response("ListChannels", &res), 166 | Err(err) => wrap_error("ListChannels", &err.to_string()), 167 | } 168 | } else { 169 | match api::list_channels(None, None, state).await { 170 | Ok(res) => wrap_response("ListChannels", &res), 171 | Err(err) => wrap_error("ListChannels", &err.to_string()), 172 | } 173 | } 174 | } 175 | SocketMessage::DeleteChannel { id, bot_id } => { 176 | match api::delete_channel(&id, &bot_id, state).await { 177 | Ok(res) => wrap_response("DeleteChannel", &res), 178 | Err(err) => wrap_error("DeleteChannel", &err.to_string()), 179 | } 180 | } 181 | 182 | SocketMessage::ChatRequest(req) => { 183 | match api::process_request(&req, &state.db).await { 184 | Ok(res) => wrap_response("ChatRequest", &res), 185 | Err(err) => wrap_error("ChatRequest", &err.to_string()), 186 | } 187 | } 188 | SocketMessage::LinkChannel { 189 | id, 190 | bot_id, 191 | device_name, 192 | } => match api::link_channel( 193 | &id, 194 | &bot_id, 195 | &device_name, 196 | state.attachments_dir.clone(), 197 | state, 198 | ) 199 | .await 200 | { 201 | Ok(res) => wrap_response("LinkChannel", &res), 202 | Err(err) => wrap_error("LinkChannel", &err.to_string()), 203 | }, 204 | _ => Ok(wrap_error( 205 | "SocketMessage", 206 | &"Invalid SocketMessage".to_owned(), 207 | )?), 208 | } 209 | } 210 | Message::Binary(d) => { 211 | debug!(">>> {} sent {} bytes: {:?}", who, d.len(), d); 212 | Ok(wrap_error( 213 | "BinaryFrame", 214 | &"Server doesn't accept binary frames".to_owned(), 215 | )?) 216 | } 217 | Message::Close(c) => { 218 | if let Some(cf) = c { 219 | debug!( 220 | ">>> {} sent close with code {} and reason `{}`", 221 | who, cf.code, cf.reason 222 | ); 223 | match cf.code { 224 | 1000 => Ok(None), // 1000 is code for "Normal" 225 | _ => Err(BitpartErrorKind::WebsocketClose.into()), 226 | } 227 | } else { 228 | debug!(">>> {who} somehow sent close message without CloseFrame"); 229 | Err(BitpartErrorKind::WebsocketClose.into()) 230 | } 231 | } 232 | 233 | Message::Pong(v) => { 234 | debug!(">>> {who} sent pong with {v:?}"); 235 | Ok(Some(Message::Text( 236 | serde_json::to_string("Pong received")?.into(), 237 | ))) 238 | } 239 | Message::Ping(v) => { 240 | debug!(">>> {who} sent ping with {v:?}"); 241 | Ok(Some(Message::Text( 242 | serde_json::to_string("Ping received")?.into(), 243 | ))) 244 | } 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /crates/bitpart/src/csml/utils.rs: -------------------------------------------------------------------------------- 1 | // Bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | // 4 | // This code is derived in part from code from the CSML project: 5 | // Copyright (C) 2020 CSML 6 | 7 | // This program is free software: you can redistribute it and/or modify 8 | // it under the terms of the GNU Affero General Public License as published by 9 | // the Free Software Foundation, either version 3 of the License, or 10 | // (at your option) any later version. 11 | 12 | // This program is distributed in the hope that it will be useful, 13 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | // GNU Affero General Public License for more details. 16 | 17 | // You should have received a copy of the GNU Affero General Public License 18 | // along with this program. If not, see . 19 | 20 | use base64::prelude::*; 21 | use bitpart_common::{ 22 | csml::FlowTrigger, 23 | error::{BitpartErrorKind, Result}, 24 | }; 25 | use chrono::{SecondsFormat, Utc}; 26 | use csml_interpreter::data::{ 27 | Client, Context, CsmlBot, CsmlFlow, Event, Interval, Memory, Message, 28 | ast::{Flow, InsertStep, InstructionScope}, 29 | context::ContextStepInfo, 30 | }; 31 | use csml_interpreter::get_step; 32 | use csml_interpreter::interpreter::json_to_literal; 33 | use md5::{Digest, Md5}; 34 | use rand::{Rng, thread_rng}; 35 | use regex::Regex; 36 | use sea_orm::DatabaseConnection; 37 | use serde_json::{Value, json, map::Map}; 38 | use std::collections::HashMap; 39 | use std::env; 40 | use tracing::debug; 41 | 42 | use super::data::ConversationData; 43 | use crate::db; 44 | 45 | fn add_info_to_message(data: &ConversationData, mut msg: Message, interaction_order: i32) -> Value { 46 | let payload = msg.message_to_json(); 47 | 48 | let mut map_msg: Map = Map::new(); 49 | map_msg.insert("payload".to_owned(), payload); 50 | map_msg.insert("interaction_order".to_owned(), json!(interaction_order)); 51 | map_msg.insert("conversation_id".to_owned(), json!(data.conversation_id)); 52 | map_msg.insert("direction".to_owned(), json!("SEND")); 53 | 54 | Value::Object(map_msg) 55 | } 56 | 57 | pub fn messages_formatter( 58 | data: &mut ConversationData, 59 | vec_msg: Vec, 60 | interaction_order: i32, 61 | end: bool, 62 | ) -> Map { 63 | let msgs = vec_msg 64 | .into_iter() 65 | .map(|msg| add_info_to_message(data, msg, interaction_order)) 66 | .collect(); 67 | let mut map: Map = Map::new(); 68 | 69 | map.insert("messages".to_owned(), Value::Array(msgs)); 70 | map.insert("conversation_end".to_owned(), Value::Bool(end)); 71 | map.insert("request_id".to_owned(), json!(data.request_id)); 72 | 73 | map.insert( 74 | "received_at".to_owned(), 75 | json!(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)), 76 | ); 77 | 78 | let mut map_client: Map = Map::new(); 79 | 80 | map_client.insert("bot_id".to_owned(), json!(data.client.bot_id)); 81 | map_client.insert("user_id".to_owned(), json!(data.client.user_id)); 82 | map_client.insert("channel_id".to_owned(), json!(data.client.channel_id)); 83 | 84 | map.insert("client".to_owned(), Value::Object(map_client)); 85 | 86 | map 87 | } 88 | 89 | fn format_and_transfer(callback_url: &str, msg: serde_json::Value) { 90 | let mut request = ureq::post(callback_url); 91 | 92 | request = request 93 | .set("Accept", "application/json") 94 | .set("Content-Type", "application/json"); 95 | 96 | let response = request.send_json(msg); 97 | 98 | if let Err(err) = response { 99 | eprintln!("callback_url call failed: {:?}", err.to_string()); 100 | } 101 | } 102 | 103 | /** 104 | * If a callback_url is defined, we must send each message to its endpoint as it comes. 105 | * Otherwise, just continue! 106 | */ 107 | fn send_to_callback_url(data: &mut ConversationData, msg: serde_json::Value) { 108 | let callback_url = match &data.callback_url { 109 | Some(callback_url) => callback_url, 110 | None => return, 111 | }; 112 | 113 | format_and_transfer(callback_url, msg) 114 | } 115 | 116 | pub fn send_msg_to_callback_url( 117 | data: &mut ConversationData, 118 | msg: Vec, 119 | interaction_order: i32, 120 | end: bool, 121 | ) { 122 | let messages = messages_formatter(data, msg, interaction_order, end); 123 | 124 | debug!( 125 | bot_id = data.client.bot_id.to_string(), 126 | user_id = data.client.user_id.to_string(), 127 | channel_id = data.client.channel_id.to_string(), 128 | flow = data.context.flow.to_string(), 129 | "conversation_end: {:?}", 130 | messages["conversation_end"] 131 | ); 132 | 133 | send_to_callback_url(data, serde_json::json!(messages)) 134 | } 135 | 136 | pub fn update_current_context( 137 | data: &mut ConversationData, 138 | memories: &HashMap, 139 | ) -> Result<()> { 140 | for (_key, mem) in memories.iter() { 141 | let lit = json_to_literal(&mem.value, Interval::default(), &data.context.flow) 142 | .map_err(|err| BitpartErrorKind::Interpreter(err.message))?; 143 | 144 | data.context.current.insert(mem.key.to_owned(), lit); 145 | } 146 | Ok(()) 147 | } 148 | 149 | /** 150 | * Retrieve a flow in a given bot by an identifier: 151 | * - matching method is case insensitive 152 | * - as name is similar to a flow's alias, both flow.name and flow.id can be matched. 153 | */ 154 | pub fn get_flow_by_id<'a>(f_id: &str, flows: &'a [CsmlFlow]) -> Result<&'a CsmlFlow> { 155 | let id = f_id.to_ascii_lowercase(); 156 | // TODO: move to_lowercase at creation of vars 157 | match flows 158 | .iter() 159 | .find(|&val| val.id.to_ascii_lowercase() == id || val.name.to_ascii_lowercase() == id) 160 | { 161 | Some(f) => Ok(f), 162 | None => { 163 | Err(BitpartErrorKind::Interpreter(format!("Flow '{}' does not exist", f_id)).into()) 164 | } 165 | } 166 | } 167 | 168 | /** 169 | * Retrieve a bot's default flow. 170 | * The default flow must exist! 171 | */ 172 | pub fn get_default_flow(bot: &CsmlBot) -> Result<&CsmlFlow> { 173 | match bot 174 | .flows 175 | .iter() 176 | .find(|&flow| flow.id == bot.default_flow || flow.name == bot.default_flow) 177 | { 178 | Some(flow) => Ok(flow), 179 | None => Err(BitpartErrorKind::Interpreter( 180 | "The bot's default_flow does not exist".to_owned(), 181 | ) 182 | .into()), 183 | } 184 | } 185 | 186 | pub async fn clean_hold_and_restart( 187 | data: &mut ConversationData, 188 | db: &DatabaseConnection, 189 | ) -> Result<()> { 190 | db::state::delete(&data.client, "hold", "position", db).await?; 191 | data.context.hold = None; 192 | Ok(()) 193 | } 194 | 195 | pub fn get_current_step_hash(context: &Context, bot: &CsmlBot) -> Result { 196 | let mut hash = Md5::new(); 197 | 198 | let step = match &context.step { 199 | ContextStepInfo::Normal(step) => { 200 | let flow = &get_flow_by_id(&context.flow, &bot.flows)?.content; 201 | 202 | let ast = match &bot.bot_ast { 203 | Some(ast) => { 204 | let base64decoded = BASE64_STANDARD.decode(ast)?; 205 | let csml_bot: HashMap = bincode::deserialize(&base64decoded[..])?; 206 | match csml_bot.get(&context.flow) { 207 | Some(flow) => flow.to_owned(), 208 | None => csml_bot 209 | .get(&get_default_flow(bot)?.name) 210 | .ok_or(BitpartErrorKind::Interpreter( 211 | "Error falling back to default flow".to_owned(), 212 | ))? 213 | .to_owned(), 214 | } 215 | } 216 | None => { 217 | return Err(BitpartErrorKind::Interpreter("not valid ast".to_string()).into()); 218 | } 219 | }; 220 | 221 | get_step(step, flow, &ast) 222 | } 223 | ContextStepInfo::UnknownFlow(step) => { 224 | let flow = &get_flow_by_id(&context.flow, &bot.flows)?.content; 225 | 226 | match &bot.bot_ast { 227 | Some(ast) => { 228 | let base64decoded = BASE64_STANDARD.decode(ast)?; 229 | let csml_bot: HashMap = bincode::deserialize(&base64decoded[..])?; 230 | 231 | let default_flow = csml_bot.get(&get_default_flow(bot)?.name).ok_or( 232 | BitpartErrorKind::Interpreter( 233 | "Error falling back to default flow".to_owned(), 234 | ), 235 | )?; 236 | 237 | match csml_bot.get(&context.flow) { 238 | Some(target_flow) => { 239 | // check if there is a inserted step with the same name as the target step 240 | let insertion_expr = target_flow.flow_instructions.get_key_value( 241 | &InstructionScope::InsertStep(InsertStep { 242 | name: step.clone(), 243 | original_name: None, 244 | from_flow: "".to_owned(), 245 | interval: Interval::default(), 246 | }), 247 | ); 248 | 249 | // if there is a inserted step get the flow of the target step and 250 | if let Some((InstructionScope::InsertStep(insert), _)) = insertion_expr 251 | { 252 | match csml_bot.get(&insert.from_flow) { 253 | Some(inserted_step_flow) => { 254 | let inserted_raw_flow = 255 | &get_flow_by_id(&insert.from_flow, &bot.flows)?.content; 256 | 257 | get_step(step, inserted_raw_flow, inserted_step_flow) 258 | } 259 | None => get_step(step, flow, default_flow), 260 | } 261 | } else { 262 | get_step(step, flow, target_flow) 263 | } 264 | } 265 | None => get_step(step, flow, default_flow), 266 | } 267 | } 268 | None => { 269 | return Err(BitpartErrorKind::Interpreter("not valid ast".to_string()).into()); 270 | } 271 | } 272 | } 273 | ContextStepInfo::InsertedStep { 274 | step, 275 | flow: inserted_flow, 276 | } => { 277 | let flow = &get_flow_by_id(inserted_flow, &bot.flows)?.content; 278 | 279 | let ast = match &bot.bot_ast { 280 | Some(ast) => { 281 | let base64decoded = BASE64_STANDARD.decode(ast)?; 282 | let csml_bot: HashMap = bincode::deserialize(&base64decoded[..])?; 283 | 284 | match csml_bot.get(inserted_flow) { 285 | Some(flow) => flow.to_owned(), 286 | None => csml_bot 287 | .get(&get_default_flow(bot)?.name) 288 | .ok_or(BitpartErrorKind::Interpreter( 289 | "Error falling back to default flow".to_owned(), 290 | ))? 291 | .to_owned(), 292 | } 293 | } 294 | None => { 295 | return Err(BitpartErrorKind::Interpreter("not valid ast".to_string()).into()); 296 | } 297 | }; 298 | 299 | get_step(step, flow, &ast) 300 | } 301 | }; 302 | 303 | hash.update(step.as_bytes()); 304 | 305 | Ok(format!("{:x}", hash.finalize())) 306 | } 307 | 308 | pub fn get_ttl_duration_value(event: Option<&Event>) -> Option { 309 | if let Some(event) = event 310 | && let Some(ttl) = event.ttl_duration 311 | { 312 | return Some(chrono::Duration::days(ttl)); 313 | } 314 | 315 | if let Ok(ttl) = env::var("TTL_DURATION") 316 | && let Ok(ttl) = ttl.parse::() 317 | { 318 | return Some(chrono::Duration::days(ttl)); 319 | } 320 | 321 | None 322 | } 323 | 324 | // pub fn get_low_data_mode_value(event: &Event) -> bool { 325 | // if let Some(low_data) = event.low_data_mode { 326 | // return low_data; 327 | // } 328 | 329 | // if let Ok(low_data) = env::var("LOW_DATA_MODE") { 330 | // if let Ok(low_data) = low_data.parse::() { 331 | // return low_data; 332 | // } 333 | // } 334 | 335 | // false 336 | // } 337 | 338 | pub async fn search_flow<'a>( 339 | event: &Event, 340 | bot: &'a CsmlBot, 341 | client: &Client, 342 | db: &DatabaseConnection, 343 | ) -> Result<(&'a CsmlFlow, String)> { 344 | match event { 345 | event if event.content_type == "flow_trigger" => { 346 | db::state::delete(client, "hold", "position", db).await?; 347 | 348 | let flow_trigger: FlowTrigger = serde_json::from_str(&event.content_value)?; 349 | 350 | match get_flow_by_id(&flow_trigger.flow_id, &bot.flows) { 351 | Ok(flow) => match flow_trigger.step_id { 352 | Some(step_id) => Ok((flow, step_id)), 353 | None => Ok((flow, "start".to_owned())), 354 | }, 355 | Err(_) => Ok(( 356 | get_flow_by_id(&bot.default_flow, &bot.flows)?, 357 | "start".to_owned(), 358 | )), 359 | } 360 | } 361 | event if event.content_type == "regex" => { 362 | let mut random_flows = vec![]; 363 | 364 | for flow in bot.flows.iter() { 365 | let contains_command = flow.commands.iter().any(|cmd| { 366 | if let Ok(action) = Regex::new(&event.content_value) { 367 | action.is_match(cmd) 368 | } else { 369 | false 370 | } 371 | }); 372 | 373 | if contains_command { 374 | random_flows.push(flow) 375 | } 376 | } 377 | 378 | // gen_range will panic if range is empty 379 | let random = if !random_flows.is_empty() { 380 | thread_rng().gen_range(0..random_flows.len()) 381 | } else { 382 | 0 383 | }; 384 | match random_flows.get(random) { 385 | Some(flow) => { 386 | db::state::delete(client, "hold", "position", db).await?; 387 | Ok((flow, "start".to_owned())) 388 | } 389 | None => Err(BitpartErrorKind::Interpreter(format!( 390 | "no match found for regex: {}", 391 | event.content_value 392 | )) 393 | .into()), 394 | } 395 | } 396 | event => { 397 | let mut random_flows = vec![]; 398 | 399 | for flow in bot.flows.iter() { 400 | let contains_command = flow 401 | .commands 402 | .iter() 403 | .any(|cmd| cmd.as_str().to_lowercase() == event.content_value.to_lowercase()); 404 | 405 | if contains_command { 406 | random_flows.push(flow) 407 | } 408 | } 409 | 410 | // gen_range will panic if range is empty 411 | let random = if !random_flows.is_empty() { 412 | thread_rng().gen_range(0..random_flows.len()) 413 | } else { 414 | 0 415 | }; 416 | match random_flows.get(random) { 417 | Some(flow) => { 418 | db::state::delete(client, "hold", "position", db).await?; 419 | Ok((flow, "start".to_owned())) 420 | } 421 | None => Err(BitpartErrorKind::Interpreter(format!( 422 | "Flow '{}' does not exist", 423 | event.content_value 424 | )) 425 | .into()), 426 | } 427 | } 428 | } 429 | } 430 | -------------------------------------------------------------------------------- /crates/presage-store-bitpart/src/lib.rs: -------------------------------------------------------------------------------- 1 | // presage-store-bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | // 4 | // This code is derived in part from code from the Presage project: 5 | // Copyright (C) 2024 Gabriel Féron 6 | 7 | // This program is free software: you can redistribute it and/or modify 8 | // it under the terms of the GNU Affero General Public License as published by 9 | // the Free Software Foundation, either version 3 of the License, or 10 | // (at your option) any later version. 11 | 12 | // This program is distributed in the hope that it will be useful, 13 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | // GNU Affero General Public License for more details. 16 | 17 | // You should have received a copy of the GNU Affero General Public License 18 | // along with this program. If not, see . 19 | 20 | use base64::prelude::*; 21 | use presage::{ 22 | libsignal_service::{ 23 | prelude::{ProfileKey, Uuid}, 24 | protocol::{IdentityKeyPair, SenderCertificate}, 25 | }, 26 | manager::RegistrationData, 27 | model::identity::OnNewIdentity, 28 | store::{ContentsStore, StateStore, Store}, 29 | }; 30 | use protocol::{AciBitpartStore, BitpartProtocolStore, BitpartTrees, PniBitpartStore}; 31 | 32 | use sea_orm::DatabaseConnection; 33 | use serde::{Serialize, de::DeserializeOwned}; 34 | use sha2::{Digest, Sha256}; 35 | use std::str; 36 | 37 | mod content; 38 | mod db; 39 | mod error; 40 | mod protobuf; 41 | mod protocol; 42 | 43 | pub use error::BitpartStoreError; 44 | 45 | #[cfg(test)] 46 | use sea_orm::ConnectionTrait; 47 | 48 | const BITPART_TREE_STATE: &str = "state"; 49 | 50 | const BITPART_KEY_REGISTRATION: &str = "registration"; 51 | const BITPART_KEY_SENDER_CERTIFICATE: &str = "sender_certificate"; 52 | 53 | #[derive(Clone)] 54 | pub struct BitpartStore { 55 | id: String, // database ID 56 | 57 | db: DatabaseConnection, 58 | 59 | /// Whether to trust new identities automatically (for instance, when a somebody's phone has changed) 60 | trust_new_identities: OnNewIdentity, 61 | } 62 | 63 | impl BitpartStore { 64 | pub async fn open( 65 | id: &str, 66 | database: &DatabaseConnection, 67 | trust_new_identities: OnNewIdentity, 68 | ) -> Result { 69 | Ok(BitpartStore { 70 | id: id.to_owned(), 71 | db: database.clone(), 72 | trust_new_identities, 73 | }) 74 | } 75 | 76 | #[cfg(test)] 77 | async fn temporary() -> Result { 78 | let db = sea_orm::Database::connect("sqlite::memory:").await?; 79 | db.execute_unprepared( 80 | "CREATE TABLE channel ( 81 | id TEXT PRIMARY KEY, 82 | bot_id TEXT, 83 | channel_id TEXT, 84 | created_at TEXT, 85 | updated_at TEXT 86 | ); 87 | INSERT INTO channel ( 88 | id, 89 | bot_id, 90 | channel_id, 91 | created_at, 92 | updated_at 93 | ) VALUES( 94 | 'test', 95 | 'bot_id', 96 | 'signal', 97 | '1678295210', 98 | '1678295210' 99 | ); 100 | CREATE TABLE channel_state ( 101 | id TEXT PRIMARY KEY, 102 | channel_id TEXT, 103 | tree TEXT, 104 | key TEXT, 105 | value TEXT, 106 | created_at TEXT DEFAULT CURRENT_TIMESTAMP, 107 | updated_at TEXT DEFAULT CURRENT_TIMESTAMP 108 | ); 109 | CREATE TRIGGER channel_state_updated_at 110 | AFTER UPDATE ON channel_state 111 | FOR EACH ROW 112 | BEGIN 113 | UPDATE channel_state 114 | SET updated_at = (datetime('now','localtime')) 115 | WHERE id = NEW.id; 116 | END;", 117 | ) 118 | .await?; 119 | Ok(Self { 120 | id: "test".to_owned(), 121 | db, 122 | trust_new_identities: OnNewIdentity::Reject, 123 | }) 124 | } 125 | 126 | pub async fn get(&self, tree: &str, key: K) -> Result, BitpartStoreError> 127 | where 128 | K: AsRef<[u8]>, 129 | V: DeserializeOwned, 130 | { 131 | let key = serde_json::to_string(key.as_ref())?; 132 | if let Some(value) = db::channel_state::get(&self.id, tree, &key, &self.db).await? { 133 | Ok(Some(serde_json::from_str(&value)?)) 134 | } else { 135 | Ok(None) 136 | } 137 | } 138 | 139 | pub async fn get_all(&self, tree: &str) -> Result, BitpartStoreError> 140 | where 141 | K: AsRef<[u8]> + DeserializeOwned + std::fmt::Debug, 142 | V: DeserializeOwned + std::fmt::Debug, 143 | { 144 | Ok(db::channel_state::get_all(&self.id, tree, &self.db) 145 | .await? 146 | .into_iter() 147 | .flat_map(move |(key, value)| { 148 | Ok::<(K, V), serde_json::Error>(( 149 | serde_json::from_str::(&key)?, 150 | serde_json::from_str::(&value)?, 151 | )) 152 | }) 153 | .collect()) 154 | } 155 | 156 | pub async fn iter<'a, V: DeserializeOwned + 'a>( 157 | &'a self, 158 | tree: &str, 159 | ) -> Result> + 'a, BitpartStoreError> { 160 | Ok(db::channel_state::get_all(&self.id, tree, &self.db) 161 | .await? 162 | .into_iter() 163 | .map(move |(_, value)| Ok(serde_json::from_str::(&value)?))) 164 | } 165 | 166 | async fn insert(&self, tree: &str, key: K, value: V) -> Result 167 | where 168 | K: AsRef<[u8]>, 169 | V: Serialize, 170 | { 171 | let key = serde_json::to_string(key.as_ref())?; 172 | let replaced = db::channel_state::set( 173 | &self.id, 174 | tree, 175 | &key, 176 | serde_json::to_string(&value)?, 177 | &self.db, 178 | ) 179 | .await?; 180 | 181 | Ok(replaced) 182 | } 183 | 184 | async fn remove(&self, tree: &str, key: K) -> Result 185 | where 186 | K: AsRef<[u8]>, 187 | { 188 | let key = serde_json::to_string(key.as_ref())?; 189 | let removed = db::channel_state::remove(&self.id, tree, &key, &self.db).await?; 190 | Ok(removed > 0) 191 | } 192 | 193 | async fn remove_all(&self, tree: &str) -> Result { 194 | let removed = db::channel_state::remove_all(&self.id, tree, &self.db).await?; 195 | Ok(removed > 0) 196 | } 197 | 198 | fn profile_key_for_uuid(&self, uuid: Uuid, key: ProfileKey) -> String { 199 | let key = uuid.into_bytes().into_iter().chain(key.get_bytes()); 200 | 201 | let mut hasher = Sha256::new(); 202 | hasher.update(key.collect::>()); 203 | format!("{:x}", hasher.finalize()) 204 | } 205 | 206 | async fn get_identity_key_pair( 207 | &self, 208 | ) -> Result, BitpartStoreError> { 209 | let key_base64: Option = 210 | self.get(BITPART_TREE_STATE, T::identity_keypair()).await?; 211 | let Some(key_base64) = key_base64 else { 212 | return Ok(None); 213 | }; 214 | let key_bytes = BASE64_STANDARD.decode(key_base64)?; 215 | IdentityKeyPair::try_from(&*key_bytes) 216 | .map(Some) 217 | .map_err(|e| BitpartStoreError::ProtobufDecode(prost::DecodeError::new(e.to_string()))) 218 | } 219 | 220 | async fn set_identity_key_pair( 221 | &self, 222 | key_pair: IdentityKeyPair, 223 | ) -> Result<(), BitpartStoreError> { 224 | let key_bytes = key_pair.serialize(); 225 | let key_base64 = BASE64_STANDARD.encode(key_bytes); 226 | self.insert(BITPART_TREE_STATE, T::identity_keypair(), key_base64) 227 | .await?; 228 | Ok(()) 229 | } 230 | } 231 | 232 | impl StateStore for BitpartStore { 233 | type StateStoreError = BitpartStoreError; 234 | 235 | async fn load_registration_data( 236 | &self, 237 | ) -> Result, Self::StateStoreError> { 238 | self.get(BITPART_TREE_STATE, BITPART_KEY_REGISTRATION).await 239 | } 240 | 241 | async fn set_aci_identity_key_pair( 242 | &self, 243 | key_pair: IdentityKeyPair, 244 | ) -> Result<(), Self::StateStoreError> { 245 | self.set_identity_key_pair::(key_pair) 246 | .await 247 | } 248 | 249 | async fn set_pni_identity_key_pair( 250 | &self, 251 | key_pair: IdentityKeyPair, 252 | ) -> Result<(), Self::StateStoreError> { 253 | self.set_identity_key_pair::(key_pair) 254 | .await 255 | } 256 | 257 | async fn save_registration_data( 258 | &mut self, 259 | state: &RegistrationData, 260 | ) -> Result<(), Self::StateStoreError> { 261 | self.insert(BITPART_TREE_STATE, BITPART_KEY_REGISTRATION, state) 262 | .await?; 263 | Ok(()) 264 | } 265 | 266 | async fn is_registered(&self) -> bool { 267 | self.load_registration_data() 268 | .await 269 | .unwrap_or_default() 270 | .is_some() 271 | } 272 | 273 | async fn clear_registration(&mut self) -> Result<(), Self::StateStoreError> { 274 | // drop registration data (includes identity keys) 275 | db::channel_state::remove_all(&self.id, BITPART_TREE_STATE, &self.db).await?; 276 | // drop all saved profile (+avatards) and profile keys 277 | self.clear_profiles().await?; 278 | 279 | // drop all keys 280 | self.aci_protocol_store().clear(true).await?; 281 | self.pni_protocol_store().clear(true).await?; 282 | 283 | Ok(()) 284 | } 285 | 286 | async fn sender_certificate(&self) -> Result, Self::StateStoreError> { 287 | let value: Option> = self 288 | .get(BITPART_TREE_STATE, BITPART_KEY_SENDER_CERTIFICATE) 289 | .await?; 290 | value 291 | .map(|value| SenderCertificate::deserialize(&value)) 292 | .transpose() 293 | .map_err(From::from) 294 | } 295 | 296 | async fn save_sender_certificate( 297 | &self, 298 | certificate: &SenderCertificate, 299 | ) -> Result<(), Self::StateStoreError> { 300 | self.insert( 301 | BITPART_TREE_STATE, 302 | BITPART_KEY_SENDER_CERTIFICATE, 303 | certificate.serialized()?, 304 | ) 305 | .await?; 306 | Ok(()) 307 | } 308 | } 309 | 310 | impl Store for BitpartStore { 311 | type Error = BitpartStoreError; 312 | type AciStore = BitpartProtocolStore; 313 | type PniStore = BitpartProtocolStore; 314 | 315 | async fn clear(&mut self) -> Result<(), BitpartStoreError> { 316 | self.clear_registration().await?; 317 | self.clear_contents().await?; 318 | 319 | Ok(()) 320 | } 321 | 322 | fn aci_protocol_store(&self) -> Self::AciStore { 323 | BitpartProtocolStore::aci_protocol_store(self.clone()) 324 | } 325 | 326 | fn pni_protocol_store(&self) -> Self::PniStore { 327 | BitpartProtocolStore::pni_protocol_store(self.clone()) 328 | } 329 | } 330 | 331 | #[cfg(test)] 332 | mod tests { 333 | use presage::libsignal_service::{ 334 | content::{ContentBody, Metadata}, 335 | prelude::Uuid, 336 | proto::DataMessage, 337 | protocol::{PreKeyId, ServiceId}, 338 | }; 339 | use presage::store::ContentsStore; 340 | use protocol::BitpartPreKeyId; 341 | use quickcheck::{Arbitrary, Gen}; 342 | use quickcheck_macros::quickcheck; 343 | 344 | use super::*; 345 | 346 | #[derive(Debug, Clone)] 347 | struct Thread(presage::store::Thread); 348 | 349 | #[derive(Debug, Clone)] 350 | struct Content(presage::libsignal_service::content::Content); 351 | 352 | impl Arbitrary for Content { 353 | fn arbitrary(g: &mut Gen) -> Self { 354 | let timestamp: u64 = Arbitrary::arbitrary(g); 355 | let contacts = [ 356 | Uuid::from_u128(Arbitrary::arbitrary(g)), 357 | Uuid::from_u128(Arbitrary::arbitrary(g)), 358 | Uuid::from_u128(Arbitrary::arbitrary(g)), 359 | ]; 360 | let sender_uuid: Uuid = *g.choose(&contacts).unwrap(); 361 | let destination_uuid: Uuid = *g.choose(&contacts).unwrap(); 362 | let metadata = Metadata { 363 | sender: ServiceId::Aci(sender_uuid.into()), 364 | destination: ServiceId::Aci(destination_uuid.into()), 365 | sender_device: Arbitrary::arbitrary(g), 366 | server_guid: None, 367 | timestamp, 368 | needs_receipt: Arbitrary::arbitrary(g), 369 | unidentified_sender: Arbitrary::arbitrary(g), 370 | was_plaintext: false, 371 | }; 372 | let content_body = ContentBody::DataMessage(DataMessage { 373 | body: Arbitrary::arbitrary(g), 374 | timestamp: Some(timestamp), 375 | ..Default::default() 376 | }); 377 | Self(presage::libsignal_service::content::Content::from_body( 378 | content_body, 379 | metadata, 380 | )) 381 | } 382 | } 383 | 384 | impl Arbitrary for Thread { 385 | fn arbitrary(g: &mut Gen) -> Self { 386 | Self(presage::store::Thread::Contact(Uuid::from_u128( 387 | Arbitrary::arbitrary(g), 388 | ))) 389 | } 390 | } 391 | 392 | fn content_with_timestamp( 393 | content: &Content, 394 | ts: u64, 395 | ) -> presage::libsignal_service::content::Content { 396 | presage::libsignal_service::content::Content { 397 | metadata: Metadata { 398 | timestamp: ts, 399 | ..content.0.metadata.clone() 400 | }, 401 | body: content.0.body.clone(), 402 | } 403 | } 404 | 405 | #[quickcheck] 406 | fn compare_pre_keys(mut pre_key_id: u32, mut next_pre_key_id: u32) { 407 | if pre_key_id > next_pre_key_id { 408 | std::mem::swap(&mut pre_key_id, &mut next_pre_key_id); 409 | } 410 | assert!( 411 | PreKeyId::from(pre_key_id).store_key() <= PreKeyId::from(next_pre_key_id).store_key() 412 | ) 413 | } 414 | 415 | #[quickcheck_async::tokio] 416 | async fn test_store_messages(thread: Thread, content: Content) -> anyhow::Result<()> { 417 | let db = BitpartStore::temporary().await?; 418 | let thread = thread.0; 419 | db.save_message(&thread, content_with_timestamp(&content, 1678295210)) 420 | .await?; 421 | db.save_message(&thread, content_with_timestamp(&content, 1678295220)) 422 | .await?; 423 | db.save_message(&thread, content_with_timestamp(&content, 1678295230)) 424 | .await?; 425 | db.save_message(&thread, content_with_timestamp(&content, 1678295240)) 426 | .await?; 427 | db.save_message(&thread, content_with_timestamp(&content, 1678280000)) 428 | .await?; 429 | 430 | assert_eq!(db.messages(&thread, ..).await.unwrap().count(), 5); 431 | assert_eq!(db.messages(&thread, 0..).await.unwrap().count(), 5); 432 | assert_eq!(db.messages(&thread, 1678280000..).await.unwrap().count(), 5); 433 | 434 | assert_eq!(db.messages(&thread, 0..1678280000).await?.count(), 0); 435 | assert_eq!(db.messages(&thread, 0..1678295210).await?.count(), 1); 436 | assert_eq!( 437 | db.messages(&thread, 1678295210..1678295240).await?.count(), 438 | 3 439 | ); 440 | assert_eq!( 441 | db.messages(&thread, 1678295210..=1678295240).await?.count(), 442 | 4 443 | ); 444 | 445 | assert_eq!( 446 | db.messages(&thread, 0..=1678295240) 447 | .await? 448 | .next() 449 | .unwrap()? 450 | .metadata 451 | .timestamp, 452 | 1678280000 453 | ); 454 | assert_eq!( 455 | db.messages(&thread, 0..=1678295240) 456 | .await? 457 | .next_back() 458 | .unwrap()? 459 | .metadata 460 | .timestamp, 461 | 1678295240 462 | ); 463 | 464 | Ok(()) 465 | } 466 | } 467 | -------------------------------------------------------------------------------- /crates/presage-store-bitpart/src/content.rs: -------------------------------------------------------------------------------- 1 | // presage-store-bitpart 2 | // Copyright (C) 2025 Throneless Tech 3 | // 4 | // This code is derived in part from code from the Presage project: 5 | // Copyright (C) 2024 Gabriel Féron 6 | 7 | // This program is free software: you can redistribute it and/or modify 8 | // it under the terms of the GNU Affero General Public License as published by 9 | // the Free Software Foundation, either version 3 of the License, or 10 | // (at your option) any later version. 11 | 12 | // This program is distributed in the hope that it will be useful, 13 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | // GNU Affero General Public License for more details. 16 | 17 | // You should have received a copy of the GNU Affero General Public License 18 | // along with this program. If not, see . 19 | 20 | use std::ops::{Bound, RangeBounds}; 21 | 22 | use presage::{ 23 | AvatarBytes, 24 | libsignal_service::{ 25 | Profile, 26 | content::Content, 27 | prelude::Uuid, 28 | zkgroup::{GroupMasterKeyBytes, profiles::ProfileKey}, 29 | }, 30 | model::{contacts::Contact, groups::Group}, 31 | store::{ContentExt, ContentsStore, StickerPack, Thread}, 32 | }; 33 | use prost::Message; 34 | use serde::de::DeserializeOwned; 35 | use sha2::{Digest, Sha256}; 36 | use tracing::{debug, trace}; 37 | 38 | use crate::{BitpartStore, BitpartStoreError, db, protobuf::ContentProto}; 39 | 40 | const BITPART_TREE_PROFILE_AVATARS: &str = "profile_avatars"; 41 | const BITPART_TREE_PROFILE_KEYS: &str = "profile_keys"; 42 | const BITPART_TREE_STICKER_PACKS: &str = "sticker_packs"; 43 | const BITPART_TREE_CONTACTS: &str = "contacts"; 44 | const BITPART_TREE_GROUP_AVATARS: &str = "group_avatars"; 45 | const BITPART_TREE_GROUPS: &str = "groups"; 46 | const BITPART_TREE_PROFILES: &str = "profiles"; 47 | const BITPART_TREE_THREADS_PREFIX: &str = "threads"; 48 | 49 | impl ContentsStore for BitpartStore { 50 | type ContentsStoreError = BitpartStoreError; 51 | 52 | type ContactsIter = BitpartContactsIter; 53 | type GroupsIter = BitpartGroupsIter; 54 | type MessagesIter = BitpartMessagesIter; 55 | type StickerPacksIter = BitpartStickerPacksIter; 56 | 57 | async fn clear_profiles(&mut self) -> Result<(), Self::ContentsStoreError> { 58 | { 59 | self.remove_all(BITPART_TREE_PROFILES).await?; 60 | self.remove_all(BITPART_TREE_PROFILE_KEYS).await?; 61 | self.remove_all(BITPART_TREE_PROFILE_AVATARS).await?; 62 | } 63 | Ok(()) 64 | } 65 | 66 | async fn clear_contents(&mut self) -> Result<(), Self::ContentsStoreError> { 67 | { 68 | self.remove_all(BITPART_TREE_CONTACTS).await?; 69 | self.remove_all(BITPART_TREE_GROUPS).await?; 70 | 71 | for tree in db::channel_state::get_trees(&self.id, &self.db) 72 | .await? 73 | .into_iter() 74 | .filter(|n| n.starts_with(BITPART_TREE_THREADS_PREFIX)) 75 | { 76 | self.remove_all(&tree).await?; 77 | } 78 | } 79 | 80 | Ok(()) 81 | } 82 | 83 | async fn clear_contacts(&mut self) -> Result<(), BitpartStoreError> { 84 | self.remove_all(BITPART_TREE_CONTACTS).await?; 85 | Ok(()) 86 | } 87 | 88 | async fn save_contact(&mut self, contact: &Contact) -> Result<(), BitpartStoreError> { 89 | self.insert(BITPART_TREE_CONTACTS, contact.uuid, contact) 90 | .await?; 91 | debug!("saved contact"); 92 | Ok(()) 93 | } 94 | 95 | async fn contacts(&self) -> Result { 96 | Ok(BitpartContactsIter { 97 | data: db::channel_state::get_all(&self.id, BITPART_TREE_CONTACTS, &self.db) 98 | .await? 99 | .into_iter() 100 | .map(|(k, v)| (k.into_bytes(), v.into_bytes())) 101 | .collect(), 102 | index: 0, 103 | }) 104 | } 105 | 106 | async fn contact_by_id(&self, id: &Uuid) -> Result, BitpartStoreError> { 107 | self.get(BITPART_TREE_CONTACTS, id).await 108 | } 109 | 110 | // Groups 111 | 112 | async fn clear_groups(&mut self) -> Result<(), BitpartStoreError> { 113 | self.remove_all(BITPART_TREE_GROUPS).await?; 114 | Ok(()) 115 | } 116 | 117 | async fn groups(&self) -> Result { 118 | Ok(BitpartGroupsIter { 119 | data: db::channel_state::get_all(&self.id, BITPART_TREE_GROUPS, &self.db) 120 | .await? 121 | .into_iter() 122 | .map(|(k, v)| (k.into_bytes(), v.into_bytes())) 123 | .collect(), 124 | index: 0, 125 | }) 126 | } 127 | 128 | async fn group( 129 | &self, 130 | master_key_bytes: GroupMasterKeyBytes, 131 | ) -> Result, BitpartStoreError> { 132 | self.get(BITPART_TREE_GROUPS, master_key_bytes).await 133 | } 134 | 135 | async fn save_group( 136 | &self, 137 | master_key: GroupMasterKeyBytes, 138 | group: impl Into, 139 | ) -> Result<(), BitpartStoreError> { 140 | self.insert(BITPART_TREE_GROUPS, master_key, group.into()) 141 | .await?; 142 | Ok(()) 143 | } 144 | 145 | async fn group_avatar( 146 | &self, 147 | master_key_bytes: GroupMasterKeyBytes, 148 | ) -> Result, BitpartStoreError> { 149 | self.get(BITPART_TREE_GROUP_AVATARS, master_key_bytes).await 150 | } 151 | 152 | async fn save_group_avatar( 153 | &self, 154 | master_key: GroupMasterKeyBytes, 155 | avatar: &AvatarBytes, 156 | ) -> Result<(), BitpartStoreError> { 157 | self.insert(BITPART_TREE_GROUP_AVATARS, master_key, avatar) 158 | .await?; 159 | Ok(()) 160 | } 161 | 162 | // Messages 163 | 164 | async fn clear_messages(&mut self) -> Result<(), BitpartStoreError> { 165 | for name in db::channel_state::get_trees(&self.id, &self.db).await? { 166 | if name.starts_with(BITPART_TREE_THREADS_PREFIX) { 167 | db::channel_state::remove_all(&self.id, &name, &self.db).await?; 168 | } 169 | } 170 | Ok(()) 171 | } 172 | 173 | async fn clear_thread(&mut self, thread: &Thread) -> Result<(), BitpartStoreError> { 174 | trace!(%thread, "clearing thread"); 175 | 176 | self.remove_all(&messages_thread_tree_name(thread)).await?; 177 | 178 | Ok(()) 179 | } 180 | 181 | async fn save_message( 182 | &self, 183 | thread: &Thread, 184 | message: Content, 185 | ) -> Result<(), BitpartStoreError> { 186 | let ts = message.timestamp(); 187 | trace!(%thread, ts, "storing a message with thread"); 188 | 189 | let tree = messages_thread_tree_name(thread); 190 | let key = ts.to_be_bytes(); 191 | 192 | let proto: ContentProto = message.into(); 193 | let value = proto.encode_to_vec(); 194 | 195 | self.insert(&tree, key, value).await?; 196 | 197 | Ok(()) 198 | } 199 | 200 | async fn delete_message( 201 | &mut self, 202 | thread: &Thread, 203 | timestamp: u64, 204 | ) -> Result { 205 | let tree = messages_thread_tree_name(thread); 206 | self.remove(&tree, timestamp.to_be_bytes()).await 207 | } 208 | 209 | async fn message( 210 | &self, 211 | thread: &Thread, 212 | timestamp: u64, 213 | ) -> Result, BitpartStoreError> { 214 | let val: Option> = self 215 | .get(&messages_thread_tree_name(thread), timestamp.to_be_bytes()) 216 | .await?; 217 | match val { 218 | Some(ref v) => { 219 | let proto = ContentProto::decode(v.as_slice())?; 220 | let content = proto.try_into()?; 221 | Ok(Some(content)) 222 | } 223 | None => Ok(None), 224 | } 225 | } 226 | 227 | async fn messages( 228 | &self, 229 | thread: &Thread, 230 | range: impl RangeBounds, 231 | ) -> Result { 232 | let mut tree_thread: Vec<(u64, Vec)> = self 233 | .get_all(&messages_thread_tree_name(thread)) 234 | .await? 235 | .into_iter() 236 | .map(|(k, v)| (u64::from_be_bytes(k), v)) 237 | .collect(); 238 | tree_thread.sort(); 239 | debug!(%thread, count = tree_thread.len(), "loading message tree"); 240 | 241 | let range = match (range.start_bound(), range.end_bound()) { 242 | (Bound::Included(start), Bound::Unbounded) => { 243 | &tree_thread[tree_thread 244 | .iter() 245 | .position(|(k, _)| k >= start) 246 | .unwrap_or(tree_thread.len())..] 247 | } 248 | (Bound::Included(start), Bound::Excluded(end)) => { 249 | &tree_thread[tree_thread 250 | .iter() 251 | .position(|(k, _)| k >= start) 252 | .unwrap_or(tree_thread.len()) 253 | ..tree_thread.iter().rposition(|(k, _)| k <= end).unwrap_or(0)] 254 | } 255 | (Bound::Included(start), Bound::Included(end)) => { 256 | &tree_thread[tree_thread 257 | .iter() 258 | .position(|(k, _)| k >= start) 259 | .unwrap_or(tree_thread.len()) 260 | ..=tree_thread.iter().rposition(|(k, _)| k <= end).unwrap_or(0)] 261 | } 262 | (Bound::Unbounded, Bound::Included(end)) => { 263 | &tree_thread[..=tree_thread.iter().rposition(|(k, _)| k <= end).unwrap_or(0)] 264 | } 265 | (Bound::Unbounded, Bound::Excluded(end)) => { 266 | &tree_thread[..tree_thread.iter().rposition(|(k, _)| k <= end).unwrap_or(0)] 267 | } 268 | (Bound::Unbounded, Bound::Unbounded) => &tree_thread, 269 | (Bound::Excluded(_), _) => { 270 | unreachable!("range that excludes the initial value") 271 | } 272 | }; 273 | 274 | let iter = Vec::from_iter( 275 | range 276 | .iter() 277 | .map(|(k, v)| ((*k).to_be_bytes().into(), v.clone())), 278 | ); 279 | let end = if !iter.is_empty() { iter.len() - 1 } else { 0 }; 280 | 281 | Ok(BitpartMessagesIter { 282 | start: 0, 283 | end, 284 | data: iter, 285 | }) 286 | } 287 | 288 | async fn upsert_profile_key( 289 | &mut self, 290 | uuid: &Uuid, 291 | key: ProfileKey, 292 | ) -> Result { 293 | db::channel_state::set( 294 | &self.id, 295 | BITPART_TREE_PROFILE_KEYS, 296 | &uuid.to_string(), 297 | String::from_utf8_lossy(&key.get_bytes()), 298 | &self.db, 299 | ) 300 | .await 301 | .map(|_| true) 302 | } 303 | 304 | async fn profile_key(&self, uuid: &Uuid) -> Result, BitpartStoreError> { 305 | self.get(BITPART_TREE_PROFILE_KEYS, uuid.as_bytes()).await 306 | } 307 | 308 | async fn save_profile( 309 | &mut self, 310 | uuid: Uuid, 311 | key: ProfileKey, 312 | profile: Profile, 313 | ) -> Result<(), BitpartStoreError> { 314 | let key = self.profile_key_for_uuid(uuid, key); 315 | self.insert(BITPART_TREE_PROFILES, key, profile).await?; 316 | Ok(()) 317 | } 318 | 319 | async fn profile( 320 | &self, 321 | uuid: Uuid, 322 | key: ProfileKey, 323 | ) -> Result, BitpartStoreError> { 324 | let key = self.profile_key_for_uuid(uuid, key); 325 | self.get(BITPART_TREE_PROFILES, key).await 326 | } 327 | 328 | async fn save_profile_avatar( 329 | &mut self, 330 | uuid: Uuid, 331 | key: ProfileKey, 332 | avatar: &AvatarBytes, 333 | ) -> Result<(), BitpartStoreError> { 334 | let key = self.profile_key_for_uuid(uuid, key); 335 | self.insert(BITPART_TREE_PROFILE_AVATARS, key, avatar) 336 | .await?; 337 | Ok(()) 338 | } 339 | 340 | async fn profile_avatar( 341 | &self, 342 | uuid: Uuid, 343 | key: ProfileKey, 344 | ) -> Result, BitpartStoreError> { 345 | let key = self.profile_key_for_uuid(uuid, key); 346 | self.get(BITPART_TREE_PROFILE_AVATARS, key).await 347 | } 348 | 349 | async fn add_sticker_pack(&mut self, pack: &StickerPack) -> Result<(), BitpartStoreError> { 350 | self.insert(BITPART_TREE_STICKER_PACKS, pack.id.clone(), pack) 351 | .await?; 352 | Ok(()) 353 | } 354 | 355 | async fn remove_sticker_pack(&mut self, id: &[u8]) -> Result { 356 | self.remove(BITPART_TREE_STICKER_PACKS, id).await 357 | } 358 | 359 | async fn sticker_pack(&self, id: &[u8]) -> Result, BitpartStoreError> { 360 | self.get(BITPART_TREE_STICKER_PACKS, id).await 361 | } 362 | 363 | async fn sticker_packs(&self) -> Result { 364 | Ok(BitpartStickerPacksIter { 365 | data: db::channel_state::get_all(&self.id, BITPART_TREE_STICKER_PACKS, &self.db) 366 | .await? 367 | .into_iter() 368 | .map(|(k, v)| (k.into_bytes(), v.into_bytes())) 369 | .collect(), 370 | index: 0, 371 | }) 372 | } 373 | } 374 | 375 | pub struct BitpartContactsIter { 376 | data: Vec<(Vec, Vec)>, 377 | index: usize, 378 | } 379 | 380 | impl BitpartContactsIter { 381 | fn decrypt_value(&self, value: &[u8]) -> Result { 382 | Ok(serde_json::from_slice(value)?) 383 | } 384 | } 385 | 386 | impl Iterator for BitpartContactsIter { 387 | type Item = Result; 388 | 389 | fn next(&mut self) -> Option { 390 | let (_, value) = self.data.get(self.index)?; 391 | self.index += 1; 392 | self.decrypt_value(value).into() 393 | } 394 | } 395 | 396 | pub struct BitpartGroupsIter { 397 | data: Vec<(Vec, Vec)>, 398 | index: usize, 399 | } 400 | 401 | impl BitpartGroupsIter { 402 | fn decrypt_value(&self, value: &[u8]) -> Result { 403 | Ok(serde_json::from_slice(value)?) 404 | } 405 | } 406 | 407 | impl Iterator for BitpartGroupsIter { 408 | type Item = Result<(GroupMasterKeyBytes, Group), BitpartStoreError>; 409 | 410 | fn next(&mut self) -> Option { 411 | let (key, value) = self.data.get(self.index)?; 412 | self.index += 1; 413 | let group = self.decrypt_value(value).ok()?; 414 | let group_master_key_bytes: Result<[u8; 32], _> = key 415 | .to_owned() 416 | .try_into() 417 | .map_err(|_| BitpartStoreError::GroupDecryption); 418 | Some(group_master_key_bytes.map(|v| (v, group))) 419 | } 420 | } 421 | 422 | pub struct BitpartStickerPacksIter { 423 | data: Vec<(Vec, Vec)>, 424 | index: usize, 425 | } 426 | 427 | impl BitpartStickerPacksIter { 428 | fn decrypt_value(&self, value: &[u8]) -> Result { 429 | Ok(serde_json::from_slice(value)?) 430 | } 431 | } 432 | 433 | impl Iterator for BitpartStickerPacksIter { 434 | type Item = Result; 435 | 436 | fn next(&mut self) -> Option { 437 | let (_, value) = self.data.get(self.index)?; 438 | self.index += 1; 439 | self.decrypt_value(value).into() 440 | } 441 | } 442 | 443 | pub struct BitpartMessagesIter { 444 | data: Vec<(Vec, Vec)>, 445 | start: usize, 446 | end: usize, 447 | } 448 | 449 | impl BitpartMessagesIter { 450 | fn decode( 451 | &self, 452 | elem: Result<(&Vec, &Vec), BitpartStoreError>, 453 | ) -> Option> { 454 | elem.and_then(|(_, value)| { 455 | ContentProto::decode(&value[..]).map_err(BitpartStoreError::from) 456 | }) 457 | .map_or_else(|e| Some(Err(e)), |p| Some(p.try_into())) 458 | } 459 | } 460 | 461 | impl Iterator for BitpartMessagesIter { 462 | type Item = Result; 463 | 464 | fn next(&mut self) -> Option { 465 | let (key, value) = self.data.get(self.start)?; 466 | self.start += 1; 467 | self.decode(Ok((key, value))) 468 | } 469 | } 470 | 471 | impl DoubleEndedIterator for BitpartMessagesIter { 472 | fn next_back(&mut self) -> Option { 473 | let (key, value) = self.data.get(self.end)?; 474 | if self.end > 0 { 475 | self.end -= 1; 476 | } 477 | self.decode(Ok((key, value))) 478 | } 479 | } 480 | 481 | fn messages_thread_tree_name(t: &Thread) -> String { 482 | use base64::prelude::*; 483 | let key = match t { 484 | Thread::Contact(uuid) => { 485 | format!("{BITPART_TREE_THREADS_PREFIX}:contact:{uuid}") 486 | } 487 | Thread::Group(group_id) => format!( 488 | "{BITPART_TREE_THREADS_PREFIX}:group:{}", 489 | BASE64_STANDARD.encode(group_id) 490 | ), 491 | }; 492 | let mut hasher = Sha256::new(); 493 | hasher.update(key.as_bytes()); 494 | format!("{BITPART_TREE_THREADS_PREFIX}:{:x}", hasher.finalize()) 495 | } 496 | --------------------------------------------------------------------------------