refactor(#2352): move matrix provisioning behind host-socket wire commands
This commit is contained in:
parent
5857f1f871
commit
9674fd42ac
3 changed files with 296 additions and 152 deletions
|
|
@ -640,17 +640,7 @@ async fn main() -> Result<()> {
|
|||
password_stdin,
|
||||
} => forge_create_user(&name, password.as_deref(), password_stdin).await,
|
||||
},
|
||||
Cmd::Matrix { cmd } => match cmd {
|
||||
MatrixCmd::CreateUser {
|
||||
name,
|
||||
password,
|
||||
password_stdin,
|
||||
} => matrix_create_user(&name, password.as_deref(), password_stdin).await,
|
||||
MatrixCmd::SyncAdmin => matrix_sync_admin().await,
|
||||
MatrixCmd::PromoteUser { name } => matrix_promote_user(&name).await,
|
||||
MatrixCmd::ResetPassword { name } => matrix_reset_password(&name).await,
|
||||
MatrixCmd::Invite { user, room } => matrix_invite(&user, room.as_deref()).await,
|
||||
},
|
||||
Cmd::Matrix { cmd } => run_matrix_cmd(&socket, cmd).await,
|
||||
Cmd::Github { cmd } => match cmd {
|
||||
GithubCmd::SetToken {
|
||||
agent,
|
||||
|
|
@ -725,6 +715,22 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Route a `matrix` subcommand to its handler. Extracted from `main`'s
|
||||
/// dispatch match so the top-level router stays small.
|
||||
async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> {
|
||||
match cmd {
|
||||
MatrixCmd::CreateUser {
|
||||
name,
|
||||
password,
|
||||
password_stdin,
|
||||
} => matrix_create_user(socket, &name, password.as_deref(), password_stdin).await,
|
||||
MatrixCmd::SyncAdmin => matrix_sync_admin(socket).await,
|
||||
MatrixCmd::PromoteUser { name } => matrix_promote_user(socket, &name).await,
|
||||
MatrixCmd::ResetPassword { name } => matrix_reset_password(socket, &name).await,
|
||||
MatrixCmd::Invite { user, room } => matrix_invite(socket, &user, room.as_deref()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// `open <home|forge|matrix>` — resolve the surface URL from the daemon,
|
||||
/// print it, then best-effort `xdg-open` it. Printing is the reliable
|
||||
/// core (headless / SSH hosts where no browser opener exists); the open
|
||||
|
|
@ -1254,160 +1260,79 @@ fn resolve_password(password: Option<&str>, password_stdin: bool) -> Result<Opti
|
|||
Ok(None)
|
||||
}
|
||||
|
||||
/// Send a matrix provisioning request to the daemon and print the
|
||||
/// operator-facing result lines it returns. The daemon owns the register +
|
||||
/// admin tokens and the matrix creds dir, so hivectl no longer links the
|
||||
/// matrix machinery — it just forwards the request and renders the reply.
|
||||
async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> {
|
||||
let resp = hive_c0re::client::request(socket, req)
|
||||
.await
|
||||
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
|
||||
if !resp.ok {
|
||||
bail!(
|
||||
"matrix: {}",
|
||||
resp.error.as_deref().unwrap_or("unknown error")
|
||||
);
|
||||
}
|
||||
for line in &resp.messages {
|
||||
println!("{line}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn matrix_create_user(
|
||||
socket: &Path,
|
||||
name: &str,
|
||||
password: Option<&str>,
|
||||
password_stdin: bool,
|
||||
) -> Result<()> {
|
||||
if !hive_c0re::matrix::is_present().await {
|
||||
bail!(
|
||||
"hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) before provisioning matrix users"
|
||||
);
|
||||
}
|
||||
let register_token =
|
||||
hive_c0re::matrix::ensure_register_token().context("read matrix register token")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("build reqwest client")?;
|
||||
let user_password = resolve_password(password, password_stdin)?;
|
||||
if agent_exists(name)? {
|
||||
if user_password.is_some() {
|
||||
// The boot-sweep / approval-time agent provisioning path
|
||||
// doesn't accept a password — agents auth by access_token,
|
||||
// never by password. Refuse rather than silently dropping it.
|
||||
bail!(
|
||||
"matrix create-user: --password / --password-stdin is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token"
|
||||
);
|
||||
}
|
||||
hive_c0re::matrix::ensure_user_for(&client, name, ®ister_token)
|
||||
.await
|
||||
.with_context(|| format!("matrix create-user {name}"))?;
|
||||
let path = Coordinator::agent_notes_dir(name).join("matrix-token");
|
||||
println!("matrix: provisioned agent user '{name}'");
|
||||
println!("token persisted at: {}", path.display());
|
||||
} else {
|
||||
let effective_password = match user_password {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
hive_c0re::matrix::random_password().context("generate random matrix password")?
|
||||
}
|
||||
};
|
||||
let token = hive_c0re::matrix::provision_user_token(
|
||||
&client,
|
||||
name,
|
||||
®ister_token,
|
||||
&effective_password,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("matrix create-user {name}"))?;
|
||||
println!("matrix: provisioned user '{name}' (not an agent — token not persisted)");
|
||||
println!("token: {token}");
|
||||
if password.is_some() || password_stdin {
|
||||
println!("password: set as supplied — use it to log into a matrix web client");
|
||||
} else {
|
||||
println!(
|
||||
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
// Resolve the password client-side (an inline flag or a stdin read);
|
||||
// the daemon never touches this process's stdin. The agent-vs-operator
|
||||
// branch + throwaway-password handling now live in the daemon handler.
|
||||
let password = resolve_password(password, password_stdin)?;
|
||||
matrix_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::MatrixCreateUser {
|
||||
name: name.to_owned(),
|
||||
password,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn matrix_sync_admin() -> Result<()> {
|
||||
if !hive_c0re::matrix::is_present().await {
|
||||
bail!(
|
||||
"hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first"
|
||||
);
|
||||
}
|
||||
let register_token =
|
||||
hive_c0re::matrix::ensure_register_token().context("read matrix register token")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("build reqwest client")?;
|
||||
hive_c0re::matrix::ensure_admin_user(&client, ®ister_token)
|
||||
.await
|
||||
.context("matrix sync-admin")?;
|
||||
let path = hive_c0re::matrix::admin_token_path();
|
||||
println!(
|
||||
"matrix: hive admin user '@{}' provisioned",
|
||||
hive_c0re::matrix::HIVE_ADMIN_LOCALPART
|
||||
);
|
||||
println!("token persisted at: {}", path.display());
|
||||
Ok(())
|
||||
async fn matrix_sync_admin(socket: &Path) -> Result<()> {
|
||||
matrix_request(socket, hive_host_sock::HostRequest::MatrixSyncAdmin).await
|
||||
}
|
||||
|
||||
async fn matrix_promote_user(name: &str) -> Result<()> {
|
||||
if !hive_c0re::matrix::is_present().await {
|
||||
bail!(
|
||||
"hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first"
|
||||
);
|
||||
}
|
||||
let admin_token = hive_c0re::matrix::read_admin_token()?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("build reqwest client")?;
|
||||
let server_name = hive_c0re::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
hive_c0re::matrix::promote_user_to_admin(&client, &admin_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix promote-user {name}"))?;
|
||||
println!("matrix: promoted @{name}:{server_name} to admin");
|
||||
Ok(())
|
||||
async fn matrix_promote_user(socket: &Path, name: &str) -> Result<()> {
|
||||
matrix_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::MatrixPromoteUser {
|
||||
name: name.to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn matrix_invite(user: &str, room: Option<&str>) -> Result<()> {
|
||||
if !hive_c0re::matrix::is_present().await {
|
||||
bail!(
|
||||
"hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first"
|
||||
);
|
||||
}
|
||||
let admin_token = hive_c0re::matrix::read_admin_token()?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("build reqwest client")?;
|
||||
let server_name = hive_c0re::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
let room_id = hive_c0re::matrix::invite_user(&client, &admin_token, user, room, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix invite {user}"))?;
|
||||
let target = if user.starts_with('@') {
|
||||
user.to_owned()
|
||||
} else {
|
||||
format!("@{user}:{server_name}")
|
||||
};
|
||||
println!("matrix: invited {target} to {room_id}");
|
||||
Ok(())
|
||||
async fn matrix_invite(socket: &Path, user: &str, room: Option<&str>) -> Result<()> {
|
||||
matrix_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::MatrixInvite {
|
||||
user: user.to_owned(),
|
||||
room: room.map(str::to_owned),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn matrix_reset_password(name: &str) -> Result<()> {
|
||||
if !hive_c0re::matrix::is_present().await {
|
||||
bail!(
|
||||
"hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first"
|
||||
);
|
||||
}
|
||||
let admin_token = hive_c0re::matrix::read_admin_token()?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("build reqwest client")?;
|
||||
let server_name = hive_c0re::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
hive_c0re::matrix::reset_user_password(&client, &admin_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix reset-password {name}"))?;
|
||||
// Password is persisted by reset_user_password.
|
||||
let pw_path = hive_c0re::paths::matrix_creds_dir().join(format!("{name}-password"));
|
||||
println!("matrix: password for @{name}:{server_name} reset");
|
||||
println!("password persisted at: {}", pw_path.display());
|
||||
println!("next: hivectl matrix create-user {name} # mints a fresh access token");
|
||||
Ok(())
|
||||
async fn matrix_reset_password(socket: &Path, name: &str) -> Result<()> {
|
||||
matrix_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::MatrixResetPassword {
|
||||
name: name.to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -175,6 +175,15 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
.map_err(anyhow::Error::msg)?;
|
||||
HostResponse::success()
|
||||
}
|
||||
HostRequest::MatrixCreateUser { name, password } => {
|
||||
handle_matrix_create_user(name, password.as_deref()).await?
|
||||
}
|
||||
HostRequest::MatrixSyncAdmin => handle_matrix_sync_admin().await?,
|
||||
HostRequest::MatrixPromoteUser { name } => handle_matrix_promote_user(name).await?,
|
||||
HostRequest::MatrixResetPassword { name } => handle_matrix_reset_password(name).await?,
|
||||
HostRequest::MatrixInvite { user, room } => {
|
||||
handle_matrix_invite(user, room.as_deref()).await?
|
||||
}
|
||||
})
|
||||
}
|
||||
.await;
|
||||
|
|
@ -245,6 +254,167 @@ async fn handle_agent_status(coord: &Arc<Coordinator>) -> HostResponse {
|
|||
HostResponse::agent_statuses(rows)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Matrix provisioning handlers
|
||||
//
|
||||
// The `hivectl matrix` subcommands used to run these in-process, which forced
|
||||
// the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …).
|
||||
// They now run daemon-side over the host socket: the daemon already holds the
|
||||
// register + admin tokens and the matrix creds dir. Each op returns the
|
||||
// operator-facing lines hivectl used to `println!` in `HostResponse::messages`
|
||||
// for the client to print verbatim.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shared reqwest client for the matrix admin HTTP calls (30s timeout,
|
||||
/// mirroring the old in-CLI client).
|
||||
fn matrix_http_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("build reqwest client")
|
||||
}
|
||||
|
||||
/// True when `name` has a state dir under the agents root, i.e. it's a
|
||||
/// managed agent rather than a bare (operator/human) matrix account.
|
||||
fn agent_exists(name: &str) -> Result<bool> {
|
||||
crate::paths::agent_state_dir(name)
|
||||
.try_exists()
|
||||
.with_context(|| format!("check agent state dir for {name}"))
|
||||
}
|
||||
|
||||
/// Guard: matrix provisioning needs the homeserver container running.
|
||||
async fn require_matrix_present() -> Result<()> {
|
||||
if crate::matrix::is_present().await {
|
||||
return Ok(());
|
||||
}
|
||||
anyhow::bail!(
|
||||
"hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) before provisioning matrix users"
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result<HostResponse> {
|
||||
require_matrix_present().await?;
|
||||
let register_token =
|
||||
crate::matrix::ensure_register_token().context("read matrix register token")?;
|
||||
let client = matrix_http_client()?;
|
||||
let mut out = Vec::new();
|
||||
if agent_exists(name)? {
|
||||
if password.is_some() {
|
||||
// Agents auth by access_token, never by password — the
|
||||
// boot-sweep provisioning path doesn't accept one. Refuse
|
||||
// rather than silently dropping it.
|
||||
anyhow::bail!(
|
||||
"matrix create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token"
|
||||
);
|
||||
}
|
||||
crate::matrix::ensure_user_for(&client, name, ®ister_token)
|
||||
.await
|
||||
.with_context(|| format!("matrix create-user {name}"))?;
|
||||
let path = Coordinator::agent_notes_dir(name).join("matrix-token");
|
||||
out.push(format!("matrix: provisioned agent user '{name}'"));
|
||||
out.push(format!("token persisted at: {}", path.display()));
|
||||
} else {
|
||||
let effective_password = match password {
|
||||
Some(p) => p.to_owned(),
|
||||
None => crate::matrix::random_password().context("generate random matrix password")?,
|
||||
};
|
||||
let token = crate::matrix::provision_user_token(
|
||||
&client,
|
||||
name,
|
||||
®ister_token,
|
||||
&effective_password,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("matrix create-user {name}"))?;
|
||||
out.push(format!(
|
||||
"matrix: provisioned user '{name}' (not an agent — token not persisted)"
|
||||
));
|
||||
out.push(format!("token: {token}"));
|
||||
if password.is_some() {
|
||||
out.push(
|
||||
"password: set as supplied — use it to log into a matrix web client".to_owned(),
|
||||
);
|
||||
} else {
|
||||
out.push(
|
||||
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(HostResponse::messages(out))
|
||||
}
|
||||
|
||||
async fn handle_matrix_sync_admin() -> Result<HostResponse> {
|
||||
require_matrix_present().await?;
|
||||
let register_token =
|
||||
crate::matrix::ensure_register_token().context("read matrix register token")?;
|
||||
let client = matrix_http_client()?;
|
||||
crate::matrix::ensure_admin_user(&client, ®ister_token)
|
||||
.await
|
||||
.context("matrix sync-admin")?;
|
||||
let path = crate::matrix::admin_token_path();
|
||||
Ok(HostResponse::messages(vec![
|
||||
format!(
|
||||
"matrix: hive admin user '@{}' provisioned",
|
||||
crate::matrix::HIVE_ADMIN_LOCALPART
|
||||
),
|
||||
format!("token persisted at: {}", path.display()),
|
||||
]))
|
||||
}
|
||||
|
||||
async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
|
||||
require_matrix_present().await?;
|
||||
let admin_token = crate::matrix::read_admin_token()?;
|
||||
let client = matrix_http_client()?;
|
||||
let server_name = crate::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
crate::matrix::promote_user_to_admin(&client, &admin_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix promote-user {name}"))?;
|
||||
Ok(HostResponse::messages(vec![format!(
|
||||
"matrix: promoted @{name}:{server_name} to admin"
|
||||
)]))
|
||||
}
|
||||
|
||||
async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
|
||||
require_matrix_present().await?;
|
||||
let admin_token = crate::matrix::read_admin_token()?;
|
||||
let client = matrix_http_client()?;
|
||||
let server_name = crate::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
let room_id = crate::matrix::invite_user(&client, &admin_token, user, room, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix invite {user}"))?;
|
||||
let target = if user.starts_with('@') {
|
||||
user.to_owned()
|
||||
} else {
|
||||
format!("@{user}:{server_name}")
|
||||
};
|
||||
Ok(HostResponse::messages(vec![format!(
|
||||
"matrix: invited {target} to {room_id}"
|
||||
)]))
|
||||
}
|
||||
|
||||
async fn handle_matrix_reset_password(name: &str) -> Result<HostResponse> {
|
||||
require_matrix_present().await?;
|
||||
let admin_token = crate::matrix::read_admin_token()?;
|
||||
let client = matrix_http_client()?;
|
||||
let server_name = crate::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
crate::matrix::reset_user_password(&client, &admin_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix reset-password {name}"))?;
|
||||
// Password is persisted by reset_user_password.
|
||||
let pw_path = crate::paths::matrix_creds_dir().join(format!("{name}-password"));
|
||||
Ok(HostResponse::messages(vec![
|
||||
format!("matrix: password for @{name}:{server_name} reset"),
|
||||
format!("password persisted at: {}", pw_path.display()),
|
||||
format!("next: hivectl matrix create-user {name} # mints a fresh access token"),
|
||||
]))
|
||||
}
|
||||
|
||||
/// Single-agent queue verbs the admin socket exposes. Each submits the
|
||||
/// matching DAG (persisting the `wanted` intent, serializing on the
|
||||
/// agent's lease, with the transient/crash-watch suppression the old
|
||||
|
|
|
|||
|
|
@ -94,6 +94,37 @@ pub enum HostRequest {
|
|||
#[serde(default)]
|
||||
scope: LifecycleScope,
|
||||
},
|
||||
/// Create or refresh a matrix account + access token for `name`.
|
||||
/// The daemon runs the provisioning (it holds the register + admin
|
||||
/// tokens and the matrix creds dir) and returns the operator-facing
|
||||
/// results (persisted-token path for agents, or the freshly-minted
|
||||
/// token + password for non-agent accounts) in
|
||||
/// [`HostResponse::messages`]. `password` is resolved by the client
|
||||
/// (inline flag or stdin) and `None` requests a random throwaway.
|
||||
MatrixCreateUser {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
},
|
||||
/// Provision (or re-provision) the hive system admin matrix account.
|
||||
/// Daemon-side equivalent of `hivectl matrix sync-admin`.
|
||||
MatrixSyncAdmin,
|
||||
/// Promote a matrix user to homeserver admin via the admin API.
|
||||
/// Uses the daemon's system admin token; `server_name` is discovered
|
||||
/// from the running homeserver.
|
||||
MatrixPromoteUser { name: String },
|
||||
/// Reset a matrix user's password via the admin API and persist the
|
||||
/// new password to the matrix creds dir so a later token mint can
|
||||
/// re-login. Returns the outcome in [`HostResponse::messages`].
|
||||
MatrixResetPassword { name: String },
|
||||
/// Invite a matrix user to the hive Space (default) or a specific
|
||||
/// `room`. Uses the daemon's admin token; idempotent
|
||||
/// (already-member / already-invited is a no-op).
|
||||
MatrixInvite {
|
||||
user: String,
|
||||
#[serde(default)]
|
||||
room: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Selects which container classes a hive-wide [`HostRequest::Stop`] /
|
||||
|
|
@ -189,6 +220,13 @@ pub struct HostResponse {
|
|||
/// been evicted from the queue's history tail.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dags: Option<Vec<jobs::DagView>>,
|
||||
/// Free-form operator-facing output lines the client prints verbatim
|
||||
/// (one per line). Carries results a request produced daemon-side that
|
||||
/// have no structured home — e.g. a freshly-minted matrix token, a
|
||||
/// reset password, or an invited room id from the `Matrix*` requests.
|
||||
/// Empty for requests that produce no such output.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub messages: Vec<String>,
|
||||
}
|
||||
|
||||
impl HostResponse {
|
||||
|
|
@ -267,4 +305,15 @@ impl HostResponse {
|
|||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A success carrying operator-facing output lines the client prints
|
||||
/// verbatim — the result shape for the `Matrix*` provisioning requests.
|
||||
#[must_use]
|
||||
pub fn messages(messages: Vec<String>) -> Self {
|
||||
Self {
|
||||
ok: true,
|
||||
messages,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue