mirror of
http://shenjack.top:5100/shenjack/icalingua-python-bot.git
synced 2024-11-23 12:41:05 +08:00
tailchat support p1
This commit is contained in:
parent
6dfbc4e879
commit
eaae60902d
4
Cargo.lock
generated
4
Cargo.lock
generated
|
@ -890,9 +890,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-sys"
|
name = "openssl-sys"
|
||||||
version = "0.9.101"
|
version = "0.9.102"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dda2b0f344e78efc2facf7d195d098df0dd72151b26ab98da807afc26c198dff"
|
checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
|
@ -26,9 +26,9 @@ rust_socketio = { version = "0.4.4", features = ["async"], optional = true }
|
||||||
# data
|
# data
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
chrono = "0.4.37"
|
chrono = "0.4"
|
||||||
toml = "0.8.12"
|
toml = "0.8"
|
||||||
colored = "2.1.0"
|
colored = "2.1"
|
||||||
|
|
||||||
# runtime
|
# runtime
|
||||||
tokio = { version = "1.37", features = ["full"] }
|
tokio = { version = "1.37", features = ["full"] }
|
||||||
|
|
|
@ -24,7 +24,6 @@ pub struct IcaConfig {
|
||||||
pub filter_list: Vec<i64>,
|
pub filter_list: Vec<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct PyConfig {
|
pub struct PyConfig {
|
||||||
/// 插件路径
|
/// 插件路径
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
pub mod ica;
|
pub mod ica;
|
||||||
|
pub mod tailchat;
|
||||||
|
|
6
ica-rs/src/data_struct/tailchat.rs
Normal file
6
ica-rs/src/data_struct/tailchat.rs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
pub mod messages;
|
||||||
|
|
||||||
|
pub type GroupId = String;
|
||||||
|
pub type ConverseId = String;
|
||||||
|
pub type UserId = String;
|
||||||
|
pub type MessageId = String;
|
119
ica-rs/src/data_struct/tailchat/messages.rs
Normal file
119
ica-rs/src/data_struct/tailchat/messages.rs
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value as JsonValue};
|
||||||
|
|
||||||
|
use crate::data_struct::tailchat::{ConverseId, GroupId, MessageId, UserId};
|
||||||
|
|
||||||
|
/*{'_id': '6606b3075163504389a6fc47',
|
||||||
|
'content': '光速!(',
|
||||||
|
'author': '6602e20d7b8d10675758e36b',
|
||||||
|
'groupId': '6602e331d31fd31b04aa0693',
|
||||||
|
'converseId': '6602f785928c4254f17726b2',
|
||||||
|
'hasRecall': False,
|
||||||
|
'meta': {'mentions': []},
|
||||||
|
'reactions': [],
|
||||||
|
'createdAt': ExtType(code=0, data=b'\x00\x00\x01\x8e\x8a+TJ'),
|
||||||
|
'updatedAt': ExtType(code=0, data=b'\x00\x00\x01\x8e\x8a+TJ'),
|
||||||
|
'__v': 0} */
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct ReciveMessage {
|
||||||
|
/// 消息ID
|
||||||
|
#[serde(rename = "_id")]
|
||||||
|
pub msg_id: MessageId,
|
||||||
|
/// 消息内容
|
||||||
|
pub content: String,
|
||||||
|
/// 发送者ID
|
||||||
|
#[serde(rename = "author")]
|
||||||
|
pub sender_id: UserId,
|
||||||
|
/// 服务器ID
|
||||||
|
#[serde(rename = "groupId")]
|
||||||
|
pub group_id: GroupId,
|
||||||
|
/// 会话ID
|
||||||
|
#[serde(rename = "converseId")]
|
||||||
|
pub converse_id: ConverseId,
|
||||||
|
/// 是否有回复?
|
||||||
|
#[serde(rename = "hasRecall")]
|
||||||
|
pub has_recall: bool,
|
||||||
|
/// 暂时懒得解析这玩意
|
||||||
|
pub meta: JsonValue,
|
||||||
|
/// 也懒得解析这玩意
|
||||||
|
pub reactions: Vec<JsonValue>,
|
||||||
|
/// 创建时间
|
||||||
|
#[serde(rename = "createdAt")]
|
||||||
|
pub created_at: JsonValue,
|
||||||
|
/// 更新时间
|
||||||
|
#[serde(rename = "updatedAt")]
|
||||||
|
pub updated_at: JsonValue,
|
||||||
|
/// 未知
|
||||||
|
#[serde(rename = "__v")]
|
||||||
|
pub v: JsonValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReciveMessage {
|
||||||
|
pub fn as_reply(&self) -> ReplyMessage {
|
||||||
|
ReplyMessage {
|
||||||
|
content: self.content.clone(),
|
||||||
|
converse_id: self.converse_id.clone(),
|
||||||
|
group_id: self.group_id.clone(),
|
||||||
|
reply_id: self.msg_id.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SendingMessage {
|
||||||
|
/// 消息内容
|
||||||
|
pub content: String,
|
||||||
|
/// 会话ID
|
||||||
|
#[serde(rename = "converseId")]
|
||||||
|
pub converse_id: ConverseId,
|
||||||
|
/// 服务器ID
|
||||||
|
#[serde(rename = "groupId")]
|
||||||
|
pub group_id: GroupId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ReplyMeta {
|
||||||
|
/// 被回复的人的ID (可以是多个?)
|
||||||
|
pub mentions: Vec<UserId>,
|
||||||
|
/// 被回复的消息ID
|
||||||
|
pub reply_id: MessageId,
|
||||||
|
/// 被回复的消息的发送者ID
|
||||||
|
pub reply_author: UserId,
|
||||||
|
/// 被回复的消息内容
|
||||||
|
pub reply_content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for ReplyMeta {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::ser::Serializer,
|
||||||
|
{
|
||||||
|
let reply = json! {
|
||||||
|
{
|
||||||
|
"replyId": self.reply_id,
|
||||||
|
"replyAuthor": self.reply_author,
|
||||||
|
"replyContent": self.reply_content,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut map = serde_json::Map::new();
|
||||||
|
map.insert("mentions".to_string(), serde_json::to_value(&self.mentions).unwrap());
|
||||||
|
map.insert("reply".to_string(), reply);
|
||||||
|
map.serialize(serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ReplyMessage {
|
||||||
|
/// 消息内容
|
||||||
|
pub content: String,
|
||||||
|
/// 会话ID
|
||||||
|
#[serde(rename = "converseId")]
|
||||||
|
pub converse_id: ConverseId,
|
||||||
|
/// 服务器ID
|
||||||
|
#[serde(rename = "groupId")]
|
||||||
|
pub group_id: GroupId,
|
||||||
|
/// 回复的消息ID
|
||||||
|
#[serde(rename = "replyId")]
|
||||||
|
pub reply_id: MessageId,
|
||||||
|
}
|
|
@ -6,6 +6,21 @@ pub enum IcaError {
|
||||||
SocketIoError(rust_socketio::error::Error),
|
SocketIoError(rust_socketio::error::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum PyPluginError {
|
||||||
|
/// 插件内未找到指定函数
|
||||||
|
/// 函数名, 模块名
|
||||||
|
FuncNotFound(String, String),
|
||||||
|
/// 插件内函数获取错误
|
||||||
|
/// pyerr, func_name, module_name
|
||||||
|
CouldNotGetFunc(pyo3::PyErr, String, String),
|
||||||
|
/// 插件内函数不可调用
|
||||||
|
FuncNotCallable(String, String),
|
||||||
|
/// 插件内函数调用错误
|
||||||
|
/// pyerr, func_name, module_name
|
||||||
|
FuncCallError(pyo3::PyErr, String, String),
|
||||||
|
}
|
||||||
|
|
||||||
impl From<rust_socketio::Error> for IcaError {
|
impl From<rust_socketio::Error> for IcaError {
|
||||||
fn from(e: rust_socketio::Error) -> Self { IcaError::SocketIoError(e) }
|
fn from(e: rust_socketio::Error) -> Self { IcaError::SocketIoError(e) }
|
||||||
}
|
}
|
||||||
|
@ -18,6 +33,25 @@ impl std::fmt::Display for IcaError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PyPluginError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
PyPluginError::FuncNotFound(name, module) => {
|
||||||
|
write!(f, "插件内未找到函数: {} in {}", name, module)
|
||||||
|
}
|
||||||
|
PyPluginError::CouldNotGetFunc(py_err, name, module) => {
|
||||||
|
write!(f, "插件内函数获取错误: {:#?}|{} in {}", py_err, name, module)
|
||||||
|
}
|
||||||
|
PyPluginError::FuncNotCallable(name, module) => {
|
||||||
|
write!(f, "插件内函数不可调用: {} in {}", name, module)
|
||||||
|
}
|
||||||
|
PyPluginError::FuncCallError(py_err, name, module) => {
|
||||||
|
write!(f, "插件内函数调用错误: {:#?}|{} in {}", py_err, name, module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl std::error::Error for IcaError {
|
impl std::error::Error for IcaError {
|
||||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
match self {
|
match self {
|
||||||
|
@ -25,3 +59,14 @@ impl std::error::Error for IcaError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for PyPluginError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
PyPluginError::FuncNotFound(_, _) => None,
|
||||||
|
PyPluginError::CouldNotGetFunc(e, _, _) => Some(e),
|
||||||
|
PyPluginError::FuncNotCallable(_, _) => None,
|
||||||
|
PyPluginError::FuncCallError(_, _) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -3,13 +3,14 @@ use std::time::Duration;
|
||||||
mod config;
|
mod config;
|
||||||
mod data_struct;
|
mod data_struct;
|
||||||
mod error;
|
mod error;
|
||||||
#[cfg(feature = "ica")]
|
|
||||||
mod ica;
|
|
||||||
// #[cfg(feature = "tailchat")]
|
|
||||||
// mod tailchat;
|
|
||||||
mod py;
|
mod py;
|
||||||
mod status;
|
mod status;
|
||||||
|
|
||||||
|
#[cfg(feature = "ica")]
|
||||||
|
mod ica;
|
||||||
|
#[cfg(feature = "tailchat")]
|
||||||
|
mod tailchat;
|
||||||
|
|
||||||
use config::BotConfig;
|
use config::BotConfig;
|
||||||
use tracing::{event, info, span, Level};
|
use tracing::{event, info, span, Level};
|
||||||
|
|
||||||
|
|
|
@ -4,12 +4,15 @@ use pyo3::prelude::*;
|
||||||
use rust_socketio::asynchronous::Client;
|
use rust_socketio::asynchronous::Client;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::data_struct::ica::messages::NewMessage;
|
use crate::data_struct::{ica, tailchat};
|
||||||
use crate::data_struct::ica::MessageId;
|
use crate::error::PyPluginError;
|
||||||
use crate::py::{class, PyPlugin, PyStatus};
|
use crate::py::{class, PyPlugin, PyStatus};
|
||||||
use crate::MainStatus;
|
use crate::MainStatus;
|
||||||
|
|
||||||
pub fn get_func<'py>(py_module: &'py PyAny, path: &PathBuf, name: &'py str) -> Option<&'py PyAny> {
|
pub fn get_func<'py>(
|
||||||
|
py_module: &Bound<'py, PyAny>,
|
||||||
|
name: &'py str,
|
||||||
|
) -> Result<Bound<'py, PyAny>, PyPluginError> {
|
||||||
// 要处理的情况:
|
// 要处理的情况:
|
||||||
// 1. 有这个函数
|
// 1. 有这个函数
|
||||||
// 2. 没有这个函数
|
// 2. 没有这个函数
|
||||||
|
@ -20,25 +23,39 @@ pub fn get_func<'py>(py_module: &'py PyAny, path: &PathBuf, name: &'py str) -> O
|
||||||
match py_module.getattr(name) {
|
match py_module.getattr(name) {
|
||||||
Ok(func) => {
|
Ok(func) => {
|
||||||
if func.is_callable() {
|
if func.is_callable() {
|
||||||
Some(func)
|
Ok(func)
|
||||||
} else {
|
} else {
|
||||||
warn!("function<{}>: {:#?} in {:?} is not callable", name, func, path);
|
// warn!("function<{}>: {:#?} in {:?} is not callable", name, func, path);
|
||||||
None
|
Err(PyPluginError::FuncNotCallable(
|
||||||
|
name.to_string(),
|
||||||
|
py_module.getattr("__name__").unwrap().extract::<String>().unwrap(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("failed to get function<{}> from {:?}: {:?}", name, path, e);
|
// warn!("failed to get function<{}> from {:?}: {:?}", name, path, e);
|
||||||
None
|
Err(PyPluginError::CouldNotGetFunc(
|
||||||
|
e,
|
||||||
|
name.to_string(),
|
||||||
|
py_module.getattr("__name__").unwrap().extract::<String>().unwrap(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
debug!("no function<{}> in module {:?}", name, path);
|
// debug!("no function<{}> in module {:?}", name, path);
|
||||||
None
|
Err(PyPluginError::FuncNotFound(
|
||||||
|
name.to_string(),
|
||||||
|
py_module.getattr("__name__").unwrap().extract::<String>().unwrap(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("failed to check function<{}> from {:?}: {:?}", name, path, e);
|
// warn!("failed to check function<{}> from {:?}: {:?}", name, path, e);
|
||||||
None
|
Err(PyPluginError::CouldNotGetFunc(
|
||||||
|
e,
|
||||||
|
name.to_string(),
|
||||||
|
py_module.getattr("__name__").unwrap().extract::<String>().unwrap(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -79,23 +96,22 @@ pub const ICA_DELETE_MESSAGE_FUNC: &str = "on_ica_delete_message";
|
||||||
pub const TAILCHAT_NEW_MESSAGE_FUNC: &str = "on_tailchat_message";
|
pub const TAILCHAT_NEW_MESSAGE_FUNC: &str = "on_tailchat_message";
|
||||||
|
|
||||||
/// 执行 new message 的 python 插件
|
/// 执行 new message 的 python 插件
|
||||||
pub async fn ica_new_message_py(message: &NewMessage, client: &Client) {
|
pub async fn ica_new_message_py(message: &ica::messages::NewMessage, client: &Client) {
|
||||||
// 验证插件是否改变
|
// 验证插件是否改变
|
||||||
verify_plugins();
|
verify_plugins();
|
||||||
|
|
||||||
let plugins = PyStatus::get_files();
|
let plugins = PyStatus::get_files();
|
||||||
for (path, plugin) in plugins.iter() {
|
for (_path, plugin) in plugins.iter() {
|
||||||
let msg = class::ica::NewMessagePy::new(message);
|
let msg = class::ica::NewMessagePy::new(message);
|
||||||
let client = class::ica::IcaClientPy::new(client);
|
let client = class::ica::IcaClientPy::new(client);
|
||||||
let args = (msg, client);
|
let args = (msg, client);
|
||||||
// 甚至实际上压根不需要await这个spawn, 直接让他自己跑就好了(离谱)
|
// 甚至实际上压根不需要await这个spawn, 直接让他自己跑就好了(离谱)
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
Python::with_gil(|py| {
|
Python::with_gil(|py| {
|
||||||
if let Some(py_func) =
|
if let Ok(py_func) = get_func(plugin.py_module.bind(py), ICA_NEW_MESSAGE_FUNC) {
|
||||||
get_func(plugin.py_module.as_ref(py), path, ICA_NEW_MESSAGE_FUNC)
|
|
||||||
{
|
|
||||||
if let Err(e) = py_func.call1(args) {
|
if let Err(e) = py_func.call1(args) {
|
||||||
warn!("failed to call function<{}>: {:?}", ICA_NEW_MESSAGE_FUNC, e);
|
let e = PyPluginError::FuncCallError(e, ICA_NEW_MESSAGE_FUNC.to_string());
|
||||||
|
// warn!("failed to call function<{}>: {:?}", ICA_NEW_MESSAGE_FUNC, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -103,19 +119,17 @@ pub async fn ica_new_message_py(message: &NewMessage, client: &Client) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ica_delete_message_py(msg_id: MessageId, client: &Client) {
|
pub async fn ica_delete_message_py(msg_id: ica::MessageId, client: &Client) {
|
||||||
verify_plugins();
|
verify_plugins();
|
||||||
|
|
||||||
let plugins = PyStatus::get_files();
|
let plugins = PyStatus::get_files();
|
||||||
for (path, plugin) in plugins.iter() {
|
for (_path, plugin) in plugins.iter() {
|
||||||
let msg_id = msg_id.clone();
|
let msg_id = msg_id.clone();
|
||||||
let client = class::ica::IcaClientPy::new(client);
|
let client = class::ica::IcaClientPy::new(client);
|
||||||
let args = (msg_id.clone(), client);
|
let args = (msg_id.clone(), client);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
Python::with_gil(|py| {
|
Python::with_gil(|py| {
|
||||||
if let Some(py_func) =
|
if let Ok(py_func) = get_func(plugin.py_module.bind(py), ICA_DELETE_MESSAGE_FUNC) {
|
||||||
get_func(plugin.py_module.as_ref(py), path, ICA_DELETE_MESSAGE_FUNC)
|
|
||||||
{
|
|
||||||
if let Err(e) = py_func.call1(args) {
|
if let Err(e) = py_func.call1(args) {
|
||||||
warn!("failed to call function<{}>: {:?}", ICA_DELETE_MESSAGE_FUNC, e);
|
warn!("failed to call function<{}>: {:?}", ICA_DELETE_MESSAGE_FUNC, e);
|
||||||
}
|
}
|
||||||
|
@ -124,3 +138,5 @@ pub async fn ica_delete_message_py(msg_id: MessageId, client: &Client) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pub async fn tailchat_new_message_py(message: )
|
||||||
|
|
|
@ -68,8 +68,8 @@ impl TryFrom<RawPyPlugin> for PyPlugin {
|
||||||
};
|
};
|
||||||
let py_module = py_module.unwrap();
|
let py_module = py_module.unwrap();
|
||||||
Python::with_gil(|py| {
|
Python::with_gil(|py| {
|
||||||
let module = py_module.as_ref(py);
|
let module = py_module.bind(py);
|
||||||
if let Some(config_func) = call::get_func(module, &path, "on_config") {
|
if let Ok(config_func) = call::get_func(module, "on_config") {
|
||||||
match config_func.call0() {
|
match config_func.call0() {
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
if config.is_instance_of::<PyTuple>() {
|
if config.is_instance_of::<PyTuple>() {
|
||||||
|
@ -96,8 +96,7 @@ impl TryFrom<RawPyPlugin> for PyPlugin {
|
||||||
};
|
};
|
||||||
match config_value {
|
match config_value {
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
let py_config = class::ConfigDataPy::new(config);
|
let py_config = Bound::new(py, class::ConfigDataPy::new(config)).unwrap();
|
||||||
let py_config = PyCell::new(py, py_config).unwrap();
|
|
||||||
module.setattr("CONFIG_DATA", py_config).unwrap();
|
module.setattr("CONFIG_DATA", py_config).unwrap();
|
||||||
Ok(PyPlugin {
|
Ok(PyPlugin {
|
||||||
file_path: path,
|
file_path: path,
|
||||||
|
@ -228,7 +227,7 @@ pub fn get_change_time(path: &Path) -> Option<SystemTime> { path.metadata().ok()
|
||||||
|
|
||||||
pub fn py_module_from_code(content: &str, path: &Path) -> PyResult<Py<PyAny>> {
|
pub fn py_module_from_code(content: &str, path: &Path) -> PyResult<Py<PyAny>> {
|
||||||
Python::with_gil(|py| -> PyResult<Py<PyAny>> {
|
Python::with_gil(|py| -> PyResult<Py<PyAny>> {
|
||||||
let module: PyResult<Py<PyAny>> = PyModule::from_code(
|
let module: PyResult<Py<PyAny>> = PyModule::from_code_bound(
|
||||||
py,
|
py,
|
||||||
content,
|
content,
|
||||||
&path.to_string_lossy(),
|
&path.to_string_lossy(),
|
||||||
|
|
1
ica-rs/src/tailchat.rs
Normal file
1
ica-rs/src/tailchat.rs
Normal file
|
@ -0,0 +1 @@
|
||||||
|
|
Loading…
Reference in New Issue
Block a user