Changes
diff --git a/README.md b/README.md
index 6d48958..9e10030 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,8 @@ using Pandoc, and append only host-validated source links.
1. Copy `config.example.toml` to a protected location and set the Matrix
identifiers and allowed room IDs.
2. Copy `persona.example.md` to the configured `persona.path` and edit it.
-3. Set `LISA_MATRIX_ACCESS_TOKEN` and `LISA_LLM_API_KEY` in the service
- environment.
+3. Set `LISA_MATRIX_PASSWORD` and `LISA_LLM_API_KEY` in the service
+ environment. Lisa requests and refreshes its own Matrix access tokens.
4. Create the parent directories for `database_path` and `matrix_store_path`
with read/write access for the service account.
5. Run `cargo run -- --config /etc/lisa/config.toml`.
diff --git a/config.example.toml b/config.example.toml
index f65b698..a3f3816 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -2,7 +2,8 @@
homeserver_url = "https://matrix.example.test"
user_id = "@lisa:example.test"
device_id = "LISABOT"
-access_token = "env:LISA_MATRIX_ACCESS_TOKEN"
+device_display_name = "Lisa bot"
+password = "env:LISA_MATRIX_PASSWORD"
allowed_room_ids = ["!private_room:example.test"]
mention_aliases = ["Lisa", "@Lisa"]
sync_timeout_ms = 30000
diff --git a/deploy/lisa.service b/deploy/lisa.service
index f061734..2512f8c 100644
--- a/deploy/lisa.service
+++ b/deploy/lisa.service
@@ -8,9 +8,10 @@ Type=simple
User=lisa
Group=lisa
EnvironmentFile=/etc/lisa/lisa.env
-ExecStart=/opt/lisa/bin/lisa --config /etc/lisa/config.toml
+ExecStart=/usr/local/bin/lisa --config /etc/lisa/config.toml
Restart=on-failure
RestartSec=5
+RestartPreventExitStatus=78
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
diff --git a/designs/design-0-architecture.md b/designs/design-0-architecture.md
index 0398bd0..368e0ef 100644
--- a/designs/design-0-architecture.md
+++ b/designs/design-0-architecture.md
@@ -94,11 +94,14 @@ into implementable interfaces:
room behavior.
- Pandoc is installed as a host executable and is new enough to support
`--sandbox`.
-- The Matrix account is already joined to each allowed room. Provisioning,
- login, room invitations, and account lifecycle are operator tasks.
-- The configured access token remains associated with the same Matrix device.
- Lisa does not call logout. Matrix transaction IDs are device-scoped, so
- changing devices weakens send idempotency across that change.
+- The Matrix account is already joined to each allowed room. Account
+ provisioning, room invitations, and password lifecycle are operator tasks.
+- The operator provides the dedicated Matrix account password through a
+ root-managed secret source. Lisa uses it to establish or re-establish its
+ own device session; no manually copied access token is required.
+- The configured device ID remains stable. Matrix transaction IDs are
+ device-scoped, so changing devices weakens send idempotency across that
+ change.
- Rooms are unencrypted, as required by the PRD.
Startup validation fails with a clear operator-facing error if these
@@ -401,7 +404,8 @@ Example:
homeserver_url = "https://matrix.example.test"
user_id = "@lisa:example.test"
device_id = "LISABOT"
-access_token = "env:LISA_MATRIX_ACCESS_TOKEN"
+device_display_name = "Lisa bot"
+password = "env:LISA_MATRIX_PASSWORD"
allowed_room_ids = ["!private_room:example.test"]
mention_aliases = ["Lisa", "@Lisa"]
sync_timeout_ms = 30000
@@ -469,6 +473,12 @@ Validation happens before the sync loop starts:
numeric limits must be non-empty and within hard safety ceilings.
- The configured Lisa user and device IDs must equal `/whoami`'s returned
identity.
+- `matrix.password` must be an `env:` reference that resolves to a non-empty
+ value. `matrix.access_token` is not accepted: it creates an operational
+ requirement to replace configuration when a homeserver uses expiring tokens.
+- `matrix.device_display_name` must be non-empty and fit within the Matrix
+ protocol's configured implementation limit. It labels the dedicated bot
+ device for operators; it does not affect invocation behavior.
- The persona file must be a regular file below a hard byte limit. The model
cannot choose its path.
- Every `env:` reference must resolve. Logs mention the configuration key but
@@ -705,26 +715,70 @@ substitute for the invocation ledger.
### 10.1 Authentication and startup
-Lisa receives a pre-provisioned access token through the environment. It
-sends it in the `Authorization: Bearer` header, never in a query string.
+Lisa authenticates as a normal Matrix client with the dedicated account
+password from `matrix.password`. The password is supplied only through a
+secret environment reference and is never written to TOML, SQLite, the Matrix
+SDK store, logs, diagnostics, or room events.
+
+On each successful password login, Lisa supplies the configured stable
+`device_id`, the configured `device_display_name`, and requests a Matrix
+refresh token. The homeserver then associates the access token and refresh
+token with the Lisa device. This is the normal Matrix login flow: the
+homeserver may issue an expiring access token together with a refresh token,
+or may issue a non-expiring access token. See the Matrix
+[login](https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3login)
+and
+[refresh](https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3refresh)
+endpoints.
+
+The `matrix-sdk` client is built with refresh-token handling enabled. While a
+client instance remains active, the SDK refreshes an expired access token and
+uses the replacement token for subsequent requests. Lisa does not copy
+rotated access or refresh tokens into its configuration or its invocation
+database. The password is the durable bootstrap credential, so restarting the
+service does not depend on preserving a rotated token outside the SDK client.
+
+Authentication errors have distinct handling:
+
+1. A transient login, DNS, TLS, or Matrix server failure enters the Matrix
+ reconnect backoff and does not alter the durable sync cursor or invocation
+ ledger.
+2. When the SDK cannot refresh a token, or Matrix returns a terminal invalid
+ authentication response, the Matrix lifecycle supervisor discards that
+ client instance and performs password login again with the same device ID.
+3. After reauthentication, Lisa calls `/whoami`, verifies the configured user
+ and device, then continues from its persisted sync cursor. It never runs a
+ replacement baseline merely because credentials changed.
+4. An invalid password, an identity mismatch, or a homeserver that does not
+ offer a usable password login flow is a startup or reconnect configuration
+ failure. Lisa logs only a stable error code and key name, does not send a
+ room message, and exits with the documented configuration-error status.
+ It never logs the password or protocol body.
+
+Using the same device ID for reauthentication is intentional. Matrix servers
+may invalidate previous tokens associated with a supplied device ID; this does
+not affect Lisa's deterministic transaction IDs because the device ID itself
+remains unchanged. Lisa never calls Matrix logout as part of ordinary retry or
+shutdown handling.
Startup order is:
1. Load and validate local configuration.
2. Start the database worker and run Lisa's SQLite migrations.
3. Build `matrix_sdk::Client` with the configured homeserver and persistent
- SDK state store, then restore the configured user, device, and access
- token as one Matrix session.
-4. Verify Pandoc.
-5. Call `client.whoami().await`.
-6. Confirm that the returned user and device IDs match configuration.
-7. If no Lisa sync cursor exists, call `client.sync_once()` without a token,
+ SDK state store, with automatic refresh-token handling enabled.
+4. Log in with the configured Matrix user ID, password, stable device ID, and
+ device display name while requesting a refresh token.
+5. Verify Pandoc.
+6. Call `client.whoami().await`.
+7. Confirm that the returned user and device IDs match configuration.
+8. If no Lisa sync cursor exists, call `client.sync_once()` without a token,
persist only `next_batch`, mark `initialized = 1`, and discard all
returned timelines as invocation candidates.
-8. If a cursor exists, begin controlled `sync_once()` calls using that
+9. If a cursor exists, begin controlled `sync_once()` calls using that
explicit token.
-9. Requeue expired `processing` and all `ready_to_send` rows.
-10. Start the dispatcher and typing-independent health status.
+10. Requeue expired `processing` and all `ready_to_send` rows.
+11. Start the dispatcher and typing-independent health status.
Discarding the first timeline is the explicit protection against answering
old messages when Lisa is first installed. The Matrix specification describes
@@ -870,6 +924,17 @@ Retry network errors and `5xx` responses with exponential backoff capped by
configuration. Do not retry ordinary `4xx` responses except token-position
recovery and rate limits.
+An authentication failure is handled before that ordinary-`4xx` rule. The
+client lifecycle supervisor first lets the SDK refresh when the response is a
+refreshable token failure. If refresh is unavailable or rejected, it replaces
+the client through the password-login sequence in section 10.1. A bad password
+or identity mismatch is not retried as a Matrix request; it remains an
+operator-visible configuration failure until the secret or configuration is
+corrected. Lisa uses exit status `78` for that permanent configuration error;
+the service unit prevents systemd from immediately restarting it. This
+distinction prevents an expired token from requiring manual configuration
+rotation while avoiding an unbounded password-login loop.
+
The sync task catches failures at its outer loop, logs a redacted error code,
backs off, and creates a new request. Matrix long polling is stateless beyond
the persisted `since` token, so no special socket-level reconnect API is
@@ -1014,8 +1079,8 @@ Representative request:
```
Unknown response fields are ignored, but required fields and types are
-strictly validated. The access token, full request, and response are never
-logged.
+strictly validated. Matrix credentials, the full request, and the response are
+never logged.
### 12.2 Prompt layers
@@ -1559,6 +1624,15 @@ account. The OS user needs:
It does not need shell login, source-tree write access, home-directory
secrets, Docker control, or broad filesystem read access.
+The Matrix account must be dedicated to Lisa and restricted by its room
+memberships. Its password is a long-lived bootstrap credential, not a general
+operator password. Store `LISA_MATRIX_PASSWORD` in the root-managed service
+secret file with mode `0600`. The file may also contain LLM and optional search
+credentials, but must never contain a Matrix access or refresh token. A user
+who can read the Lisa process environment can use the account, so the service
+account must not have interactive login access and the host must not grant
+untrusted users process-inspection access.
+
### 18.2 Service manager hardening
For a systemd deployment, use protections such as:
@@ -1567,9 +1641,11 @@ For a systemd deployment, use protections such as:
[Service]
User=lisa
Group=lisa
-ExecStart=/opt/lisa/bin/lisa --config /etc/lisa/config.toml
+EnvironmentFile=/etc/lisa/lisa.env
+ExecStart=/usr/local/bin/lisa --config /etc/lisa/config.toml
Restart=on-failure
RestartSec=5
+RestartPreventExitStatus=78
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
@@ -1613,8 +1689,11 @@ and previous application release.
Configuration:
- Accept valid `env:` references without exposing their values.
-- Reject literal secrets, missing variables, duplicate rooms, invalid URLs,
- invalid Matrix IDs, unsafe limits, and oversized personas.
+- Reject literal secrets, legacy access-token configuration, missing or empty
+ password variables, duplicate rooms, invalid URLs, invalid Matrix IDs,
+ unsafe limits, and oversized personas.
+- Redact the Matrix password in `Debug`, `Display`, structured errors, and
+ captured logs.
Invocation classification:
@@ -1714,10 +1793,22 @@ SDK-compatible HTTP protocol fake for deterministic failure cases. Use
test setup:
- First sync ignores historical mentions.
+- Password login includes the configured user and stable device ID, requests a
+ refresh token, and rejects a response whose `/whoami` identity differs from
+ configuration.
+- An expired access token triggers one SDK refresh, retains the same Matrix
+ device identity, and resumes the failed Matrix operation without creating a
+ duplicate invocation or response.
+- A rejected refresh token rebuilds the Matrix client and performs password
+ login, then resumes from the persisted sync cursor without a replacement
+ baseline sync.
+- Reauthentication with an invalid password emits a redacted operator error,
+ sends no room event, and leaves durable invocations retryable for a later
+ successful reconnect.
- Invocation processing consumes returned `SyncResponse` batches rather than
SDK event-handler callbacks.
-- Lisa's explicitly persisted token wins if the SDK store contains a newer
- token from a response that was not committed to Lisa's ledger.
+- Refresh-token rotation never changes Lisa's persisted sync cursor,
+ invocation ledger, or deterministic transaction IDs.
- Incremental mention produces one reply.
- Reply to Lisa without a mention produces one reply.
- Ordinary message and unallowed-room mention produce none.
@@ -1757,8 +1848,9 @@ integration scenario. The release gate requires:
1. Create the Cargo package, configuration types, secret resolution, and
structured `tracing` setup.
2. Implement the dedicated SQLite worker, migrations, and state transitions.
-3. Configure `matrix-sdk` without encryption, restore the Matrix session, and
- add SDK-backed protocol fixtures.
+3. Configure `matrix-sdk` without encryption, implement password bootstrap,
+ automatic token refresh, reauthentication, and SDK-backed protocol
+ fixtures.
4. Implement startup baseline and controlled `sync_once()` ingestion.
5. Implement Ruma event classification and deterministic reply sending with
`with_transaction_id()`.
@@ -1866,6 +1958,8 @@ the only supported extension mechanism.
| LLM context exceeds model capacity | Event, character, tool, and adapter request limits |
| Pandoc consumes excessive resources | Input/output/time bounds and OS sandboxing |
| Secrets appear in logs | Structured allowlisted fields, redaction filter, canary tests |
+| Matrix access token expires | Request and handle refresh tokens; reauthenticate with the dedicated password when refresh fails |
+| Matrix password is exposed | Root-only secret file, dedicated account, no process inspection for untrusted users, and secret-canary tests |
| Persona conflicts with capability | Host policy precedence and runtime authorization |
| SQLite is corrupted | Fail closed, restore backup, never guess deduplication state |
diff --git a/docs/operations.md b/docs/operations.md
index d8b120e..e08fd48 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -7,9 +7,12 @@ model-selected web destinations.
Use [`deploy/lisa.service`](../deploy/lisa.service) as a systemd starting point.
Keep credentials in `/etc/lisa/lisa.env` with restrictive permissions; do not
-place tokens in the TOML file or the systemd unit. The service restarts after
-temporary failures and uses its durable Matrix transaction IDs when retrying a
-reply.
+place them in the TOML file or the systemd unit. Set
+`LISA_MATRIX_PASSWORD` for Lisa's dedicated Matrix account, not a manually
+created access token. Lisa requests refresh tokens and renews access tokens
+while it runs; after a rejected refresh it logs in again with the password.
+The service stops without restart for a bad password or identity mismatch,
+using exit status 78, until the configuration is corrected.
## Health and diagnostics
diff --git a/src/app.rs b/src/app.rs
index 730fab0..8392dda 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -139,7 +139,18 @@ impl LisaApp {
}
result = self.pollOnce() =>
{
- let added = result?;
+ let added = match result
+ {
+ Ok(added) => added,
+ Err(error) if error.needsMatrixReauthentication() =>
+ {
+ warn!("matrix_session_reauthentication_started");
+ self.matrix.reconnect().await?;
+ info!("matrix_session_reauthentication_complete");
+ continue;
+ }
+ Err(error) => return Err(error),
+ };
if added > 0
{
info!(candidate_count = added, "matrix_candidates_ingested");
diff --git a/src/config.rs b/src/config.rs
index 14e07a6..ea5fd99 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -17,6 +17,7 @@ const MAX_CONCURRENT_ROOMS: u32 = 64;
const MAX_CONTEXT_EVENTS: u32 = 200;
const MAX_CONTEXT_CHARACTERS: u32 = 200_000;
const MAX_LLM_ITERATIONS: u32 = 16;
+const MAX_DEVICE_DISPLAY_NAME_CHARACTERS: usize = 256;
/// Holds a resolved secret while preventing accidental debug output.
#[derive(Clone, Eq, PartialEq)]
@@ -36,7 +37,7 @@ impl fmt::Debug for SecretString {
}
}
-/// Holds the Matrix settings needed during the foundation phase.
+/// Holds the Matrix settings needed for Lisa's dedicated client session.
#[derive(Clone, Debug)]
pub struct MatrixConfig {
/// Absolute Matrix homeserver URL.
@@ -45,8 +46,10 @@ pub struct MatrixConfig {
pub user_id: String,
/// Stable Matrix device ID used for transaction idempotency.
pub device_id: String,
- /// Access token resolved from the environment.
- pub access_token: SecretString,
+ /// Human-readable label assigned to Lisa's Matrix device.
+ pub device_display_name: String,
+ /// Password resolved from the environment for automatic session renewal.
+ pub password: SecretString,
/// Exact room IDs in which Lisa may operate.
pub allowed_room_ids: HashSet<String>,
/// Human-facing aliases accepted as Lisa mentions.
@@ -192,11 +195,13 @@ struct RawConfig {
}
#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
struct RawMatrixConfig {
homeserver_url: String,
user_id: String,
device_id: String,
- access_token: String,
+ device_display_name: String,
+ password: String,
allowed_room_ids: Vec<String>,
mention_aliases: Vec<String>,
sync_timeout_ms: u64,
@@ -280,8 +285,8 @@ impl AppConfig {
let homeserver_url = parse_http_url("matrix.homeserver_url", &raw.matrix.homeserver_url)?;
validate_user_id(&raw.matrix.user_id)?;
validate_device_id(&raw.matrix.device_id)?;
- let access_token =
- resolve_secret("matrix.access_token", &raw.matrix.access_token, &get_env)?;
+ validate_device_display_name(&raw.matrix.device_display_name)?;
+ let password = resolve_secret("matrix.password", &raw.matrix.password, &get_env)?;
validate_limits(&raw.matrix, &raw.runtime)?;
validate_llm(&raw.llm)?;
validate_persona(&raw.persona)?;
@@ -296,7 +301,8 @@ impl AppConfig {
homeserver_url,
user_id: raw.matrix.user_id,
device_id: raw.matrix.device_id,
- access_token,
+ device_display_name: raw.matrix.device_display_name,
+ password,
allowed_room_ids,
mention_aliases,
sync_timeout_ms: raw.matrix.sync_timeout_ms,
@@ -405,6 +411,16 @@ fn validate_device_id(value: &str) -> Result<(), ConfigurationError> {
Ok(())
}
+fn validate_device_display_name(value: &str) -> Result<(), ConfigurationError> {
+ if value.trim().is_empty() || value.chars().count() > MAX_DEVICE_DISPLAY_NAME_CHARACTERS {
+ return Err(invalid(
+ "matrix.device_display_name",
+ "must be non-empty and no more than 256 characters",
+ ));
+ }
+ Ok(())
+}
+
fn validate_rooms(values: Vec<String>) -> Result<HashSet<String>, ConfigurationError> {
if values.is_empty() {
return Err(invalid("matrix.allowed_room_ids", "must not be empty"));
@@ -595,7 +611,8 @@ mod tests {
homeserver_url = "https://matrix.example.test"
user_id = "@lisa:example.test"
device_id = "LISABOT"
-access_token = "env:LISA_MATRIX_ACCESS_TOKEN"
+device_display_name = "Lisa bot"
+password = "env:LISA_MATRIX_PASSWORD"
allowed_room_ids = ["!private:example.test"]
mention_aliases = ["Lisa", "@Lisa"]
sync_timeout_ms = 30000
@@ -651,22 +668,28 @@ user_agent = "LisaBot/1.0"
"#;
#[test]
- fn resolves_secret_without_exposing_it() {
+ fn resolves_password_without_exposing_it() {
let config = AppConfig::from_toml_with_env(CONFIG, |name| {
- matches!(name, "LISA_MATRIX_ACCESS_TOKEN" | "LISA_LLM_API_KEY")
+ matches!(name, "LISA_MATRIX_PASSWORD" | "LISA_LLM_API_KEY")
.then(|| "secret-canary".to_owned())
})
.unwrap();
- assert_eq!(config.matrix.access_token.expose(), "secret-canary");
- assert_eq!(format!("{:?}", config.matrix.access_token), "[REDACTED]");
+ assert_eq!(config.matrix.password.expose(), "secret-canary");
+ assert_eq!(format!("{:?}", config.matrix.password), "[REDACTED]");
}
#[test]
fn rejects_literal_secret_and_duplicate_room() {
- let literal = CONFIG.replace("env:LISA_MATRIX_ACCESS_TOKEN", "secret");
+ let literal = CONFIG.replace("env:LISA_MATRIX_PASSWORD", "secret");
assert!(AppConfig::from_toml_with_env(&literal, |_| None).is_err());
+ let legacy = CONFIG.replace(
+ "password = \"env:LISA_MATRIX_PASSWORD\"",
+ "password = \"env:LISA_MATRIX_PASSWORD\"\naccess_token = \"env:LISA_MATRIX_ACCESS_TOKEN\"",
+ );
+ assert!(AppConfig::from_toml_with_env(&legacy, |_| Some("token".to_owned())).is_err());
+
let duplicate = CONFIG.replace(
"[\"!private:example.test\"]",
"[\"!private:example.test\", \"!private:example.test\"]",
diff --git a/src/errors.rs b/src/errors.rs
index 902c39f..cc3e6ee 100644
--- a/src/errors.rs
+++ b/src/errors.rs
@@ -53,6 +53,27 @@ pub enum MatrixError {
/// Reports a Matrix session identity mismatch.
#[error("Matrix identity does not match the configured account")]
IdentityMismatch,
+
+ /// Reports a rejected Matrix password without exposing it.
+ #[error("Matrix credentials were rejected")]
+ InvalidCredentials,
+}
+
+impl MatrixError {
+ /// Returns whether rebuilding the Matrix session can recover this error.
+ #[must_use]
+ pub fn needsReauthentication(&self) -> bool {
+ let Self::Sdk(error) = self else {
+ return false;
+ };
+ let matrix_sdk::Error::Http(error) = error else {
+ return false;
+ };
+ matches!(
+ error.client_api_error_kind(),
+ Some(matrix_sdk::ruma::api::error::ErrorKind::UnknownToken(_))
+ ) || matches!(error.as_ref(), matrix_sdk::HttpError::RefreshToken(_))
+ }
}
/// Reports a bounded LLM completion failure without exposing request content.
@@ -90,3 +111,21 @@ pub enum AppError {
#[error(transparent)]
Llm(#[from] LlmError),
}
+
+impl AppError {
+ /// Returns whether the Matrix session must be recreated before continuing.
+ #[must_use]
+ pub fn needsMatrixReauthentication(&self) -> bool {
+ matches!(self, Self::Matrix(error) if error.needsReauthentication())
+ }
+
+ /// Returns whether systemd should stop restarting for this error.
+ #[must_use]
+ pub fn isPermanentConfigurationFailure(&self) -> bool {
+ matches!(
+ self,
+ Self::Configuration(_)
+ | Self::Matrix(MatrixError::IdentityMismatch | MatrixError::InvalidCredentials)
+ )
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 21bd0de..30478a1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9,6 +9,8 @@ use std::{
use lisa::{app::LisaApp, config::AppConfig, logging, state::StateStore};
+const CONFIGURATION_ERROR_EXIT: u8 = 78;
+
struct Command
{
config_path: PathBuf,
@@ -28,7 +30,7 @@ async fn main() -> ExitCode {
Ok(config) => config,
Err(error) => {
eprintln!("configuration error: {error}");
- return ExitCode::FAILURE;
+ return ExitCode::from(CONFIGURATION_ERROR_EXIT);
}
};
logging::init(&config.runtime.log_level);
@@ -44,7 +46,14 @@ async fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("Lisa stopped: {error}");
- ExitCode::FAILURE
+ if error.isPermanentConfigurationFailure()
+ {
+ ExitCode::from(CONFIGURATION_ERROR_EXIT)
+ }
+ else
+ {
+ ExitCode::FAILURE
+ }
}
}
}
diff --git a/src/matrix.rs b/src/matrix.rs
index 45ba423..4e89c6d 100644
--- a/src/matrix.rs
+++ b/src/matrix.rs
@@ -1,11 +1,14 @@
//! Matrix SDK session handling, controlled sync ingestion, and event policy.
-use std::time::Duration;
+use std::{
+ path::{Path, PathBuf},
+ sync::Arc,
+ time::Duration,
+};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use matrix_sdk::{
- Client, SessionMeta, SessionTokens,
- authentication::matrix::MatrixSession,
+ Client,
config::SyncSettings,
ruma::{
OwnedEventId, OwnedRoomId, OwnedTransactionId, UInt,
@@ -19,6 +22,7 @@ use matrix_sdk::{
};
use serde_json::Value;
use sha2::{Digest, Sha256};
+use tokio::sync::{Mutex, RwLock};
use crate::{config::MatrixConfig, errors::MatrixError, models::InvocationCandidate};
@@ -52,36 +56,53 @@ pub enum Classification {
/// Wraps the Matrix SDK while keeping Lisa's sync cursor authoritative.
#[derive(Clone, Debug)]
pub struct MatrixClient {
- client: Client,
+ client: Arc<RwLock<Client>>,
config: MatrixConfig,
+ matrix_store_path: Arc<PathBuf>,
+ reconnect_lock: Arc<Mutex<()>>,
}
impl MatrixClient {
- /// Builds an unencrypted Matrix SDK client and restores Lisa's session.
+ /// Builds an unencrypted Matrix SDK client and creates Lisa's session.
pub async fn connect(
config: MatrixConfig,
- matrix_store_path: &std::path::Path,
+ matrix_store_path: &Path,
) -> Result<Self, MatrixError> {
+ let matrix_store_path = Arc::new(matrix_store_path.to_owned());
+ let client = Self::login(&config, &matrix_store_path).await?;
+ Ok(Self {
+ client: Arc::new(RwLock::new(client)),
+ config,
+ matrix_store_path,
+ reconnect_lock: Arc::new(Mutex::new(())),
+ })
+ }
+
+ /// Recreates Lisa's Matrix session after a terminal token failure.
+ pub async fn reconnect(&self) -> Result<(), MatrixError> {
+ let _guard = self.reconnect_lock.lock().await;
+ let client = Self::login(&self.config, &self.matrix_store_path).await?;
+ *self.client.write().await = client;
+ Ok(())
+ }
+
+ async fn login(config: &MatrixConfig, matrix_store_path: &Path) -> Result<Client, MatrixError> {
let client = Client::builder()
.homeserver_url(config.homeserver_url.as_str())
.sqlite_store(matrix_store_path, None)
+ .handle_refresh_tokens()
.build()
.await
.map_err(|error| MatrixError::InvalidResponse(error.to_string()))?;
- let session = MatrixSession {
- meta: SessionMeta {
- user_id: config
- .user_id
- .parse::<matrix_sdk::ruma::OwnedUserId>()
- .map_err(|error| MatrixError::InvalidResponse(error.to_string()))?,
- device_id: config.device_id.clone().into(),
- },
- tokens: SessionTokens {
- access_token: config.access_token.expose().to_owned(),
- refresh_token: None,
- },
- };
- client.restore_session(session).await?;
+ client
+ .matrix_auth()
+ .login_username(&config.user_id, config.password.expose())
+ .device_id(&config.device_id)
+ .initial_device_display_name(&config.device_display_name)
+ .request_refresh_token()
+ .send()
+ .await
+ .map_err(loginError)?;
let identity = client
.whoami()
@@ -96,17 +117,22 @@ impl MatrixClient {
return Err(MatrixError::IdentityMismatch);
}
- Ok(Self { client, config })
+ Ok(client)
+ }
+
+ async fn activeClient(&self) -> Client {
+ self.client.read().await.clone()
}
/// Executes exactly one explicit-token Matrix sync request.
pub async fn syncOnce(&self, since: Option<String>) -> Result<SyncBatch, MatrixError> {
+ let client = self.activeClient().await;
let mut settings =
SyncSettings::new().timeout(Duration::from_millis(self.config.sync_timeout_ms));
if let Some(token) = since {
settings = settings.token(token);
}
- let response = self.client.sync_once(settings).await?;
+ let response = client.sync_once(settings).await?;
let mut rooms = Vec::new();
for (room_id, update) in response.rooms.joined {
if !self.config.allowed_room_ids.contains(room_id.as_str()) {
@@ -135,13 +161,14 @@ impl MatrixClient {
room_id: &str,
event_id: &str,
) -> Result<bool, MatrixError> {
+ let client = self.activeClient().await;
let room_id: OwnedRoomId = room_id
.parse::<OwnedRoomId>()
.map_err(|error| MatrixError::InvalidResponse(error.to_string()))?;
let event_id: OwnedEventId = event_id
.parse::<OwnedEventId>()
.map_err(|error| MatrixError::InvalidResponse(error.to_string()))?;
- let Some(room) = self.client.get_room(&room_id) else {
+ let Some(room) = client.get_room(&room_id) else {
return Ok(false);
};
let event = room.event(&event_id, None).await?;
@@ -157,11 +184,12 @@ impl MatrixClient {
response_json: &str,
transaction_id: &str,
) -> Result<String, MatrixError> {
+ let client = self.activeClient().await;
let room_id: OwnedRoomId = room_id
.parse::<OwnedRoomId>()
.map_err(|error| MatrixError::InvalidResponse(error.to_string()))?;
let transaction_id: OwnedTransactionId = transaction_id.into();
- let Some(room) = self.client.get_room(&room_id) else {
+ let Some(room) = client.get_room(&room_id) else {
return Err(MatrixError::InvalidResponse(
"configured room is unavailable".to_owned(),
));
@@ -182,13 +210,14 @@ impl MatrixClient {
event_id: &str,
maximum_events: u32,
) -> Result<Vec<Value>, MatrixError> {
+ let client = self.activeClient().await;
let room_id: OwnedRoomId = room_id
.parse::<OwnedRoomId>()
.map_err(|error| MatrixError::InvalidResponse(error.to_string()))?;
let event_id: OwnedEventId = event_id
.parse::<OwnedEventId>()
.map_err(|error| MatrixError::InvalidResponse(error.to_string()))?;
- let Some(room) = self.client.get_room(&room_id) else {
+ let Some(room) = client.get_room(&room_id) else {
return Err(MatrixError::InvalidResponse(
"configured room is unavailable".to_owned(),
));
@@ -207,10 +236,11 @@ impl MatrixClient {
/// Updates Lisa's typing notice for one configured joined room.
pub async fn setTyping(&self, room_id: &str, typing: bool) -> Result<(), MatrixError> {
+ let client = self.activeClient().await;
let room_id: OwnedRoomId = room_id
.parse::<OwnedRoomId>()
.map_err(|error| MatrixError::InvalidResponse(error.to_string()))?;
- let Some(room) = self.client.get_room(&room_id) else {
+ let Some(room) = client.get_room(&room_id) else {
return Err(MatrixError::InvalidResponse(
"configured room is unavailable".to_owned(),
));
@@ -220,6 +250,21 @@ impl MatrixClient {
}
}
+fn loginError(error: matrix_sdk::Error) -> MatrixError {
+ if let matrix_sdk::Error::Http(http_error) = &error
+ && matches!(
+ http_error.client_api_error_kind(),
+ Some(matrix_sdk::ruma::api::error::ErrorKind::Forbidden)
+ )
+ {
+ MatrixError::InvalidCredentials
+ }
+ else
+ {
+ MatrixError::Sdk(error)
+ }
+}
+
/// Returns a deterministic transaction ID for an invocation's single reply.
#[must_use]
pub fn transactionId(room_id: &str, event_id: &str) -> String {
@@ -371,7 +416,8 @@ mod tests {
homeserver_url = "https://matrix.example.test"
user_id = "@lisa:example.test"
device_id = "LISABOT"
-access_token = "env:TOKEN"
+device_display_name = "Lisa bot"
+password = "env:TOKEN"
allowed_room_ids = ["!room:example.test"]
mention_aliases = ["Lisa", "@Lisa"]
sync_timeout_ms = 30000