mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-05-12 02:21:17 +08:00
Compare commits
3 Commits
feat_custo
...
feat_skip_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d451cfa0 | ||
|
|
744bb536b3 | ||
|
|
91ab64a068 |
@@ -1,5 +1,6 @@
|
|||||||
use bili_sync_entity::rule::Rule;
|
use bili_sync_entity::rule::Rule;
|
||||||
use bili_sync_entity::*;
|
use bili_sync_entity::*;
|
||||||
|
use sea_orm::prelude::DateTime;
|
||||||
use sea_orm::{DerivePartialModel, FromQueryResult};
|
use sea_orm::{DerivePartialModel, FromQueryResult};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
@@ -218,6 +219,7 @@ pub struct VideoSourceDetail {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub use_dynamic_api: Option<bool>,
|
pub use_dynamic_api: Option<bool>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
pub latest_row_at: Option<DateTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use sea_orm::DatabaseConnection;
|
|||||||
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
|
||||||
use crate::bilibili::BiliClient;
|
use crate::bilibili::BiliClient;
|
||||||
use crate::config::{Config, VersionedConfig};
|
use crate::config::{Config, VersionedConfig};
|
||||||
use crate::notifier::Notifier;
|
use crate::notifier::{Message, Notifier};
|
||||||
|
|
||||||
pub(super) fn router() -> Router {
|
pub(super) fn router() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -41,7 +41,10 @@ pub async fn ping_notifiers(
|
|||||||
*ignore_cache = Some(());
|
*ignore_cache = Some(());
|
||||||
}
|
}
|
||||||
notifier
|
notifier
|
||||||
.notify(bili_client.inner_client(), "This is a test notification from BiliSync.")
|
.notify(bili_client.inner_client(), Message{
|
||||||
|
message: "This is a test notification from BiliSync.".into(),
|
||||||
|
image_url: Some("https://socialify.git.ci/amtoaer/bili-sync/image?description=1&font=KoHo&issues=1&language=1&logo=https%3A%2F%2Fs2.loli.net%2F2023%2F12%2F02%2F9EwT2yInOu1d3zm.png&name=1&owner=1&pattern=Signal&pulls=1&stargazers=1&theme=Light".to_owned()),
|
||||||
|
})
|
||||||
.await?;
|
.await?;
|
||||||
Ok(ApiResponse::ok(()))
|
Ok(ApiResponse::ok(()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ pub async fn get_video_sources_details(
|
|||||||
collection::Column::Name,
|
collection::Column::Name,
|
||||||
collection::Column::Path,
|
collection::Column::Path,
|
||||||
collection::Column::Rule,
|
collection::Column::Rule,
|
||||||
collection::Column::Enabled
|
collection::Column::Enabled,
|
||||||
|
collection::Column::LatestRowAt
|
||||||
])
|
])
|
||||||
.into_model::<VideoSourceDetail>()
|
.into_model::<VideoSourceDetail>()
|
||||||
.all(&db),
|
.all(&db),
|
||||||
@@ -115,7 +116,8 @@ pub async fn get_video_sources_details(
|
|||||||
favorite::Column::Name,
|
favorite::Column::Name,
|
||||||
favorite::Column::Path,
|
favorite::Column::Path,
|
||||||
favorite::Column::Rule,
|
favorite::Column::Rule,
|
||||||
favorite::Column::Enabled
|
favorite::Column::Enabled,
|
||||||
|
favorite::Column::LatestRowAt
|
||||||
])
|
])
|
||||||
.into_model::<VideoSourceDetail>()
|
.into_model::<VideoSourceDetail>()
|
||||||
.all(&db),
|
.all(&db),
|
||||||
@@ -127,7 +129,8 @@ pub async fn get_video_sources_details(
|
|||||||
submission::Column::Path,
|
submission::Column::Path,
|
||||||
submission::Column::Enabled,
|
submission::Column::Enabled,
|
||||||
submission::Column::Rule,
|
submission::Column::Rule,
|
||||||
submission::Column::UseDynamicApi
|
submission::Column::UseDynamicApi,
|
||||||
|
submission::Column::LatestRowAt
|
||||||
])
|
])
|
||||||
.into_model::<VideoSourceDetail>()
|
.into_model::<VideoSourceDetail>()
|
||||||
.all(&db),
|
.all(&db),
|
||||||
@@ -138,7 +141,8 @@ pub async fn get_video_sources_details(
|
|||||||
watch_later::Column::Id,
|
watch_later::Column::Id,
|
||||||
watch_later::Column::Path,
|
watch_later::Column::Path,
|
||||||
watch_later::Column::Enabled,
|
watch_later::Column::Enabled,
|
||||||
watch_later::Column::Rule
|
watch_later::Column::Rule,
|
||||||
|
watch_later::Column::LatestRowAt
|
||||||
])
|
])
|
||||||
.into_model::<VideoSourceDetail>()
|
.into_model::<VideoSourceDetail>()
|
||||||
.all(&db)
|
.all(&db)
|
||||||
@@ -152,6 +156,7 @@ pub async fn get_video_sources_details(
|
|||||||
rule_display: None,
|
rule_display: None,
|
||||||
use_dynamic_api: None,
|
use_dynamic_api: None,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
latest_row_at: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for sources in [&mut collections, &mut favorites, &mut submissions, &mut watch_later] {
|
for sources in [&mut collections, &mut favorites, &mut submissions, &mut watch_later] {
|
||||||
@@ -159,6 +164,7 @@ pub async fn get_video_sources_details(
|
|||||||
if let Some(rule) = &item.rule {
|
if let Some(rule) = &item.rule {
|
||||||
item.rule_display = Some(rule.to_string());
|
item.rule_display = Some(rule.to_string());
|
||||||
}
|
}
|
||||||
|
item.latest_row_at = item.latest_row_at.filter(|dt| dt.and_utc().timestamp() != 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(ApiResponse::ok(VideoSourcesDetailsResponse {
|
Ok(ApiResponse::ok(VideoSourcesDetailsResponse {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
mod info;
|
mod info;
|
||||||
mod message;
|
mod message;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use futures::future;
|
use futures::future;
|
||||||
pub use info::DownloadNotifyInfo;
|
pub use info::DownloadNotifyInfo;
|
||||||
@@ -16,10 +18,14 @@ pub enum Notifier {
|
|||||||
Telegram {
|
Telegram {
|
||||||
bot_token: String,
|
bot_token: String,
|
||||||
chat_id: String,
|
chat_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
skip_image: bool,
|
||||||
},
|
},
|
||||||
Webhook {
|
Webhook {
|
||||||
url: String,
|
url: String,
|
||||||
template: Option<String>,
|
template: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
headers: Option<HashMap<String, String>>,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
// 一个内部辅助字段,用于决定是否强制渲染当前模板,在测试时使用
|
// 一个内部辅助字段,用于决定是否强制渲染当前模板,在测试时使用
|
||||||
ignore_cache: Option<()>,
|
ignore_cache: Option<()>,
|
||||||
@@ -56,8 +62,14 @@ impl Notifier {
|
|||||||
|
|
||||||
async fn notify_internal<'a>(&self, client: &reqwest::Client, message: &Message<'a>) -> Result<()> {
|
async fn notify_internal<'a>(&self, client: &reqwest::Client, message: &Message<'a>) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
Notifier::Telegram { bot_token, chat_id } => {
|
Notifier::Telegram {
|
||||||
if let Some(img_url) = &message.image_url {
|
bot_token,
|
||||||
|
chat_id,
|
||||||
|
skip_image,
|
||||||
|
} => {
|
||||||
|
if let Some(img_url) = &message.image_url
|
||||||
|
&& !*skip_image
|
||||||
|
{
|
||||||
let url = format!("https://api.telegram.org/bot{}/sendPhoto", bot_token);
|
let url = format!("https://api.telegram.org/bot{}/sendPhoto", bot_token);
|
||||||
let params = [
|
let params = [
|
||||||
("chat_id", chat_id.as_str()),
|
("chat_id", chat_id.as_str()),
|
||||||
@@ -74,6 +86,7 @@ impl Notifier {
|
|||||||
Notifier::Webhook {
|
Notifier::Webhook {
|
||||||
url,
|
url,
|
||||||
template,
|
template,
|
||||||
|
headers,
|
||||||
ignore_cache,
|
ignore_cache,
|
||||||
} => {
|
} => {
|
||||||
let key = webhook_template_key(url);
|
let key = webhook_template_key(url);
|
||||||
@@ -82,12 +95,20 @@ impl Notifier {
|
|||||||
Some(_) => handlebar.render_template(webhook_template_content(template), &message)?,
|
Some(_) => handlebar.render_template(webhook_template_content(template), &message)?,
|
||||||
None => handlebar.render(&key, &message)?,
|
None => handlebar.render(&key, &message)?,
|
||||||
};
|
};
|
||||||
client
|
let mut headers_map = header::HeaderMap::new();
|
||||||
.post(url)
|
headers_map.insert(header::CONTENT_TYPE, "application/json".try_into()?);
|
||||||
.header(header::CONTENT_TYPE, "application/json")
|
|
||||||
.body(payload)
|
if let Some(custom_headers) = headers {
|
||||||
.send()
|
for (key, value) in custom_headers {
|
||||||
.await?;
|
if let (Ok(key), Ok(value)) =
|
||||||
|
(header::HeaderName::try_from(key), header::HeaderValue::try_from(value))
|
||||||
|
{
|
||||||
|
headers_map.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.post(url).headers(headers_map).body(payload).send().await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export interface VideoSourceDetail {
|
|||||||
ruleDisplay: string | null;
|
ruleDisplay: string | null;
|
||||||
useDynamicApi: boolean | null;
|
useDynamicApi: boolean | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
latestRowAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoSourcesDetailsResponse {
|
export interface VideoSourcesDetailsResponse {
|
||||||
@@ -305,12 +306,14 @@ export interface TelegramNotifier {
|
|||||||
type: 'telegram';
|
type: 'telegram';
|
||||||
bot_token: string;
|
bot_token: string;
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
|
skip_image: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebhookNotifier {
|
export interface WebhookNotifier {
|
||||||
type: 'webhook';
|
type: 'webhook';
|
||||||
url: string;
|
url: string;
|
||||||
template?: string | null;
|
template?: string | null;
|
||||||
|
headers?: Record<string, string> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Notifier = TelegramNotifier | WebhookNotifier;
|
export type Notifier = TelegramNotifier | WebhookNotifier;
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import type { Notifier } from '$lib/types';
|
import type { Notifier } from '$lib/types';
|
||||||
|
|
||||||
const jsonExample = '{"text": "您的消息内容"}';
|
|
||||||
|
|
||||||
export let notifier: Notifier | null = null;
|
export let notifier: Notifier | null = null;
|
||||||
export let onSave: (notifier: Notifier) => void;
|
export let onSave: (notifier: Notifier) => void;
|
||||||
export let onCancel: () => void;
|
export let onCancel: () => void;
|
||||||
@@ -14,8 +13,10 @@
|
|||||||
let type: 'telegram' | 'webhook' = 'telegram';
|
let type: 'telegram' | 'webhook' = 'telegram';
|
||||||
let botToken = '';
|
let botToken = '';
|
||||||
let chatId = '';
|
let chatId = '';
|
||||||
|
let skipImage = false;
|
||||||
let webhookUrl = '';
|
let webhookUrl = '';
|
||||||
let webhookTemplate = '';
|
let webhookTemplate = '';
|
||||||
|
let webhookHeaders: { key: string; value: string }[] = [];
|
||||||
|
|
||||||
// 初始化表单
|
// 初始化表单
|
||||||
$: {
|
$: {
|
||||||
@@ -24,22 +25,29 @@
|
|||||||
type = 'telegram';
|
type = 'telegram';
|
||||||
botToken = notifier.bot_token;
|
botToken = notifier.bot_token;
|
||||||
chatId = notifier.chat_id;
|
chatId = notifier.chat_id;
|
||||||
|
skipImage = notifier.skip_image;
|
||||||
} else {
|
} else {
|
||||||
type = 'webhook';
|
type = 'webhook';
|
||||||
webhookUrl = notifier.url;
|
webhookUrl = notifier.url;
|
||||||
webhookTemplate = notifier.template || '';
|
webhookTemplate = notifier.template || '';
|
||||||
|
if (notifier.headers) {
|
||||||
|
webhookHeaders = Object.entries(notifier.headers).map(([key, value]) => ({ key, value }));
|
||||||
|
} else {
|
||||||
|
webhookHeaders = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
type = 'telegram';
|
type = 'telegram';
|
||||||
botToken = '';
|
botToken = '';
|
||||||
chatId = '';
|
chatId = '';
|
||||||
|
skipImage = false;
|
||||||
webhookUrl = '';
|
webhookUrl = '';
|
||||||
webhookTemplate = '';
|
webhookTemplate = '';
|
||||||
|
webhookHeaders = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
// 验证表单
|
|
||||||
if (type === 'telegram') {
|
if (type === 'telegram') {
|
||||||
if (!botToken.trim()) {
|
if (!botToken.trim()) {
|
||||||
toast.error('请输入 Bot Token');
|
toast.error('请输入 Bot Token');
|
||||||
@@ -53,7 +61,8 @@
|
|||||||
const newNotifier: Notifier = {
|
const newNotifier: Notifier = {
|
||||||
type: 'telegram',
|
type: 'telegram',
|
||||||
bot_token: botToken.trim(),
|
bot_token: botToken.trim(),
|
||||||
chat_id: chatId.trim()
|
chat_id: chatId.trim(),
|
||||||
|
skip_image: skipImage
|
||||||
};
|
};
|
||||||
onSave(newNotifier);
|
onSave(newNotifier);
|
||||||
} else {
|
} else {
|
||||||
@@ -62,7 +71,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 简单的 URL 验证
|
|
||||||
try {
|
try {
|
||||||
new URL(webhookUrl.trim());
|
new URL(webhookUrl.trim());
|
||||||
} catch {
|
} catch {
|
||||||
@@ -70,10 +78,20 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const { key, value } of webhookHeaders) {
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
if (trimmedKey && trimmedValue) {
|
||||||
|
headers[trimmedKey] = trimmedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newNotifier: Notifier = {
|
const newNotifier: Notifier = {
|
||||||
type: 'webhook',
|
type: 'webhook',
|
||||||
url: webhookUrl.trim(),
|
url: webhookUrl.trim(),
|
||||||
template: webhookTemplate.trim() || null
|
template: webhookTemplate.trim() || null,
|
||||||
|
headers: Object.keys(headers).length > 0 ? headers : null
|
||||||
};
|
};
|
||||||
onSave(newNotifier);
|
onSave(newNotifier);
|
||||||
}
|
}
|
||||||
@@ -108,14 +126,14 @@
|
|||||||
<Input id="chat-id" placeholder="-1001234567890" bind:value={chatId} />
|
<Input id="chat-id" placeholder="-1001234567890" bind:value={chatId} />
|
||||||
<p class="text-muted-foreground text-xs">目标聊天室的 ID(个人用户、群组或频道)</p>
|
<p class="text-muted-foreground text-xs">目标聊天室的 ID(个人用户、群组或频道)</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox id="skip-image" bind:checked={skipImage} />
|
||||||
|
<Label for="skip-image" class="text-sm font-normal">仅发送文字</Label>
|
||||||
|
</div>
|
||||||
{:else if type === 'webhook'}
|
{:else if type === 'webhook'}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="webhook-url">Webhook URL</Label>
|
<Label for="webhook-url">Webhook URL</Label>
|
||||||
<Input id="webhook-url" placeholder="https://example.com/webhook" bind:value={webhookUrl} />
|
<Input id="webhook-url" placeholder="请输入 Webhook 地址" bind:value={webhookUrl} />
|
||||||
<p class="text-muted-foreground text-xs">
|
|
||||||
接收通知的 Webhook 地址<br />
|
|
||||||
格式示例:{jsonExample}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="webhook-template">模板(可选)</Label>
|
<Label for="webhook-template">模板(可选)</Label>
|
||||||
@@ -127,7 +145,48 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
<p class="text-muted-foreground text-xs">
|
<p class="text-muted-foreground text-xs">
|
||||||
用于渲染 Webhook 的 Handlebars 模板。如果不填写,将使用默认模板。<br />
|
用于渲染 Webhook 的 Handlebars 模板。如果不填写,将使用默认模板。<br />
|
||||||
可用变量:<code class="text-xs">message</code>(通知内容)
|
可用变量:<code class="text-xs">message</code>(通知内容)、<code class="text-xs"
|
||||||
|
>image_url</code
|
||||||
|
>(封面图片地址,无图时为 null)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label>自定义请求头(可选)</Label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (webhookHeaders = [...webhookHeaders, { key: '', value: '' }])}
|
||||||
|
>
|
||||||
|
+ 添加请求头
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#each webhookHeaders as header, index (index)}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Header 名称(例如 Authorization)"
|
||||||
|
bind:value={header.key}
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Header 值"
|
||||||
|
bind:value={header.value}
|
||||||
|
class="flex-1"
|
||||||
|
type={header.key.toLowerCase() === 'authorization' ? 'password' : 'text'}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (webhookHeaders = webhookHeaders.filter((_, i) => i !== index))}
|
||||||
|
class="h-10 px-2"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
添加自定义请求头,例如:Authorization: Bearer your_token
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -366,8 +366,9 @@
|
|||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head class="w-[20%]">名称</Table.Head>
|
<Table.Head class="w-[20%]">名称</Table.Head>
|
||||||
<Table.Head class="w-[30%]">下载路径</Table.Head>
|
<Table.Head class="w-[30%]">下载路径</Table.Head>
|
||||||
|
<Table.Head class="w-[15%]">最新视频时间</Table.Head>
|
||||||
<Table.Head class="w-[15%]">过滤规则</Table.Head>
|
<Table.Head class="w-[15%]">过滤规则</Table.Head>
|
||||||
<Table.Head class="w-[15%]">启用状态</Table.Head>
|
<Table.Head class="w-[10%]">启用状态</Table.Head>
|
||||||
<Table.Head class="w-[10%] text-right">操作</Table.Head>
|
<Table.Head class="w-[10%] text-right">操作</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
@@ -387,6 +388,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{#if source.latestRowAt}
|
||||||
|
<Badge variant="secondary" class="flex w-fit items-center gap-1.5">
|
||||||
|
{new Date(source.latestRowAt).toLocaleString('zh-CN')}
|
||||||
|
</Badge>
|
||||||
|
{:else}
|
||||||
|
<Badge variant="secondary" class="flex w-fit items-center gap-1.5">
|
||||||
|
-
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{#if source.rule && source.rule.length > 0}
|
{#if source.rule && source.rule.length > 0}
|
||||||
<Tooltip.Root disableHoverableContent={true}>
|
<Tooltip.Root disableHoverableContent={true}>
|
||||||
|
|||||||
Reference in New Issue
Block a user