opt
This commit is contained in:
10
.env.example
10
.env.example
@@ -11,6 +11,10 @@ PUBLIC_PATH=change-me-random-hash-path
|
|||||||
# 例如:https://sub.example.com
|
# 例如:https://sub.example.com
|
||||||
PUBLIC_BASE_URL=
|
PUBLIC_BASE_URL=
|
||||||
|
|
||||||
# 上游这里先放“能直接返回 Clash/Mihomo YAML proxies 文件”的地址
|
# airport-a / airport-b 都会自动识别:
|
||||||
AIRPORT_A_URL=https://example.com/airport-a.yaml
|
# 1. Clash/Mihomo YAML proxies 文件
|
||||||
AIRPORT_B_URL=https://example.com/airport-b.yaml
|
# 2. base64 编码 URI 订阅
|
||||||
|
# 3. 直接明文 URI 订阅
|
||||||
|
AIRPORT_A_URL=
|
||||||
|
# 允许留空;留空时该机场会自动跳过,不参与默认查询
|
||||||
|
AIRPORT_B_URL=
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -13,8 +13,12 @@
|
|||||||
- provider 单源输出、merged provider 输出、thin client 输出、bundle 输出
|
- provider 单源输出、merged provider 输出、thin client 输出、bundle 输出
|
||||||
- 服务端内部继续解耦:抓取、配额头解析、provider 构建、规则加载、profile 组装分层处理
|
- 服务端内部继续解耦:抓取、配额头解析、provider 构建、规则加载、profile 组装分层处理
|
||||||
|
|
||||||
> 当前版本仍然只支持上游已经能返回 Clash/Mihomo YAML `proxies:` 文件的地址。
|
> 当前版本会自动识别上游输入格式,支持:
|
||||||
> 这很适合先接你现有的 `sub-wrapper` / `sub-store` / `subconverter(-extended)` 输出。
|
> 1. 已经能返回 Clash/Mihomo YAML `proxies:` 文件的地址
|
||||||
|
> 2. base64 编码的 URI 订阅
|
||||||
|
> 3. 明文 URI 订阅
|
||||||
|
>
|
||||||
|
> 当前已兼容常见的 `anytls://`、`vless://`、`trojan://`、`ss://`、`vmess://`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,7 +66,8 @@ cp .env.example .env
|
|||||||
|
|
||||||
- `PUBLIC_PATH` 改成足够长的随机字符串
|
- `PUBLIC_PATH` 改成足够长的随机字符串
|
||||||
- `PUBLIC_BASE_URL` 建议填写你反代后的最终访问地址,例如 `https://sub.example.com`
|
- `PUBLIC_BASE_URL` 建议填写你反代后的最终访问地址,例如 `https://sub.example.com`
|
||||||
- `AIRPORT_A_URL` / `AIRPORT_B_URL` 改成你的上游 YAML provider 地址
|
- `AIRPORT_A_URL` / `AIRPORT_B_URL` 都可以直接填订阅地址,项目会自动判断是 YAML 还是 URI 订阅
|
||||||
|
- 允许把其中一个留空;留空时这个机场会自动跳过
|
||||||
|
|
||||||
3. 启动:
|
3. 启动:
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def _base_url(request: Request) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_sources(sources: str | None) -> list[tuple[str, SourceConfig]]:
|
def _resolve_sources(sources: str | None) -> list[tuple[str, SourceConfig]]:
|
||||||
enabled = [(name, src) for name, src in app_config.sources.items() if src.enabled]
|
enabled = [(name, src) for name, src in app_config.sources.items() if src.enabled and str(src.url).strip()]
|
||||||
if not sources:
|
if not sources:
|
||||||
return enabled
|
return enabled
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ def _resolve_sources(sources: str | None) -> list[tuple[str, SourceConfig]]:
|
|||||||
if name in seen:
|
if name in seen:
|
||||||
continue
|
continue
|
||||||
source = app_config.sources.get(name)
|
source = app_config.sources.get(name)
|
||||||
if source is None or not source.enabled:
|
if source is None or not source.enabled or not str(source.url).strip():
|
||||||
raise HTTPException(status_code=404, detail=f"source not found or disabled: {name}")
|
raise HTTPException(status_code=404, detail=f"source not found or disabled: {name}")
|
||||||
selected.append((name, source))
|
selected.append((name, source))
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, HttpUrl
|
|||||||
|
|
||||||
class SourceConfig(BaseModel):
|
class SourceConfig(BaseModel):
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
kind: Literal["clash_yaml"] = "clash_yaml"
|
kind: Literal["auto", "clash_yaml", "base64_uri", "uri"] = "auto"
|
||||||
url: str
|
url: str
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
headers: dict[str, str] = Field(default_factory=dict)
|
headers: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import yaml
|
import yaml
|
||||||
@@ -45,10 +48,7 @@ async def build_provider_document(name: str, source: SourceConfig) -> ProviderDo
|
|||||||
|
|
||||||
fetched = await fetch_source(name, source)
|
fetched = await fetch_source(name, source)
|
||||||
|
|
||||||
if source.kind != "clash_yaml":
|
proxies = parse_source_proxies(fetched.text, source.kind)
|
||||||
raise ValueError(f"Unsupported source kind: {source.kind}")
|
|
||||||
|
|
||||||
proxies = parse_clash_yaml_proxies(fetched.text)
|
|
||||||
proxies = transform_proxies(proxies, source, settings.max_proxy_name_length)
|
proxies = transform_proxies(proxies, source, settings.max_proxy_name_length)
|
||||||
|
|
||||||
document = ProviderDocument(proxies=proxies)
|
document = ProviderDocument(proxies=proxies)
|
||||||
@@ -134,6 +134,324 @@ def parse_clash_yaml_proxies(text: str) -> list[dict[str, Any]]:
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def parse_source_proxies(text: str, source_kind: str) -> list[dict[str, Any]]:
|
||||||
|
parsers: dict[str, list] = {
|
||||||
|
"auto": [parse_clash_yaml_proxies, parse_base64_uri_proxies, parse_uri_text_proxies],
|
||||||
|
"clash_yaml": [parse_clash_yaml_proxies],
|
||||||
|
"base64_uri": [parse_base64_uri_proxies, parse_uri_text_proxies],
|
||||||
|
"uri": [parse_uri_text_proxies, parse_base64_uri_proxies],
|
||||||
|
}
|
||||||
|
errors: list[str] = []
|
||||||
|
for parser in parsers.get(source_kind, []):
|
||||||
|
try:
|
||||||
|
proxies = parser(text)
|
||||||
|
if proxies:
|
||||||
|
return proxies
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
errors.append(f"{parser.__name__}: {exc}")
|
||||||
|
detail = "; ".join(errors) if errors else f"unsupported source kind: {source_kind}"
|
||||||
|
raise ValueError(f"Failed to parse upstream subscription: {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_base64_uri_proxies(text: str) -> list[dict[str, Any]]:
|
||||||
|
decoded = decode_base64_subscription(text)
|
||||||
|
return parse_uri_text_proxies(decoded)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_uri_text_proxies(text: str) -> list[dict[str, Any]]:
|
||||||
|
candidates = [
|
||||||
|
line.strip()
|
||||||
|
for line in text.splitlines()
|
||||||
|
if line.strip() and not line.strip().startswith("#")
|
||||||
|
]
|
||||||
|
proxies: list[dict[str, Any]] = []
|
||||||
|
unsupported: set[str] = set()
|
||||||
|
|
||||||
|
for line in candidates:
|
||||||
|
if "://" not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
scheme = line.split("://", 1)[0].lower()
|
||||||
|
if scheme == "anytls":
|
||||||
|
proxies.append(parse_anytls_uri(line))
|
||||||
|
continue
|
||||||
|
if scheme == "trojan":
|
||||||
|
proxies.append(parse_trojan_uri(line))
|
||||||
|
continue
|
||||||
|
if scheme == "vless":
|
||||||
|
proxies.append(parse_vless_uri(line))
|
||||||
|
continue
|
||||||
|
if scheme == "ss":
|
||||||
|
proxies.append(parse_ss_uri(line))
|
||||||
|
continue
|
||||||
|
if scheme == "vmess":
|
||||||
|
proxies.append(parse_vmess_uri(line))
|
||||||
|
continue
|
||||||
|
|
||||||
|
unsupported.add(scheme)
|
||||||
|
|
||||||
|
if not proxies:
|
||||||
|
detail = f"unsupported URI schemes: {', '.join(sorted(unsupported))}" if unsupported else "no proxy URIs found"
|
||||||
|
raise ValueError(f"Base64 subscription parsing failed: {detail}")
|
||||||
|
|
||||||
|
return proxies
|
||||||
|
|
||||||
|
|
||||||
|
def decode_base64_subscription(text: str) -> str:
|
||||||
|
compact = "".join(text.strip().split())
|
||||||
|
if not compact:
|
||||||
|
raise ValueError("Base64 subscription is empty")
|
||||||
|
padded = compact + ("=" * (-len(compact) % 4))
|
||||||
|
try:
|
||||||
|
return base64.b64decode(padded, validate=False).decode("utf-8")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise ValueError("Upstream content is not valid base64 subscription text") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_anytls_uri(uri: str) -> dict[str, Any]:
|
||||||
|
parsed = urlparse(uri)
|
||||||
|
server = parsed.hostname
|
||||||
|
port = parsed.port
|
||||||
|
password = unquote(parsed.username or "")
|
||||||
|
if not server or not port or not password:
|
||||||
|
raise ValueError("Invalid anytls URI: missing server, port, or password")
|
||||||
|
|
||||||
|
params = parse_qs(parsed.query, keep_blank_values=False)
|
||||||
|
proxy: dict[str, Any] = {
|
||||||
|
"name": unquote(parsed.fragment or f"{server}:{port}"),
|
||||||
|
"type": "anytls",
|
||||||
|
"server": server,
|
||||||
|
"port": port,
|
||||||
|
"password": password,
|
||||||
|
"udp": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
sni = _first_param(params, "sni", "serverName", "servername", "peer")
|
||||||
|
if sni:
|
||||||
|
proxy["sni"] = sni
|
||||||
|
|
||||||
|
fingerprint = _first_param(params, "fp", "fingerprint", "client-fingerprint", "clientFingerprint")
|
||||||
|
if fingerprint:
|
||||||
|
proxy["client-fingerprint"] = fingerprint
|
||||||
|
|
||||||
|
insecure = _first_param(params, "insecure", "allowInsecure", "skip-cert-verify")
|
||||||
|
if insecure is not None:
|
||||||
|
proxy["skip-cert-verify"] = insecure.lower() in {"1", "true", "yes"}
|
||||||
|
|
||||||
|
udp = _first_param(params, "udp")
|
||||||
|
if udp is not None:
|
||||||
|
proxy["udp"] = udp.lower() in {"1", "true", "yes"}
|
||||||
|
|
||||||
|
alpn = _first_param(params, "alpn")
|
||||||
|
if alpn:
|
||||||
|
proxy["alpn"] = [item.strip() for item in alpn.split(",") if item.strip()]
|
||||||
|
|
||||||
|
for uri_key, proxy_key in (
|
||||||
|
("idle-session-check-interval", "idle-session-check-interval"),
|
||||||
|
("idleSessionCheckInterval", "idle-session-check-interval"),
|
||||||
|
("idle-session-timeout", "idle-session-timeout"),
|
||||||
|
("idleSessionTimeout", "idle-session-timeout"),
|
||||||
|
("min-idle-session", "min-idle-session"),
|
||||||
|
("minIdleSession", "min-idle-session"),
|
||||||
|
):
|
||||||
|
value = _first_param(params, uri_key)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
proxy[proxy_key] = int(value)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
|
||||||
|
def parse_trojan_uri(uri: str) -> dict[str, Any]:
|
||||||
|
parsed = urlparse(uri)
|
||||||
|
server = parsed.hostname
|
||||||
|
port = parsed.port
|
||||||
|
password = unquote(parsed.username or "")
|
||||||
|
if not server or not port or not password:
|
||||||
|
raise ValueError("Invalid trojan URI: missing server, port, or password")
|
||||||
|
|
||||||
|
params = parse_qs(parsed.query, keep_blank_values=False)
|
||||||
|
proxy: dict[str, Any] = {
|
||||||
|
"name": unquote(parsed.fragment or f"{server}:{port}"),
|
||||||
|
"type": "trojan",
|
||||||
|
"server": server,
|
||||||
|
"port": port,
|
||||||
|
"password": password,
|
||||||
|
"udp": True,
|
||||||
|
}
|
||||||
|
_apply_tls_like_params(proxy, params, default_sni=server)
|
||||||
|
network = _first_param(params, "type", "network")
|
||||||
|
if network:
|
||||||
|
proxy["network"] = network
|
||||||
|
ws_path = _first_param(params, "path")
|
||||||
|
if ws_path and proxy.get("network") == "ws":
|
||||||
|
proxy["ws-opts"] = {"path": ws_path}
|
||||||
|
host = _first_param(params, "host", "Host")
|
||||||
|
if host:
|
||||||
|
proxy["ws-opts"]["headers"] = {"Host": host}
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
|
||||||
|
def parse_vless_uri(uri: str) -> dict[str, Any]:
|
||||||
|
parsed = urlparse(uri)
|
||||||
|
server = parsed.hostname
|
||||||
|
port = parsed.port
|
||||||
|
uuid = unquote(parsed.username or "")
|
||||||
|
if not server or not port or not uuid:
|
||||||
|
raise ValueError("Invalid vless URI: missing server, port, or uuid")
|
||||||
|
|
||||||
|
params = parse_qs(parsed.query, keep_blank_values=False)
|
||||||
|
proxy: dict[str, Any] = {
|
||||||
|
"name": unquote(parsed.fragment or f"{server}:{port}"),
|
||||||
|
"type": "vless",
|
||||||
|
"server": server,
|
||||||
|
"port": port,
|
||||||
|
"uuid": uuid,
|
||||||
|
"udp": True,
|
||||||
|
}
|
||||||
|
network = _first_param(params, "type", "network") or "tcp"
|
||||||
|
proxy["network"] = network
|
||||||
|
flow = _first_param(params, "flow")
|
||||||
|
if flow:
|
||||||
|
proxy["flow"] = flow
|
||||||
|
if (_first_param(params, "security") or "").lower() == "reality":
|
||||||
|
public_key = _first_param(params, "pbk", "public-key")
|
||||||
|
if public_key:
|
||||||
|
proxy["reality-opts"] = {"public-key": public_key}
|
||||||
|
short_id = _first_param(params, "sid", "short-id")
|
||||||
|
if short_id:
|
||||||
|
proxy["reality-opts"]["short-id"] = short_id
|
||||||
|
else:
|
||||||
|
_apply_tls_like_params(proxy, params, default_sni=server)
|
||||||
|
if network == "ws":
|
||||||
|
proxy["ws-opts"] = {"path": _first_param(params, "path") or "/"}
|
||||||
|
host = _first_param(params, "host", "Host")
|
||||||
|
if host:
|
||||||
|
proxy["ws-opts"]["headers"] = {"Host": host}
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ss_uri(uri: str) -> dict[str, Any]:
|
||||||
|
rest = uri[len("ss://") :]
|
||||||
|
fragment = ""
|
||||||
|
if "#" in rest:
|
||||||
|
rest, fragment = rest.split("#", 1)
|
||||||
|
query = ""
|
||||||
|
if "?" in rest:
|
||||||
|
rest, query = rest.split("?", 1)
|
||||||
|
|
||||||
|
if "@" not in rest:
|
||||||
|
decoded = decode_base64_subscription(rest)
|
||||||
|
if "@" not in decoded:
|
||||||
|
raise ValueError("Invalid ss URI: missing server section")
|
||||||
|
userinfo, server_part = decoded.rsplit("@", 1)
|
||||||
|
else:
|
||||||
|
userinfo, server_part = rest.rsplit("@", 1)
|
||||||
|
try:
|
||||||
|
userinfo = decode_base64_subscription(userinfo)
|
||||||
|
except ValueError:
|
||||||
|
userinfo = unquote(userinfo)
|
||||||
|
|
||||||
|
if ":" not in userinfo or ":" not in server_part:
|
||||||
|
raise ValueError("Invalid ss URI: malformed credentials or server")
|
||||||
|
cipher, password = userinfo.split(":", 1)
|
||||||
|
server, port_text = server_part.rsplit(":", 1)
|
||||||
|
params = parse_qs(query, keep_blank_values=False)
|
||||||
|
|
||||||
|
proxy: dict[str, Any] = {
|
||||||
|
"name": unquote(fragment or f"{server}:{port_text}"),
|
||||||
|
"type": "ss",
|
||||||
|
"server": server.strip("[]"),
|
||||||
|
"port": int(port_text),
|
||||||
|
"cipher": cipher,
|
||||||
|
"password": password,
|
||||||
|
"udp": True,
|
||||||
|
}
|
||||||
|
plugin = _first_param(params, "plugin")
|
||||||
|
if plugin:
|
||||||
|
proxy["plugin"] = plugin.split(";", 1)[0]
|
||||||
|
plugin_opts: dict[str, Any] = {}
|
||||||
|
for item in plugin.split(";")[1:]:
|
||||||
|
if "=" not in item:
|
||||||
|
continue
|
||||||
|
key, value = item.split("=", 1)
|
||||||
|
plugin_opts[key] = value
|
||||||
|
if plugin_opts:
|
||||||
|
proxy["plugin-opts"] = plugin_opts
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
|
||||||
|
def parse_vmess_uri(uri: str) -> dict[str, Any]:
|
||||||
|
raw = uri[len("vmess://") :]
|
||||||
|
decoded = decode_base64_subscription(raw)
|
||||||
|
try:
|
||||||
|
data = json.loads(decoded)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError("Invalid vmess URI JSON payload") from exc
|
||||||
|
|
||||||
|
server = str(data.get("add") or "").strip()
|
||||||
|
port = int(str(data.get("port") or "0"))
|
||||||
|
uuid = str(data.get("id") or "").strip()
|
||||||
|
if not server or not port or not uuid:
|
||||||
|
raise ValueError("Invalid vmess URI: missing add, port, or id")
|
||||||
|
|
||||||
|
network = str(data.get("net") or "tcp").strip() or "tcp"
|
||||||
|
proxy: dict[str, Any] = {
|
||||||
|
"name": str(data.get("ps") or f"{server}:{port}"),
|
||||||
|
"type": "vmess",
|
||||||
|
"server": server,
|
||||||
|
"port": port,
|
||||||
|
"uuid": uuid,
|
||||||
|
"alterId": int(str(data.get("aid") or "0")),
|
||||||
|
"cipher": "auto",
|
||||||
|
"udp": True,
|
||||||
|
"network": network,
|
||||||
|
}
|
||||||
|
if str(data.get("tls") or "").lower() == "tls":
|
||||||
|
proxy["tls"] = True
|
||||||
|
sni = str(data.get("sni") or data.get("host") or "").strip()
|
||||||
|
if sni:
|
||||||
|
proxy["servername"] = sni
|
||||||
|
if str(data.get("allowInsecure") or "").lower() in {"1", "true"}:
|
||||||
|
proxy["skip-cert-verify"] = True
|
||||||
|
if network == "ws":
|
||||||
|
proxy["ws-opts"] = {"path": str(data.get("path") or "/")}
|
||||||
|
host = str(data.get("host") or "").strip()
|
||||||
|
if host:
|
||||||
|
proxy["ws-opts"]["headers"] = {"Host": host}
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_tls_like_params(proxy: dict[str, Any], params: dict[str, list[str]], *, default_sni: str | None = None) -> None:
|
||||||
|
security = (_first_param(params, "security") or "").lower()
|
||||||
|
if security in {"tls", "xtls"} or any(key in params for key in ("sni", "peer", "allowInsecure", "insecure")):
|
||||||
|
proxy["tls"] = True
|
||||||
|
sni = _first_param(params, "sni", "peer", "serverName", "servername") or default_sni
|
||||||
|
if sni:
|
||||||
|
proxy["sni"] = sni
|
||||||
|
proxy["servername"] = sni
|
||||||
|
insecure = _first_param(params, "insecure", "allowInsecure", "skip-cert-verify")
|
||||||
|
if insecure is not None:
|
||||||
|
proxy["skip-cert-verify"] = insecure.lower() in {"1", "true", "yes"}
|
||||||
|
alpn = _first_param(params, "alpn")
|
||||||
|
if alpn:
|
||||||
|
proxy["alpn"] = [item.strip() for item in alpn.split(",") if item.strip()]
|
||||||
|
fp = _first_param(params, "fp", "fingerprint", "client-fingerprint", "clientFingerprint")
|
||||||
|
if fp:
|
||||||
|
proxy["client-fingerprint"] = fp
|
||||||
|
|
||||||
|
|
||||||
|
def _first_param(params: dict[str, list[str]], *keys: str) -> str | None:
|
||||||
|
for key in keys:
|
||||||
|
values = params.get(key)
|
||||||
|
if values:
|
||||||
|
return unquote(values[0])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def transform_proxies(
|
def transform_proxies(
|
||||||
proxies: list[dict[str, Any]], source: SourceConfig, max_proxy_name_length: int
|
proxies: list[dict[str, Any]], source: SourceConfig, max_proxy_name_length: int
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ public_path: ${PUBLIC_PATH}
|
|||||||
sources:
|
sources:
|
||||||
airport-a:
|
airport-a:
|
||||||
enabled: true
|
enabled: true
|
||||||
display_name: 蛋挞云
|
display_name: A
|
||||||
kind: clash_yaml
|
kind: auto
|
||||||
url: ${AIRPORT_A_URL}
|
url: ${AIRPORT_A_URL}
|
||||||
prefix: "[A] "
|
prefix: "[A] "
|
||||||
include_regex: ""
|
include_regex: ""
|
||||||
exclude_regex: ""
|
exclude_regex: ""
|
||||||
|
|
||||||
airport-b:
|
airport-b:
|
||||||
enabled: true
|
enabled: false
|
||||||
display_name: AnyTLS
|
display_name: B
|
||||||
kind: clash_yaml
|
kind: auto
|
||||||
url: ${AIRPORT_B_URL}
|
url: ${AIRPORT_B_URL}
|
||||||
prefix: "[B] "
|
prefix: "[B] "
|
||||||
include_regex: ""
|
include_regex: ""
|
||||||
|
|||||||
Reference in New Issue
Block a user