chore(infra): add gRPC layer, update protobufs, remove immediate module
- Add gRPC service modules: auth, channel, channel settings, member, permission - Update protobuf definitions and generated code - Remove immediate/ real-time module (superseded by IM service) - Update etcd discovery and registration - Update cache, error, config, and build infrastructure - Add ADR documentation - Update OpenAPI spec
This commit is contained in:
@@ -1,29 +1,73 @@
|
|||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
use std::fs;
|
||||||
tonic_prost_build::configure()
|
use std::path::{Path, PathBuf};
|
||||||
.build_client(true)
|
|
||||||
.build_server(false)
|
|
||||||
.compile_protos(&["proto/email/email.proto"], &["proto/email"])?;
|
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
|
||||||
|
let out_dir = PathBuf::from(std::env::var("OUT_DIR")?);
|
||||||
|
fs::create_dir_all(&out_dir)?;
|
||||||
|
let email_dir = manifest_dir.join("proto/email");
|
||||||
|
let email_protos = proto_files(&email_dir)?;
|
||||||
|
for proto in &email_protos {
|
||||||
|
println!("cargo:rerun-if-changed={}", proto.display());
|
||||||
|
}
|
||||||
tonic_prost_build::configure()
|
tonic_prost_build::configure()
|
||||||
.build_client(true)
|
.build_client(true)
|
||||||
.build_server(false)
|
.build_server(false)
|
||||||
.compile_protos(
|
.out_dir(&out_dir)
|
||||||
&[
|
.compile_protos(&email_protos, &[email_dir])?;
|
||||||
"proto/git/oid.proto",
|
|
||||||
"proto/git/tagger.proto",
|
let git_dir = manifest_dir.join("proto/git");
|
||||||
"proto/git/repository.proto",
|
let git_protos = proto_files(&git_dir)?;
|
||||||
"proto/git/commit.proto",
|
for proto in &git_protos {
|
||||||
"proto/git/branch.proto",
|
println!("cargo:rerun-if-changed={}", proto.display());
|
||||||
"proto/git/tag.proto",
|
}
|
||||||
"proto/git/tree.proto",
|
tonic_prost_build::configure()
|
||||||
"proto/git/diff.proto",
|
.build_client(true)
|
||||||
"proto/git/merge.proto",
|
.build_server(false)
|
||||||
"proto/git/blame.proto",
|
.type_attribute(
|
||||||
"proto/git/archive.proto",
|
".",
|
||||||
"proto/git/pack.proto",
|
"#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)]",
|
||||||
],
|
)
|
||||||
&["proto/git"],
|
.extern_path(".google.protobuf.Timestamp", "crate::pb::Timestamp")
|
||||||
)?;
|
.out_dir(&out_dir)
|
||||||
|
.compile_protos(&git_protos, &[git_dir])?;
|
||||||
|
|
||||||
|
let this_dir = manifest_dir.join("proto/this");
|
||||||
|
let this_protos = proto_files(&this_dir)?;
|
||||||
|
for proto in &this_protos {
|
||||||
|
println!("cargo:rerun-if-changed={}", proto.display());
|
||||||
|
}
|
||||||
|
tonic_prost_build::configure()
|
||||||
|
.build_client(false)
|
||||||
|
.build_server(true)
|
||||||
|
.out_dir(&out_dir)
|
||||||
|
.compile_protos(&this_protos, &[this_dir])?;
|
||||||
|
|
||||||
|
let im_dir = manifest_dir.join("proto/this/im");
|
||||||
|
let im_protos = proto_files(&im_dir)?;
|
||||||
|
for proto in &im_protos {
|
||||||
|
println!("cargo:rerun-if-changed={}", proto.display());
|
||||||
|
}
|
||||||
|
tonic_prost_build::configure()
|
||||||
|
.build_client(false)
|
||||||
|
.build_server(true)
|
||||||
|
.out_dir(&out_dir)
|
||||||
|
.compile_protos(&im_protos, &[im_dir])?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn proto_files(proto_dir: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
|
||||||
|
let mut files = fs::read_dir(proto_dir)?
|
||||||
|
.map(|entry| entry.map(|entry| entry.path()))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
files.retain(|path| path.extension().is_some_and(|ext| ext == "proto"));
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
if files.is_empty() {
|
||||||
|
return Err(format!("no .proto files found in {}", proto_dir.display()).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+61
-20
@@ -18,10 +18,10 @@ pub struct AppCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppCache {
|
impl AppCache {
|
||||||
pub fn from_config(config: &AppConfig) -> AppResult<Self> {
|
pub async fn from_config(config: &AppConfig) -> AppResult<Self> {
|
||||||
let cap = config.lru_default_capacity()?;
|
let cap = config.lru_default_capacity()?;
|
||||||
let ttl = Duration::from_secs(config.lru_default_ttl_secs()?);
|
let ttl = Duration::from_secs(config.lru_default_ttl_secs()?);
|
||||||
let l2 = AppRedis::from_config(config)?;
|
let l2 = AppRedis::from_config(config).await?;
|
||||||
let key_prefix = config.redis_key_prefix()?;
|
let key_prefix = config.redis_key_prefix()?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
l1: LruTtlCache::new(cap, ttl),
|
l1: LruTtlCache::new(cap, ttl),
|
||||||
@@ -31,17 +31,18 @@ impl AppCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
|
pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
|
||||||
if let Some(json) = self.l1.get(&key.to_string()) {
|
if let Some(json) = self.l1.get(&key.to_string()) {
|
||||||
return serde_json::from_str(&json).ok();
|
return serde_json::from_str(&json).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
let full_key = self.full_key(key);
|
let full_key = self.full_key(key);
|
||||||
let mut conn = self.l2.get_connection().ok()?;
|
let mut conn = self.l2.get_connection();
|
||||||
let json: String = Cmd::new()
|
let json: String = Cmd::new()
|
||||||
.arg("GET")
|
.arg("GET")
|
||||||
.arg(&full_key)
|
.arg(&full_key)
|
||||||
.query::<Option<String>>(&mut *conn.inner_mut())
|
.query_async::<Option<String>>(&mut conn)
|
||||||
|
.await
|
||||||
.ok()??;
|
.ok()??;
|
||||||
|
|
||||||
let value: T = serde_json::from_str(&json).ok()?;
|
let value: T = serde_json::from_str(&json).ok()?;
|
||||||
@@ -49,46 +50,86 @@ impl AppCache {
|
|||||||
Some(value)
|
Some(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set<T: Serialize>(&self, key: &str, value: &T, ttl: Option<Duration>) -> AppResult<()> {
|
pub async fn get_l2_only<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
|
||||||
|
let full_key = self.full_key(key);
|
||||||
|
let mut conn = self.l2.get_connection();
|
||||||
|
let json: String = Cmd::new()
|
||||||
|
.arg("GET")
|
||||||
|
.arg(&full_key)
|
||||||
|
.query_async::<Option<String>>(&mut conn)
|
||||||
|
.await
|
||||||
|
.ok()??;
|
||||||
|
|
||||||
|
serde_json::from_str(&json).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set<T: Serialize>(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
value: &T,
|
||||||
|
ttl: Option<Duration>,
|
||||||
|
) -> AppResult<()> {
|
||||||
let json = serde_json::to_string(value)?;
|
let json = serde_json::to_string(value)?;
|
||||||
let full_key = self.full_key(key);
|
let full_key = self.full_key(key);
|
||||||
let ttl_duration = ttl.unwrap_or(self.default_ttl);
|
let ttl_duration = ttl.unwrap_or(self.default_ttl);
|
||||||
let ttl_secs = ttl_duration.as_secs() as usize;
|
let ttl_secs = ttl_duration.as_secs() as usize;
|
||||||
let mut conn = self.l2.get_connection()?;
|
let mut conn = self.l2.get_connection();
|
||||||
Cmd::new()
|
Cmd::new()
|
||||||
.arg("SETEX")
|
.arg("SETEX")
|
||||||
.arg(&full_key)
|
.arg(&full_key)
|
||||||
.arg(ttl_secs)
|
.arg(ttl_secs)
|
||||||
.arg(&json)
|
.arg(&json)
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
.query_async::<()>(&mut conn)
|
||||||
|
.await?;
|
||||||
self.l1.insert_with_ttl(key.to_string(), json, ttl_duration);
|
self.l1.insert_with_ttl(key.to_string(), json, ttl_duration);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete(&self, key: &str) -> AppResult<()> {
|
pub async fn set_l2_only<T: Serialize>(
|
||||||
self.l1.remove(&key.to_string());
|
&self,
|
||||||
|
key: &str,
|
||||||
|
value: &T,
|
||||||
|
ttl: Option<Duration>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let json = serde_json::to_string(value)?;
|
||||||
let full_key = self.full_key(key);
|
let full_key = self.full_key(key);
|
||||||
let mut conn = self.l2.get_connection()?;
|
let ttl_duration = ttl.unwrap_or(self.default_ttl);
|
||||||
|
let ttl_secs = ttl_duration.as_secs() as usize;
|
||||||
|
let mut conn = self.l2.get_connection();
|
||||||
Cmd::new()
|
Cmd::new()
|
||||||
.arg("DEL")
|
.arg("SETEX")
|
||||||
.arg(&full_key)
|
.arg(&full_key)
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
.arg(ttl_secs)
|
||||||
|
.arg(&json)
|
||||||
|
.query_async::<()>(&mut conn)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn exists(&self, key: &str) -> bool {
|
pub async fn delete(&self, key: &str) -> AppResult<()> {
|
||||||
|
self.l1.remove(&key.to_string());
|
||||||
|
let full_key = self.full_key(key);
|
||||||
|
let mut conn = self.l2.get_connection();
|
||||||
|
Cmd::new()
|
||||||
|
.arg("DEL")
|
||||||
|
.arg(&full_key)
|
||||||
|
.query_async::<()>(&mut conn)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn exists(&self, key: &str) -> bool {
|
||||||
if self.l1.get(&key.to_string()).is_some() {
|
if self.l1.get(&key.to_string()).is_some() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let full_key = self.full_key(key);
|
let full_key = self.full_key(key);
|
||||||
if let Ok(mut conn) = self.l2.get_connection() {
|
let mut conn = self.l2.get_connection();
|
||||||
return Cmd::new()
|
Cmd::new()
|
||||||
.arg("EXISTS")
|
.arg("EXISTS")
|
||||||
.arg(&full_key)
|
.arg(&full_key)
|
||||||
.query(&mut *conn.inner_mut())
|
.query_async::<bool>(&mut conn)
|
||||||
.unwrap_or(false);
|
.await
|
||||||
}
|
.unwrap_or(false)
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn full_key(&self, key: &str) -> String {
|
fn full_key(&self, key: &str) -> String {
|
||||||
|
|||||||
Vendored
+45
-60
@@ -1,15 +1,13 @@
|
|||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::error::AppError;
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::error::AppResult;
|
use futures_util::future::BoxFuture;
|
||||||
use r2d2::Pool;
|
|
||||||
use redis::cluster::ClusterClient;
|
use redis::cluster::ClusterClient;
|
||||||
use redis::{Client, ConnectionLike, RedisError};
|
use redis::{Client, FromRedisValue};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
enum RedisBackend {
|
enum RedisBackend {
|
||||||
Single(Pool<Client>),
|
Single(redis::aio::ConnectionManager),
|
||||||
Cluster(Pool<ClusterClient>),
|
Cluster(redis::cluster_async::ClusterConnection),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -18,100 +16,87 @@ pub struct AppRedis {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppRedis {
|
impl AppRedis {
|
||||||
pub fn from_config(config: &AppConfig) -> AppResult<Self> {
|
pub async fn from_config(config: &AppConfig) -> AppResult<Self> {
|
||||||
let backend = if config.redis_cluster_enabled()? {
|
let backend = if config.redis_cluster_enabled()? {
|
||||||
let nodes = config.redis_cluster_nodes()?;
|
let nodes = config.redis_cluster_nodes()?;
|
||||||
let cluster_client =
|
let cluster_client =
|
||||||
ClusterClient::new(nodes.iter().map(|s| s.as_str()).collect::<Vec<_>>())?;
|
ClusterClient::new(nodes.iter().map(|s| s.as_str()).collect::<Vec<_>>())?;
|
||||||
let pool = Self::build_pool(config, cluster_client)?;
|
let conn = cluster_client.get_async_connection().await?;
|
||||||
RedisBackend::Cluster(pool)
|
RedisBackend::Cluster(conn)
|
||||||
} else {
|
} else {
|
||||||
let url = config
|
let url = config
|
||||||
.redis_url()?
|
.redis_url()?
|
||||||
.ok_or_else(|| AppError::Config("APP_REDIS_URL is not set".into()))?;
|
.ok_or_else(|| AppError::Config("APP_REDIS_URL is not set".into()))?;
|
||||||
let client = Client::open(url.as_str())?;
|
let client = Client::open(url.as_str())?;
|
||||||
let pool = Self::build_pool(config, client)?;
|
let conn = client.get_connection_manager().await?;
|
||||||
RedisBackend::Single(pool)
|
RedisBackend::Single(conn)
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self { backend })
|
Ok(Self { backend })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_pool<M: r2d2::ManageConnection>(config: &AppConfig, manager: M) -> AppResult<Pool<M>> {
|
pub fn get_connection(&self) -> RedisConnection {
|
||||||
let max_conn = config.redis_max_connections()?;
|
|
||||||
let min_conn = config.redis_min_connections()?;
|
|
||||||
let idle_timeout = config.redis_idle_timeout()?;
|
|
||||||
let conn_timeout = config.redis_connection_timeout()?;
|
|
||||||
|
|
||||||
Ok(r2d2::Builder::new()
|
|
||||||
.max_size(max_conn)
|
|
||||||
.min_idle(Some(min_conn))
|
|
||||||
.idle_timeout(Some(Duration::from_secs(idle_timeout)))
|
|
||||||
.connection_timeout(Duration::from_secs(conn_timeout))
|
|
||||||
.build(manager)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_connection(&self) -> Result<PooledRedisConnection, r2d2::Error> {
|
|
||||||
match &self.backend {
|
match &self.backend {
|
||||||
RedisBackend::Single(pool) => pool.get().map(PooledRedisConnection::Single),
|
RedisBackend::Single(cm) => RedisConnection::Single(cm.clone()),
|
||||||
RedisBackend::Cluster(pool) => pool.get().map(PooledRedisConnection::Cluster),
|
RedisBackend::Cluster(cc) => RedisConnection::Cluster(cc.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::large_enum_variant)]
|
pub enum RedisConnection {
|
||||||
pub enum PooledRedisConnection {
|
Single(redis::aio::ConnectionManager),
|
||||||
Single(r2d2::PooledConnection<Client>),
|
Cluster(redis::cluster_async::ClusterConnection),
|
||||||
Cluster(r2d2::PooledConnection<ClusterClient>),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PooledRedisConnection {
|
impl redis::aio::ConnectionLike for RedisConnection {
|
||||||
pub fn inner_mut(&mut self) -> &mut dyn ConnectionLike {
|
fn req_packed_command<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
cmd: &'a redis::Cmd,
|
||||||
|
) -> BoxFuture<'a, redis::RedisResult<redis::Value>> {
|
||||||
match self {
|
match self {
|
||||||
PooledRedisConnection::Single(conn) => conn,
|
Self::Single(c) => Box::pin(c.req_packed_command(cmd)),
|
||||||
PooledRedisConnection::Cluster(conn) => conn,
|
Self::Cluster(c) => Box::pin(c.req_packed_command(cmd)),
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConnectionLike for PooledRedisConnection {
|
|
||||||
fn req_packed_command(&mut self, cmd: &[u8]) -> Result<redis::Value, RedisError> {
|
|
||||||
match self {
|
|
||||||
PooledRedisConnection::Single(conn) => conn.req_packed_command(cmd),
|
|
||||||
PooledRedisConnection::Cluster(conn) => conn.req_packed_command(cmd),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn req_packed_commands(
|
fn req_packed_commands<'a>(
|
||||||
&mut self,
|
&'a mut self,
|
||||||
cmd: &[u8],
|
cmd: &'a redis::Pipeline,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
count: usize,
|
count: usize,
|
||||||
) -> Result<Vec<redis::Value>, RedisError> {
|
) -> BoxFuture<'a, redis::RedisResult<Vec<redis::Value>>> {
|
||||||
match self {
|
match self {
|
||||||
PooledRedisConnection::Single(conn) => conn.req_packed_commands(cmd, offset, count),
|
Self::Single(c) => Box::pin(c.req_packed_commands(cmd, offset, count)),
|
||||||
PooledRedisConnection::Cluster(conn) => conn.req_packed_commands(cmd, offset, count),
|
Self::Cluster(c) => Box::pin(c.req_packed_commands(cmd, offset, count)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_db(&self) -> i64 {
|
fn get_db(&self) -> i64 {
|
||||||
match self {
|
match self {
|
||||||
PooledRedisConnection::Single(conn) => conn.get_db(),
|
Self::Single(c) => c.get_db(),
|
||||||
PooledRedisConnection::Cluster(conn) => conn.get_db(),
|
Self::Cluster(c) => c.get_db(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_connection(&mut self) -> bool {
|
impl RedisConnection {
|
||||||
|
pub async fn query_async<T: FromRedisValue>(
|
||||||
|
&mut self,
|
||||||
|
cmd: &mut redis::Cmd,
|
||||||
|
) -> redis::RedisResult<T> {
|
||||||
match self {
|
match self {
|
||||||
PooledRedisConnection::Single(conn) => conn.check_connection(),
|
Self::Single(c) => cmd.query_async(c).await,
|
||||||
PooledRedisConnection::Cluster(conn) => conn.check_connection(),
|
Self::Cluster(c) => cmd.query_async(c).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_open(&self) -> bool {
|
pub async fn query_pipeline_async<T: FromRedisValue>(
|
||||||
|
&mut self,
|
||||||
|
pipe: &mut redis::Pipeline,
|
||||||
|
) -> redis::RedisResult<T> {
|
||||||
match self {
|
match self {
|
||||||
PooledRedisConnection::Single(conn) => conn.is_open(),
|
Self::Single(c) => pipe.query_async(c).await,
|
||||||
PooledRedisConnection::Cluster(conn) => conn.is_open(),
|
Self::Cluster(c) => pipe.query_async(c).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,4 +27,8 @@ impl AppConfig {
|
|||||||
pub fn rpc_default_timeout_secs(&self) -> AppResult<u64> {
|
pub fn rpc_default_timeout_secs(&self) -> AppResult<u64> {
|
||||||
self.get_env_or("APP_RPC_DEFAULT_TIMEOUT_SECS", 10)
|
self.get_env_or("APP_RPC_DEFAULT_TIMEOUT_SECS", 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn email_rpc_addr(&self) -> AppResult<Option<String>> {
|
||||||
|
self.get_env::<String>("APP_EMAIL_RPC_ADDR")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# ADR-NNN: 标题 / Title
|
||||||
|
|
||||||
|
## 状态 / Status
|
||||||
|
|
||||||
|
**Accepted** | Superseded | Deprecated
|
||||||
|
|
||||||
|
**日期 / Date**: YYYY-MM-DD
|
||||||
|
|
||||||
|
## 背景 / Context
|
||||||
|
|
||||||
|
描述问题背景和驱动因素。
|
||||||
|
|
||||||
|
Describe the context and driving factors.
|
||||||
|
|
||||||
|
## 决策 / Decision
|
||||||
|
|
||||||
|
描述做出的决策。
|
||||||
|
|
||||||
|
Describe the decision that was made.
|
||||||
|
|
||||||
|
## 考虑的方案 / Considered Options
|
||||||
|
|
||||||
|
1. **方案 A** — 描述 / Description
|
||||||
|
2. **方案 B** — 描述 / Description
|
||||||
|
3. **方案 C** — 描述 / Description
|
||||||
|
|
||||||
|
## 后果 / Consequences
|
||||||
|
|
||||||
|
### 正面 / Positive
|
||||||
|
|
||||||
|
- 优点 1 / Advantage 1
|
||||||
|
- 优点 2 / Advantage 2
|
||||||
|
|
||||||
|
### 负面 / Negative
|
||||||
|
|
||||||
|
- 缺点 1 / Disadvantage 1
|
||||||
|
- 缺点 2 / Disadvantage 2
|
||||||
|
|
||||||
|
### 风险 / Risks
|
||||||
|
|
||||||
|
- 风险 1 / Risk 1
|
||||||
|
- 风险 2 / Risk 2
|
||||||
|
|
||||||
|
## 相关决策 / Related Decisions
|
||||||
|
|
||||||
|
- [ADR-XXX](xxx-related-decision.md)
|
||||||
|
|
||||||
|
## 参考 / References
|
||||||
|
|
||||||
|
- [链接 / Link](url)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# ADR-001: 选择 Actix-web 作为 Web 框架 / Choice of Actix-web as Web Framework
|
||||||
|
|
||||||
|
## 状态 / Status
|
||||||
|
|
||||||
|
**Accepted**
|
||||||
|
|
||||||
|
**日期 / Date**: 2024-01-01
|
||||||
|
|
||||||
|
## 背景 / Context
|
||||||
|
|
||||||
|
appks 是一个协作开发平台后端,需要一个高性能、可靠的 Rust Web 框架来处理 HTTP 请求、WebSocket 连接和中间件。
|
||||||
|
|
||||||
|
appks is a collaborative development platform backend that needs a high-performance, reliable Rust web framework for HTTP requests, WebSocket connections, and middleware.
|
||||||
|
|
||||||
|
## 决策 / Decision
|
||||||
|
|
||||||
|
选择 **Actix-web** 作为 Web 框架。
|
||||||
|
|
||||||
|
Chose **Actix-web** as the web framework.
|
||||||
|
|
||||||
|
## 考虑的方案 / Considered Options
|
||||||
|
|
||||||
|
1. **Actix-web** — 成熟的 actor 模型框架,性能优异
|
||||||
|
2. **Axum** — Tokio 生态新兴框架,Tower 集成
|
||||||
|
3. **Rocket** — 易用性优先的框架
|
||||||
|
|
||||||
|
## 后果 / Consequences
|
||||||
|
|
||||||
|
### 正面 / Positive
|
||||||
|
|
||||||
|
- 优异的性能表现 / Excellent performance
|
||||||
|
- 成熟的生态系统 / Mature ecosystem
|
||||||
|
- 良好的 WebSocket 支持 / Good WebSocket support
|
||||||
|
- 活跃的社区维护 / Active community maintenance
|
||||||
|
|
||||||
|
### 负面 / Negative
|
||||||
|
|
||||||
|
- 学习曲线较陡 / Steeper learning curve
|
||||||
|
- Actor 模型需要适应 / Actor model requires adaptation
|
||||||
|
|
||||||
|
### 风险 / Risks
|
||||||
|
|
||||||
|
- 框架版本升级可能带来 breaking changes / Framework upgrades may bring breaking changes
|
||||||
|
|
||||||
|
## 参考 / References
|
||||||
|
|
||||||
|
- [Actix-web 官方文档](https://actix.rs/)
|
||||||
|
- [TechEmpower Web Framework Benchmarks](https://www.techempower.com/benchmarks/)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# ADR-002: 两级缓存架构 / Two-Tier Caching Architecture
|
||||||
|
|
||||||
|
## 状态 / Status
|
||||||
|
|
||||||
|
**Accepted**
|
||||||
|
|
||||||
|
**日期 / Date**: 2024-01-01
|
||||||
|
|
||||||
|
## 背景 / Context
|
||||||
|
|
||||||
|
平台需要缓存机制来减少数据库负载,提高响应速度。需要在性能和一致性之间取得平衡。
|
||||||
|
|
||||||
|
The platform needs a caching mechanism to reduce database load and improve response times. A balance between performance and consistency is required.
|
||||||
|
|
||||||
|
## 决策 / Decision
|
||||||
|
|
||||||
|
采用 **两级缓存架构**:L1 (内存 LRU-TTL) + L2 (Redis)。
|
||||||
|
|
||||||
|
Adopted **two-tier caching**: L1 (in-memory LRU-TTL) + L2 (Redis).
|
||||||
|
|
||||||
|
## 考虑的方案 / Considered Options
|
||||||
|
|
||||||
|
1. **纯 Redis** — 简单但网络延迟高
|
||||||
|
2. **纯内存缓存** — 快但不跨实例共享
|
||||||
|
3. **两级缓存** — 兼顾速度和共享
|
||||||
|
|
||||||
|
## 后果 / Consequences
|
||||||
|
|
||||||
|
### 正面 / Positive
|
||||||
|
|
||||||
|
- L1 提供极低延迟 / L1 provides ultra-low latency
|
||||||
|
- L2 提供跨实例共享 / L2 provides cross-instance sharing
|
||||||
|
- 减少数据库负载 / Reduces database load
|
||||||
|
|
||||||
|
### 负面 / Negative
|
||||||
|
|
||||||
|
- 缓存一致性复杂 / Cache consistency is complex
|
||||||
|
- 内存占用增加 / Increased memory usage
|
||||||
|
|
||||||
|
### 风险 / Risks
|
||||||
|
|
||||||
|
- 缓存雪崩 / Cache avalanche
|
||||||
|
- 缓存穿透 / Cache penetration
|
||||||
|
|
||||||
|
## 实现细节 / Implementation Details
|
||||||
|
|
||||||
|
- **L1**: `DashMap + Mutex<LruTracker>`, TTL 5 分钟
|
||||||
|
- **L2**: Redis via r2d2, TTL 可配置
|
||||||
|
- **策略**: L1 miss → L2 miss → 数据库查询
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# ADR-003: 使用 NATS JetStream 作为消息队列 / NATS JetStream for Messaging
|
||||||
|
|
||||||
|
## 状态 / Status
|
||||||
|
|
||||||
|
**Accepted**
|
||||||
|
|
||||||
|
**日期 / Date**: 2024-01-01
|
||||||
|
|
||||||
|
## 背景 / Context
|
||||||
|
|
||||||
|
平台的实时 IM 系统需要可靠的消息传递机制,支持发布/订阅模式和消息持久化。
|
||||||
|
|
||||||
|
The platform's real-time IM system needs reliable messaging with pub/sub patterns and message persistence.
|
||||||
|
|
||||||
|
## 决策 / Decision
|
||||||
|
|
||||||
|
使用 **NATS JetStream** 作为消息队列。
|
||||||
|
|
||||||
|
Use **NATS JetStream** as the message queue.
|
||||||
|
|
||||||
|
## 考虑的方案 / Considered Options
|
||||||
|
|
||||||
|
1. **NATS JetStream** — 轻量级、高性能
|
||||||
|
2. **Apache Kafka** — 高吞吐但运维复杂
|
||||||
|
3. **RabbitMQ** — 功能丰富但性能较低
|
||||||
|
|
||||||
|
## 后果 / Consequences
|
||||||
|
|
||||||
|
### 正面 / Positive
|
||||||
|
|
||||||
|
- 轻量级部署 / Lightweight deployment
|
||||||
|
- 高性能消息传递 / High-performance messaging
|
||||||
|
- 内置持久化 / Built-in persistence
|
||||||
|
- 良好的 Rust 客户端支持 / Good Rust client support
|
||||||
|
|
||||||
|
### 负面 / Negative
|
||||||
|
|
||||||
|
- 生态系统不如 Kafka 成熟 / Ecosystem less mature than Kafka
|
||||||
|
- 监控工具有限 / Limited monitoring tools
|
||||||
|
|
||||||
|
## 实现细节 / Implementation Details
|
||||||
|
|
||||||
|
- Publisher: 发布事件到 JetStream
|
||||||
|
- Subscriber: 订阅并处理事件
|
||||||
|
- Stream prefix: 可配置的流前缀
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# ADR-004: 使用 etcd 进行服务发现 / etcd for Service Discovery
|
||||||
|
|
||||||
|
## 状态 / Status
|
||||||
|
|
||||||
|
**Accepted**
|
||||||
|
|
||||||
|
**日期 / Date**: 2024-01-01
|
||||||
|
|
||||||
|
## 背景 / Context
|
||||||
|
|
||||||
|
平台依赖多个外部 gRPC 微服务(Git、Email),需要动态发现机制来连接这些服务。
|
||||||
|
|
||||||
|
The platform depends on multiple external gRPC microservices (Git, Email) and needs dynamic discovery to connect to them.
|
||||||
|
|
||||||
|
## 决策 / Decision
|
||||||
|
|
||||||
|
使用 **etcd** 进行服务发现和注册。
|
||||||
|
|
||||||
|
Use **etcd** for service discovery and registration.
|
||||||
|
|
||||||
|
## 考虑的方案 / Considered Options
|
||||||
|
|
||||||
|
1. **etcd** — Kubernetes 生态标准,强一致性
|
||||||
|
2. **Consul** — 功能丰富但较重
|
||||||
|
3. **ZooKeeper** — 经典但运维复杂
|
||||||
|
4. **静态配置** — 简单但不灵活
|
||||||
|
|
||||||
|
## 后果 / Consequences
|
||||||
|
|
||||||
|
### 正面 / Positive
|
||||||
|
|
||||||
|
- 与 Kubernetes 生态一致 / Aligned with Kubernetes ecosystem
|
||||||
|
- 强一致性保证 / Strong consistency guarantees
|
||||||
|
- 租约机制支持健康检查 / Lease mechanism supports health checks
|
||||||
|
- Watch 机制支持实时更新 / Watch mechanism supports real-time updates
|
||||||
|
|
||||||
|
### 负面 / Negative
|
||||||
|
|
||||||
|
- 额外的基础设施依赖 / Additional infrastructure dependency
|
||||||
|
- 运维复杂度增加 / Increased operational complexity
|
||||||
|
|
||||||
|
## 实现细节 / Implementation Details
|
||||||
|
|
||||||
|
- **注册**: `register.rs` — 自注册 + 租约保活
|
||||||
|
- **发现**: `discovery.rs` — Watch 动态连接
|
||||||
|
- **客户端**: tonic/prost gRPC 客户端
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR-005: 统一错误处理策略 / Unified Error Handling Strategy
|
||||||
|
|
||||||
|
## 状态 / Status
|
||||||
|
|
||||||
|
**Accepted**
|
||||||
|
|
||||||
|
**日期 / Date**: 2024-01-01
|
||||||
|
|
||||||
|
## 背景 / Context
|
||||||
|
|
||||||
|
平台需要统一的错误处理机制,确保错误信息对用户友好,同时保留足够的调试信息。
|
||||||
|
|
||||||
|
The platform needs a unified error handling mechanism that provides user-friendly error messages while retaining sufficient debugging information.
|
||||||
|
|
||||||
|
## 决策 / Decision
|
||||||
|
|
||||||
|
使用 **AppError 枚举 + AppResult 类型别名** 作为统一错误处理策略。
|
||||||
|
|
||||||
|
Use **AppError enum + AppResult type alias** as the unified error handling strategy.
|
||||||
|
|
||||||
|
## 考虑的方案 / Considered Options
|
||||||
|
|
||||||
|
1. **自定义枚举 (AppError)** — 类型安全、可扩展
|
||||||
|
2. **anyhow** — 简单但类型信息丢失
|
||||||
|
3. **thiserror + 手动实现** — 灵活但工作量大
|
||||||
|
|
||||||
|
## 后果 / Consequences
|
||||||
|
|
||||||
|
### 正面 / Positive
|
||||||
|
|
||||||
|
- 类型安全的错误处理 / Type-safe error handling
|
||||||
|
- 统一的错误响应格式 / Unified error response format
|
||||||
|
- 便于错误分类和监控 / Easy error classification and monitoring
|
||||||
|
- 与 actix-web 集成良好 / Good integration with actix-web
|
||||||
|
|
||||||
|
### 负面 / Negative
|
||||||
|
|
||||||
|
- 需要维护错误枚举 / Need to maintain error enum
|
||||||
|
- 新增错误类型需要更新枚举 / New error types require enum updates
|
||||||
|
|
||||||
|
## 实现细节 / Implementation Details
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// AppError 定义
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("User not found")]
|
||||||
|
UserNotFound,
|
||||||
|
#[error("Invalid password")]
|
||||||
|
InvalidPassword,
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Database(#[from] sqlx::Error),
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppResult 类型别名
|
||||||
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误码映射 / Error Code Mapping
|
||||||
|
|
||||||
|
| Postgres Code | 含义 | HTTP Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 23505 | 唯一约束违反 | 409 Conflict |
|
||||||
|
| 23503 | 外键约束违反 | 400 Bad Request |
|
||||||
|
| 23514 | 检查约束违反 | 400 Bad Request |
|
||||||
|
| 23502 | 非空约束违反 | 400 Bad Request |
|
||||||
|
| 23P01 | 排他约束违反 | 409 Conflict |
|
||||||
@@ -14,9 +14,6 @@ pub enum AppError {
|
|||||||
#[error("redis error: {0}")]
|
#[error("redis error: {0}")]
|
||||||
Redis(#[from] redis::RedisError),
|
Redis(#[from] redis::RedisError),
|
||||||
|
|
||||||
#[error("r2d2 error: {0}")]
|
|
||||||
R2d2(#[from] r2d2::Error),
|
|
||||||
|
|
||||||
#[error("json error: {0}")]
|
#[error("json error: {0}")]
|
||||||
Json(#[from] serde_json::Error),
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
@@ -131,6 +128,7 @@ impl actix_web::ResponseError for AppError {
|
|||||||
| AppError::InvalidEmailCode
|
| AppError::InvalidEmailCode
|
||||||
| AppError::RsaDecodeError
|
| AppError::RsaDecodeError
|
||||||
| AppError::RsaGenerationError => StatusCode::BAD_REQUEST,
|
| AppError::RsaGenerationError => StatusCode::BAD_REQUEST,
|
||||||
|
AppError::Database(e) => db_error_status_code(e),
|
||||||
AppError::PasswordHashError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
AppError::PasswordHashError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
@@ -139,6 +137,7 @@ impl actix_web::ResponseError for AppError {
|
|||||||
fn error_response(&self) -> HttpResponse {
|
fn error_response(&self) -> HttpResponse {
|
||||||
let status = self.status_code();
|
let status = self.status_code();
|
||||||
let message = if status == actix_web::http::StatusCode::INTERNAL_SERVER_ERROR {
|
let message = if status == actix_web::http::StatusCode::INTERNAL_SERVER_ERROR {
|
||||||
|
tracing::error!(?self, "internal server error");
|
||||||
"internal server error".to_string()
|
"internal server error".to_string()
|
||||||
} else {
|
} else {
|
||||||
self.to_string()
|
self.to_string()
|
||||||
@@ -146,3 +145,25 @@ impl actix_web::ResponseError for AppError {
|
|||||||
HttpResponse::build(status).json(serde_json::json!({ "error": message }))
|
HttpResponse::build(status).json(serde_json::json!({ "error": message }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn db_error_status_code(e: &sqlx::Error) -> actix_web::http::StatusCode {
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
match e {
|
||||||
|
sqlx::Error::Database(db_err) => {
|
||||||
|
match db_err.code().as_ref().map(|c| c.as_ref()) {
|
||||||
|
// unique_violation
|
||||||
|
Some("23505") => StatusCode::CONFLICT,
|
||||||
|
// foreign_key_violation
|
||||||
|
Some("23503") => StatusCode::CONFLICT,
|
||||||
|
// check_violation
|
||||||
|
Some("23514") => StatusCode::BAD_REQUEST,
|
||||||
|
// not_null_violation
|
||||||
|
Some("23502") => StatusCode::BAD_REQUEST,
|
||||||
|
// exclusion_violation
|
||||||
|
Some("23P01") => StatusCode::CONFLICT,
|
||||||
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+147
-22
@@ -5,18 +5,151 @@ use uuid::Uuid;
|
|||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::pb::{EmailClient, RepoClient};
|
use crate::pb::{EmailClient, RepoClient};
|
||||||
|
|
||||||
use super::types::ServiceInstance;
|
use super::types::{GitksPeerInfo, ServiceInstance};
|
||||||
use super::{EtcdRegistry, EtcdRegistryInner};
|
use super::{EtcdRegistry, EtcdRegistryInner, storage_name_to_uuid};
|
||||||
|
|
||||||
|
/// etcd prefix where gitks nodes register themselves (gitks::cluster::ClusterManager).
|
||||||
|
const GITKS_NODES_PREFIX: &str = "/gitks/nodes/";
|
||||||
|
|
||||||
impl EtcdRegistry {
|
impl EtcdRegistry {
|
||||||
pub async fn start_discovery(&self) -> AppResult<()> {
|
pub async fn start_discovery(&self) -> AppResult<()> {
|
||||||
self.load_initial("git").await?;
|
// Discover gitks nodes from gitks's own etcd prefix.
|
||||||
|
self.load_gitks_nodes().await?;
|
||||||
|
self.spawn_gitks_watch();
|
||||||
|
|
||||||
|
// Discover mail services from appks's service prefix.
|
||||||
|
if self.email_client.is_none() {
|
||||||
self.load_initial("mail").await?;
|
self.load_initial("mail").await?;
|
||||||
self.spawn_watch("git");
|
|
||||||
self.spawn_watch("mail");
|
self.spawn_watch("mail");
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Section: gitks node discovery (from /gitks/nodes/)
|
||||||
|
|
||||||
|
async fn load_gitks_nodes(&self) -> AppResult<()> {
|
||||||
|
let resp = {
|
||||||
|
let mut client = self.inner.client.lock().await;
|
||||||
|
client
|
||||||
|
.get(GITKS_NODES_PREFIX, Some(GetOptions::new().with_prefix()))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::Config(format!("etcd get {GITKS_NODES_PREFIX} failed: {e}"))
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
for kv in resp.kvs() {
|
||||||
|
let key = kv.key_str().unwrap_or_default();
|
||||||
|
let value = kv.value_str().unwrap_or_default();
|
||||||
|
if let Ok(peer) = serde_json::from_str::<GitksPeerInfo>(value) {
|
||||||
|
Self::upsert_gitks_node(&self.inner, key, &peer);
|
||||||
|
} else {
|
||||||
|
tracing::warn!(key = key, "failed to parse gitks peer info from etcd");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
prefix = GITKS_NODES_PREFIX,
|
||||||
|
count = self.inner.git_nodes.len(),
|
||||||
|
"gitks node discovery complete"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_gitks_watch(&self) {
|
||||||
|
let inner = self.inner.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match Self::gitks_watch_loop(&inner).await {
|
||||||
|
Ok(()) => break,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "gitks etcd watch disconnected, retrying in 3s");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn gitks_watch_loop(inner: &EtcdRegistryInner) -> AppResult<()> {
|
||||||
|
let mut stream = {
|
||||||
|
let mut client = inner.client.lock().await;
|
||||||
|
client
|
||||||
|
.watch(GITKS_NODES_PREFIX, Some(WatchOptions::new().with_prefix()))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::Config(format!("etcd watch {GITKS_NODES_PREFIX} failed: {e}"))
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Some(resp) = stream.next().await {
|
||||||
|
let resp =
|
||||||
|
resp.map_err(|e| AppError::Config(format!("gitks watch stream error: {e}")))?;
|
||||||
|
|
||||||
|
for event in resp.events() {
|
||||||
|
let Some(kv) = event.kv() else { continue };
|
||||||
|
let key = kv.key_str().unwrap_or_default();
|
||||||
|
|
||||||
|
match event.event_type() {
|
||||||
|
etcd_client::EventType::Put => {
|
||||||
|
let value = kv.value_str().unwrap_or_default();
|
||||||
|
if let Ok(peer) = serde_json::from_str::<GitksPeerInfo>(value) {
|
||||||
|
Self::upsert_gitks_node(inner, key, &peer);
|
||||||
|
tracing::info!(
|
||||||
|
storage_name = %peer.storage_name,
|
||||||
|
grpc_addr = %peer.grpc_addr,
|
||||||
|
"gitks node upserted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
etcd_client::EventType::Delete => {
|
||||||
|
let storage_name = key.strip_prefix(GITKS_NODES_PREFIX).unwrap_or(&key);
|
||||||
|
let node_id = storage_name_to_uuid(storage_name);
|
||||||
|
inner.git_nodes.remove(&node_id);
|
||||||
|
tracing::info!(storage_name = storage_name, "gitks node removed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_gitks_node(inner: &EtcdRegistryInner, key: &str, peer: &GitksPeerInfo) {
|
||||||
|
let node_id = storage_name_to_uuid(&peer.storage_name);
|
||||||
|
|
||||||
|
if peer.grpc_addr.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
storage_name = %peer.storage_name,
|
||||||
|
key = key,
|
||||||
|
"gitks peer has empty grpc_addr, skipping"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match RepoClient::lazy_connect(&peer.grpc_addr) {
|
||||||
|
Ok(client) => {
|
||||||
|
inner.git_nodes.insert(node_id, client);
|
||||||
|
tracing::debug!(
|
||||||
|
storage_name = %peer.storage_name,
|
||||||
|
node_id = %node_id,
|
||||||
|
grpc_addr = %peer.grpc_addr,
|
||||||
|
"gitks node connected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
storage_name = %peer.storage_name,
|
||||||
|
grpc_addr = %peer.grpc_addr,
|
||||||
|
error = %e,
|
||||||
|
"gitks node connect failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section: mail service discovery (from appks's own etcd prefix)
|
||||||
|
|
||||||
async fn load_initial(&self, service: &str) -> AppResult<()> {
|
async fn load_initial(&self, service: &str) -> AppResult<()> {
|
||||||
let prefix = self.service_prefix(service);
|
let prefix = self.service_prefix(service);
|
||||||
let resp = {
|
let resp = {
|
||||||
@@ -38,7 +171,7 @@ impl EtcdRegistry {
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
service = service,
|
service = service,
|
||||||
prefix = prefix.as_str(),
|
prefix = prefix.as_str(),
|
||||||
"etcd initial discovery complete"
|
"etcd mail discovery complete"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -66,7 +199,7 @@ impl EtcdRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn watch_loop(inner: &EtcdRegistryInner, prefix: &str, service: &str) -> AppResult<()> {
|
async fn watch_loop(inner: &EtcdRegistryInner, prefix: &str, service: &str) -> AppResult<()> {
|
||||||
let (mut watcher, mut stream) = {
|
let mut stream = {
|
||||||
let mut client = inner.client.lock().await;
|
let mut client = inner.client.lock().await;
|
||||||
client
|
client
|
||||||
.watch(prefix, Some(WatchOptions::new().with_prefix()))
|
.watch(prefix, Some(WatchOptions::new().with_prefix()))
|
||||||
@@ -74,8 +207,6 @@ impl EtcdRegistry {
|
|||||||
.map_err(|e| AppError::Config(format!("etcd watch {prefix} failed: {e}")))?
|
.map_err(|e| AppError::Config(format!("etcd watch {prefix} failed: {e}")))?
|
||||||
};
|
};
|
||||||
|
|
||||||
let _keep = &mut watcher;
|
|
||||||
|
|
||||||
while let Some(resp) = stream.next().await {
|
while let Some(resp) = stream.next().await {
|
||||||
let resp =
|
let resp =
|
||||||
resp.map_err(|e| AppError::Config(format!("etcd watch stream error: {e}")))?;
|
resp.map_err(|e| AppError::Config(format!("etcd watch stream error: {e}")))?;
|
||||||
@@ -89,12 +220,12 @@ impl EtcdRegistry {
|
|||||||
let value = kv.value_str().unwrap_or_default();
|
let value = kv.value_str().unwrap_or_default();
|
||||||
if let Ok(instance) = serde_json::from_str::<ServiceInstance>(value) {
|
if let Ok(instance) = serde_json::from_str::<ServiceInstance>(value) {
|
||||||
Self::upsert_instance(inner, service, key, &instance);
|
Self::upsert_instance(inner, service, key, &instance);
|
||||||
tracing::info!(service = service, key = key, "etcd service upserted");
|
tracing::info!(service = service, key = key, "mail service upserted");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
etcd_client::EventType::Delete => {
|
etcd_client::EventType::Delete => {
|
||||||
Self::remove_instance(inner, service, key);
|
Self::remove_instance(inner, service, key);
|
||||||
tracing::info!(service = service, key = key, "etcd service removed");
|
tracing::info!(service = service, key = key, "mail service removed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,20 +254,17 @@ impl EtcdRegistry {
|
|||||||
let addr = instance.addr.clone();
|
let addr = instance.addr.clone();
|
||||||
|
|
||||||
match service {
|
match service {
|
||||||
"git" => match RepoClient::lazy_connect(&addr) {
|
|
||||||
Ok(client) => {
|
|
||||||
inner.git_nodes.insert(node_id, client);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(key = key, addr = addr.as_str(), error = %e, "git client connect failed");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"mail" => match EmailClient::lazy_connect(&addr) {
|
"mail" => match EmailClient::lazy_connect(&addr) {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
inner.mail_nodes.insert(node_id, client);
|
inner.mail_nodes.insert(node_id, client);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(key = key, addr = addr.as_str(), error = %e, "mail client connect failed");
|
tracing::error!(
|
||||||
|
key = key,
|
||||||
|
addr = addr.as_str(),
|
||||||
|
error = %e,
|
||||||
|
"mail client connect failed"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -148,9 +276,6 @@ impl EtcdRegistry {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match service {
|
match service {
|
||||||
"git" => {
|
|
||||||
inner.git_nodes.remove(&node_id);
|
|
||||||
}
|
|
||||||
"mail" => {
|
"mail" => {
|
||||||
inner.mail_nodes.remove(&node_id);
|
inner.mail_nodes.remove(&node_id);
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-2
@@ -2,7 +2,7 @@ mod discovery;
|
|||||||
mod register;
|
mod register;
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
pub use types::ServiceInstance;
|
pub use types::{GitksPeerInfo, ServiceInstance};
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::AtomicI64;
|
use std::sync::atomic::AtomicI64;
|
||||||
@@ -19,6 +19,7 @@ use crate::pb::{EmailClient, RepoClient};
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct EtcdRegistry {
|
pub struct EtcdRegistry {
|
||||||
pub(crate) inner: Arc<EtcdRegistryInner>,
|
pub(crate) inner: Arc<EtcdRegistryInner>,
|
||||||
|
email_client: Option<EmailClient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct EtcdRegistryInner {
|
pub(crate) struct EtcdRegistryInner {
|
||||||
@@ -38,7 +39,7 @@ impl EtcdRegistry {
|
|||||||
let opts = etcd_client::ConnectOptions::new()
|
let opts = etcd_client::ConnectOptions::new()
|
||||||
.with_connect_timeout(std::time::Duration::from_secs(timeout));
|
.with_connect_timeout(std::time::Duration::from_secs(timeout));
|
||||||
|
|
||||||
let client = Client::connect(&endpoints, Some(opts))
|
let client = Client::connect(endpoints, Some(opts))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Config(format!("etcd connect failed: {e}")))?;
|
.map_err(|e| AppError::Config(format!("etcd connect failed: {e}")))?;
|
||||||
|
|
||||||
@@ -54,6 +55,25 @@ impl EtcdRegistry {
|
|||||||
|
|
||||||
let key_prefix = config.etcd_key_prefix()?;
|
let key_prefix = config.etcd_key_prefix()?;
|
||||||
|
|
||||||
|
let email_client = match config.email_rpc_addr()? {
|
||||||
|
Some(addr) if !addr.is_empty() => match EmailClient::lazy_connect(&addr) {
|
||||||
|
Ok(client) => {
|
||||||
|
tracing::info!(addr = %addr, "email client connected via APP_EMAIL_RPC_ADDR");
|
||||||
|
Some(client)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(addr = %addr, error = %e, "email client connect via APP_EMAIL_RPC_ADDR failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
tracing::info!(
|
||||||
|
"APP_EMAIL_RPC_ADDR not set, will fall back to etcd discovery for email"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: Arc::new(EtcdRegistryInner {
|
inner: Arc::new(EtcdRegistryInner {
|
||||||
client: Mutex::new(client),
|
client: Mutex::new(client),
|
||||||
@@ -63,6 +83,7 @@ impl EtcdRegistry {
|
|||||||
mail_nodes: DashMap::new(),
|
mail_nodes: DashMap::new(),
|
||||||
lease_id: AtomicI64::new(0),
|
lease_id: AtomicI64::new(0),
|
||||||
}),
|
}),
|
||||||
|
email_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +96,9 @@ impl EtcdRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_email_client(&self) -> Option<EmailClient> {
|
pub fn get_email_client(&self) -> Option<EmailClient> {
|
||||||
|
if let Some(ref client) = self.email_client {
|
||||||
|
return Some(client.clone());
|
||||||
|
}
|
||||||
self.inner
|
self.inner
|
||||||
.mail_nodes
|
.mail_nodes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -85,4 +109,18 @@ impl EtcdRegistry {
|
|||||||
pub fn has_git_nodes(&self) -> bool {
|
pub fn has_git_nodes(&self) -> bool {
|
||||||
!self.inner.git_nodes.is_empty()
|
!self.inner.git_nodes.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sort available gitks node UUIDs for deterministic selection.
|
||||||
|
pub fn git_node_ids_sorted(&self) -> Vec<Uuid> {
|
||||||
|
let mut ids: Vec<Uuid> = self.git_node_ids();
|
||||||
|
ids.sort();
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a deterministic UUID from a gitks storage_name.
|
||||||
|
/// Uses UUID v5 with DNS namespace so the same storage_name always
|
||||||
|
/// maps to the same UUID across all appks instances.
|
||||||
|
pub fn storage_name_to_uuid(storage_name: &str) -> Uuid {
|
||||||
|
Uuid::new_v5(&Uuid::NAMESPACE_DNS, storage_name.as_bytes())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,3 +8,19 @@ pub struct ServiceInstance {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub metadata: HashMap<String, String>,
|
pub metadata: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Information about a gitks peer node, registered in etcd under /gitks/nodes/.
|
||||||
|
/// Mirrors gitks::cluster::types::PeerInfo.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GitksPeerInfo {
|
||||||
|
/// Logical storage name (e.g. "node-a", "default")
|
||||||
|
pub storage_name: String,
|
||||||
|
/// ractor_cluster TCP address (e.g. "10.0.1.4:4697")
|
||||||
|
#[serde(default)]
|
||||||
|
pub cluster_addr: String,
|
||||||
|
/// gRPC service address (e.g. "http://10.0.1.4:50051")
|
||||||
|
pub grpc_addr: String,
|
||||||
|
/// Software version
|
||||||
|
#[serde(default)]
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|||||||
+4
-1
@@ -2,10 +2,13 @@ use appks::api::openapi::OpenApiDoc;
|
|||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
println!("Generating OpenAPI documentation...");
|
||||||
let json = OpenApiDoc::openapi().to_pretty_json();
|
let json = OpenApiDoc::openapi().to_pretty_json();
|
||||||
if let Ok(json) = json {
|
if let Ok(json) = json {
|
||||||
if let Err(e) = std::fs::write("openapi.json", json) {
|
if let Err(e) = std::fs::write("openapi.json", json) {
|
||||||
eprintln!("{}", e);
|
println!("Failed to write OpenAPI documentation. {}", e);
|
||||||
|
} else {
|
||||||
|
println!("OpenAPI documentation generated successfully.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
use tonic::{Request, Response, Status};
|
||||||
|
|
||||||
|
use crate::pb::im::internal_auth_service_server::InternalAuthService as InternalAuthServiceTrait;
|
||||||
|
use crate::pb::im::{AuthenticateRequest, AuthenticateResponse};
|
||||||
|
use crate::service::internal_auth::InternalAuthService;
|
||||||
|
|
||||||
|
pub struct InternalAuthGrpcService {
|
||||||
|
service: InternalAuthService,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InternalAuthGrpcService {
|
||||||
|
pub fn new(service: InternalAuthService) -> Self {
|
||||||
|
Self { service }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl InternalAuthServiceTrait for InternalAuthGrpcService {
|
||||||
|
async fn authenticate(
|
||||||
|
&self,
|
||||||
|
request: Request<AuthenticateRequest>,
|
||||||
|
) -> Result<Response<AuthenticateResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
if req.api_key.is_empty() {
|
||||||
|
return Ok(Response::new(AuthenticateResponse {
|
||||||
|
authenticated: false,
|
||||||
|
service_name: String::new(),
|
||||||
|
service_id: String::new(),
|
||||||
|
scopes: vec![],
|
||||||
|
expires_at: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.service.verify_api_key(&req.api_key).await {
|
||||||
|
Ok(Some(identity)) => Ok(Response::new(AuthenticateResponse {
|
||||||
|
authenticated: true,
|
||||||
|
service_name: identity.service_name,
|
||||||
|
service_id: identity.service_id,
|
||||||
|
scopes: identity.scopes,
|
||||||
|
expires_at: identity.expires_at,
|
||||||
|
})),
|
||||||
|
Ok(None) => Ok(Response::new(AuthenticateResponse {
|
||||||
|
authenticated: false,
|
||||||
|
service_name: String::new(),
|
||||||
|
service_id: String::new(),
|
||||||
|
scopes: vec![],
|
||||||
|
expires_at: 0,
|
||||||
|
})),
|
||||||
|
Err(e) => Err(Status::internal(format!("auth verification failed: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+443
@@ -0,0 +1,443 @@
|
|||||||
|
use tonic::{Request, Response, Status};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::channels::ChannelStats;
|
||||||
|
use crate::models::common::{ChannelKind, ChannelType, Visibility};
|
||||||
|
use crate::models::workspaces::Workspace;
|
||||||
|
use crate::pb::im::channel_service_server::ChannelService;
|
||||||
|
use crate::pb::im::{
|
||||||
|
CreateCategoryRequest, CreateCategoryResponse, CreateChannelRequest, CreateChannelResponse,
|
||||||
|
DeleteCategoryRequest, DeleteCategoryResponse, DeleteChannelRequest, DeleteChannelResponse,
|
||||||
|
GetChannelRequest, GetChannelResponse, GetChannelStatsRequest, GetChannelStatsResponse,
|
||||||
|
ListCategoriesRequest, ListCategoriesResponse, ListChannelsRequest, ListChannelsResponse,
|
||||||
|
UpdateCategoryRequest, UpdateCategoryResponse, UpdateChannelRequest, UpdateChannelResponse,
|
||||||
|
};
|
||||||
|
use crate::service::im::categories::{CreateCategoryParams, UpdateCategoryParams};
|
||||||
|
use crate::service::im::channels::{ChannelListFilters, CreateChannelParams, UpdateChannelParams};
|
||||||
|
use crate::service::im::session::ImSession;
|
||||||
|
use crate::service::AppService;
|
||||||
|
|
||||||
|
pub struct ChannelGrpcService {
|
||||||
|
service: AppService,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelGrpcService {
|
||||||
|
pub fn new(service: AppService) -> Self {
|
||||||
|
Self { service }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn system_session() -> ImSession {
|
||||||
|
ImSession::new(Uuid::nil())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, Status> {
|
||||||
|
Uuid::parse_str(s).map_err(|e| Status::invalid_argument(format!("{field}: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_workspace_name(&self, workspace_id: Uuid) -> Result<String, Status> {
|
||||||
|
Workspace::find_by_id(self.service.ctx.db.reader(), workspace_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?
|
||||||
|
.map(|w| w.name)
|
||||||
|
.ok_or_else(|| Status::not_found("workspace not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_proto_timestamp(dt: chrono::DateTime<chrono::Utc>) -> prost_types::Timestamp {
|
||||||
|
prost_types::Timestamp {
|
||||||
|
seconds: dt.timestamp(),
|
||||||
|
nanos: dt.timestamp_subsec_nanos() as i32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_channel_to_proto(c: crate::models::channels::Channel) -> crate::pb::im::Channel {
|
||||||
|
let channel_type = match c.channel_type {
|
||||||
|
ChannelType::Public => crate::pb::im::ChannelType::Public,
|
||||||
|
ChannelType::Private => crate::pb::im::ChannelType::Private,
|
||||||
|
ChannelType::Direct => crate::pb::im::ChannelType::Direct,
|
||||||
|
ChannelType::Group => crate::pb::im::ChannelType::Group,
|
||||||
|
ChannelType::Repo => crate::pb::im::ChannelType::Repo,
|
||||||
|
ChannelType::System => crate::pb::im::ChannelType::System,
|
||||||
|
ChannelType::Unknown => crate::pb::im::ChannelType::Unspecified,
|
||||||
|
};
|
||||||
|
|
||||||
|
let channel_kind = match c.channel_kind {
|
||||||
|
ChannelKind::Text => crate::pb::im::ChannelKind::Text,
|
||||||
|
ChannelKind::Voice => crate::pb::im::ChannelKind::Voice,
|
||||||
|
ChannelKind::Stage => crate::pb::im::ChannelKind::Stage,
|
||||||
|
ChannelKind::Forum => crate::pb::im::ChannelKind::Forum,
|
||||||
|
ChannelKind::Announcement => crate::pb::im::ChannelKind::Announcement,
|
||||||
|
ChannelKind::Unknown => crate::pb::im::ChannelKind::Unspecified,
|
||||||
|
};
|
||||||
|
|
||||||
|
let visibility = match c.visibility {
|
||||||
|
Visibility::Public => crate::pb::im::Visibility::Public,
|
||||||
|
Visibility::Private => crate::pb::im::Visibility::Private,
|
||||||
|
Visibility::Internal => crate::pb::im::Visibility::Internal,
|
||||||
|
Visibility::Workspace => crate::pb::im::Visibility::Workspace,
|
||||||
|
Visibility::Protected => crate::pb::im::Visibility::Protected,
|
||||||
|
Visibility::Hidden => crate::pb::im::Visibility::Hidden,
|
||||||
|
Visibility::Secret => crate::pb::im::Visibility::Secret,
|
||||||
|
Visibility::Unknown => crate::pb::im::Visibility::Unspecified,
|
||||||
|
};
|
||||||
|
|
||||||
|
crate::pb::im::Channel {
|
||||||
|
id: c.id.to_string(),
|
||||||
|
workspace_id: c.workspace_id.to_string(),
|
||||||
|
category_id: c.category_id.map(|id| id.to_string()),
|
||||||
|
parent_channel_id: c.parent_channel_id.map(|id| id.to_string()),
|
||||||
|
name: c.name,
|
||||||
|
topic: c.topic,
|
||||||
|
description: c.description,
|
||||||
|
channel_type: channel_type.into(),
|
||||||
|
channel_kind: channel_kind.into(),
|
||||||
|
visibility: visibility.into(),
|
||||||
|
position: c.position.unwrap_or(0),
|
||||||
|
nsfw: c.nsfw,
|
||||||
|
read_only: c.read_only,
|
||||||
|
archived: c.archived,
|
||||||
|
created_by: Some(c.created_by.to_string()),
|
||||||
|
rate_limit_per_user: c.rate_limit_per_user,
|
||||||
|
archived_at: c.archived_at.map(Self::to_proto_timestamp),
|
||||||
|
last_message_id: c.last_message_id.map(|id| id.to_string()),
|
||||||
|
last_message_at: c.last_message_at.map(Self::to_proto_timestamp),
|
||||||
|
created_at: Some(Self::to_proto_timestamp(c.created_at)),
|
||||||
|
updated_at: Some(Self::to_proto_timestamp(c.updated_at)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_category_to_proto(
|
||||||
|
c: crate::models::channels::ChannelCategory,
|
||||||
|
) -> crate::pb::im::ChannelCategory {
|
||||||
|
crate::pb::im::ChannelCategory {
|
||||||
|
id: c.id.to_string(),
|
||||||
|
workspace_id: c.workspace_id.to_string(),
|
||||||
|
name: c.name,
|
||||||
|
position: c.position,
|
||||||
|
collapsed: c.collapsed,
|
||||||
|
created_at: Some(Self::to_proto_timestamp(c.created_at)),
|
||||||
|
updated_at: Some(Self::to_proto_timestamp(c.updated_at)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_stats_to_proto(s: ChannelStats) -> crate::pb::im::ChannelStats {
|
||||||
|
crate::pb::im::ChannelStats {
|
||||||
|
channel_id: s.channel_id.to_string(),
|
||||||
|
members_count: s.members_count as i32,
|
||||||
|
messages_count: s.messages_count as i32,
|
||||||
|
threads_count: s.threads_count as i32,
|
||||||
|
reactions_count: s.reactions_count as i32,
|
||||||
|
mentions_count: s.mentions_count as i32,
|
||||||
|
files_count: s.files_count as i32,
|
||||||
|
last_activity_at: s.last_activity_at.map(Self::to_proto_timestamp),
|
||||||
|
updated_at: Some(Self::to_proto_timestamp(s.updated_at)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_category_workspace(&self, category_id: Uuid) -> Result<String, Status> {
|
||||||
|
let workspace_id: Uuid = sqlx::query_scalar(
|
||||||
|
"SELECT workspace_id FROM channel_category WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(category_id)
|
||||||
|
.fetch_optional(self.service.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?
|
||||||
|
.ok_or_else(|| Status::not_found("category not found"))?;
|
||||||
|
|
||||||
|
self.resolve_workspace_name(workspace_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl ChannelService for ChannelGrpcService {
|
||||||
|
async fn get_channel(
|
||||||
|
&self,
|
||||||
|
request: Request<GetChannelRequest>,
|
||||||
|
) -> Result<Response<GetChannelResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let session = Self::system_session();
|
||||||
|
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let wk_name = self.resolve_workspace_name(channel.workspace_id).await?;
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.channel_get(&session, &wk_name, channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(GetChannelResponse {
|
||||||
|
channel: Some(Self::model_channel_to_proto(channel)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_channels(
|
||||||
|
&self,
|
||||||
|
request: Request<ListChannelsRequest>,
|
||||||
|
) -> Result<Response<ListChannelsResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let session = Self::system_session();
|
||||||
|
|
||||||
|
let channel_type = req.channel_type()
|
||||||
|
.as_str_name()
|
||||||
|
.strip_prefix("CHANNEL_TYPE_")
|
||||||
|
.map(|s| s.to_lowercase())
|
||||||
|
.filter(|s| s != "unspecified");
|
||||||
|
let channel_kind = req.channel_kind()
|
||||||
|
.as_str_name()
|
||||||
|
.strip_prefix("CHANNEL_KIND_")
|
||||||
|
.map(|s| s.to_lowercase())
|
||||||
|
.filter(|s| s != "unspecified");
|
||||||
|
|
||||||
|
let filters = ChannelListFilters {
|
||||||
|
channel_type,
|
||||||
|
channel_kind,
|
||||||
|
category_id: req.category_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()),
|
||||||
|
archived: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let channels = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.channel_list(
|
||||||
|
&session,
|
||||||
|
&req.workspace_name,
|
||||||
|
filters,
|
||||||
|
req.limit as i64,
|
||||||
|
req.offset as i64,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let total = channels.len() as i32;
|
||||||
|
let proto_channels: Vec<_> = channels
|
||||||
|
.into_iter()
|
||||||
|
.map(Self::model_channel_to_proto)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Response::new(ListChannelsResponse {
|
||||||
|
channels: proto_channels,
|
||||||
|
total,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_channel(
|
||||||
|
&self,
|
||||||
|
request: Request<CreateChannelRequest>,
|
||||||
|
) -> Result<Response<CreateChannelResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let session = Self::system_session();
|
||||||
|
|
||||||
|
let params = CreateChannelParams {
|
||||||
|
name: req.name,
|
||||||
|
topic: req.topic,
|
||||||
|
description: req.description,
|
||||||
|
channel_type: req.channel_type,
|
||||||
|
channel_kind: req.channel_kind,
|
||||||
|
visibility: req.visibility,
|
||||||
|
category_id: req.category_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()),
|
||||||
|
parent_channel_id: req.parent_channel_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()),
|
||||||
|
nsfw: None,
|
||||||
|
rate_limit_per_user: req.rate_limit_per_user,
|
||||||
|
};
|
||||||
|
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.channel_create(&session, &req.workspace_name, params, Uuid::nil())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(CreateChannelResponse {
|
||||||
|
channel: Some(Self::model_channel_to_proto(channel)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_channel(
|
||||||
|
&self,
|
||||||
|
request: Request<UpdateChannelRequest>,
|
||||||
|
) -> Result<Response<UpdateChannelResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let session = Self::system_session();
|
||||||
|
|
||||||
|
let existing = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
let wk_name = self.resolve_workspace_name(existing.workspace_id).await?;
|
||||||
|
|
||||||
|
let params = UpdateChannelParams {
|
||||||
|
name: req.name,
|
||||||
|
topic: req.topic,
|
||||||
|
description: req.description,
|
||||||
|
visibility: req.visibility,
|
||||||
|
category_id: req.category_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()),
|
||||||
|
position: req.position,
|
||||||
|
nsfw: req.nsfw,
|
||||||
|
rate_limit_per_user: req.rate_limit_per_user,
|
||||||
|
archived: req.archived,
|
||||||
|
read_only: req.read_only,
|
||||||
|
};
|
||||||
|
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.channel_update(&session, &wk_name, channel_id, params, Uuid::nil())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(UpdateChannelResponse {
|
||||||
|
channel: Some(Self::model_channel_to_proto(channel)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_channel(
|
||||||
|
&self,
|
||||||
|
request: Request<DeleteChannelRequest>,
|
||||||
|
) -> Result<Response<DeleteChannelResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let session = Self::system_session();
|
||||||
|
|
||||||
|
let existing = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
let wk_name = self.resolve_workspace_name(existing.workspace_id).await?;
|
||||||
|
|
||||||
|
self.service
|
||||||
|
.im
|
||||||
|
.channel_delete(&session, &wk_name, channel_id, Uuid::nil())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(DeleteChannelResponse {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_channel_stats(
|
||||||
|
&self,
|
||||||
|
request: Request<GetChannelStatsRequest>,
|
||||||
|
) -> Result<Response<GetChannelStatsResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
|
||||||
|
let stats = sqlx::query_as::<_, ChannelStats>(
|
||||||
|
"SELECT * FROM channel_stats WHERE channel_id = $1",
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.fetch_optional(self.service.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
match stats {
|
||||||
|
Some(s) => Ok(Response::new(GetChannelStatsResponse {
|
||||||
|
stats: Some(Self::model_stats_to_proto(s)),
|
||||||
|
})),
|
||||||
|
None => Err(Status::not_found("Channel stats not found")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_categories(
|
||||||
|
&self,
|
||||||
|
request: Request<ListCategoriesRequest>,
|
||||||
|
) -> Result<Response<ListCategoriesResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let session = Self::system_session();
|
||||||
|
|
||||||
|
let categories = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.category_list(&session, &req.workspace_name)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let proto_categories: Vec<_> = categories
|
||||||
|
.into_iter()
|
||||||
|
.map(Self::model_category_to_proto)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Response::new(ListCategoriesResponse {
|
||||||
|
categories: proto_categories,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_category(
|
||||||
|
&self,
|
||||||
|
request: Request<CreateCategoryRequest>,
|
||||||
|
) -> Result<Response<CreateCategoryResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let session = Self::system_session();
|
||||||
|
|
||||||
|
let params = CreateCategoryParams {
|
||||||
|
name: req.name,
|
||||||
|
position: req.position,
|
||||||
|
};
|
||||||
|
|
||||||
|
let category = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.category_create(&session, &req.workspace_name, params)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(CreateCategoryResponse {
|
||||||
|
category: Some(Self::model_category_to_proto(category)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_category(
|
||||||
|
&self,
|
||||||
|
request: Request<UpdateCategoryRequest>,
|
||||||
|
) -> Result<Response<UpdateCategoryResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let category_id = Self::parse_uuid(&req.category_id, "category_id")?;
|
||||||
|
let session = Self::system_session();
|
||||||
|
let wk_name = self.resolve_category_workspace(category_id).await?;
|
||||||
|
|
||||||
|
let params = UpdateCategoryParams {
|
||||||
|
name: req.name,
|
||||||
|
position: req.position,
|
||||||
|
collapsed: req.collapsed,
|
||||||
|
};
|
||||||
|
|
||||||
|
let category = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.category_update(&session, &wk_name, category_id, params)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(UpdateCategoryResponse {
|
||||||
|
category: Some(Self::model_category_to_proto(category)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_category(
|
||||||
|
&self,
|
||||||
|
request: Request<DeleteCategoryRequest>,
|
||||||
|
) -> Result<Response<DeleteCategoryResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let category_id = Self::parse_uuid(&req.category_id, "category_id")?;
|
||||||
|
let session = Self::system_session();
|
||||||
|
let wk_name = self.resolve_category_workspace(category_id).await?;
|
||||||
|
|
||||||
|
self.service
|
||||||
|
.im
|
||||||
|
.category_delete(&session, &wk_name, category_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(DeleteCategoryResponse {}))
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+243
@@ -0,0 +1,243 @@
|
|||||||
|
use tonic::{Request, Response, Status};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::channels::ChannelMember;
|
||||||
|
use crate::pb::im::member_service_server::MemberService;
|
||||||
|
use crate::pb::im::{
|
||||||
|
ChannelMember as PbChannelMember, InviteMemberRequest, InviteMemberResponse,
|
||||||
|
IsMemberRequest, IsMemberResponse, JoinChannelRequest, JoinChannelResponse,
|
||||||
|
KickMemberRequest, KickMemberResponse, LeaveChannelRequest, LeaveChannelResponse,
|
||||||
|
ListMembersRequest, ListMembersResponse, UpdateMemberRequest, UpdateMemberResponse,
|
||||||
|
};
|
||||||
|
use crate::service::im::session::ImSession;
|
||||||
|
use crate::service::im::members::{InviteMemberParams, UpdateMemberParams};
|
||||||
|
use crate::service::AppService;
|
||||||
|
|
||||||
|
pub struct MemberGrpcService {
|
||||||
|
service: AppService,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemberGrpcService {
|
||||||
|
pub fn new(service: AppService) -> Self {
|
||||||
|
Self { service }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_workspace_name(&self, channel_id: Uuid) -> Result<String, Status> {
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let ws_name: String = sqlx::query_scalar("SELECT name FROM workspace WHERE id = $1")
|
||||||
|
.bind(channel.workspace_id)
|
||||||
|
.fetch_optional(self.service.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?
|
||||||
|
.ok_or_else(|| Status::not_found("workspace not found"))?;
|
||||||
|
|
||||||
|
Ok(ws_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, Status> {
|
||||||
|
Uuid::parse_str(s).map_err(|_| Status::invalid_argument(format!("invalid {}", field)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_timestamp(dt: chrono::DateTime<chrono::Utc>) -> prost_types::Timestamp {
|
||||||
|
prost_types::Timestamp {
|
||||||
|
seconds: dt.timestamp(),
|
||||||
|
nanos: dt.timestamp_subsec_nanos() as i32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_pb_member(m: ChannelMember) -> PbChannelMember {
|
||||||
|
PbChannelMember {
|
||||||
|
id: m.id.to_string(),
|
||||||
|
channel_id: m.channel_id.to_string(),
|
||||||
|
user_id: m.user_id.to_string(),
|
||||||
|
role: m.role.to_string(),
|
||||||
|
status: m.status.to_string(),
|
||||||
|
muted: m.muted,
|
||||||
|
pinned: m.pinned,
|
||||||
|
last_read_message_id: m.last_read_message_id.map(|id| id.to_string()),
|
||||||
|
last_read_at: m.last_read_at.map(Self::to_timestamp),
|
||||||
|
joined_at: m.joined_at.map(Self::to_timestamp),
|
||||||
|
left_at: m.left_at.map(Self::to_timestamp),
|
||||||
|
created_at: Some(Self::to_timestamp(m.created_at)),
|
||||||
|
updated_at: Some(Self::to_timestamp(m.updated_at)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl MemberService for MemberGrpcService {
|
||||||
|
async fn list_members(
|
||||||
|
&self,
|
||||||
|
request: Request<ListMembersRequest>,
|
||||||
|
) -> Result<Response<ListMembersResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let wk_name = self.resolve_workspace_name(channel_id).await?;
|
||||||
|
|
||||||
|
let session = ImSession::new(Uuid::nil());
|
||||||
|
let members = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.member_list(&session, &wk_name, channel_id, req.limit as i64, req.offset as i64)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let pb_members: Vec<PbChannelMember> = members.into_iter().map(Self::to_pb_member).collect();
|
||||||
|
let total = pb_members.len() as i32;
|
||||||
|
|
||||||
|
Ok(Response::new(ListMembersResponse {
|
||||||
|
members: pb_members,
|
||||||
|
total,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invite_member(
|
||||||
|
&self,
|
||||||
|
request: Request<InviteMemberRequest>,
|
||||||
|
) -> Result<Response<InviteMemberResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
let wk_name = self.resolve_workspace_name(channel_id).await?;
|
||||||
|
|
||||||
|
let params = InviteMemberParams {
|
||||||
|
user_id,
|
||||||
|
role: req.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = ImSession::new(Uuid::nil());
|
||||||
|
let member = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.member_invite(&session, &wk_name, channel_id, params)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(InviteMemberResponse {
|
||||||
|
member: Some(Self::to_pb_member(member)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_member(
|
||||||
|
&self,
|
||||||
|
request: Request<UpdateMemberRequest>,
|
||||||
|
) -> Result<Response<UpdateMemberResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
let wk_name = self.resolve_workspace_name(channel_id).await?;
|
||||||
|
|
||||||
|
let params = UpdateMemberParams {
|
||||||
|
role: req.role,
|
||||||
|
muted: req.muted,
|
||||||
|
pinned: req.pinned,
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = ImSession::new(Uuid::nil());
|
||||||
|
let member = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.member_update(&session, &wk_name, channel_id, user_id, params)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(UpdateMemberResponse {
|
||||||
|
member: Some(Self::to_pb_member(member)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn kick_member(
|
||||||
|
&self,
|
||||||
|
request: Request<KickMemberRequest>,
|
||||||
|
) -> Result<Response<KickMemberResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
let wk_name = self.resolve_workspace_name(channel_id).await?;
|
||||||
|
|
||||||
|
let session = ImSession::new(Uuid::nil());
|
||||||
|
self.service
|
||||||
|
.im
|
||||||
|
.member_kick(&session, &wk_name, channel_id, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(KickMemberResponse {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn join_channel(
|
||||||
|
&self,
|
||||||
|
request: Request<JoinChannelRequest>,
|
||||||
|
) -> Result<Response<JoinChannelResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
let wk_name = self.resolve_workspace_name(channel_id).await?;
|
||||||
|
|
||||||
|
let session = ImSession::new(user_id);
|
||||||
|
let member = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.member_join(&session, &wk_name, channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(JoinChannelResponse {
|
||||||
|
member: Some(Self::to_pb_member(member)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn leave_channel(
|
||||||
|
&self,
|
||||||
|
request: Request<LeaveChannelRequest>,
|
||||||
|
) -> Result<Response<LeaveChannelResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
let wk_name = self.resolve_workspace_name(channel_id).await?;
|
||||||
|
|
||||||
|
let session = ImSession::new(user_id);
|
||||||
|
self.service
|
||||||
|
.im
|
||||||
|
.member_leave(&session, &wk_name, channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(LeaveChannelResponse {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_member(
|
||||||
|
&self,
|
||||||
|
request: Request<IsMemberRequest>,
|
||||||
|
) -> Result<Response<IsMemberResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
|
||||||
|
let is_member = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.is_channel_member(channel_id, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let role = if is_member {
|
||||||
|
self.service
|
||||||
|
.im
|
||||||
|
.channel_member_role(channel_id, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Response::new(IsMemberResponse { is_member, role }))
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod channel;
|
||||||
|
pub mod channel_settings;
|
||||||
|
pub mod member;
|
||||||
|
pub mod permission;
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use crate::pb::im::channel_audit_service_server::ChannelAuditServiceServer;
|
||||||
|
use crate::pb::im::channel_invitation_service_server::ChannelInvitationServiceServer;
|
||||||
|
use crate::pb::im::channel_repo_link_service_server::ChannelRepoLinkServiceServer;
|
||||||
|
use crate::pb::im::channel_role_service_server::ChannelRoleServiceServer;
|
||||||
|
use crate::pb::im::channel_service_server::ChannelServiceServer;
|
||||||
|
use crate::pb::im::channel_slash_command_service_server::ChannelSlashCommandServiceServer;
|
||||||
|
use crate::pb::im::channel_webhook_service_server::ChannelWebhookServiceServer;
|
||||||
|
use crate::pb::im::custom_emoji_service_server::CustomEmojiServiceServer;
|
||||||
|
use crate::pb::im::forum_tag_service_server::ForumTagServiceServer;
|
||||||
|
use crate::pb::im::im_integration_service_server::ImIntegrationServiceServer;
|
||||||
|
use crate::pb::im::internal_auth_service_server::InternalAuthServiceServer;
|
||||||
|
use crate::pb::im::member_service_server::MemberServiceServer;
|
||||||
|
use crate::pb::im::permission_service_server::PermissionServiceServer;
|
||||||
|
use crate::pb::im::stage_service_server::StageServiceServer;
|
||||||
|
use crate::pb::im::voice_service_server::VoiceServiceServer;
|
||||||
|
use crate::service::AppService;
|
||||||
|
|
||||||
|
pub async fn start_grpc_server(
|
||||||
|
addr: SocketAddr,
|
||||||
|
service: AppService,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let auth_service = service.internal_auth.clone();
|
||||||
|
let channel_svc = channel::ChannelGrpcService::new(service.clone());
|
||||||
|
let member_svc = member::MemberGrpcService::new(service.clone());
|
||||||
|
let permission_svc = permission::PermissionGrpcService::new(service.clone());
|
||||||
|
let internal_auth_svc = auth::InternalAuthGrpcService::new(auth_service);
|
||||||
|
|
||||||
|
let cs = channel_settings::ChannelSettingsServices::new(service);
|
||||||
|
|
||||||
|
tracing::info!(%addr, "gRPC server listening");
|
||||||
|
|
||||||
|
tonic::transport::Server::builder()
|
||||||
|
.add_service(InternalAuthServiceServer::new(internal_auth_svc))
|
||||||
|
.add_service(ChannelServiceServer::new(channel_svc))
|
||||||
|
.add_service(MemberServiceServer::new(member_svc))
|
||||||
|
.add_service(PermissionServiceServer::new(permission_svc))
|
||||||
|
.add_service(ChannelRoleServiceServer::new(cs.channel_role))
|
||||||
|
.add_service(ChannelInvitationServiceServer::new(cs.channel_invitation))
|
||||||
|
.add_service(ChannelWebhookServiceServer::new(cs.channel_webhook))
|
||||||
|
.add_service(ChannelSlashCommandServiceServer::new(cs.channel_slash_command))
|
||||||
|
.add_service(ChannelRepoLinkServiceServer::new(cs.channel_repo_link))
|
||||||
|
.add_service(ImIntegrationServiceServer::new(cs.im_integration))
|
||||||
|
.add_service(CustomEmojiServiceServer::new(cs.custom_emoji))
|
||||||
|
.add_service(ForumTagServiceServer::new(cs.forum_tag))
|
||||||
|
.add_service(VoiceServiceServer::new(cs.voice))
|
||||||
|
.add_service(StageServiceServer::new(cs.stage))
|
||||||
|
.add_service(ChannelAuditServiceServer::new(cs.channel_audit))
|
||||||
|
.serve(addr)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
use tonic::{Request, Response, Status};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::channels::ChannelPermissionOverwrite;
|
||||||
|
use crate::models::common::{OverwriteTarget, Role};
|
||||||
|
use crate::pb::im::permission_service_server::PermissionService;
|
||||||
|
use crate::pb::im::{
|
||||||
|
CheckPermissionRequest, CheckPermissionResponse, DeletePermissionOverwriteRequest,
|
||||||
|
DeletePermissionOverwriteResponse, EnsureReadableRequest, EnsureReadableResponse,
|
||||||
|
GetPermissionOverwritesRequest, GetPermissionOverwritesResponse, GetPermissionsRequest,
|
||||||
|
GetPermissionsResponse, PermissionOverwrite, ResolveChannelRequest, ResolveChannelResponse,
|
||||||
|
SetPermissionOverwriteRequest, SetPermissionOverwriteResponse,
|
||||||
|
};
|
||||||
|
use crate::service::util::role_level;
|
||||||
|
use crate::service::AppService;
|
||||||
|
|
||||||
|
pub struct PermissionGrpcService {
|
||||||
|
service: AppService,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PermissionGrpcService {
|
||||||
|
pub fn new(service: AppService) -> Self {
|
||||||
|
Self { service }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, Status> {
|
||||||
|
Uuid::parse_str(s).map_err(|e| Status::invalid_argument(format!("{field}: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn im_permission_to_str(v: i32) -> &'static str {
|
||||||
|
match v {
|
||||||
|
1 => "READ_CHANNEL",
|
||||||
|
2 => "SEND_MESSAGE",
|
||||||
|
3 => "MANAGE_THREADS",
|
||||||
|
4 => "MANAGE_REACTIONS",
|
||||||
|
5 => "MANAGE_PINS",
|
||||||
|
6 => "INVITE_MEMBERS",
|
||||||
|
7 => "KICK_MEMBERS",
|
||||||
|
8 => "MANAGE_CHANNEL",
|
||||||
|
9 => "MANAGE_ROLES",
|
||||||
|
10 => "MANAGE_WEBHOOKS",
|
||||||
|
11 => "MANAGE_EMOJIS",
|
||||||
|
12 => "VIEW_AUDIT_LOG",
|
||||||
|
13 => "MANAGE_INTEGRATIONS",
|
||||||
|
14 => "SEND_TTS",
|
||||||
|
15 => "USE_SLASH_COMMANDS",
|
||||||
|
16 => "ATTACH_FILES",
|
||||||
|
17 => "MENTION_EVERYONE",
|
||||||
|
18 => "MANAGE_MESSAGES",
|
||||||
|
19 => "ADMIN",
|
||||||
|
_ => "UNSPECIFIED",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn str_to_im_permission(s: &str) -> i32 {
|
||||||
|
match s {
|
||||||
|
"READ_CHANNEL" => 1,
|
||||||
|
"SEND_MESSAGE" => 2,
|
||||||
|
"MANAGE_THREADS" => 3,
|
||||||
|
"MANAGE_REACTIONS" => 4,
|
||||||
|
"MANAGE_PINS" => 5,
|
||||||
|
"INVITE_MEMBERS" => 6,
|
||||||
|
"KICK_MEMBERS" => 7,
|
||||||
|
"MANAGE_CHANNEL" => 8,
|
||||||
|
"MANAGE_ROLES" => 9,
|
||||||
|
"MANAGE_WEBHOOKS" => 10,
|
||||||
|
"MANAGE_EMOJIS" => 11,
|
||||||
|
"VIEW_AUDIT_LOG" => 12,
|
||||||
|
"MANAGE_INTEGRATIONS" => 13,
|
||||||
|
"SEND_TTS" => 14,
|
||||||
|
"USE_SLASH_COMMANDS" => 15,
|
||||||
|
"ATTACH_FILES" => 16,
|
||||||
|
"MENTION_EVERYONE" => 17,
|
||||||
|
"MANAGE_MESSAGES" => 18,
|
||||||
|
"ADMIN" => 19,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permission_requires_role(p: i32) -> Role {
|
||||||
|
match p {
|
||||||
|
1 => Role::Viewer,
|
||||||
|
2 => Role::Member,
|
||||||
|
3 => Role::Member,
|
||||||
|
4 => Role::Member,
|
||||||
|
5 => Role::Moderator,
|
||||||
|
6 => Role::Moderator,
|
||||||
|
7 => Role::Moderator,
|
||||||
|
8 => Role::Admin,
|
||||||
|
9 => Role::Admin,
|
||||||
|
10 => Role::Admin,
|
||||||
|
11 => Role::Admin,
|
||||||
|
12 => Role::Moderator,
|
||||||
|
13 => Role::Admin,
|
||||||
|
14 => Role::Member,
|
||||||
|
15 => Role::Member,
|
||||||
|
16 => Role::Member,
|
||||||
|
17 => Role::Moderator,
|
||||||
|
18 => Role::Moderator,
|
||||||
|
19 => Role::Admin,
|
||||||
|
_ => Role::Owner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_to_permissions(role: Role) -> Vec<i32> {
|
||||||
|
let level = role_level(role);
|
||||||
|
let mut perms = Vec::new();
|
||||||
|
|
||||||
|
if level >= role_level(Role::Viewer) {
|
||||||
|
perms.push(1);
|
||||||
|
}
|
||||||
|
if level >= role_level(Role::Member) {
|
||||||
|
perms.extend_from_slice(&[2, 3, 4, 14, 15, 16]);
|
||||||
|
}
|
||||||
|
if level >= role_level(Role::Moderator) {
|
||||||
|
perms.extend_from_slice(&[5, 6, 7, 12, 17, 18]);
|
||||||
|
}
|
||||||
|
if level >= role_level(Role::Admin) {
|
||||||
|
perms.extend_from_slice(&[8, 9, 10, 11, 13, 19]);
|
||||||
|
}
|
||||||
|
|
||||||
|
perms.sort();
|
||||||
|
perms.dedup();
|
||||||
|
perms
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overwrite_to_proto(o: ChannelPermissionOverwrite) -> PermissionOverwrite {
|
||||||
|
PermissionOverwrite {
|
||||||
|
id: o.id.to_string(),
|
||||||
|
channel_id: o.channel_id.to_string(),
|
||||||
|
target_type: o.target_type.as_str().to_string(),
|
||||||
|
target_id: o.target_id.to_string(),
|
||||||
|
allow: o
|
||||||
|
.allow
|
||||||
|
.iter()
|
||||||
|
.map(|p| Self::str_to_im_permission(p))
|
||||||
|
.collect(),
|
||||||
|
deny: o
|
||||||
|
.deny
|
||||||
|
.iter()
|
||||||
|
.map(|p| Self::str_to_im_permission(p))
|
||||||
|
.collect(),
|
||||||
|
created_at: o.created_at.to_rfc3339(),
|
||||||
|
updated_at: o.updated_at.to_rfc3339(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl PermissionService for PermissionGrpcService {
|
||||||
|
async fn check_permission(
|
||||||
|
&self,
|
||||||
|
request: Request<CheckPermissionRequest>,
|
||||||
|
) -> Result<Response<CheckPermissionResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_uid = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::not_found(e.to_string()))?;
|
||||||
|
|
||||||
|
let role = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.channel_member_role(channel.id, user_uid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let required_role = Self::permission_requires_role(req.permission);
|
||||||
|
let allowed = role_level(role) >= role_level(required_role);
|
||||||
|
|
||||||
|
Ok(Response::new(CheckPermissionResponse {
|
||||||
|
allowed,
|
||||||
|
role: role.as_str().to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_permissions(
|
||||||
|
&self,
|
||||||
|
request: Request<GetPermissionsRequest>,
|
||||||
|
) -> Result<Response<GetPermissionsResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_uid = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::not_found(e.to_string()))?;
|
||||||
|
|
||||||
|
let role = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.channel_member_role(channel.id, user_uid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let permissions = Self::role_to_permissions(role);
|
||||||
|
|
||||||
|
Ok(Response::new(GetPermissionsResponse {
|
||||||
|
permissions,
|
||||||
|
role: role.as_str().to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_permission_overwrite(
|
||||||
|
&self,
|
||||||
|
request: Request<SetPermissionOverwriteRequest>,
|
||||||
|
) -> Result<Response<SetPermissionOverwriteResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let target_id = Self::parse_uuid(&req.target_id, "target_id")?;
|
||||||
|
let target_type: OverwriteTarget = req.target_type.parse().unwrap_or(OverwriteTarget::Unknown);
|
||||||
|
|
||||||
|
let allow: Vec<String> = req
|
||||||
|
.allow
|
||||||
|
.into_iter()
|
||||||
|
.map(|v| Self::im_permission_to_str(v).to_string())
|
||||||
|
.collect();
|
||||||
|
let deny: Vec<String> = req
|
||||||
|
.deny
|
||||||
|
.into_iter()
|
||||||
|
.map(|v| Self::im_permission_to_str(v).to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
|
||||||
|
let overwrite = sqlx::query_as::<_, ChannelPermissionOverwrite>(
|
||||||
|
"INSERT INTO channel_permission_overwrite \
|
||||||
|
(id, channel_id, target_type, target_id, allow, deny, created_by, created_at, updated_at) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
|
||||||
|
ON CONFLICT (channel_id, target_type, target_id) \
|
||||||
|
DO UPDATE SET allow = $5, deny = $6, updated_at = $9 \
|
||||||
|
RETURNING id, channel_id, target_type, target_id, allow, deny, created_by, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(target_type)
|
||||||
|
.bind(target_id)
|
||||||
|
.bind(&allow)
|
||||||
|
.bind(&deny)
|
||||||
|
.bind(Uuid::nil())
|
||||||
|
.bind(now)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_one(self.service.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(SetPermissionOverwriteResponse {
|
||||||
|
overwrite: Some(Self::overwrite_to_proto(overwrite)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_permission_overwrites(
|
||||||
|
&self,
|
||||||
|
request: Request<GetPermissionOverwritesRequest>,
|
||||||
|
) -> Result<Response<GetPermissionOverwritesResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
|
||||||
|
let overwrites = sqlx::query_as::<_, ChannelPermissionOverwrite>(
|
||||||
|
"SELECT id, channel_id, target_type, target_id, allow, deny, created_by, created_at, updated_at \
|
||||||
|
FROM channel_permission_overwrite WHERE channel_id = $1",
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.fetch_all(self.service.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let proto_overwrites: Vec<_> = overwrites
|
||||||
|
.into_iter()
|
||||||
|
.map(Self::overwrite_to_proto)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Response::new(GetPermissionOverwritesResponse {
|
||||||
|
overwrites: proto_overwrites,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_permission_overwrite(
|
||||||
|
&self,
|
||||||
|
request: Request<DeletePermissionOverwriteRequest>,
|
||||||
|
) -> Result<Response<DeletePermissionOverwriteResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let target_id = Self::parse_uuid(&req.target_id, "target_id")?;
|
||||||
|
let target_type: OverwriteTarget = req.target_type.parse().unwrap_or(OverwriteTarget::Unknown);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM channel_permission_overwrite \
|
||||||
|
WHERE channel_id = $1 AND target_type = $2 AND target_id = $3",
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(target_type)
|
||||||
|
.bind(target_id)
|
||||||
|
.execute(self.service.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(DeletePermissionOverwriteResponse {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_channel(
|
||||||
|
&self,
|
||||||
|
request: Request<ResolveChannelRequest>,
|
||||||
|
) -> Result<Response<ResolveChannelResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::not_found(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(ResolveChannelResponse {
|
||||||
|
channel_id: channel.id.to_string(),
|
||||||
|
workspace_id: channel.workspace_id.to_string(),
|
||||||
|
name: channel.name,
|
||||||
|
visibility: channel.visibility.as_str().to_string(),
|
||||||
|
channel_type: channel.channel_type.as_str().to_string(),
|
||||||
|
read_only: channel.read_only,
|
||||||
|
archived: channel.archived,
|
||||||
|
created_by: Some(channel.created_by.to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_readable(
|
||||||
|
&self,
|
||||||
|
request: Request<EnsureReadableRequest>,
|
||||||
|
) -> Result<Response<EnsureReadableResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_uid = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::not_found(e.to_string()))?;
|
||||||
|
|
||||||
|
let allowed = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.ensure_channel_readable(user_uid, &channel)
|
||||||
|
.await
|
||||||
|
.is_ok();
|
||||||
|
|
||||||
|
Ok(Response::new(EnsureReadableResponse { allowed }))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::queue::NatsQueue;
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
ArticleEvent, CategoryEvent, DraftEvent, FollowEvent, MemberEvent, MessageEvent, PollEvent,
|
|
||||||
PresenceEvent, ReactionEvent, ThreadEvent, TypingEvent, WsOutbound, WsSessionManager,
|
|
||||||
WsSinkManager,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct NatsWsBridge {
|
|
||||||
queue: Arc<NatsQueue>,
|
|
||||||
sessions: Arc<WsSessionManager>,
|
|
||||||
sinks: Arc<WsSinkManager>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NatsWsBridge {
|
|
||||||
pub fn new(
|
|
||||||
queue: Arc<NatsQueue>,
|
|
||||||
sessions: Arc<WsSessionManager>,
|
|
||||||
sinks: Arc<WsSinkManager>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
queue,
|
|
||||||
sessions,
|
|
||||||
sinks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run_ephemeral(self, subject: &str) {
|
|
||||||
let Ok(mut sub) = self.queue.subscribe_ephemeral(subject.to_string()).await else {
|
|
||||||
tracing::warn!(subject, "nats ws bridge subscribe failed");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
while let Some(msg) = sub.next().await {
|
|
||||||
self.dispatch(msg.subject.as_str(), msg.payload.as_ref(), request_id(&msg))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn dispatch(&self, subject: &str, payload: &[u8], request_id: Uuid) {
|
|
||||||
if subject.starts_with("im.message.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Message { request_id, data });
|
|
||||||
} else if subject.starts_with("im.thread.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Thread { request_id, data });
|
|
||||||
} else if subject.starts_with("im.member.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Member { request_id, data });
|
|
||||||
} else if subject.starts_with("im.reaction.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Reaction { request_id, data });
|
|
||||||
} else if subject.starts_with("im.poll.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Poll { request_id, data });
|
|
||||||
} else if subject.starts_with("im.article.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Article { request_id, data });
|
|
||||||
} else if subject.starts_with("im.typing.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Typing { request_id, data });
|
|
||||||
} else if subject.starts_with("im.presence.") {
|
|
||||||
self.presence_event(payload, request_id);
|
|
||||||
} else if subject.starts_with("im.channel.") {
|
|
||||||
self.channel_meta_event(subject, payload, request_id);
|
|
||||||
} else if subject.starts_with("im.category.") {
|
|
||||||
self.category_event(payload, request_id);
|
|
||||||
} else if subject.starts_with("im.draft.") {
|
|
||||||
self.draft_event(payload, request_id);
|
|
||||||
} else if subject.starts_with("im.follow.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Follow { request_id, data });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn channel_event<T, F>(&self, payload: &[u8], build: F)
|
|
||||||
where
|
|
||||||
T: serde::de::DeserializeOwned + ChannelScoped,
|
|
||||||
F: Fn(T) -> WsOutbound,
|
|
||||||
{
|
|
||||||
let Ok(data) = serde_json::from_slice::<T>(payload) else {
|
|
||||||
tracing::warn!("nats ws bridge decode channel event failed");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let channel_id = data.channel_id();
|
|
||||||
let subscribers = self.sessions.subscribers(channel_id);
|
|
||||||
let delivered = self.sinks.send_many(subscribers, build(data));
|
|
||||||
tracing::debug!(%channel_id, delivered, "nats event forwarded to ws subscribers");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn presence_event(&self, payload: &[u8], request_id: Uuid) {
|
|
||||||
let Ok(data) = serde_json::from_slice::<PresenceEvent>(payload) else {
|
|
||||||
tracing::warn!("nats ws bridge decode presence event failed");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let ids = self.sessions.user_connections(data.user_id);
|
|
||||||
let delivered = self
|
|
||||||
.sinks
|
|
||||||
.send_many(ids, WsOutbound::Presence { request_id, data });
|
|
||||||
tracing::debug!(delivered, "nats presence forwarded to ws subscribers");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn category_event(&self, payload: &[u8], request_id: Uuid) {
|
|
||||||
let Ok(data) = serde_json::from_slice::<CategoryEvent>(payload) else {
|
|
||||||
tracing::warn!("nats ws bridge decode category event failed");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let targets = self.sessions.workspace_connections(&data.workspace_name);
|
|
||||||
let delivered = self
|
|
||||||
.sinks
|
|
||||||
.send_many(targets, WsOutbound::Category { request_id, data });
|
|
||||||
tracing::debug!(delivered, "nats category event forwarded to ws subscribers");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draft_event(&self, payload: &[u8], request_id: Uuid) {
|
|
||||||
let Ok(data) = serde_json::from_slice::<DraftEvent>(payload) else {
|
|
||||||
tracing::warn!("nats ws bridge decode draft event failed");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let targets = self.sessions.user_connections(data.user_id);
|
|
||||||
let delivered = self
|
|
||||||
.sinks
|
|
||||||
.send_many(targets, WsOutbound::Draft { request_id, data });
|
|
||||||
tracing::debug!(delivered, "nats draft event forwarded to ws subscribers");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn channel_meta_event(&self, subject: &str, payload: &[u8], request_id: Uuid) {
|
|
||||||
let Ok(data) = serde_json::from_slice::<super::ChannelEvent>(payload) else {
|
|
||||||
tracing::warn!("nats ws bridge decode channel event failed");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let mut targets = data
|
|
||||||
.workspace_name
|
|
||||||
.as_deref()
|
|
||||||
.map(|workspace| self.sessions.workspace_connections(workspace))
|
|
||||||
.unwrap_or_else(|| self.sessions.subscribers(data.channel_id));
|
|
||||||
if targets.is_empty()
|
|
||||||
&& let Some(id) = subject
|
|
||||||
.rsplit('.')
|
|
||||||
.next()
|
|
||||||
.and_then(|v| v.parse::<Uuid>().ok())
|
|
||||||
{
|
|
||||||
targets = self.sessions.subscribers(id);
|
|
||||||
}
|
|
||||||
let delivered = self
|
|
||||||
.sinks
|
|
||||||
.send_many(targets, WsOutbound::Channel { request_id, data });
|
|
||||||
tracing::debug!(delivered, "nats channel event forwarded to ws subscribers");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait ChannelScoped {
|
|
||||||
fn channel_id(&self) -> Uuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelScoped for MessageEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl ChannelScoped for ThreadEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl ChannelScoped for MemberEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl ChannelScoped for ReactionEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl ChannelScoped for PollEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl ChannelScoped for ArticleEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl ChannelScoped for TypingEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl ChannelScoped for FollowEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request_id(msg: &async_nats::Message) -> Uuid {
|
|
||||||
msg.headers
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|h| h.get("X-Request-Id"))
|
|
||||||
.and_then(|v| v.as_str().parse().ok())
|
|
||||||
.unwrap_or_else(Uuid::nil)
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::cache::redis::AppRedis;
|
|
||||||
use crate::error::AppResult;
|
|
||||||
use ::redis::Cmd;
|
|
||||||
|
|
||||||
use super::redis_keys::*;
|
|
||||||
|
|
||||||
pub struct DedupManager {
|
|
||||||
redis: AppRedis,
|
|
||||||
window_secs: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DedupManager {
|
|
||||||
pub fn new(redis: AppRedis) -> Self {
|
|
||||||
Self {
|
|
||||||
redis,
|
|
||||||
window_secs: WS_DEDUP_WINDOW_SECS,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_and_mark(&self, message_id: Uuid, channel_id: Uuid) -> AppResult<bool> {
|
|
||||||
let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let result: Option<String> = Cmd::new()
|
|
||||||
.arg("SET")
|
|
||||||
.arg(&key)
|
|
||||||
.arg("1")
|
|
||||||
.arg("NX")
|
|
||||||
.arg("EX")
|
|
||||||
.arg(self.window_secs)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(crate::error::AppError::Redis)?;
|
|
||||||
Ok(result.is_some())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_duplicate(&self, message_id: Uuid, channel_id: Uuid) -> AppResult<bool> {
|
|
||||||
let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let exists: bool = Cmd::new()
|
|
||||||
.arg("EXISTS")
|
|
||||||
.arg(&key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(crate::error::AppError::Redis)?;
|
|
||||||
Ok(exists)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear(&self, message_id: Uuid, channel_id: Uuid) -> AppResult<()> {
|
|
||||||
let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
Cmd::new()
|
|
||||||
.arg("DEL")
|
|
||||||
.arg(&key)
|
|
||||||
.query::<()>(&mut *conn.inner_mut())
|
|
||||||
.map_err(crate::error::AppError::Redis)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TransportEnvelope<T> {
|
|
||||||
#[serde(default = "Uuid::now_v7")]
|
|
||||||
pub message_id: Uuid,
|
|
||||||
pub request_id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub payload: T,
|
|
||||||
#[serde(default = "default_timestamp")]
|
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub attempt: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_timestamp() -> chrono::DateTime<chrono::Utc> {
|
|
||||||
chrono::Utc::now()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> TransportEnvelope<T> {
|
|
||||||
pub fn new(request_id: Uuid, user_id: Uuid, payload: T) -> Self {
|
|
||||||
Self {
|
|
||||||
message_id: Uuid::now_v7(),
|
|
||||||
request_id,
|
|
||||||
user_id,
|
|
||||||
payload,
|
|
||||||
created_at: chrono::Utc::now(),
|
|
||||||
attempt: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn retry(self) -> Self {
|
|
||||||
Self {
|
|
||||||
attempt: self.attempt + 1,
|
|
||||||
..self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::immediate::dedup::DedupManager;
|
|
||||||
use crate::immediate::limiter::HandlerLimiter;
|
|
||||||
use crate::immediate::nats::ImNats;
|
|
||||||
use crate::immediate::outbound::*;
|
|
||||||
use crate::immediate::rate_limit::{LocalRateLimiter, RateLimiter};
|
|
||||||
use crate::immediate::reconnect::ReconnectManager;
|
|
||||||
use crate::immediate::session::{WsSession, WsSessionManager};
|
|
||||||
use crate::service::ImService;
|
|
||||||
use crate::service::im::messages::EditMessageParams;
|
|
||||||
use crate::service::im::messages::SendMessageParams;
|
|
||||||
use crate::service::im::presence::UpdatePresenceParams;
|
|
||||||
use crate::service::im::session::ImSession;
|
|
||||||
|
|
||||||
use super::inbound::WsInbound;
|
|
||||||
use super::redis_keys::*;
|
|
||||||
use super::sink::WsSinkManager;
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WsHandler {
|
|
||||||
nats: Arc<ImNats>,
|
|
||||||
manager: Arc<WsSessionManager>,
|
|
||||||
sinks: Arc<WsSinkManager>,
|
|
||||||
service: ImService,
|
|
||||||
dedup: Arc<DedupManager>,
|
|
||||||
rate_limiter: Arc<RateLimiter>,
|
|
||||||
local_limiter: Arc<LocalRateLimiter>,
|
|
||||||
handler_limiter: Arc<HandlerLimiter>,
|
|
||||||
reconnect: Arc<ReconnectManager>,
|
|
||||||
session: Option<WsSession>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl WsHandler {
|
|
||||||
pub fn new(
|
|
||||||
manager: Arc<WsSessionManager>,
|
|
||||||
sinks: Arc<WsSinkManager>,
|
|
||||||
service: ImService,
|
|
||||||
nats: Arc<ImNats>,
|
|
||||||
dedup: Arc<DedupManager>,
|
|
||||||
rate_limiter: Arc<RateLimiter>,
|
|
||||||
reconnect: Arc<ReconnectManager>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
nats,
|
|
||||||
manager,
|
|
||||||
sinks,
|
|
||||||
service,
|
|
||||||
dedup,
|
|
||||||
rate_limiter,
|
|
||||||
local_limiter: Arc::new(LocalRateLimiter::new(WS_MAX_MESSAGES_PER_SEC)),
|
|
||||||
handler_limiter: Arc::new(HandlerLimiter::new(1024)),
|
|
||||||
reconnect,
|
|
||||||
session: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn session(&self) -> Option<&WsSession> {
|
|
||||||
self.session.as_ref()
|
|
||||||
}
|
|
||||||
pub fn is_authenticated(&self) -> bool {
|
|
||||||
self.session.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_disconnect(&self) {
|
|
||||||
if let Some(s) = &self.session
|
|
||||||
&& let Err(e) = self.manager.unregister_connection(s)
|
|
||||||
{
|
|
||||||
tracing::warn!(conn = %s.connection_id, error = %e, "unregister failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle(&mut self, msg: WsInbound) -> Vec<WsOutbound> {
|
|
||||||
match msg {
|
|
||||||
WsInbound::Auth { request_id, token } => self.handle_auth(request_id, token).await,
|
|
||||||
m => {
|
|
||||||
let Some(s) = &self.session else {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id: request_id_of(&m),
|
|
||||||
code: "not_authenticated".into(),
|
|
||||||
message: "authenticate first".into(),
|
|
||||||
}];
|
|
||||||
};
|
|
||||||
if !self.manager.is_deliverable(s.connection_id) {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id: request_id_of(&m),
|
|
||||||
code: "session_not_active".into(),
|
|
||||||
message: "session is not active".into(),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
let Ok(_permit) = self.handler_limiter.try_acquire() else {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id: request_id_of(&m),
|
|
||||||
code: "overloaded".into(),
|
|
||||||
message: "too many inflight messages".into(),
|
|
||||||
}];
|
|
||||||
};
|
|
||||||
if !self.local_limiter.check() {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id: request_id_of(&m),
|
|
||||||
code: "rate_limit_exceeded".into(),
|
|
||||||
message: "too many messages".into(),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
match self.rate_limiter.check(s.connection_id) {
|
|
||||||
Ok(true) => {}
|
|
||||||
Ok(false) => {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id: request_id_of(&m),
|
|
||||||
code: "rate_limit_exceeded".into(),
|
|
||||||
message: "too many messages".into(),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(error = %e, "rate limit check failed"),
|
|
||||||
}
|
|
||||||
self.dispatch(s, m).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn dispatch(&self, session: &WsSession, msg: WsInbound) -> Vec<WsOutbound> {
|
|
||||||
match msg {
|
|
||||||
WsInbound::Heartbeat { request_id } => {
|
|
||||||
if let Err(e) = self.manager.heartbeat(session) {
|
|
||||||
tracing::warn!(user = %session.user_id, error = %e, "heartbeat failed");
|
|
||||||
}
|
|
||||||
vec![WsOutbound::HeartbeatAck {
|
|
||||||
request_id,
|
|
||||||
timestamp_ms: chrono::Utc::now().timestamp_millis(),
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
WsInbound::JoinChannel {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
} => match self.service.resolve_channel(channel_id).await {
|
|
||||||
Ok(channel) => match self
|
|
||||||
.service
|
|
||||||
.ensure_channel_readable(session.user_id, &channel)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => {
|
|
||||||
self.manager
|
|
||||||
.subscribe_channel(session.connection_id, channel_id);
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
Err(e) => vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "join_channel_failed".into(),
|
|
||||||
message: e.to_string(),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
Err(e) => vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "join_channel_failed".into(),
|
|
||||||
message: e.to_string(),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
WsInbound::LeaveChannel {
|
|
||||||
request_id: _,
|
|
||||||
channel_id,
|
|
||||||
} => {
|
|
||||||
self.manager
|
|
||||||
.unsubscribe_channel(session.connection_id, channel_id);
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
WsInbound::TypingStart {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
thread_id,
|
|
||||||
} => {
|
|
||||||
let _ = self
|
|
||||||
.manager
|
|
||||||
.set_typing(channel_id, thread_id, session.user_id);
|
|
||||||
self.nats
|
|
||||||
.emit(
|
|
||||||
&ImNats::typing_subject(channel_id),
|
|
||||||
request_id,
|
|
||||||
&TypingEvent {
|
|
||||||
channel_id,
|
|
||||||
thread_id,
|
|
||||||
user_id: session.user_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
WsInbound::TypingStop {
|
|
||||||
request_id: _,
|
|
||||||
channel_id,
|
|
||||||
thread_id,
|
|
||||||
} => {
|
|
||||||
let _ = self
|
|
||||||
.manager
|
|
||||||
.clear_typing(channel_id, thread_id, session.user_id);
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
WsInbound::MessageSend {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
body,
|
|
||||||
thread_id,
|
|
||||||
reply_to,
|
|
||||||
message_type,
|
|
||||||
} => {
|
|
||||||
if body.len() > WS_MAX_MESSAGE_BYTES {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "message_too_large".into(),
|
|
||||||
message: "message body too large".into(),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
match self.dedup.check_and_mark(request_id, channel_id) {
|
|
||||||
Ok(true) => {}
|
|
||||||
Ok(false) => {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "duplicate".into(),
|
|
||||||
message: "duplicate message".into(),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(error = %e, "dedup check failed"),
|
|
||||||
}
|
|
||||||
let ctx = ImSession::new(session.user_id);
|
|
||||||
let params = SendMessageParams {
|
|
||||||
body,
|
|
||||||
message_type,
|
|
||||||
thread_id,
|
|
||||||
reply_to_message_id: reply_to,
|
|
||||||
pinned: None,
|
|
||||||
attachments: None,
|
|
||||||
embeds: None,
|
|
||||||
};
|
|
||||||
match self
|
|
||||||
.service
|
|
||||||
.message_send(
|
|
||||||
&ctx,
|
|
||||||
&session.workspace_name,
|
|
||||||
channel_id,
|
|
||||||
params,
|
|
||||||
request_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(msg) => vec![WsOutbound::SeqAck {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
seq: msg.seq,
|
|
||||||
}],
|
|
||||||
Err(e) => {
|
|
||||||
if let Err(clear_err) = self.dedup.clear(request_id, channel_id) {
|
|
||||||
tracing::warn!(error = %clear_err, "dedup clear failed after message send error");
|
|
||||||
}
|
|
||||||
vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "message_send_failed".into(),
|
|
||||||
message: e.to_string(),
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WsInbound::MessageEdit {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
message_id,
|
|
||||||
body,
|
|
||||||
} => {
|
|
||||||
if body.len() > WS_MAX_MESSAGE_BYTES {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "message_too_large".into(),
|
|
||||||
message: "message body too large".into(),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
let ctx = ImSession::new(session.user_id);
|
|
||||||
let params = EditMessageParams { body };
|
|
||||||
match self
|
|
||||||
.service
|
|
||||||
.message_edit(
|
|
||||||
&ctx,
|
|
||||||
&session.workspace_name,
|
|
||||||
channel_id,
|
|
||||||
message_id,
|
|
||||||
params,
|
|
||||||
request_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => vec![],
|
|
||||||
Err(e) => vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "message_edit_failed".into(),
|
|
||||||
message: e.to_string(),
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WsInbound::MessageDelete {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
message_id,
|
|
||||||
} => {
|
|
||||||
let ctx = ImSession::new(session.user_id);
|
|
||||||
match self
|
|
||||||
.service
|
|
||||||
.message_delete(
|
|
||||||
&ctx,
|
|
||||||
&session.workspace_name,
|
|
||||||
channel_id,
|
|
||||||
message_id,
|
|
||||||
request_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => vec![],
|
|
||||||
Err(e) => vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "message_delete_failed".into(),
|
|
||||||
message: e.to_string(),
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WsInbound::PresenceUpdate {
|
|
||||||
request_id,
|
|
||||||
status,
|
|
||||||
custom_status_text,
|
|
||||||
custom_status_emoji,
|
|
||||||
} => {
|
|
||||||
let ctx = ImSession::new(session.user_id);
|
|
||||||
let params = UpdatePresenceParams {
|
|
||||||
status,
|
|
||||||
custom_status_text: custom_status_text.clone(),
|
|
||||||
custom_status_emoji: custom_status_emoji.clone(),
|
|
||||||
};
|
|
||||||
match self
|
|
||||||
.service
|
|
||||||
.presence_update(&ctx, &session.workspace_name, params)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(p) => {
|
|
||||||
self.nats
|
|
||||||
.emit(
|
|
||||||
&ImNats::presence_subject(session.user_id),
|
|
||||||
request_id,
|
|
||||||
&PresenceEvent {
|
|
||||||
user_id: session.user_id,
|
|
||||||
status: p.status.to_string(),
|
|
||||||
custom_status_text,
|
|
||||||
custom_status_emoji,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
Err(e) => vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "presence_update_failed".into(),
|
|
||||||
message: e.to_string(),
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WsInbound::ReadReceipt {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
last_read_message_id,
|
|
||||||
last_seq,
|
|
||||||
} => {
|
|
||||||
if let Some(seq) = last_seq
|
|
||||||
&& let Err(e) =
|
|
||||||
self.reconnect
|
|
||||||
.save_read_position(session.user_id, channel_id, seq)
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "save read position failed");
|
|
||||||
}
|
|
||||||
vec![WsOutbound::ReadReceiptAck {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
last_read_message_id,
|
|
||||||
last_seq,
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
WsInbound::Auth { .. } => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close_replaced_connection(&self, old_id: Uuid, new_id: Uuid) {
|
|
||||||
let _ = self.sinks.send(
|
|
||||||
old_id,
|
|
||||||
WsOutbound::Error {
|
|
||||||
request_id: Uuid::nil(),
|
|
||||||
code: "session_replaced".into(),
|
|
||||||
message: format!("session replaced by {new_id}"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.sinks.detach(old_id);
|
|
||||||
if let Some(old) = self.manager.get_session(old_id)
|
|
||||||
&& let Err(e) = self.manager.unregister_connection(&old)
|
|
||||||
{
|
|
||||||
tracing::warn!(conn = %old_id, error = %e, "unregister replaced connection failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_auth(&mut self, request_id: Uuid, token: String) -> Vec<WsOutbound> {
|
|
||||||
match self.manager.redeem_token(&token) {
|
|
||||||
Ok(session) => {
|
|
||||||
match self.manager.register_connection_with_replacement(&session) {
|
|
||||||
Ok(Some(old_id)) => {
|
|
||||||
self.close_replaced_connection(old_id, session.connection_id)
|
|
||||||
}
|
|
||||||
Ok(None) => {}
|
|
||||||
Err(e) => tracing::warn!(error = %e, "register connection failed"),
|
|
||||||
}
|
|
||||||
let cid = session.connection_id;
|
|
||||||
let interval = self.manager.heartbeat_interval_secs();
|
|
||||||
self.session = Some(session);
|
|
||||||
vec![WsOutbound::AuthOk {
|
|
||||||
request_id,
|
|
||||||
connection_id: cid,
|
|
||||||
heartbeat_interval_secs: interval,
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
Err(e) => vec![WsOutbound::AuthError {
|
|
||||||
request_id,
|
|
||||||
message: e.to_string(),
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn request_id_of(msg: &WsInbound) -> Uuid {
|
|
||||||
match msg {
|
|
||||||
WsInbound::Auth { request_id, .. } => *request_id,
|
|
||||||
WsInbound::Heartbeat { request_id } => *request_id,
|
|
||||||
WsInbound::JoinChannel { request_id, .. } => *request_id,
|
|
||||||
WsInbound::LeaveChannel { request_id, .. } => *request_id,
|
|
||||||
WsInbound::TypingStart { request_id, .. } => *request_id,
|
|
||||||
WsInbound::TypingStop { request_id, .. } => *request_id,
|
|
||||||
WsInbound::MessageSend { request_id, .. } => *request_id,
|
|
||||||
WsInbound::MessageEdit { request_id, .. } => *request_id,
|
|
||||||
WsInbound::MessageDelete { request_id, .. } => *request_id,
|
|
||||||
WsInbound::PresenceUpdate { request_id, .. } => *request_id,
|
|
||||||
WsInbound::ReadReceipt { request_id, .. } => *request_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum WsInbound {
|
|
||||||
Auth {
|
|
||||||
request_id: Uuid,
|
|
||||||
token: String,
|
|
||||||
},
|
|
||||||
Heartbeat {
|
|
||||||
request_id: Uuid,
|
|
||||||
},
|
|
||||||
JoinChannel {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
},
|
|
||||||
LeaveChannel {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
},
|
|
||||||
TypingStart {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
},
|
|
||||||
TypingStop {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
},
|
|
||||||
MessageSend {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
body: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
reply_to: Option<Uuid>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
message_type: Option<String>,
|
|
||||||
},
|
|
||||||
MessageEdit {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
body: String,
|
|
||||||
},
|
|
||||||
MessageDelete {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
},
|
|
||||||
PresenceUpdate {
|
|
||||||
request_id: Uuid,
|
|
||||||
status: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
custom_status_text: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
custom_status_emoji: Option<String>,
|
|
||||||
},
|
|
||||||
ReadReceipt {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
last_read_message_id: Uuid,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
last_seq: Option<i64>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
|
|
||||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct HandlerLimitError;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct HandlerLimiter {
|
|
||||||
sem: Arc<Semaphore>,
|
|
||||||
max_inflight: usize,
|
|
||||||
rejected: Arc<AtomicU64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HandlerLimiter {
|
|
||||||
pub fn new(max_inflight: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
sem: Arc::new(Semaphore::new(max_inflight)),
|
|
||||||
max_inflight,
|
|
||||||
rejected: Arc::new(AtomicU64::new(0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn try_acquire(&self) -> Result<OwnedSemaphorePermit, HandlerLimitError> {
|
|
||||||
match self.sem.clone().try_acquire_owned() {
|
|
||||||
Ok(permit) => Ok(permit),
|
|
||||||
Err(_) => {
|
|
||||||
self.rejected.fetch_add(1, Ordering::Relaxed);
|
|
||||||
Err(HandlerLimitError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn inflight(&self) -> usize {
|
|
||||||
self.max_inflight - self.sem.available_permits()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn available(&self) -> usize {
|
|
||||||
self.sem.available_permits()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn rejected_total(&self) -> u64 {
|
|
||||||
self.rejected.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
mod bridge;
|
|
||||||
mod dedup;
|
|
||||||
mod envelope;
|
|
||||||
mod handler;
|
|
||||||
mod inbound;
|
|
||||||
mod limiter;
|
|
||||||
mod nats;
|
|
||||||
mod outbound;
|
|
||||||
mod rate_limit;
|
|
||||||
mod reconnect;
|
|
||||||
mod redis_keys;
|
|
||||||
mod runtime;
|
|
||||||
mod seq;
|
|
||||||
mod session;
|
|
||||||
mod session_redis;
|
|
||||||
mod sink;
|
|
||||||
mod typing;
|
|
||||||
|
|
||||||
pub use bridge::NatsWsBridge;
|
|
||||||
pub use dedup::DedupManager;
|
|
||||||
pub use envelope::TransportEnvelope;
|
|
||||||
pub use inbound::WsInbound;
|
|
||||||
pub use limiter::HandlerLimiter;
|
|
||||||
pub use nats::ImNats;
|
|
||||||
pub use outbound::{
|
|
||||||
ArticleAction, ArticleEvent, CategoryAction, CategoryEvent, ChannelAction, ChannelEvent,
|
|
||||||
DraftAction, DraftEvent, FollowAction, FollowEvent, MemberAction, MemberEvent, MessageAction,
|
|
||||||
MessageEvent, PollAction, PollEvent, PresenceEvent, ReactionAction, ReactionEvent,
|
|
||||||
ThreadAction, ThreadEvent, TypingEvent, WsOutbound,
|
|
||||||
};
|
|
||||||
pub use rate_limit::{LocalRateLimiter, RateLimiter};
|
|
||||||
pub use reconnect::ReconnectManager;
|
|
||||||
pub use runtime::WsRuntime;
|
|
||||||
pub use seq::SeqAllocator;
|
|
||||||
pub use session::{WsSession, WsSessionManager, WsSessionState};
|
|
||||||
pub use sink::{WsReceiver, WsSender, WsSinkManager};
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::queue::NatsQueue;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ImNats {
|
|
||||||
inner: Arc<NatsQueue>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImNats {
|
|
||||||
pub fn new(nats: Arc<NatsQueue>) -> Self {
|
|
||||||
Self { inner: nats }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn emit<T: Serialize>(&self, subject: &str, request_id: Uuid, event: &T) {
|
|
||||||
if let Err(e) = self
|
|
||||||
.inner
|
|
||||||
.publish_with_headers(
|
|
||||||
subject,
|
|
||||||
&serde_json::to_vec(event).unwrap_or_default(),
|
|
||||||
vec![("X-Request-Id".into(), request_id.to_string())],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(subject, error = %e, "nats emit failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn channel_subject(channel_id: Uuid) -> String {
|
|
||||||
format!("im.channel.{channel_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn message_subject(channel_id: Uuid) -> String {
|
|
||||||
format!("im.message.{channel_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn thread_subject(channel_id: Uuid, thread_id: Uuid) -> String {
|
|
||||||
format!("im.thread.{channel_id}.{thread_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn member_subject(channel_id: Uuid) -> String {
|
|
||||||
format!("im.member.{channel_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn reaction_subject(channel_id: Uuid) -> String {
|
|
||||||
format!("im.reaction.{channel_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn typing_subject(channel_id: Uuid) -> String {
|
|
||||||
format!("im.typing.{channel_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn presence_subject(user_id: Uuid) -> String {
|
|
||||||
format!("im.presence.{user_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn poll_subject(channel_id: Uuid) -> String {
|
|
||||||
format!("im.poll.{channel_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn article_subject(channel_id: Uuid) -> String {
|
|
||||||
format!("im.article.{channel_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn workspace_channels_subject(workspace_name: &str) -> String {
|
|
||||||
format!("im.ws_channels.{workspace_name}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum WsOutbound {
|
|
||||||
AuthOk {
|
|
||||||
request_id: Uuid,
|
|
||||||
connection_id: Uuid,
|
|
||||||
heartbeat_interval_secs: u64,
|
|
||||||
},
|
|
||||||
AuthError {
|
|
||||||
request_id: Uuid,
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
HeartbeatAck {
|
|
||||||
request_id: Uuid,
|
|
||||||
timestamp_ms: i64,
|
|
||||||
},
|
|
||||||
Error {
|
|
||||||
request_id: Uuid,
|
|
||||||
code: String,
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
Typing {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: TypingEvent,
|
|
||||||
},
|
|
||||||
Presence {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: PresenceEvent,
|
|
||||||
},
|
|
||||||
Message {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: MessageEvent,
|
|
||||||
},
|
|
||||||
Channel {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: ChannelEvent,
|
|
||||||
},
|
|
||||||
Thread {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: ThreadEvent,
|
|
||||||
},
|
|
||||||
Member {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: MemberEvent,
|
|
||||||
},
|
|
||||||
Reaction {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: ReactionEvent,
|
|
||||||
},
|
|
||||||
Poll {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: PollEvent,
|
|
||||||
},
|
|
||||||
Article {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: ArticleEvent,
|
|
||||||
},
|
|
||||||
Category {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: CategoryEvent,
|
|
||||||
},
|
|
||||||
Draft {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: DraftEvent,
|
|
||||||
},
|
|
||||||
Follow {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: FollowEvent,
|
|
||||||
},
|
|
||||||
ReadReceiptAck {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
last_read_message_id: Uuid,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
last_seq: Option<i64>,
|
|
||||||
},
|
|
||||||
SeqAck {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
seq: i64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TypingEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub thread_id: Option<Uuid>,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PresenceEvent {
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub status: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub custom_status_text: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub custom_status_emoji: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MessageEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub thread_id: Option<Uuid>,
|
|
||||||
pub message_id: Uuid,
|
|
||||||
pub author_id: Uuid,
|
|
||||||
pub action: MessageAction,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub body: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub seq: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum MessageAction {
|
|
||||||
Created,
|
|
||||||
Edited,
|
|
||||||
Deleted,
|
|
||||||
Pinned,
|
|
||||||
Unpinned,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ChannelEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub action: ChannelAction,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub workspace_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum ChannelAction {
|
|
||||||
Created,
|
|
||||||
Updated,
|
|
||||||
Deleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ThreadEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub thread_id: Uuid,
|
|
||||||
pub action: ThreadAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum ThreadAction {
|
|
||||||
Created,
|
|
||||||
Updated,
|
|
||||||
Deleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MemberEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub action: MemberAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum MemberAction {
|
|
||||||
Joined,
|
|
||||||
Left,
|
|
||||||
Kicked,
|
|
||||||
Updated,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ReactionEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub message_id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub action: ReactionAction,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub content: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum ReactionAction {
|
|
||||||
Added,
|
|
||||||
Removed,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PollEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub poll_id: Uuid,
|
|
||||||
pub action: PollAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum PollAction {
|
|
||||||
Created,
|
|
||||||
Voted,
|
|
||||||
Deleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ArticleEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub article_id: Uuid,
|
|
||||||
pub action: ArticleAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum ArticleAction {
|
|
||||||
Created,
|
|
||||||
Updated,
|
|
||||||
Published,
|
|
||||||
Unpublished,
|
|
||||||
Deleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CategoryEvent {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub category_id: Uuid,
|
|
||||||
pub action: CategoryAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum CategoryAction {
|
|
||||||
Created,
|
|
||||||
Updated,
|
|
||||||
Deleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct DraftEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub thread_id: Option<Uuid>,
|
|
||||||
pub action: DraftAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum DraftAction {
|
|
||||||
Saved,
|
|
||||||
Deleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct FollowEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub follow_id: Uuid,
|
|
||||||
pub action: FollowAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum FollowAction {
|
|
||||||
Created,
|
|
||||||
Deleted,
|
|
||||||
Retried,
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::cache::redis::AppRedis;
|
|
||||||
use crate::error::AppResult;
|
|
||||||
use ::redis::Cmd;
|
|
||||||
|
|
||||||
use super::redis_keys::*;
|
|
||||||
|
|
||||||
pub struct RateLimiter {
|
|
||||||
redis: AppRedis,
|
|
||||||
max_per_sec: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RateLimiter {
|
|
||||||
pub fn new(redis: AppRedis) -> Self {
|
|
||||||
Self {
|
|
||||||
redis,
|
|
||||||
max_per_sec: WS_MAX_MESSAGES_PER_SEC,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_limit(redis: AppRedis, max_per_sec: u32) -> Self {
|
|
||||||
Self { redis, max_per_sec }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check(&self, connection_id: Uuid) -> AppResult<bool> {
|
|
||||||
let key = format!("{WS_RATE_PREFIX}{connection_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let count: i64 = Cmd::new()
|
|
||||||
.arg("INCR")
|
|
||||||
.arg(&key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(crate::error::AppError::Redis)?;
|
|
||||||
if count == 1 {
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("EXPIRE")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(1_u64)
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
}
|
|
||||||
Ok(count <= self.max_per_sec as i64)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_sliding(&self, connection_id: Uuid) -> AppResult<bool> {
|
|
||||||
let key = format!("{WS_RATE_PREFIX}{connection_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let count: i64 = Cmd::new()
|
|
||||||
.arg("INCR")
|
|
||||||
.arg(&key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(crate::error::AppError::Redis)?;
|
|
||||||
if count == 1 {
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("EXPIRE")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(2_u64)
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
}
|
|
||||||
Ok(count <= self.max_per_sec as i64)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remaining(&self, connection_id: Uuid) -> AppResult<u32> {
|
|
||||||
let key = format!("{WS_RATE_PREFIX}{connection_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let count: Option<i64> = Cmd::new()
|
|
||||||
.arg("GET")
|
|
||||||
.arg(&key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(crate::error::AppError::Redis)?;
|
|
||||||
Ok(self.max_per_sec.saturating_sub(count.unwrap_or(0) as u32))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct LocalRateLimiter {
|
|
||||||
count: std::sync::atomic::AtomicU32,
|
|
||||||
start: std::sync::Mutex<Instant>,
|
|
||||||
max_per_sec: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LocalRateLimiter {
|
|
||||||
pub fn new(max_per_sec: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
count: std::sync::atomic::AtomicU32::new(0),
|
|
||||||
start: std::sync::Mutex::new(Instant::now()),
|
|
||||||
max_per_sec,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check(&self) -> bool {
|
|
||||||
let mut start = self.start.lock().unwrap();
|
|
||||||
if start.elapsed().as_secs() >= 1 {
|
|
||||||
self.count.store(0, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
*start = Instant::now();
|
|
||||||
}
|
|
||||||
drop(start);
|
|
||||||
self.count
|
|
||||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
|
||||||
< self.max_per_sec
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::cache::redis::AppRedis;
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use ::redis::Cmd;
|
|
||||||
|
|
||||||
use super::redis_keys::*;
|
|
||||||
|
|
||||||
pub struct ReconnectManager {
|
|
||||||
redis: AppRedis,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReconnectManager {
|
|
||||||
pub fn new(redis: AppRedis) -> Self {
|
|
||||||
Self { redis }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save_read_position(&self, user_id: Uuid, channel_id: Uuid, seq: i64) -> AppResult<()> {
|
|
||||||
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
Cmd::new()
|
|
||||||
.arg("SETEX")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(WS_RECONNECT_STATE_TTL_SECS)
|
|
||||||
.arg(seq.to_string())
|
|
||||||
.query::<()>(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save_read_positions(
|
|
||||||
&self,
|
|
||||||
user_id: Uuid,
|
|
||||||
positions: &HashMap<Uuid, i64>,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
for (channel_id, seq) in positions {
|
|
||||||
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
|
|
||||||
Cmd::new()
|
|
||||||
.arg("SETEX")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(WS_RECONNECT_STATE_TTL_SECS)
|
|
||||||
.arg(seq.to_string())
|
|
||||||
.query::<()>(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_last_seq(&self, user_id: Uuid, channel_id: Uuid) -> AppResult<Option<i64>> {
|
|
||||||
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let val: Option<String> = Cmd::new()
|
|
||||||
.arg("GET")
|
|
||||||
.arg(&key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
Ok(val.and_then(|v| v.parse().ok()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_all_positions(&self, user_id: Uuid) -> AppResult<HashMap<Uuid, i64>> {
|
|
||||||
let pattern = format!("{WS_RECONNECT_PREFIX}{user_id}:*");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let keys: Vec<String> = Cmd::new()
|
|
||||||
.arg("KEYS")
|
|
||||||
.arg(&pattern)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
let mut result = HashMap::new();
|
|
||||||
let prefix_len = format!("{WS_RECONNECT_PREFIX}{user_id}:").len();
|
|
||||||
for key in &keys {
|
|
||||||
if let Some(channel_str) = key.get(prefix_len..)
|
|
||||||
&& let Ok(channel_id) = channel_str.parse::<Uuid>()
|
|
||||||
{
|
|
||||||
let val: Option<String> = Cmd::new()
|
|
||||||
.arg("GET")
|
|
||||||
.arg(key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
if let Some(v) = val
|
|
||||||
&& let Ok(seq) = v.parse::<i64>()
|
|
||||||
{
|
|
||||||
result.insert(channel_id, seq);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cleanup_channel(&self, user_id: Uuid, channel_id: Uuid) -> AppResult<()> {
|
|
||||||
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("DEL")
|
|
||||||
.arg(&key)
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#![allow(dead_code)]
|
|
||||||
pub const WS_TOKEN_PREFIX: &str = "im:ws:token:";
|
|
||||||
pub const WS_ONLINE_PREFIX: &str = "im:ws:online:";
|
|
||||||
pub const WS_CONNS_PREFIX: &str = "im:ws:conns:";
|
|
||||||
pub const WS_SEQ_PREFIX: &str = "im:seq:";
|
|
||||||
pub const WS_DEDUP_PREFIX: &str = "im:dedup:";
|
|
||||||
pub const WS_RATE_PREFIX: &str = "im:rate:";
|
|
||||||
pub const WS_RECONNECT_PREFIX: &str = "im:reconnect:";
|
|
||||||
|
|
||||||
pub const WS_TOKEN_TTL_SECS: u64 = 30;
|
|
||||||
pub const WS_ONLINE_TTL_SECS: u64 = 60;
|
|
||||||
pub const WS_HEARTBEAT_INTERVAL_SECS: u64 = 30;
|
|
||||||
pub const WS_HEARTBEAT_TIMEOUT_SECS: u64 = 60;
|
|
||||||
pub const WS_MAX_IDLE_SECS: u64 = 300;
|
|
||||||
pub const WS_MAX_MESSAGE_BYTES: usize = 64 * 1024;
|
|
||||||
pub const WS_MAX_MESSAGES_PER_SEC: u32 = 100;
|
|
||||||
pub const WS_SEQ_SEGMENT_SIZE: u64 = 1024;
|
|
||||||
pub const WS_DEDUP_WINDOW_SECS: u64 = 300;
|
|
||||||
pub const WS_RECONNECT_STATE_TTL_SECS: u64 = 86400;
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::queue::NatsQueue;
|
|
||||||
|
|
||||||
use super::{NatsWsBridge, WsReceiver, WsSender, WsSessionManager, WsSinkManager};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WsRuntime {
|
|
||||||
sessions: Arc<WsSessionManager>,
|
|
||||||
sinks: Arc<WsSinkManager>,
|
|
||||||
bridge: NatsWsBridge,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WsRuntime {
|
|
||||||
pub fn new(queue: Arc<NatsQueue>, sessions: Arc<WsSessionManager>) -> Self {
|
|
||||||
let sinks = Arc::new(WsSinkManager::new());
|
|
||||||
let bridge = NatsWsBridge::new(queue, sessions.clone(), sinks.clone());
|
|
||||||
Self {
|
|
||||||
sessions,
|
|
||||||
sinks,
|
|
||||||
bridge,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sinks(&self) -> Arc<WsSinkManager> {
|
|
||||||
self.sinks.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sessions(&self) -> Arc<WsSessionManager> {
|
|
||||||
self.sessions.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn attach(&self, connection_id: Uuid) -> WsReceiver {
|
|
||||||
let (tx, rx): (WsSender, WsReceiver) = WsSinkManager::channel();
|
|
||||||
self.sinks.attach(connection_id, tx);
|
|
||||||
rx
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn detach(&self, connection_id: Uuid) {
|
|
||||||
self.sinks.detach(connection_id);
|
|
||||||
self.sessions.unsubscribe_all(connection_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_nats_bridge(&self) {
|
|
||||||
let bridge = self.bridge.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
bridge.run_ephemeral("im.>").await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicI64, Ordering};
|
|
||||||
|
|
||||||
use dashmap::DashMap;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::cache::redis::AppRedis;
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use ::redis::Cmd;
|
|
||||||
|
|
||||||
use super::redis_keys::*;
|
|
||||||
|
|
||||||
struct Segment {
|
|
||||||
end: i64,
|
|
||||||
next: AtomicI64,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SeqAllocator {
|
|
||||||
redis: AppRedis,
|
|
||||||
segments: DashMap<Uuid, Arc<Segment>>,
|
|
||||||
locks: DashMap<Uuid, Arc<Mutex<()>>>,
|
|
||||||
segment_size: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_RETRIES: u32 = 3;
|
|
||||||
|
|
||||||
impl SeqAllocator {
|
|
||||||
pub fn new(redis: AppRedis) -> Self {
|
|
||||||
Self {
|
|
||||||
redis,
|
|
||||||
segments: DashMap::new(),
|
|
||||||
locks: DashMap::new(),
|
|
||||||
segment_size: WS_SEQ_SEGMENT_SIZE,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn next(&self, channel_id: Uuid) -> AppResult<i64> {
|
|
||||||
for _ in 0..MAX_RETRIES {
|
|
||||||
if let Some(seq) = self.try_allocate(&channel_id) {
|
|
||||||
return Ok(seq);
|
|
||||||
}
|
|
||||||
let lock = self
|
|
||||||
.locks
|
|
||||||
.entry(channel_id)
|
|
||||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
|
||||||
.clone();
|
|
||||||
let _guard = lock.lock().await;
|
|
||||||
if let Some(seq) = self.try_allocate(&channel_id) {
|
|
||||||
return Ok(seq);
|
|
||||||
}
|
|
||||||
self.refresh(channel_id).await?;
|
|
||||||
}
|
|
||||||
Err(AppError::InternalServerError(
|
|
||||||
"seq allocation exhausted retries".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn bootstrap(&self, channel_id: Uuid, db_max: i64) -> AppResult<i64> {
|
|
||||||
let key = format!("{WS_SEQ_PREFIX}{channel_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let current: i64 = Cmd::new()
|
|
||||||
.arg("SET")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(db_max)
|
|
||||||
.arg("NX")
|
|
||||||
.arg("EX")
|
|
||||||
.arg(86400)
|
|
||||||
.query::<Option<String>>(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
let existing: i64 = Cmd::new()
|
|
||||||
.arg("GET")
|
|
||||||
.arg(&key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)
|
|
||||||
.unwrap_or(db_max);
|
|
||||||
if existing < db_max { db_max } else { existing }
|
|
||||||
});
|
|
||||||
self.segments.remove(&channel_id);
|
|
||||||
Ok(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_allocate(&self, channel_id: &Uuid) -> Option<i64> {
|
|
||||||
let state = self.segments.get(channel_id)?;
|
|
||||||
let next = state.next.fetch_add(1, Ordering::Relaxed);
|
|
||||||
if next < state.end { Some(next) } else { None }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn refresh(&self, channel_id: Uuid) -> AppResult<()> {
|
|
||||||
let key = format!("{WS_SEQ_PREFIX}{channel_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let counter: i64 = Cmd::new()
|
|
||||||
.arg("INCRBY")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(self.segment_size as i64)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
|
|
||||||
let start = counter - self.segment_size as i64 + 1;
|
|
||||||
let end = counter + 1;
|
|
||||||
self.segments.insert(
|
|
||||||
channel_id,
|
|
||||||
Arc::new(Segment {
|
|
||||||
end,
|
|
||||||
next: AtomicI64::new(start),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("EXPIRE")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(86400_u64)
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use dashmap::DashMap;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::cache::redis::AppRedis;
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use crate::queue::NatsQueue;
|
|
||||||
use ::redis::Cmd;
|
|
||||||
|
|
||||||
use super::redis_keys::*;
|
|
||||||
use super::session_redis::{heartbeat_redis, register_redis_online, unregister_redis_online};
|
|
||||||
use super::typing;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum WsSessionState {
|
|
||||||
Connecting,
|
|
||||||
Authenticated,
|
|
||||||
Replaced,
|
|
||||||
Closing,
|
|
||||||
Closed,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WsSessionState {
|
|
||||||
pub fn is_deliverable(self) -> bool {
|
|
||||||
matches!(self, Self::Authenticated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct WsSession {
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub device_id: String,
|
|
||||||
pub connection_id: Uuid,
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub connected_at: i64,
|
|
||||||
pub authenticated_at: Option<i64>,
|
|
||||||
pub state: WsSessionState,
|
|
||||||
pub superseded_by: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WsSessionManager {
|
|
||||||
redis: AppRedis,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
nats: Arc<NatsQueue>,
|
|
||||||
user_devices: Arc<DashMap<Uuid, HashMap<String, Uuid>>>,
|
|
||||||
sessions: Arc<DashMap<Uuid, WsSession>>,
|
|
||||||
channel_routes: Arc<DashMap<Uuid, HashSet<Uuid>>>,
|
|
||||||
session_channels: Arc<DashMap<Uuid, HashSet<Uuid>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WsSessionManager {
|
|
||||||
pub fn new(redis: AppRedis, nats: Arc<NatsQueue>) -> Self {
|
|
||||||
Self {
|
|
||||||
redis,
|
|
||||||
nats,
|
|
||||||
user_devices: Arc::new(DashMap::new()),
|
|
||||||
sessions: Arc::new(DashMap::new()),
|
|
||||||
channel_routes: Arc::new(DashMap::new()),
|
|
||||||
session_channels: Arc::new(DashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn issue_token(&self, user_id: Uuid, workspace_name: &str) -> AppResult<String> {
|
|
||||||
self.issue_token_for_device(user_id, workspace_name, "default")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn issue_token_for_device(
|
|
||||||
&self,
|
|
||||||
user_id: Uuid,
|
|
||||||
workspace_name: &str,
|
|
||||||
device_id: &str,
|
|
||||||
) -> AppResult<String> {
|
|
||||||
let token = format!("ws_{}", Uuid::now_v7());
|
|
||||||
let session = WsSession {
|
|
||||||
user_id,
|
|
||||||
device_id: device_id.to_string(),
|
|
||||||
connection_id: Uuid::nil(),
|
|
||||||
workspace_name: workspace_name.to_string(),
|
|
||||||
connected_at: 0,
|
|
||||||
authenticated_at: None,
|
|
||||||
state: WsSessionState::Connecting,
|
|
||||||
superseded_by: None,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&session)?;
|
|
||||||
let key = format!("{WS_TOKEN_PREFIX}{token}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
Cmd::new()
|
|
||||||
.arg("SETEX")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(WS_TOKEN_TTL_SECS)
|
|
||||||
.arg(&json)
|
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
|
||||||
Ok(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn redeem_token(&self, token: &str) -> AppResult<WsSession> {
|
|
||||||
let key = format!("{WS_TOKEN_PREFIX}{token}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let json: Option<String> = Cmd::new()
|
|
||||||
.arg("GETDEL")
|
|
||||||
.arg(&key)
|
|
||||||
.query::<Option<String>>(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
let json = json.ok_or(AppError::Unauthorized)?;
|
|
||||||
let mut session: WsSession = serde_json::from_str(&json)
|
|
||||||
.map_err(|e| AppError::Config(format!("invalid ws session: {e}")))?;
|
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
|
||||||
session.connection_id = Uuid::now_v7();
|
|
||||||
session.connected_at = now;
|
|
||||||
session.authenticated_at = Some(now);
|
|
||||||
session.state = WsSessionState::Authenticated;
|
|
||||||
session.superseded_by = None;
|
|
||||||
Ok(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn register_connection(&self, session: &WsSession) -> AppResult<()> {
|
|
||||||
let _ = self.register_connection_with_replacement(session)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn register_connection_with_replacement(
|
|
||||||
&self,
|
|
||||||
session: &WsSession,
|
|
||||||
) -> AppResult<Option<Uuid>> {
|
|
||||||
let mut current = session.clone();
|
|
||||||
current.state = WsSessionState::Authenticated;
|
|
||||||
current.superseded_by = None;
|
|
||||||
self.sessions.insert(current.connection_id, current.clone());
|
|
||||||
|
|
||||||
let replaced = {
|
|
||||||
let mut entry = self.user_devices.entry(current.user_id).or_default();
|
|
||||||
entry.insert(current.device_id.clone(), current.connection_id)
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(old_id) = replaced
|
|
||||||
&& old_id != current.connection_id
|
|
||||||
{
|
|
||||||
if let Some(mut old) = self.sessions.get_mut(&old_id) {
|
|
||||||
old.state = WsSessionState::Replaced;
|
|
||||||
old.superseded_by = Some(current.connection_id);
|
|
||||||
}
|
|
||||||
self.unsubscribe_all(old_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
register_redis_online(&self.redis, ¤t)?;
|
|
||||||
Ok(replaced.filter(|old| *old != current.connection_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unregister_connection(&self, session: &WsSession) -> AppResult<()> {
|
|
||||||
let removed = self.sessions.remove(&session.connection_id).map(|(_, s)| s);
|
|
||||||
let current = removed.as_ref().unwrap_or(session);
|
|
||||||
self.unsubscribe_all(current.connection_id);
|
|
||||||
|
|
||||||
if let Some(mut devices) = self.user_devices.get_mut(¤t.user_id)
|
|
||||||
&& devices.get(¤t.device_id).copied() == Some(current.connection_id)
|
|
||||||
{
|
|
||||||
devices.remove(¤t.device_id);
|
|
||||||
}
|
|
||||||
self.user_devices
|
|
||||||
.remove_if(¤t.user_id, |_, devices| devices.is_empty());
|
|
||||||
unregister_redis_online(&self.redis, current)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn heartbeat(&self, session: &WsSession) -> AppResult<()> {
|
|
||||||
if !self.is_deliverable(session.connection_id) {
|
|
||||||
return Err(AppError::Unauthorized);
|
|
||||||
}
|
|
||||||
heartbeat_redis(&self.redis, session)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribe_channel(&self, connection_id: Uuid, channel_id: Uuid) {
|
|
||||||
self.channel_routes
|
|
||||||
.entry(channel_id)
|
|
||||||
.or_default()
|
|
||||||
.insert(connection_id);
|
|
||||||
self.session_channels
|
|
||||||
.entry(connection_id)
|
|
||||||
.or_default()
|
|
||||||
.insert(channel_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unsubscribe_channel(&self, connection_id: Uuid, channel_id: Uuid) {
|
|
||||||
if let Some(mut sessions) = self.channel_routes.get_mut(&channel_id) {
|
|
||||||
sessions.remove(&connection_id);
|
|
||||||
}
|
|
||||||
self.channel_routes
|
|
||||||
.remove_if(&channel_id, |_, sessions| sessions.is_empty());
|
|
||||||
if let Some(mut channels) = self.session_channels.get_mut(&connection_id) {
|
|
||||||
channels.remove(&channel_id);
|
|
||||||
}
|
|
||||||
self.session_channels
|
|
||||||
.remove_if(&connection_id, |_, channels| channels.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unsubscribe_all(&self, connection_id: Uuid) {
|
|
||||||
let channels = self
|
|
||||||
.session_channels
|
|
||||||
.remove(&connection_id)
|
|
||||||
.map(|(_, channels)| channels)
|
|
||||||
.unwrap_or_default();
|
|
||||||
for channel_id in channels {
|
|
||||||
if let Some(mut sessions) = self.channel_routes.get_mut(&channel_id) {
|
|
||||||
sessions.remove(&connection_id);
|
|
||||||
}
|
|
||||||
self.channel_routes
|
|
||||||
.remove_if(&channel_id, |_, sessions| sessions.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribers(&self, channel_id: Uuid) -> Vec<Uuid> {
|
|
||||||
self.channel_routes
|
|
||||||
.get(&channel_id)
|
|
||||||
.map(|sessions| sessions.iter().copied().collect())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn user_connections(&self, user_id: Uuid) -> Vec<Uuid> {
|
|
||||||
self.user_devices
|
|
||||||
.get(&user_id)
|
|
||||||
.map(|devices| devices.values().copied().collect())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn workspace_connections(&self, workspace_name: &str) -> Vec<Uuid> {
|
|
||||||
self.sessions
|
|
||||||
.iter()
|
|
||||||
.filter_map(|entry| {
|
|
||||||
let session = entry.value();
|
|
||||||
(session.workspace_name == workspace_name && session.state.is_deliverable())
|
|
||||||
.then_some(session.connection_id)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_session(&self, connection_id: Uuid) -> Option<WsSession> {
|
|
||||||
self.sessions
|
|
||||||
.get(&connection_id)
|
|
||||||
.map(|session| session.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_deliverable(&self, connection_id: Uuid) -> bool {
|
|
||||||
self.sessions
|
|
||||||
.get(&connection_id)
|
|
||||||
.map(|session| session.state.is_deliverable() && session.superseded_by.is_none())
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_user_online(&self, user_id: Uuid) -> AppResult<bool> {
|
|
||||||
Ok(self
|
|
||||||
.user_devices
|
|
||||||
.get(&user_id)
|
|
||||||
.map(|devices| !devices.is_empty())
|
|
||||||
.unwrap_or(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_connection_count(&self, user_id: Uuid) -> AppResult<u32> {
|
|
||||||
Ok(self
|
|
||||||
.user_devices
|
|
||||||
.get(&user_id)
|
|
||||||
.map(|devices| devices.len() as u32)
|
|
||||||
.unwrap_or(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_typing(
|
|
||||||
&self,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
typing::set_typing(&self.redis, channel_id, thread_id, user_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_typing(
|
|
||||||
&self,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
typing::clear_typing(&self.redis, channel_id, thread_id, user_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_typing_users(
|
|
||||||
&self,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
) -> AppResult<Vec<Uuid>> {
|
|
||||||
typing::get_typing_users(&self.redis, channel_id, thread_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn heartbeat_interval(&self) -> Duration {
|
|
||||||
Duration::from_secs(WS_HEARTBEAT_INTERVAL_SECS)
|
|
||||||
}
|
|
||||||
pub fn heartbeat_interval_secs(&self) -> u64 {
|
|
||||||
WS_HEARTBEAT_INTERVAL_SECS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
use crate::cache::redis::AppRedis;
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use crate::service::im::util::PRESENCE_PREFIX;
|
|
||||||
use ::redis::Cmd;
|
|
||||||
|
|
||||||
use super::redis_keys::*;
|
|
||||||
use super::session::WsSession;
|
|
||||||
|
|
||||||
pub fn register_redis_online(redis: &AppRedis, session: &WsSession) -> AppResult<()> {
|
|
||||||
let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id);
|
|
||||||
let conn_id = session.connection_id.to_string();
|
|
||||||
let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id);
|
|
||||||
let mut conn = redis.get_connection()?;
|
|
||||||
|
|
||||||
Cmd::new()
|
|
||||||
.arg("SADD")
|
|
||||||
.arg(&set_key)
|
|
||||||
.arg(&conn_id)
|
|
||||||
.query::<i32>(&mut *conn.inner_mut())?;
|
|
||||||
Cmd::new()
|
|
||||||
.arg("EXPIRE")
|
|
||||||
.arg(&set_key)
|
|
||||||
.arg(WS_ONLINE_TTL_SECS)
|
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
|
||||||
Cmd::new()
|
|
||||||
.arg("SETEX")
|
|
||||||
.arg(&meta_key)
|
|
||||||
.arg(WS_ONLINE_TTL_SECS)
|
|
||||||
.arg(session.workspace_name.as_str())
|
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unregister_redis_online(redis: &AppRedis, session: &WsSession) -> AppResult<()> {
|
|
||||||
let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id);
|
|
||||||
let conn_id = session.connection_id.to_string();
|
|
||||||
let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id);
|
|
||||||
let mut conn = redis.get_connection()?;
|
|
||||||
|
|
||||||
Cmd::new()
|
|
||||||
.arg("SREM")
|
|
||||||
.arg(&set_key)
|
|
||||||
.arg(&conn_id)
|
|
||||||
.query::<i32>(&mut *conn.inner_mut())?;
|
|
||||||
|
|
||||||
let remaining: i32 = Cmd::new()
|
|
||||||
.arg("SCARD")
|
|
||||||
.arg(&set_key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
if remaining == 0 {
|
|
||||||
Cmd::new()
|
|
||||||
.arg("DEL")
|
|
||||||
.arg(&set_key)
|
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
|
||||||
let pk = format!("{PRESENCE_PREFIX}{}", session.user_id);
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("DEL")
|
|
||||||
.arg(&pk)
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
}
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("DEL")
|
|
||||||
.arg(&meta_key)
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn heartbeat_redis(redis: &AppRedis, session: &WsSession) -> AppResult<()> {
|
|
||||||
let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id);
|
|
||||||
let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id);
|
|
||||||
let pk = format!("{PRESENCE_PREFIX}{}", session.user_id);
|
|
||||||
let mut conn = redis.get_connection()?;
|
|
||||||
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("EXPIRE")
|
|
||||||
.arg(&set_key)
|
|
||||||
.arg(WS_ONLINE_TTL_SECS)
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("SETEX")
|
|
||||||
.arg(&meta_key)
|
|
||||||
.arg(WS_ONLINE_TTL_SECS)
|
|
||||||
.arg(session.workspace_name.as_str())
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("SETEX")
|
|
||||||
.arg(&pk)
|
|
||||||
.arg(WS_ONLINE_TTL_SECS)
|
|
||||||
.arg("online")
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use dashmap::DashMap;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use super::WsOutbound;
|
|
||||||
|
|
||||||
pub type WsSender = mpsc::UnboundedSender<WsOutbound>;
|
|
||||||
pub type WsReceiver = mpsc::UnboundedReceiver<WsOutbound>;
|
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
pub struct WsSinkManager {
|
|
||||||
sinks: Arc<DashMap<Uuid, WsSender>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WsSinkManager {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn channel() -> (WsSender, WsReceiver) {
|
|
||||||
mpsc::unbounded_channel()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn attach(&self, connection_id: Uuid, sender: WsSender) {
|
|
||||||
self.sinks.insert(connection_id, sender);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn detach(&self, connection_id: Uuid) {
|
|
||||||
self.sinks.remove(&connection_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn send(&self, connection_id: Uuid, message: WsOutbound) -> bool {
|
|
||||||
self.sinks
|
|
||||||
.get(&connection_id)
|
|
||||||
.map(|sink| sink.send(message).is_ok())
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn send_many<I>(&self, ids: I, message: WsOutbound) -> usize
|
|
||||||
where
|
|
||||||
I: IntoIterator<Item = Uuid>,
|
|
||||||
{
|
|
||||||
ids.into_iter()
|
|
||||||
.filter(|id| self.send(*id, message.clone()))
|
|
||||||
.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn contains(&self, connection_id: Uuid) -> bool {
|
|
||||||
self.sinks.contains_key(&connection_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::cache::redis::AppRedis;
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use crate::service::im::util::{TYPING_PREFIX, TYPING_TTL_SECS};
|
|
||||||
use ::redis::Cmd;
|
|
||||||
|
|
||||||
pub fn set_typing(
|
|
||||||
redis: &AppRedis,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
let key = typing_key(channel_id, thread_id, user_id);
|
|
||||||
let mut conn = redis.get_connection()?;
|
|
||||||
Cmd::new()
|
|
||||||
.arg("SETEX")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(TYPING_TTL_SECS as u64)
|
|
||||||
.arg("1")
|
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_typing(
|
|
||||||
redis: &AppRedis,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
let key = typing_key(channel_id, thread_id, user_id);
|
|
||||||
let mut conn = redis.get_connection()?;
|
|
||||||
Cmd::new()
|
|
||||||
.arg("DEL")
|
|
||||||
.arg(&key)
|
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_typing_users(
|
|
||||||
redis: &AppRedis,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
) -> AppResult<Vec<Uuid>> {
|
|
||||||
let pattern = match thread_id {
|
|
||||||
Some(tid) => format!("{TYPING_PREFIX}{channel_id}:{tid}:*"),
|
|
||||||
None => format!("{TYPING_PREFIX}{channel_id}:*"),
|
|
||||||
};
|
|
||||||
let mut conn = redis.get_connection()?;
|
|
||||||
let keys: Vec<String> = Cmd::new()
|
|
||||||
.arg("KEYS")
|
|
||||||
.arg(&pattern)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
let mut ids = Vec::with_capacity(keys.len());
|
|
||||||
for key in &keys {
|
|
||||||
if let Some(part) = key.rsplit(':').next()
|
|
||||||
&& let Ok(uid) = part.parse::<Uuid>()
|
|
||||||
{
|
|
||||||
ids.push(uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn typing_key(channel_id: Uuid, thread_id: Option<Uuid>, user_id: Uuid) -> String {
|
|
||||||
match thread_id {
|
|
||||||
Some(tid) => format!("{TYPING_PREFIX}{channel_id}:{tid}:{user_id}"),
|
|
||||||
None => format!("{TYPING_PREFIX}{channel_id}:{user_id}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ pub mod cache;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod etcd;
|
pub mod etcd;
|
||||||
pub mod immediate;
|
pub mod grpc;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod pb;
|
pub mod pb;
|
||||||
pub mod queue;
|
pub mod queue;
|
||||||
|
|||||||
+17459
-416
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
// Generated from proto/this/*.proto (package appks.v1)
|
||||||
|
// Compiled via tonic-build in build.rs using OUT_DIR + include!
|
||||||
|
// Build server = true, build client = false (appks is the server side).
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/appks.v1.rs"));
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Generated from proto/this/im/*.proto (package appks.im.v1)
|
||||||
|
// Compiled via tonic-build in build.rs using OUT_DIR + include!
|
||||||
|
// Build server = true, build client = false (appks is the server side for IM RPCs).
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/appks.im.v1.rs"));
|
||||||
@@ -1,8 +1,37 @@
|
|||||||
|
pub mod appks;
|
||||||
pub mod email;
|
pub mod email;
|
||||||
|
pub mod im;
|
||||||
pub mod repo;
|
pub mod repo;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use tonic::transport::{Channel, Endpoint};
|
use tonic::transport::{Channel, Endpoint};
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq, Hash, prost::Message, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct Timestamp {
|
||||||
|
#[prost(int64, tag = "1")]
|
||||||
|
pub seconds: i64,
|
||||||
|
#[prost(int32, tag = "2")]
|
||||||
|
pub nanos: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<prost_types::Timestamp> for Timestamp {
|
||||||
|
fn from(t: prost_types::Timestamp) -> Self {
|
||||||
|
Self {
|
||||||
|
seconds: t.seconds,
|
||||||
|
nanos: t.nanos,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Timestamp> for prost_types::Timestamp {
|
||||||
|
fn from(t: Timestamp) -> Self {
|
||||||
|
Self {
|
||||||
|
seconds: t.seconds,
|
||||||
|
nanos: t.nanos,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RepoClient {
|
pub struct RepoClient {
|
||||||
pub repository: repo::repository_service_client::RepositoryServiceClient<Channel>,
|
pub repository: repo::repository_service_client::RepositoryServiceClient<Channel>,
|
||||||
@@ -15,6 +44,8 @@ pub struct RepoClient {
|
|||||||
pub blame: repo::blame_service_client::BlameServiceClient<Channel>,
|
pub blame: repo::blame_service_client::BlameServiceClient<Channel>,
|
||||||
pub archive: repo::archive_service_client::ArchiveServiceClient<Channel>,
|
pub archive: repo::archive_service_client::ArchiveServiceClient<Channel>,
|
||||||
pub pack: repo::pack_service_client::PackServiceClient<Channel>,
|
pub pack: repo::pack_service_client::PackServiceClient<Channel>,
|
||||||
|
pub ref_: repo::ref_service_client::RefServiceClient<Channel>,
|
||||||
|
pub remote: repo::remote_service_client::RemoteServiceClient<Channel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RepoClient {
|
impl RepoClient {
|
||||||
@@ -41,7 +72,9 @@ impl RepoClient {
|
|||||||
merge: repo::merge_service_client::MergeServiceClient::new(channel.clone()),
|
merge: repo::merge_service_client::MergeServiceClient::new(channel.clone()),
|
||||||
blame: repo::blame_service_client::BlameServiceClient::new(channel.clone()),
|
blame: repo::blame_service_client::BlameServiceClient::new(channel.clone()),
|
||||||
archive: repo::archive_service_client::ArchiveServiceClient::new(channel.clone()),
|
archive: repo::archive_service_client::ArchiveServiceClient::new(channel.clone()),
|
||||||
pack: repo::pack_service_client::PackServiceClient::new(channel),
|
pack: repo::pack_service_client::PackServiceClient::new(channel.clone()),
|
||||||
|
ref_: repo::ref_service_client::RefServiceClient::new(channel.clone()),
|
||||||
|
remote: repo::remote_service_client::RemoteServiceClient::new(channel),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,3 +115,18 @@ impl std::ops::DerefMut for EmailClient {
|
|||||||
&mut self.inner
|
&mut self.inner
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Section: Appks gRPC server traits
|
||||||
|
//
|
||||||
|
// Core services (package appks.v1) live in pb::appks::
|
||||||
|
// - RepoService
|
||||||
|
//
|
||||||
|
// IM services (package appks.im.v1) live in pb::im::
|
||||||
|
// - ChannelService, MemberService, PermissionService
|
||||||
|
// - InternalAuthService
|
||||||
|
// - ChannelRoleService, ChannelInvitationService, ChannelWebhookService
|
||||||
|
// - ChannelSlashCommandService, ChannelRepoLinkService, ImIntegrationService
|
||||||
|
// - CustomEmojiService, ForumTagService, VoiceService, StageService
|
||||||
|
// - ChannelAuditService
|
||||||
|
//
|
||||||
|
// Implementations are in grpc/ and wired into the tonic server in grpc/mod.rs.
|
||||||
|
|||||||
@@ -154,6 +154,102 @@ message CompareCommitsResponse {
|
|||||||
Oid merge_base = 4;
|
Oid merge_base = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message FindCommitRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
ObjectSelector revision = 2;
|
||||||
|
bool include_stats = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListCommitsByOidRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
repeated bytes oids = 2; // binary OID values
|
||||||
|
bool include_stats = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListCommitsByOidResponse {
|
||||||
|
repeated Commit commits = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CommitIsAncestorRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string ancestor_oid = 2;
|
||||||
|
string descendant_oid = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CommitIsAncestorResponse {
|
||||||
|
bool is_ancestor = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CheckObjectsExistRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
repeated string revisions = 2; // hex OIDs or rev expressions
|
||||||
|
}
|
||||||
|
|
||||||
|
message RevisionExistence {
|
||||||
|
string revision = 1;
|
||||||
|
bool exists = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CheckObjectsExistResponse {
|
||||||
|
repeated RevisionExistence revisions = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CommitsByMessageRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string query = 2; // regex or literal to search in commit messages
|
||||||
|
string revision = 3; // limit to this branch/ref (empty = all branches)
|
||||||
|
uint32 limit = 4;
|
||||||
|
uint32 offset = 5;
|
||||||
|
bool case_insensitive = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CommitsByMessageResponse {
|
||||||
|
repeated Commit commits = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message GetCommitStatsRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
ObjectSelector revision = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LastCommitForPathRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string path = 2;
|
||||||
|
string revision = 3; // limit history to this ref
|
||||||
|
bool literal_pathspec = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LastCommitForPathResponse {
|
||||||
|
Commit commit = 1;
|
||||||
|
string path = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message CountCommitsRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string revision = 2;
|
||||||
|
string path = 3;
|
||||||
|
string since = 4; // ISO 8601 date
|
||||||
|
string until = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CountCommitsResponse {
|
||||||
|
uint64 count = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CountDivergingCommitsRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string left = 2;
|
||||||
|
string right = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CountDivergingCommitsResponse {
|
||||||
|
uint64 left_count = 1;
|
||||||
|
uint64 right_count = 2;
|
||||||
|
}
|
||||||
|
|
||||||
service CommitService {
|
service CommitService {
|
||||||
rpc ListCommits(ListCommitsRequest) returns (ListCommitsResponse);
|
rpc ListCommits(ListCommitsRequest) returns (ListCommitsResponse);
|
||||||
rpc GetCommit(GetCommitRequest) returns (Commit);
|
rpc GetCommit(GetCommitRequest) returns (Commit);
|
||||||
@@ -162,4 +258,15 @@ service CommitService {
|
|||||||
rpc RevertCommit(RevertCommitRequest) returns (CreateCommitResponse);
|
rpc RevertCommit(RevertCommitRequest) returns (CreateCommitResponse);
|
||||||
rpc CherryPickCommit(CherryPickCommitRequest) returns (CreateCommitResponse);
|
rpc CherryPickCommit(CherryPickCommitRequest) returns (CreateCommitResponse);
|
||||||
rpc CompareCommits(CompareCommitsRequest) returns (CompareCommitsResponse);
|
rpc CompareCommits(CompareCommitsRequest) returns (CompareCommitsResponse);
|
||||||
|
|
||||||
|
rpc FindCommit(FindCommitRequest) returns (Commit);
|
||||||
|
rpc ListCommitsByOid(ListCommitsByOidRequest) returns (ListCommitsByOidResponse);
|
||||||
|
rpc CommitIsAncestor(CommitIsAncestorRequest) returns (CommitIsAncestorResponse);
|
||||||
|
rpc CheckObjectsExist(CheckObjectsExistRequest) returns (CheckObjectsExistResponse);
|
||||||
|
rpc CommitsByMessage(CommitsByMessageRequest) returns (CommitsByMessageResponse);
|
||||||
|
rpc GetCommitStats(GetCommitStatsRequest) returns (CommitStats);
|
||||||
|
rpc LastCommitForPath(LastCommitForPathRequest) returns (LastCommitForPathResponse);
|
||||||
|
|
||||||
|
rpc CountCommits(CountCommitsRequest) returns (CountCommitsResponse);
|
||||||
|
rpc CountDivergingCommits(CountDivergingCommitsRequest) returns (CountDivergingCommitsResponse);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,9 +132,67 @@ message GetDiffStatsRequest {
|
|||||||
DiffOptions options = 4;
|
DiffOptions options = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message RawDiffRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string base = 2; // revision or OID
|
||||||
|
string head = 3;
|
||||||
|
DiffOptions options = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RawDiffResponse {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RawPatchRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string base = 2;
|
||||||
|
string head = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RawPatchResponse {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message FindChangedPathsRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string base = 2;
|
||||||
|
string head = 3;
|
||||||
|
repeated string paths = 4; // filter to these paths
|
||||||
|
}
|
||||||
|
|
||||||
|
message ChangedPath {
|
||||||
|
enum Status {
|
||||||
|
CHANGED_PATH_STATUS_UNSPECIFIED = 0;
|
||||||
|
CHANGED_PATH_STATUS_ADDED = 1;
|
||||||
|
CHANGED_PATH_STATUS_MODIFIED = 2;
|
||||||
|
CHANGED_PATH_STATUS_DELETED = 3;
|
||||||
|
CHANGED_PATH_STATUS_RENAMED = 4;
|
||||||
|
CHANGED_PATH_STATUS_COPIED = 5;
|
||||||
|
CHANGED_PATH_STATUS_TYPE_CHANGED = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
Status status = 1;
|
||||||
|
string old_path = 2;
|
||||||
|
string new_path = 3;
|
||||||
|
uint32 additions = 4;
|
||||||
|
uint32 deletions = 5;
|
||||||
|
bool binary = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FindChangedPathsResponse {
|
||||||
|
repeated ChangedPath paths = 1;
|
||||||
|
}
|
||||||
|
|
||||||
service DiffService {
|
service DiffService {
|
||||||
rpc GetDiff(GetDiffRequest) returns (GetDiffResponse);
|
rpc GetDiff(GetDiffRequest) returns (GetDiffResponse);
|
||||||
rpc GetCommitDiff(GetCommitDiffRequest) returns (GetDiffResponse);
|
rpc GetCommitDiff(GetCommitDiffRequest) returns (GetDiffResponse);
|
||||||
rpc GetPatch(GetPatchRequest) returns (stream GetPatchResponse);
|
rpc GetPatch(GetPatchRequest) returns (stream GetPatchResponse);
|
||||||
rpc GetDiffStats(GetDiffStatsRequest) returns (DiffStats);
|
rpc GetDiffStats(GetDiffStatsRequest) returns (DiffStats);
|
||||||
|
|
||||||
|
rpc RawDiff(RawDiffRequest) returns (stream RawDiffResponse);
|
||||||
|
rpc RawPatch(RawPatchRequest) returns (stream RawPatchResponse);
|
||||||
|
|
||||||
|
rpc FindChangedPaths(FindChangedPathsRequest) returns (FindChangedPathsResponse);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package gitks;
|
||||||
|
|
||||||
|
import "repository.proto";
|
||||||
|
|
||||||
|
// HookService provides gRPC callback hooks for git operations.
|
||||||
|
// External services can implement this interface to receive hook callbacks.
|
||||||
|
service HookService {
|
||||||
|
// Pre-receive hook callback: validate push before it happens.
|
||||||
|
rpc PreReceiveHook(PreReceiveHookRequest) returns (PreReceiveHookResponse);
|
||||||
|
|
||||||
|
// Update hook callback: validate each ref update individually.
|
||||||
|
rpc UpdateHook(UpdateHookRequest) returns (UpdateHookResponse);
|
||||||
|
|
||||||
|
// Post-receive hook callback: notify after push has completed.
|
||||||
|
rpc PostReceiveHook(PostReceiveHookRequest) returns (PostReceiveHookResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
message PreReceiveHookRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
repeated RefUpdate ref_updates = 2;
|
||||||
|
string push_options = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PreReceiveHookResponse {
|
||||||
|
bool accept = 1;
|
||||||
|
string rejection_message = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateHookRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string ref_name = 2;
|
||||||
|
string old_oid = 3;
|
||||||
|
string new_oid = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateHookResponse {
|
||||||
|
bool accept = 1;
|
||||||
|
string rejection_message = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PostReceiveHookRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
repeated RefUpdate ref_updates = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PostReceiveHookResponse {
|
||||||
|
repeated HookAction actions = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RefUpdate {
|
||||||
|
string old_oid = 1;
|
||||||
|
string new_oid = 2;
|
||||||
|
string ref_name = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HookAction {
|
||||||
|
string action_type = 1; // "trigger_ci", "update_index", etc.
|
||||||
|
string payload = 2;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ message GitProtocolFeatures {
|
|||||||
repeated string capabilities = 2;
|
repeated string capabilities = 2;
|
||||||
repeated string server_options = 3;
|
repeated string server_options = 3;
|
||||||
repeated string agent = 4;
|
repeated string agent = 4;
|
||||||
|
bool stateless = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ReferenceAdvertisement {
|
message ReferenceAdvertisement {
|
||||||
@@ -24,11 +25,13 @@ message AdvertiseRefsRequest {
|
|||||||
RepositoryHeader repository = 1;
|
RepositoryHeader repository = 1;
|
||||||
GitProtocolFeatures protocol = 2;
|
GitProtocolFeatures protocol = 2;
|
||||||
string service = 3;
|
string service = 3;
|
||||||
|
bool raw = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AdvertiseRefsResponse {
|
message AdvertiseRefsResponse {
|
||||||
repeated ReferenceAdvertisement references = 1;
|
repeated ReferenceAdvertisement references = 1;
|
||||||
repeated string capabilities = 2;
|
repeated string capabilities = 2;
|
||||||
|
bytes raw_data = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message UploadPackRequest {
|
message UploadPackRequest {
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package gitks;
|
||||||
|
|
||||||
|
import "google/protobuf/empty.proto";
|
||||||
|
import "oid.proto";
|
||||||
|
import "repository.proto";
|
||||||
|
|
||||||
|
|
||||||
|
message FindDefaultBranchNameRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FindDefaultBranchNameResponse {
|
||||||
|
string name = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RefExistsRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string ref_name = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RefExistsResponse {
|
||||||
|
bool exists = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message RefUpdateEntry {
|
||||||
|
string ref_name = 1;
|
||||||
|
string new_oid = 2;
|
||||||
|
string old_oid = 3; // expected old OID (empty = no check)
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateReferencesRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
repeated RefUpdateEntry updates = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateReferencesResponse {
|
||||||
|
repeated string failed_refs = 1;
|
||||||
|
string error = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteRefsRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
repeated string ref_names = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteRefsResponse {
|
||||||
|
repeated string failed_refs = 1;
|
||||||
|
string error = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message FindRefsByOIDRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string oid = 2;
|
||||||
|
RefFilter filter = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RefFilter {
|
||||||
|
repeated string prefixes = 1; // e.g. ["refs/heads/", "refs/tags/"]
|
||||||
|
uint32 limit = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FoundRef {
|
||||||
|
string ref_name = 1;
|
||||||
|
string target_oid = 2;
|
||||||
|
bool symbolic = 3;
|
||||||
|
string symbolic_target = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FindRefsByOIDResponse {
|
||||||
|
repeated FoundRef refs = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message ListRefsRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
repeated string prefixes = 2;
|
||||||
|
string pattern = 3; // glob pattern, e.g. "refs/heads/*"
|
||||||
|
repeated string containing_oids = 4;
|
||||||
|
SortDirection sort_direction = 5;
|
||||||
|
Pagination pagination = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListRefsResponse {
|
||||||
|
repeated FoundRef refs = 1;
|
||||||
|
PageInfo page_info = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
service RefService {
|
||||||
|
rpc FindDefaultBranchName(FindDefaultBranchNameRequest) returns (FindDefaultBranchNameResponse);
|
||||||
|
rpc RefExists(RefExistsRequest) returns (RefExistsResponse);
|
||||||
|
rpc UpdateReferences(UpdateReferencesRequest) returns (UpdateReferencesResponse);
|
||||||
|
rpc DeleteRefs(DeleteRefsRequest) returns (DeleteRefsResponse);
|
||||||
|
rpc FindRefsByOID(FindRefsByOIDRequest) returns (FindRefsByOIDResponse);
|
||||||
|
rpc ListRefs(ListRefsRequest) returns (ListRefsResponse);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package gitks;
|
||||||
|
|
||||||
|
import "oid.proto";
|
||||||
|
import "repository.proto";
|
||||||
|
|
||||||
|
|
||||||
|
message FindRemoteRepositoryRequest {
|
||||||
|
string remote_url = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoteHead {
|
||||||
|
string ref_name = 1;
|
||||||
|
string target_oid = 2;
|
||||||
|
bool symbolic = 3;
|
||||||
|
string symbolic_target = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FindRemoteRepositoryResponse {
|
||||||
|
repeated RemoteHead refs = 1;
|
||||||
|
bool exists = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FindRemoteRootRefRequest {
|
||||||
|
string remote_url = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FindRemoteRootRefResponse {
|
||||||
|
string ref_name = 1;
|
||||||
|
string target_oid = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message UpdateRemoteMirrorRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string remote_url = 2;
|
||||||
|
string remote_name = 3; // defaults to "origin"
|
||||||
|
bool force = 4;
|
||||||
|
bool prune = 5;
|
||||||
|
repeated string refspecs = 6; // if empty, fetch all refs
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateRemoteMirrorResponse {
|
||||||
|
bool ok = 1;
|
||||||
|
string error = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
service RemoteService {
|
||||||
|
rpc FindRemoteRepository(FindRemoteRepositoryRequest) returns (FindRemoteRepositoryResponse);
|
||||||
|
rpc FindRemoteRootRef(FindRemoteRootRefRequest) returns (FindRemoteRootRefResponse);
|
||||||
|
rpc UpdateRemoteMirror(UpdateRemoteMirrorRequest) returns (UpdateRemoteMirrorResponse);
|
||||||
|
}
|
||||||
@@ -139,6 +139,276 @@ message RepositoryMaintenanceResponse {
|
|||||||
string stderr = 3;
|
string stderr = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message ListHooksRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HookInfo {
|
||||||
|
string hook_type = 1;
|
||||||
|
string level = 2; // "server" or "custom"
|
||||||
|
string path = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListHooksResponse {
|
||||||
|
repeated HookInfo hooks = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetCustomHookRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string hook_name = 2; // "pre-receive", "update", "post-receive"
|
||||||
|
string content = 3; // Hook script content
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoveCustomHookRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string hook_name = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
enum SnapshotStorage {
|
||||||
|
SNAPSHOT_STORAGE_LOCAL = 0;
|
||||||
|
SNAPSHOT_STORAGE_S3 = 1;
|
||||||
|
SNAPSHOT_STORAGE_GCS = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SnapshotInfo {
|
||||||
|
string snapshot_id = 1;
|
||||||
|
string relative_path = 2;
|
||||||
|
uint64 size_bytes = 3;
|
||||||
|
string created_at = 4; // ISO 8601
|
||||||
|
string head_oid = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateSnapshotRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
SnapshotStorage storage = 2;
|
||||||
|
string storage_path = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateSnapshotResponse {
|
||||||
|
string snapshot_id = 1;
|
||||||
|
uint64 size_bytes = 2;
|
||||||
|
string head_oid = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RestoreSnapshotRequest {
|
||||||
|
RepositoryHeader target_repository = 1;
|
||||||
|
string snapshot_id = 2;
|
||||||
|
SnapshotStorage storage = 3;
|
||||||
|
string storage_path = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListSnapshotsRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
uint32 limit = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListSnapshotsResponse {
|
||||||
|
repeated SnapshotInfo snapshots = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteSnapshotRequest {
|
||||||
|
string snapshot_id = 1;
|
||||||
|
SnapshotStorage storage = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
enum MoveRepositoryState {
|
||||||
|
MOVE_STATE_UNKNOWN = 0;
|
||||||
|
MOVE_STATE_PREPARING = 1;
|
||||||
|
MOVE_STATE_TRANSFERRING = 2;
|
||||||
|
MOVE_STATE_VERIFYING = 3;
|
||||||
|
MOVE_STATE_COMPLETED = 4;
|
||||||
|
MOVE_STATE_FAILED = 5;
|
||||||
|
MOVE_STATE_CANCELLED = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MoveRepositoryRequest {
|
||||||
|
RepositoryHeader source_repository = 1;
|
||||||
|
RepositoryHeader target_repository = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MoveRepositoryResponse {
|
||||||
|
MoveRepositoryState state = 1;
|
||||||
|
string error_message = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FetchRepositoryDataRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FetchRepositoryDataResponse {
|
||||||
|
bytes data = 1;
|
||||||
|
bool done = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message FindMergeBaseRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
repeated bytes revisions = 2; // hex OIDs to find merge-base for
|
||||||
|
}
|
||||||
|
|
||||||
|
message FindMergeBaseResponse {
|
||||||
|
string base_oid = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message WriteRefRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string ref_name = 2;
|
||||||
|
string new_oid = 3;
|
||||||
|
string old_oid = 4; // expected old OID (empty = no check)
|
||||||
|
bool force = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message WriteRefResponse {
|
||||||
|
bool ok = 1;
|
||||||
|
string error = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message SearchFilesByContentRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string query = 2; // regex pattern
|
||||||
|
string revision = 3; // tree-ish to search in (default HEAD)
|
||||||
|
uint32 max_results = 4; // default 100
|
||||||
|
bool case_sensitive = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SearchFilesByContentResponse {
|
||||||
|
repeated SearchResult results = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SearchFilesByNameRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string query = 2; // regex pattern for file names
|
||||||
|
string revision = 3;
|
||||||
|
uint32 max_results = 4;
|
||||||
|
bool recursive = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SearchFilesByNameResponse {
|
||||||
|
repeated SearchResult results = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SearchResult {
|
||||||
|
string path = 1;
|
||||||
|
uint32 line = 2; // 0 for name-only search
|
||||||
|
string matched_text = 3; // the surrounding line content
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message ObjectsSizeRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
repeated string oids = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ObjectsSizeResponse {
|
||||||
|
repeated ObjectSize sizes = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ObjectSize {
|
||||||
|
string oid = 1;
|
||||||
|
uint64 size = 2;
|
||||||
|
bool found = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RepositorySizeRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RepositorySizeResponse {
|
||||||
|
uint64 size_bytes = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message FindLicenseRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FindLicenseResponse {
|
||||||
|
string license_spdx = 1; // SPDX identifier, e.g. "MIT"
|
||||||
|
string license_name = 2; // human-readable name
|
||||||
|
double confidence = 3; // 0.0 — 1.0
|
||||||
|
string license_path = 4; // path to LICENSE file
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
enum OptimizeStrategy {
|
||||||
|
OPTIMIZE_STRATEGY_UNSPECIFIED = 0;
|
||||||
|
OPTIMIZE_STRATEGY_HEURISTIC = 1; // auto-decide based on repo state
|
||||||
|
OPTIMIZE_STRATEGY_AGGRESSIVE = 2;
|
||||||
|
OPTIMIZE_STRATEGY_INCREMENTAL = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OptimizeRepositoryRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
OptimizeStrategy strategy = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OptimizeRepositoryResponse {
|
||||||
|
bool ok = 1;
|
||||||
|
string stdout = 2;
|
||||||
|
string stderr = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message GetRawChangesRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string base = 2; // revision or OID
|
||||||
|
string head = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RawChange {
|
||||||
|
enum Operation {
|
||||||
|
RAW_CHANGE_OPERATION_UNSPECIFIED = 0;
|
||||||
|
RAW_CHANGE_OPERATION_ADDED = 1;
|
||||||
|
RAW_CHANGE_OPERATION_MODIFIED = 2;
|
||||||
|
RAW_CHANGE_OPERATION_DELETED = 3;
|
||||||
|
RAW_CHANGE_OPERATION_RENAMED = 4;
|
||||||
|
RAW_CHANGE_OPERATION_COPIED = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
Operation operation = 1;
|
||||||
|
string old_path = 2;
|
||||||
|
string new_path = 3;
|
||||||
|
uint32 old_mode = 4;
|
||||||
|
uint32 new_mode = 5;
|
||||||
|
string old_oid = 6;
|
||||||
|
string new_oid = 7;
|
||||||
|
double similarity = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetRawChangesResponse {
|
||||||
|
repeated RawChange changes = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message FetchRemoteRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string remote_url = 2;
|
||||||
|
string remote_name = 3; // defaults to "origin"
|
||||||
|
repeated string refspecs = 4;
|
||||||
|
bool force = 5;
|
||||||
|
bool prune = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FetchRemoteResponse {
|
||||||
|
bool ok = 1;
|
||||||
|
string error = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateRepositoryFromURLRequest {
|
||||||
|
RepositoryHeader repository = 1;
|
||||||
|
string remote_url = 2;
|
||||||
|
bool mirror = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateRepositoryFromURLResponse {
|
||||||
|
Repository repository = 1;
|
||||||
|
}
|
||||||
|
|
||||||
service RepositoryService {
|
service RepositoryService {
|
||||||
rpc GetRepository(GetRepositoryRequest) returns (Repository);
|
rpc GetRepository(GetRepositoryRequest) returns (Repository);
|
||||||
rpc InitRepository(InitRepositoryRequest) returns (Repository);
|
rpc InitRepository(InitRepositoryRequest) returns (Repository);
|
||||||
@@ -154,4 +424,33 @@ service RepositoryService {
|
|||||||
rpc GarbageCollect(GarbageCollectRequest) returns (RepositoryMaintenanceResponse);
|
rpc GarbageCollect(GarbageCollectRequest) returns (RepositoryMaintenanceResponse);
|
||||||
rpc Repack(RepackRequest) returns (RepositoryMaintenanceResponse);
|
rpc Repack(RepackRequest) returns (RepositoryMaintenanceResponse);
|
||||||
rpc WriteCommitGraph(WriteCommitGraphRequest) returns (RepositoryMaintenanceResponse);
|
rpc WriteCommitGraph(WriteCommitGraphRequest) returns (RepositoryMaintenanceResponse);
|
||||||
|
|
||||||
|
// Hooks management
|
||||||
|
rpc ListHooks(ListHooksRequest) returns (ListHooksResponse);
|
||||||
|
rpc SetCustomHook(SetCustomHookRequest) returns (google.protobuf.Empty);
|
||||||
|
rpc RemoveCustomHook(RemoveCustomHookRequest) returns (google.protobuf.Empty);
|
||||||
|
|
||||||
|
// Snapshot operations
|
||||||
|
rpc CreateSnapshot(CreateSnapshotRequest) returns (CreateSnapshotResponse);
|
||||||
|
rpc RestoreSnapshot(RestoreSnapshotRequest) returns (google.protobuf.Empty);
|
||||||
|
rpc ListSnapshots(ListSnapshotsRequest) returns (ListSnapshotsResponse);
|
||||||
|
rpc DeleteSnapshot(DeleteSnapshotRequest) returns (google.protobuf.Empty);
|
||||||
|
|
||||||
|
// Repository move
|
||||||
|
rpc MoveRepository(MoveRepositoryRequest) returns (MoveRepositoryResponse);
|
||||||
|
rpc FetchRepositoryData(FetchRepositoryDataRequest) returns (stream FetchRepositoryDataResponse);
|
||||||
|
|
||||||
|
rpc FindMergeBase(FindMergeBaseRequest) returns (FindMergeBaseResponse);
|
||||||
|
rpc WriteRef(WriteRefRequest) returns (WriteRefResponse);
|
||||||
|
rpc SearchFilesByContent(SearchFilesByContentRequest) returns (SearchFilesByContentResponse);
|
||||||
|
rpc SearchFilesByName(SearchFilesByNameRequest) returns (SearchFilesByNameResponse);
|
||||||
|
|
||||||
|
rpc ObjectsSize(ObjectsSizeRequest) returns (ObjectsSizeResponse);
|
||||||
|
rpc RepositorySize(RepositorySizeRequest) returns (RepositorySizeResponse);
|
||||||
|
rpc FetchRemote(FetchRemoteRequest) returns (FetchRemoteResponse);
|
||||||
|
rpc CreateRepositoryFromURL(CreateRepositoryFromURLRequest) returns (CreateRepositoryFromURLResponse);
|
||||||
|
|
||||||
|
rpc FindLicense(FindLicenseRequest) returns (FindLicenseResponse);
|
||||||
|
rpc OptimizeRepository(OptimizeRepositoryRequest) returns (OptimizeRepositoryResponse);
|
||||||
|
rpc GetRawChanges(GetRawChangesRequest) returns (GetRawChangesResponse);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package appks.im.v1;
|
||||||
|
|
||||||
|
// Internal service-to-service authentication.
|
||||||
|
// appks issues API keys (stored in Redis), remote services
|
||||||
|
// carry the key in gRPC metadata "x-api-key", and call
|
||||||
|
// Authenticate to verify identity.
|
||||||
|
|
||||||
|
message AuthenticateRequest {
|
||||||
|
string api_key = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AuthenticateResponse {
|
||||||
|
bool authenticated = 1;
|
||||||
|
string service_name = 2;
|
||||||
|
string service_id = 3;
|
||||||
|
repeated string scopes = 4;
|
||||||
|
int64 expires_at = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
service InternalAuthService {
|
||||||
|
// Verify an API key and return the associated service identity.
|
||||||
|
// Called by remote services to authenticate themselves.
|
||||||
|
rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse);
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package appks.im.v1;
|
||||||
|
|
||||||
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
|
// Channel management service for the IM microservice.
|
||||||
|
// Provides CRUD for channels and categories, plus channel statistics.
|
||||||
|
|
||||||
|
// ── Enums ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum ChannelType {
|
||||||
|
CHANNEL_TYPE_UNSPECIFIED = 0;
|
||||||
|
CHANNEL_TYPE_PUBLIC = 1;
|
||||||
|
CHANNEL_TYPE_PRIVATE = 2;
|
||||||
|
CHANNEL_TYPE_DIRECT = 3;
|
||||||
|
CHANNEL_TYPE_GROUP = 4;
|
||||||
|
CHANNEL_TYPE_REPO = 5;
|
||||||
|
CHANNEL_TYPE_SYSTEM = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChannelKind {
|
||||||
|
CHANNEL_KIND_UNSPECIFIED = 0;
|
||||||
|
CHANNEL_KIND_TEXT = 1;
|
||||||
|
CHANNEL_KIND_VOICE = 2;
|
||||||
|
CHANNEL_KIND_STAGE = 3;
|
||||||
|
CHANNEL_KIND_FORUM = 4;
|
||||||
|
CHANNEL_KIND_ANNOUNCEMENT = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Visibility {
|
||||||
|
VISIBILITY_UNSPECIFIED = 0;
|
||||||
|
VISIBILITY_PUBLIC = 1;
|
||||||
|
VISIBILITY_PRIVATE = 2;
|
||||||
|
VISIBILITY_INTERNAL = 3;
|
||||||
|
VISIBILITY_WORKSPACE = 4;
|
||||||
|
VISIBILITY_PROTECTED = 5;
|
||||||
|
VISIBILITY_HIDDEN = 6;
|
||||||
|
VISIBILITY_SECRET = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Messages ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message Channel {
|
||||||
|
string id = 1;
|
||||||
|
string workspace_id = 2;
|
||||||
|
optional string category_id = 3;
|
||||||
|
optional string parent_channel_id = 4;
|
||||||
|
string name = 5;
|
||||||
|
optional string topic = 6;
|
||||||
|
optional string description = 7;
|
||||||
|
ChannelType channel_type = 8;
|
||||||
|
ChannelKind channel_kind = 9;
|
||||||
|
Visibility visibility = 10;
|
||||||
|
int32 position = 11;
|
||||||
|
bool nsfw = 12;
|
||||||
|
bool read_only = 13;
|
||||||
|
bool archived = 14;
|
||||||
|
optional string created_by = 15;
|
||||||
|
optional int32 rate_limit_per_user = 16;
|
||||||
|
optional google.protobuf.Timestamp archived_at = 17;
|
||||||
|
optional string last_message_id = 18;
|
||||||
|
optional google.protobuf.Timestamp last_message_at = 19;
|
||||||
|
google.protobuf.Timestamp created_at = 20;
|
||||||
|
google.protobuf.Timestamp updated_at = 21;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ChannelStats {
|
||||||
|
string channel_id = 1;
|
||||||
|
int32 members_count = 2;
|
||||||
|
int32 messages_count = 3;
|
||||||
|
int32 threads_count = 4;
|
||||||
|
int32 reactions_count = 5;
|
||||||
|
int32 mentions_count = 6;
|
||||||
|
int32 files_count = 7;
|
||||||
|
optional google.protobuf.Timestamp last_activity_at = 8;
|
||||||
|
google.protobuf.Timestamp updated_at = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ChannelCategory {
|
||||||
|
string id = 1;
|
||||||
|
string workspace_id = 2;
|
||||||
|
string name = 3;
|
||||||
|
int32 position = 4;
|
||||||
|
bool collapsed = 5;
|
||||||
|
google.protobuf.Timestamp created_at = 6;
|
||||||
|
google.protobuf.Timestamp updated_at = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Requests / Responses ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
message GetChannelRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetChannelResponse {
|
||||||
|
Channel channel = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListChannelsRequest {
|
||||||
|
string workspace_name = 1;
|
||||||
|
optional string category_id = 2;
|
||||||
|
optional ChannelType channel_type = 3;
|
||||||
|
optional ChannelKind channel_kind = 4;
|
||||||
|
int32 limit = 5;
|
||||||
|
int32 offset = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListChannelsResponse {
|
||||||
|
repeated Channel channels = 1;
|
||||||
|
int32 total = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateChannelRequest {
|
||||||
|
string workspace_name = 1;
|
||||||
|
string name = 2;
|
||||||
|
optional string topic = 3;
|
||||||
|
optional string description = 4;
|
||||||
|
optional string channel_type = 5;
|
||||||
|
optional string channel_kind = 6;
|
||||||
|
optional string visibility = 7;
|
||||||
|
optional string category_id = 8;
|
||||||
|
optional string parent_channel_id = 9;
|
||||||
|
optional string created_by = 10;
|
||||||
|
optional int32 rate_limit_per_user = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateChannelResponse {
|
||||||
|
Channel channel = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateChannelRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
optional string name = 2;
|
||||||
|
optional string topic = 3;
|
||||||
|
optional string description = 4;
|
||||||
|
optional string visibility = 5;
|
||||||
|
optional int32 position = 6;
|
||||||
|
optional bool nsfw = 7;
|
||||||
|
optional bool read_only = 8;
|
||||||
|
optional bool archived = 9;
|
||||||
|
optional string category_id = 10;
|
||||||
|
optional int32 rate_limit_per_user = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateChannelResponse {
|
||||||
|
Channel channel = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteChannelRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteChannelResponse {}
|
||||||
|
|
||||||
|
message GetChannelStatsRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetChannelStatsResponse {
|
||||||
|
ChannelStats stats = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListCategoriesRequest {
|
||||||
|
string workspace_name = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListCategoriesResponse {
|
||||||
|
repeated ChannelCategory categories = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateCategoryRequest {
|
||||||
|
string workspace_name = 1;
|
||||||
|
string name = 2;
|
||||||
|
optional int32 position = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateCategoryResponse {
|
||||||
|
ChannelCategory category = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateCategoryRequest {
|
||||||
|
string category_id = 1;
|
||||||
|
optional string name = 2;
|
||||||
|
optional int32 position = 3;
|
||||||
|
optional bool collapsed = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateCategoryResponse {
|
||||||
|
ChannelCategory category = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteCategoryRequest {
|
||||||
|
string category_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteCategoryResponse {}
|
||||||
|
|
||||||
|
// ── Service ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
service ChannelService {
|
||||||
|
rpc GetChannel(GetChannelRequest) returns (GetChannelResponse);
|
||||||
|
rpc ListChannels(ListChannelsRequest) returns (ListChannelsResponse);
|
||||||
|
rpc CreateChannel(CreateChannelRequest) returns (CreateChannelResponse);
|
||||||
|
rpc UpdateChannel(UpdateChannelRequest) returns (UpdateChannelResponse);
|
||||||
|
rpc DeleteChannel(DeleteChannelRequest) returns (DeleteChannelResponse);
|
||||||
|
rpc GetChannelStats(GetChannelStatsRequest) returns (GetChannelStatsResponse);
|
||||||
|
rpc ListCategories(ListCategoriesRequest) returns (ListCategoriesResponse);
|
||||||
|
rpc CreateCategory(CreateCategoryRequest) returns (CreateCategoryResponse);
|
||||||
|
rpc UpdateCategory(UpdateCategoryRequest) returns (UpdateCategoryResponse);
|
||||||
|
rpc DeleteCategory(DeleteCategoryRequest) returns (DeleteCategoryResponse);
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package appks.im.v1;
|
||||||
|
|
||||||
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
|
// ── ChannelMemberRole ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message ChannelRole {
|
||||||
|
string id = 1;
|
||||||
|
string channel_id = 2;
|
||||||
|
string name = 3;
|
||||||
|
repeated string permissions = 4;
|
||||||
|
bool assignable = 5;
|
||||||
|
google.protobuf.Timestamp created_at = 6;
|
||||||
|
google.protobuf.Timestamp updated_at = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListChannelRolesRequest { string channel_id = 1; }
|
||||||
|
message ListChannelRolesResponse { repeated ChannelRole roles = 1; }
|
||||||
|
|
||||||
|
message CreateChannelRoleRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string name = 2;
|
||||||
|
repeated string permissions = 3;
|
||||||
|
bool assignable = 4;
|
||||||
|
}
|
||||||
|
message CreateChannelRoleResponse { ChannelRole role = 1; }
|
||||||
|
|
||||||
|
message UpdateChannelRoleRequest {
|
||||||
|
string role_id = 1;
|
||||||
|
optional string name = 2;
|
||||||
|
repeated string permissions = 3;
|
||||||
|
optional bool assignable = 4;
|
||||||
|
}
|
||||||
|
message UpdateChannelRoleResponse { ChannelRole role = 1; }
|
||||||
|
|
||||||
|
message DeleteChannelRoleRequest { string role_id = 1; }
|
||||||
|
message DeleteChannelRoleResponse {}
|
||||||
|
|
||||||
|
service ChannelRoleService {
|
||||||
|
rpc ListChannelRoles(ListChannelRolesRequest) returns (ListChannelRolesResponse);
|
||||||
|
rpc CreateChannelRole(CreateChannelRoleRequest) returns (CreateChannelRoleResponse);
|
||||||
|
rpc UpdateChannelRole(UpdateChannelRoleRequest) returns (UpdateChannelRoleResponse);
|
||||||
|
rpc DeleteChannelRole(DeleteChannelRoleRequest) returns (DeleteChannelRoleResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ChannelInvitation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message ChannelInvitation {
|
||||||
|
string id = 1;
|
||||||
|
string channel_id = 2;
|
||||||
|
string invited_by = 3;
|
||||||
|
string invited_user_id = 4;
|
||||||
|
string role = 5;
|
||||||
|
string status = 6;
|
||||||
|
google.protobuf.Timestamp created_at = 7;
|
||||||
|
google.protobuf.Timestamp updated_at = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListInvitationsRequest { string channel_id = 1; }
|
||||||
|
message ListInvitationsResponse { repeated ChannelInvitation invitations = 1; }
|
||||||
|
|
||||||
|
message CreateInvitationRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string invited_user_id = 2;
|
||||||
|
string role = 3;
|
||||||
|
}
|
||||||
|
message CreateInvitationResponse { ChannelInvitation invitation = 1; }
|
||||||
|
|
||||||
|
message AcceptInvitationRequest { string invitation_id = 1; }
|
||||||
|
message AcceptInvitationResponse { ChannelInvitation invitation = 1; }
|
||||||
|
|
||||||
|
message RevokeInvitationRequest { string invitation_id = 1; }
|
||||||
|
message RevokeInvitationResponse {}
|
||||||
|
|
||||||
|
service ChannelInvitationService {
|
||||||
|
rpc ListInvitations(ListInvitationsRequest) returns (ListInvitationsResponse);
|
||||||
|
rpc CreateInvitation(CreateInvitationRequest) returns (CreateInvitationResponse);
|
||||||
|
rpc AcceptInvitation(AcceptInvitationRequest) returns (AcceptInvitationResponse);
|
||||||
|
rpc RevokeInvitation(RevokeInvitationRequest) returns (RevokeInvitationResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ChannelWebhook ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message ChannelWebhook {
|
||||||
|
string id = 1;
|
||||||
|
string channel_id = 2;
|
||||||
|
string name = 3;
|
||||||
|
string url = 4;
|
||||||
|
string secret = 5;
|
||||||
|
repeated string events = 6;
|
||||||
|
bool active = 7;
|
||||||
|
google.protobuf.Timestamp created_at = 8;
|
||||||
|
google.protobuf.Timestamp updated_at = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListWebhooksRequest { string channel_id = 1; }
|
||||||
|
message ListWebhooksResponse { repeated ChannelWebhook webhooks = 1; }
|
||||||
|
|
||||||
|
message CreateWebhookRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string url = 3;
|
||||||
|
optional string secret = 4;
|
||||||
|
repeated string events = 5;
|
||||||
|
}
|
||||||
|
message CreateWebhookResponse { ChannelWebhook webhook = 1; }
|
||||||
|
|
||||||
|
message UpdateWebhookRequest {
|
||||||
|
string webhook_id = 1;
|
||||||
|
optional string name = 2;
|
||||||
|
optional string url = 3;
|
||||||
|
optional string secret = 4;
|
||||||
|
repeated string events = 5;
|
||||||
|
optional bool active = 6;
|
||||||
|
}
|
||||||
|
message UpdateWebhookResponse { ChannelWebhook webhook = 1; }
|
||||||
|
|
||||||
|
message DeleteWebhookRequest { string webhook_id = 1; }
|
||||||
|
message DeleteWebhookResponse {}
|
||||||
|
|
||||||
|
service ChannelWebhookService {
|
||||||
|
rpc ListWebhooks(ListWebhooksRequest) returns (ListWebhooksResponse);
|
||||||
|
rpc CreateWebhook(CreateWebhookRequest) returns (CreateWebhookResponse);
|
||||||
|
rpc UpdateWebhook(UpdateWebhookRequest) returns (UpdateWebhookResponse);
|
||||||
|
rpc DeleteWebhook(DeleteWebhookRequest) returns (DeleteWebhookResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ChannelSlashCommand ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message ChannelSlashCommand {
|
||||||
|
string id = 1;
|
||||||
|
string channel_id = 2;
|
||||||
|
string command = 3;
|
||||||
|
string description = 4;
|
||||||
|
string request_url = 5;
|
||||||
|
repeated string scopes = 6;
|
||||||
|
google.protobuf.Timestamp created_at = 7;
|
||||||
|
google.protobuf.Timestamp updated_at = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListSlashCommandsRequest { string channel_id = 1; }
|
||||||
|
message ListSlashCommandsResponse { repeated ChannelSlashCommand commands = 1; }
|
||||||
|
|
||||||
|
message CreateSlashCommandRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string command = 2;
|
||||||
|
string description = 3;
|
||||||
|
string request_url = 4;
|
||||||
|
repeated string scopes = 5;
|
||||||
|
}
|
||||||
|
message CreateSlashCommandResponse { ChannelSlashCommand command = 1; }
|
||||||
|
|
||||||
|
message UpdateSlashCommandRequest {
|
||||||
|
string command_id = 1;
|
||||||
|
optional string description = 2;
|
||||||
|
optional string request_url = 3;
|
||||||
|
repeated string scopes = 4;
|
||||||
|
}
|
||||||
|
message UpdateSlashCommandResponse { ChannelSlashCommand command = 1; }
|
||||||
|
|
||||||
|
message DeleteSlashCommandRequest { string command_id = 1; }
|
||||||
|
message DeleteSlashCommandResponse {}
|
||||||
|
|
||||||
|
service ChannelSlashCommandService {
|
||||||
|
rpc ListSlashCommands(ListSlashCommandsRequest) returns (ListSlashCommandsResponse);
|
||||||
|
rpc CreateSlashCommand(CreateSlashCommandRequest) returns (CreateSlashCommandResponse);
|
||||||
|
rpc UpdateSlashCommand(UpdateSlashCommandRequest) returns (UpdateSlashCommandResponse);
|
||||||
|
rpc DeleteSlashCommand(DeleteSlashCommandRequest) returns (DeleteSlashCommandResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ChannelRepoLink ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message ChannelRepoLink {
|
||||||
|
string id = 1;
|
||||||
|
string channel_id = 2;
|
||||||
|
string repo_id = 3;
|
||||||
|
string link_type = 4;
|
||||||
|
repeated string events = 5;
|
||||||
|
google.protobuf.Timestamp created_at = 6;
|
||||||
|
google.protobuf.Timestamp updated_at = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListRepoLinksRequest { string channel_id = 1; }
|
||||||
|
message ListRepoLinksResponse { repeated ChannelRepoLink links = 1; }
|
||||||
|
|
||||||
|
message CreateRepoLinkRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string repo_id = 2;
|
||||||
|
string link_type = 3;
|
||||||
|
repeated string events = 4;
|
||||||
|
}
|
||||||
|
message CreateRepoLinkResponse { ChannelRepoLink link = 1; }
|
||||||
|
|
||||||
|
message DeleteRepoLinkRequest { string link_id = 1; }
|
||||||
|
message DeleteRepoLinkResponse {}
|
||||||
|
|
||||||
|
service ChannelRepoLinkService {
|
||||||
|
rpc ListRepoLinks(ListRepoLinksRequest) returns (ListRepoLinksResponse);
|
||||||
|
rpc CreateRepoLink(CreateRepoLinkRequest) returns (CreateRepoLinkResponse);
|
||||||
|
rpc DeleteRepoLink(DeleteRepoLinkRequest) returns (DeleteRepoLinkResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ImIntegration ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message ImIntegration {
|
||||||
|
string id = 1;
|
||||||
|
string channel_id = 2;
|
||||||
|
string provider = 3;
|
||||||
|
string external_channel_id = 4;
|
||||||
|
string sync_direction = 5;
|
||||||
|
bool active = 6;
|
||||||
|
google.protobuf.Timestamp created_at = 7;
|
||||||
|
google.protobuf.Timestamp updated_at = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListIntegrationsRequest { string channel_id = 1; }
|
||||||
|
message ListIntegrationsResponse { repeated ImIntegration integrations = 1; }
|
||||||
|
|
||||||
|
message CreateIntegrationRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string provider = 2;
|
||||||
|
string external_channel_id = 3;
|
||||||
|
string sync_direction = 4;
|
||||||
|
}
|
||||||
|
message CreateIntegrationResponse { ImIntegration integration = 1; }
|
||||||
|
|
||||||
|
message UpdateIntegrationRequest {
|
||||||
|
string integration_id = 1;
|
||||||
|
optional string sync_direction = 2;
|
||||||
|
optional bool active = 3;
|
||||||
|
}
|
||||||
|
message UpdateIntegrationResponse { ImIntegration integration = 1; }
|
||||||
|
|
||||||
|
message DeleteIntegrationRequest { string integration_id = 1; }
|
||||||
|
message DeleteIntegrationResponse {}
|
||||||
|
|
||||||
|
service ImIntegrationService {
|
||||||
|
rpc ListIntegrations(ListIntegrationsRequest) returns (ListIntegrationsResponse);
|
||||||
|
rpc CreateIntegration(CreateIntegrationRequest) returns (CreateIntegrationResponse);
|
||||||
|
rpc UpdateIntegration(UpdateIntegrationRequest) returns (UpdateIntegrationResponse);
|
||||||
|
rpc DeleteIntegration(DeleteIntegrationRequest) returns (DeleteIntegrationResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CustomEmoji ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message CustomEmoji {
|
||||||
|
string id = 1;
|
||||||
|
string workspace_id = 2;
|
||||||
|
string name = 3;
|
||||||
|
string image_url = 4;
|
||||||
|
google.protobuf.Timestamp created_at = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListCustomEmojisRequest { string workspace_id = 1; }
|
||||||
|
message ListCustomEmojisResponse { repeated CustomEmoji emojis = 1; }
|
||||||
|
|
||||||
|
message CreateCustomEmojiRequest {
|
||||||
|
string workspace_id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string image_url = 3;
|
||||||
|
}
|
||||||
|
message CreateCustomEmojiResponse { CustomEmoji emoji = 1; }
|
||||||
|
|
||||||
|
message DeleteCustomEmojiRequest { string emoji_id = 1; }
|
||||||
|
message DeleteCustomEmojiResponse {}
|
||||||
|
|
||||||
|
service CustomEmojiService {
|
||||||
|
rpc ListCustomEmojis(ListCustomEmojisRequest) returns (ListCustomEmojisResponse);
|
||||||
|
rpc CreateCustomEmoji(CreateCustomEmojiRequest) returns (CreateCustomEmojiResponse);
|
||||||
|
rpc DeleteCustomEmoji(DeleteCustomEmojiRequest) returns (DeleteCustomEmojiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ForumTag ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message ForumTag {
|
||||||
|
string id = 1;
|
||||||
|
string channel_id = 2;
|
||||||
|
string name = 3;
|
||||||
|
bool moderated = 4;
|
||||||
|
int32 position = 5;
|
||||||
|
google.protobuf.Timestamp created_at = 6;
|
||||||
|
google.protobuf.Timestamp updated_at = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListForumTagsRequest { string channel_id = 1; }
|
||||||
|
message ListForumTagsResponse { repeated ForumTag tags = 1; }
|
||||||
|
|
||||||
|
message CreateForumTagRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string name = 2;
|
||||||
|
bool moderated = 3;
|
||||||
|
optional int32 position = 4;
|
||||||
|
}
|
||||||
|
message CreateForumTagResponse { ForumTag tag = 1; }
|
||||||
|
|
||||||
|
message UpdateForumTagRequest {
|
||||||
|
string tag_id = 1;
|
||||||
|
optional string name = 2;
|
||||||
|
optional bool moderated = 3;
|
||||||
|
optional int32 position = 4;
|
||||||
|
}
|
||||||
|
message UpdateForumTagResponse { ForumTag tag = 1; }
|
||||||
|
|
||||||
|
message DeleteForumTagRequest { string tag_id = 1; }
|
||||||
|
message DeleteForumTagResponse {}
|
||||||
|
|
||||||
|
service ForumTagService {
|
||||||
|
rpc ListForumTags(ListForumTagsRequest) returns (ListForumTagsResponse);
|
||||||
|
rpc CreateForumTag(CreateForumTagRequest) returns (CreateForumTagResponse);
|
||||||
|
rpc UpdateForumTag(UpdateForumTagRequest) returns (UpdateForumTagResponse);
|
||||||
|
rpc DeleteForumTag(DeleteForumTagRequest) returns (DeleteForumTagResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── VoiceParticipant ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message VoiceParticipant {
|
||||||
|
string id = 1;
|
||||||
|
string channel_id = 2;
|
||||||
|
string user_id = 3;
|
||||||
|
bool muted = 4;
|
||||||
|
bool deafened = 5;
|
||||||
|
google.protobuf.Timestamp joined_at = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListVoiceParticipantsRequest { string channel_id = 1; }
|
||||||
|
message ListVoiceParticipantsResponse { repeated VoiceParticipant participants = 1; }
|
||||||
|
|
||||||
|
message UpdateVoiceStateRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string user_id = 2;
|
||||||
|
optional bool muted = 3;
|
||||||
|
optional bool deafened = 4;
|
||||||
|
}
|
||||||
|
message UpdateVoiceStateResponse { VoiceParticipant participant = 1; }
|
||||||
|
|
||||||
|
service VoiceService {
|
||||||
|
rpc ListVoiceParticipants(ListVoiceParticipantsRequest) returns (ListVoiceParticipantsResponse);
|
||||||
|
rpc UpdateVoiceState(UpdateVoiceStateRequest) returns (UpdateVoiceStateResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message Stage {
|
||||||
|
string id = 1;
|
||||||
|
string channel_id = 2;
|
||||||
|
string topic = 3;
|
||||||
|
string privacy_level = 4;
|
||||||
|
bool discoverable = 5;
|
||||||
|
google.protobuf.Timestamp started_at = 6;
|
||||||
|
google.protobuf.Timestamp ended_at = 7;
|
||||||
|
google.protobuf.Timestamp created_at = 8;
|
||||||
|
google.protobuf.Timestamp updated_at = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetStageRequest { string channel_id = 1; }
|
||||||
|
message GetStageResponse { Stage stage = 1; }
|
||||||
|
|
||||||
|
message CreateStageRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string topic = 2;
|
||||||
|
string privacy_level = 3;
|
||||||
|
bool discoverable = 4;
|
||||||
|
}
|
||||||
|
message CreateStageResponse { Stage stage = 1; }
|
||||||
|
|
||||||
|
message UpdateStageRequest {
|
||||||
|
string stage_id = 1;
|
||||||
|
optional string topic = 2;
|
||||||
|
optional string privacy_level = 3;
|
||||||
|
optional bool discoverable = 4;
|
||||||
|
}
|
||||||
|
message UpdateStageResponse { Stage stage = 1; }
|
||||||
|
|
||||||
|
message DeleteStageRequest { string stage_id = 1; }
|
||||||
|
message DeleteStageResponse {}
|
||||||
|
|
||||||
|
service StageService {
|
||||||
|
rpc GetStage(GetStageRequest) returns (GetStageResponse);
|
||||||
|
rpc CreateStage(CreateStageRequest) returns (CreateStageResponse);
|
||||||
|
rpc UpdateStage(UpdateStageRequest) returns (UpdateStageResponse);
|
||||||
|
rpc DeleteStage(DeleteStageRequest) returns (DeleteStageResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ChannelEvent (Audit Log) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
message ChannelAuditEvent {
|
||||||
|
string id = 1;
|
||||||
|
string channel_id = 2;
|
||||||
|
string actor_id = 3;
|
||||||
|
string event_type = 4;
|
||||||
|
string target_type = 5;
|
||||||
|
string target_id = 6;
|
||||||
|
optional string old_value = 7;
|
||||||
|
optional string new_value = 8;
|
||||||
|
google.protobuf.Timestamp created_at = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListChannelEventsRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
int32 limit = 2;
|
||||||
|
int32 offset = 3;
|
||||||
|
}
|
||||||
|
message ListChannelEventsResponse {
|
||||||
|
repeated ChannelAuditEvent events = 1;
|
||||||
|
int32 total = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
service ChannelAuditService {
|
||||||
|
rpc ListChannelEvents(ListChannelEventsRequest) returns (ListChannelEventsResponse);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package appks.im.v1;
|
||||||
|
|
||||||
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
|
// Member management service for the IM microservice.
|
||||||
|
// Provides CRUD for channel members, join/leave, and membership checks.
|
||||||
|
|
||||||
|
// ── Enums ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ROLE_UNSPECIFIED = 0;
|
||||||
|
ROLE_OWNER = 1;
|
||||||
|
ROLE_ADMIN = 2;
|
||||||
|
ROLE_MAINTAINER = 3;
|
||||||
|
ROLE_MODERATOR = 4;
|
||||||
|
ROLE_MEMBER = 5;
|
||||||
|
ROLE_CONTRIBUTOR = 6;
|
||||||
|
ROLE_VIEWER = 7;
|
||||||
|
ROLE_GUEST = 8;
|
||||||
|
ROLE_BOT = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MemberStatus {
|
||||||
|
MEMBER_STATUS_UNSPECIFIED = 0;
|
||||||
|
MEMBER_STATUS_ACTIVE = 1;
|
||||||
|
MEMBER_STATUS_INVITED = 2;
|
||||||
|
MEMBER_STATUS_LEFT = 3;
|
||||||
|
MEMBER_STATUS_KICKED = 4;
|
||||||
|
MEMBER_STATUS_BANNED = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Messages ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message ChannelMember {
|
||||||
|
string id = 1;
|
||||||
|
string channel_id = 2;
|
||||||
|
string user_id = 3;
|
||||||
|
string role = 4;
|
||||||
|
string status = 5;
|
||||||
|
bool muted = 6;
|
||||||
|
bool pinned = 7;
|
||||||
|
optional string last_read_message_id = 8;
|
||||||
|
optional google.protobuf.Timestamp last_read_at = 9;
|
||||||
|
optional google.protobuf.Timestamp joined_at = 10;
|
||||||
|
optional google.protobuf.Timestamp left_at = 11;
|
||||||
|
google.protobuf.Timestamp created_at = 12;
|
||||||
|
google.protobuf.Timestamp updated_at = 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Requests / Responses ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
message ListMembersRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
optional string status = 2;
|
||||||
|
int32 limit = 3;
|
||||||
|
int32 offset = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListMembersResponse {
|
||||||
|
repeated ChannelMember members = 1;
|
||||||
|
int32 total = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message InviteMemberRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string user_id = 2;
|
||||||
|
optional string role = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message InviteMemberResponse {
|
||||||
|
ChannelMember member = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateMemberRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string user_id = 2;
|
||||||
|
optional string role = 3;
|
||||||
|
optional bool muted = 4;
|
||||||
|
optional bool pinned = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateMemberResponse {
|
||||||
|
ChannelMember member = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message KickMemberRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string user_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message KickMemberResponse {}
|
||||||
|
|
||||||
|
message JoinChannelRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string user_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message JoinChannelResponse {
|
||||||
|
ChannelMember member = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LeaveChannelRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string user_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LeaveChannelResponse {}
|
||||||
|
|
||||||
|
message IsMemberRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string user_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message IsMemberResponse {
|
||||||
|
bool is_member = 1;
|
||||||
|
string role = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
service MemberService {
|
||||||
|
rpc ListMembers(ListMembersRequest) returns (ListMembersResponse);
|
||||||
|
rpc InviteMember(InviteMemberRequest) returns (InviteMemberResponse);
|
||||||
|
rpc UpdateMember(UpdateMemberRequest) returns (UpdateMemberResponse);
|
||||||
|
rpc KickMember(KickMemberRequest) returns (KickMemberResponse);
|
||||||
|
rpc JoinChannel(JoinChannelRequest) returns (JoinChannelResponse);
|
||||||
|
rpc LeaveChannel(LeaveChannelRequest) returns (LeaveChannelResponse);
|
||||||
|
rpc IsMember(IsMemberRequest) returns (IsMemberResponse);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package appks.im.v1;
|
||||||
|
|
||||||
|
// IM-specific permissions for channel operations.
|
||||||
|
// Separate from the general Permission enum used for repo/workspace access.
|
||||||
|
enum ImPermission {
|
||||||
|
IM_PERMISSION_UNSPECIFIED = 0;
|
||||||
|
IM_PERMISSION_READ_CHANNEL = 1;
|
||||||
|
IM_PERMISSION_SEND_MESSAGE = 2;
|
||||||
|
IM_PERMISSION_MANAGE_THREADS = 3;
|
||||||
|
IM_PERMISSION_MANAGE_REACTIONS = 4;
|
||||||
|
IM_PERMISSION_MANAGE_PINS = 5;
|
||||||
|
IM_PERMISSION_INVITE_MEMBERS = 6;
|
||||||
|
IM_PERMISSION_KICK_MEMBERS = 7;
|
||||||
|
IM_PERMISSION_MANAGE_CHANNEL = 8;
|
||||||
|
IM_PERMISSION_MANAGE_ROLES = 9;
|
||||||
|
IM_PERMISSION_MANAGE_WEBHOOKS = 10;
|
||||||
|
IM_PERMISSION_MANAGE_EMOJIS = 11;
|
||||||
|
IM_PERMISSION_VIEW_AUDIT_LOG = 12;
|
||||||
|
IM_PERMISSION_MANAGE_INTEGRATIONS = 13;
|
||||||
|
IM_PERMISSION_SEND_TTS = 14;
|
||||||
|
IM_PERMISSION_USE_SLASH_COMMANDS = 15;
|
||||||
|
IM_PERMISSION_ATTACH_FILES = 16;
|
||||||
|
IM_PERMISSION_MENTION_EVERYONE = 17;
|
||||||
|
IM_PERMISSION_MANAGE_MESSAGES = 18;
|
||||||
|
IM_PERMISSION_ADMIN = 19;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Messages ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message PermissionOverwrite {
|
||||||
|
string id = 1;
|
||||||
|
string channel_id = 2;
|
||||||
|
string target_type = 3;
|
||||||
|
string target_id = 4;
|
||||||
|
repeated ImPermission allow = 5;
|
||||||
|
repeated ImPermission deny = 6;
|
||||||
|
string created_at = 7;
|
||||||
|
string updated_at = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Requests / Responses ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
message CheckPermissionRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string user_id = 2;
|
||||||
|
ImPermission permission = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CheckPermissionResponse {
|
||||||
|
bool allowed = 1;
|
||||||
|
string role = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetPermissionsRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string user_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetPermissionsResponse {
|
||||||
|
repeated ImPermission permissions = 1;
|
||||||
|
string role = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetPermissionOverwriteRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string target_type = 2;
|
||||||
|
string target_id = 3;
|
||||||
|
repeated ImPermission allow = 4;
|
||||||
|
repeated ImPermission deny = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetPermissionOverwriteResponse {
|
||||||
|
PermissionOverwrite overwrite = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetPermissionOverwritesRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetPermissionOverwritesResponse {
|
||||||
|
repeated PermissionOverwrite overwrites = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeletePermissionOverwriteRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string target_type = 2;
|
||||||
|
string target_id = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeletePermissionOverwriteResponse {}
|
||||||
|
|
||||||
|
message ResolveChannelRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ResolveChannelResponse {
|
||||||
|
string channel_id = 1;
|
||||||
|
string workspace_id = 2;
|
||||||
|
string name = 3;
|
||||||
|
string visibility = 4;
|
||||||
|
string channel_type = 5;
|
||||||
|
bool read_only = 6;
|
||||||
|
bool archived = 7;
|
||||||
|
optional string created_by = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EnsureReadableRequest {
|
||||||
|
string channel_id = 1;
|
||||||
|
string user_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EnsureReadableResponse {
|
||||||
|
bool allowed = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
service PermissionService {
|
||||||
|
rpc CheckPermission(CheckPermissionRequest) returns (CheckPermissionResponse);
|
||||||
|
rpc GetPermissions(GetPermissionsRequest) returns (GetPermissionsResponse);
|
||||||
|
rpc SetPermissionOverwrite(SetPermissionOverwriteRequest) returns (SetPermissionOverwriteResponse);
|
||||||
|
rpc GetPermissionOverwrites(GetPermissionOverwritesRequest) returns (GetPermissionOverwritesResponse);
|
||||||
|
rpc DeletePermissionOverwrite(DeletePermissionOverwriteRequest) returns (DeletePermissionOverwriteResponse);
|
||||||
|
rpc ResolveChannel(ResolveChannelRequest) returns (ResolveChannelResponse);
|
||||||
|
rpc EnsureReadable(EnsureReadableRequest) returns (EnsureReadableResponse);
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package appks.v1;
|
||||||
|
|
||||||
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
|
// Repository-related services for gitshell.
|
||||||
|
// gitshell calls these RPCs to:
|
||||||
|
// 1. Check branch protection rules before accepting a push.
|
||||||
|
// 2. Locate which storage node hosts a given repository.
|
||||||
|
// 3. Verify user/agent permissions on a repository.
|
||||||
|
// 4. Acquire / release push locks for concurrency control.
|
||||||
|
|
||||||
|
// ── Enums ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum PushLockStatus {
|
||||||
|
PUSH_LOCK_STATUS_UNSPECIFIED = 0;
|
||||||
|
PUSH_LOCK_STATUS_QUEUED = 1;
|
||||||
|
PUSH_LOCK_STATUS_ACTIVE = 2;
|
||||||
|
PUSH_LOCK_STATUS_FINISHED = 3;
|
||||||
|
PUSH_LOCK_STATUS_FAILED = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MergeStrategy {
|
||||||
|
MERGE_STRATEGY_UNSPECIFIED = 0;
|
||||||
|
MERGE_STRATEGY_MERGE = 1;
|
||||||
|
MERGE_STRATEGY_SQUASH = 2;
|
||||||
|
MERGE_STRATEGY_REBASE = 3;
|
||||||
|
MERGE_STRATEGY_FAST_FORWARD = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Branch Protection ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message BranchProtectionRule {
|
||||||
|
string id = 1;
|
||||||
|
string repo_id = 2;
|
||||||
|
string pattern = 3;
|
||||||
|
int32 require_approvals = 4;
|
||||||
|
bool require_status_checks = 5;
|
||||||
|
repeated string required_status_checks = 6;
|
||||||
|
bool require_linear_history = 7;
|
||||||
|
bool allow_force_pushes = 8;
|
||||||
|
bool allow_deletions = 9;
|
||||||
|
bool require_signed_commits = 10;
|
||||||
|
bool require_code_owner_review = 11;
|
||||||
|
bool dismiss_stale_reviews = 12;
|
||||||
|
bool restrict_pushes = 13;
|
||||||
|
repeated string push_allowances = 14;
|
||||||
|
bool restrict_review_dismissal = 15;
|
||||||
|
repeated string dismissal_allowances = 16;
|
||||||
|
bool require_conversation_resolution = 17;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CheckBranchProtectionRequest {
|
||||||
|
string workspace_name = 1;
|
||||||
|
string repo_name = 2;
|
||||||
|
string branch_name = 3;
|
||||||
|
// The user attempting the push (for push-allowance checks).
|
||||||
|
optional string user_id = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CheckBranchProtectionResponse {
|
||||||
|
bool protected = 1;
|
||||||
|
BranchProtectionRule rule = 2;
|
||||||
|
// Human-readable reasons why the push would be blocked.
|
||||||
|
repeated string block_reasons = 3;
|
||||||
|
// Whether the given user is exempt (in push_allowances).
|
||||||
|
bool user_allowed = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Repository Locate ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message StorageNode {
|
||||||
|
string node_id = 1;
|
||||||
|
string address = 2;
|
||||||
|
// Labels for routing decisions (e.g. region, disk-type).
|
||||||
|
map<string, string> labels = 3;
|
||||||
|
bool healthy = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LocateRepositoryRequest {
|
||||||
|
string workspace_name = 1;
|
||||||
|
string repo_name = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LocateRepositoryResponse {
|
||||||
|
bool found = 1;
|
||||||
|
string repo_id = 2;
|
||||||
|
// The storage path on the node (e.g. "ab/cd/12345.git").
|
||||||
|
string storage_path = 3;
|
||||||
|
// Primary storage node that hosts the repository.
|
||||||
|
StorageNode primary_node = 4;
|
||||||
|
// Additional replica / failover nodes.
|
||||||
|
repeated StorageNode replica_nodes = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Permission Check ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message PermissionScope {
|
||||||
|
string scope = 1; // e.g. "repo:read", "repo:write"
|
||||||
|
optional string resource = 2; // e.g. specific repo name if scoped
|
||||||
|
}
|
||||||
|
|
||||||
|
message CheckRepoPermissionRequest {
|
||||||
|
string workspace_name = 1;
|
||||||
|
string repo_name = 2;
|
||||||
|
// The principal to check — either a user_id or a deploy_key_id.
|
||||||
|
oneof principal {
|
||||||
|
string user_id = 3;
|
||||||
|
string deploy_key_id = 4;
|
||||||
|
}
|
||||||
|
// The required permission level.
|
||||||
|
string required_permission = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CheckRepoPermissionResponse {
|
||||||
|
bool allowed = 1;
|
||||||
|
// The actual resolved permission (may be higher than required).
|
||||||
|
string resolved_permission = 2;
|
||||||
|
// If not allowed, a human-readable reason.
|
||||||
|
string reason = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Push Lock ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message PushLock {
|
||||||
|
string id = 1;
|
||||||
|
string repo_id = 2;
|
||||||
|
string pusher_id = 3;
|
||||||
|
string ref_name = 4;
|
||||||
|
PushLockStatus status = 5;
|
||||||
|
int32 queue_position = 6;
|
||||||
|
google.protobuf.Timestamp queued_at = 7;
|
||||||
|
google.protobuf.Timestamp started_at = 8;
|
||||||
|
google.protobuf.Timestamp finished_at = 9;
|
||||||
|
string storage_node_id = 10;
|
||||||
|
string lease_token = 11;
|
||||||
|
string error_message = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AcquirePushLockRequest {
|
||||||
|
string workspace_name = 1;
|
||||||
|
string repo_name = 2;
|
||||||
|
string ref_name = 3;
|
||||||
|
string pusher_id = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AcquirePushLockResponse {
|
||||||
|
bool acquired = 1;
|
||||||
|
PushLock lock = 2;
|
||||||
|
// If not immediately acquired, estimated wait in seconds.
|
||||||
|
int32 estimated_wait_seconds = 3;
|
||||||
|
string error = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReleasePushLockRequest {
|
||||||
|
string lock_id = 1;
|
||||||
|
// Must match the lease_token from AcquirePushLock.
|
||||||
|
string lease_token = 2;
|
||||||
|
// Whether the push succeeded.
|
||||||
|
bool success = 3;
|
||||||
|
optional string error_message = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReleasePushLockResponse {
|
||||||
|
bool released = 1;
|
||||||
|
string error = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetPushLockRequest {
|
||||||
|
string lock_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetPushLockResponse {
|
||||||
|
PushLock lock = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListPushLocksRequest {
|
||||||
|
string workspace_name = 1;
|
||||||
|
string repo_name = 2;
|
||||||
|
// Filter by status; if unspecified, returns all active locks.
|
||||||
|
optional PushLockStatus status = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListPushLocksResponse {
|
||||||
|
repeated PushLock locks = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Repository Metadata ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
message RepoInfo {
|
||||||
|
string id = 1;
|
||||||
|
string workspace_id = 2;
|
||||||
|
string owner_id = 3;
|
||||||
|
string name = 4;
|
||||||
|
optional string description = 5;
|
||||||
|
string default_branch = 6;
|
||||||
|
string visibility = 7;
|
||||||
|
string status = 8;
|
||||||
|
bool is_fork = 9;
|
||||||
|
optional string forked_from_repo_id = 10;
|
||||||
|
string storage_path = 11;
|
||||||
|
string git_service = 12;
|
||||||
|
google.protobuf.Timestamp archived_at = 13;
|
||||||
|
google.protobuf.Timestamp created_at = 14;
|
||||||
|
google.protobuf.Timestamp updated_at = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetRepoInfoRequest {
|
||||||
|
string workspace_name = 1;
|
||||||
|
string repo_name = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetRepoInfoResponse {
|
||||||
|
bool found = 1;
|
||||||
|
RepoInfo repo = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
service RepoService {
|
||||||
|
// ── Branch Protection ──
|
||||||
|
// Check whether a branch is protected and whether a push is allowed.
|
||||||
|
rpc CheckBranchProtection(CheckBranchProtectionRequest) returns (CheckBranchProtectionResponse);
|
||||||
|
|
||||||
|
// ── Repository Locate ──
|
||||||
|
// Find which storage node(s) host a repository.
|
||||||
|
rpc LocateRepository(LocateRepositoryRequest) returns (LocateRepositoryResponse);
|
||||||
|
|
||||||
|
// ── Permission Check ──
|
||||||
|
// Verify that a user or deploy key has the required permission on a repo.
|
||||||
|
rpc CheckRepoPermission(CheckRepoPermissionRequest) returns (CheckRepoPermissionResponse);
|
||||||
|
|
||||||
|
// ── Push Lock ──
|
||||||
|
// Acquire an exclusive push lock for a ref.
|
||||||
|
rpc AcquirePushLock(AcquirePushLockRequest) returns (AcquirePushLockResponse);
|
||||||
|
// Release a previously acquired push lock.
|
||||||
|
rpc ReleasePushLock(ReleasePushLockRequest) returns (ReleasePushLockResponse);
|
||||||
|
// Get the current state of a push lock.
|
||||||
|
rpc GetPushLock(GetPushLockRequest) returns (GetPushLockResponse);
|
||||||
|
// List active push locks for a repository.
|
||||||
|
rpc ListPushLocks(ListPushLocksRequest) returns (ListPushLocksResponse);
|
||||||
|
|
||||||
|
// ── Repository Metadata ──
|
||||||
|
// Get lightweight repository metadata (for gitshell to resolve repo names).
|
||||||
|
rpc GetRepoInfo(GetRepoInfoRequest) returns (GetRepoInfoResponse);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user