feat: sync durable moderation and appeals
This commit is contained in:
parent
e1a95c7318
commit
9f467f4be7
140 changed files with 13730 additions and 316 deletions
|
|
@ -67,6 +67,12 @@ func (s *server) routes() http.Handler {
|
||||||
mux.Handle("GET /api/gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftAnimationAPI)))
|
mux.Handle("GET /api/gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftAnimationAPI)))
|
||||||
mux.Handle("GET /api/gifts/{id}/collectibles", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectiblesAPI)))
|
mux.Handle("GET /api/gifts/{id}/collectibles", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectiblesAPI)))
|
||||||
mux.Handle("GET /api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectibleAnimationAPI)))
|
mux.Handle("GET /api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectibleAnimationAPI)))
|
||||||
|
mux.Handle("GET /api/moderation/cases", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCasesAPI)))
|
||||||
|
mux.Handle("GET /api/moderation/cases/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCaseAPI)))
|
||||||
|
mux.Handle("GET /api/moderation/reports/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleModerationReportAPI)))
|
||||||
|
mux.Handle("POST /api/moderation/cases/{id}/claim", s.requireAuthAPI(http.HandlerFunc(s.handleClaimModerationCaseAPI)))
|
||||||
|
mux.Handle("POST /api/moderation/cases/{id}/decide", s.requireAuthAPI(http.HandlerFunc(s.handleDecideModerationCaseAPI)))
|
||||||
|
mux.Handle("POST /api/moderation/cases/{id}/appeals/{appeal_id}/review", s.requireAuthAPI(http.HandlerFunc(s.handleReviewModerationAppealAPI)))
|
||||||
mux.Handle("POST /api/actions/set-frozen", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountFrozenAPI)))
|
mux.Handle("POST /api/actions/set-frozen", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountFrozenAPI)))
|
||||||
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
|
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
|
||||||
mux.Handle("POST /api/actions/grant-stars", s.requireAuthAPI(http.HandlerFunc(s.handleGrantStarsAPI)))
|
mux.Handle("POST /api/actions/grant-stars", s.requireAuthAPI(http.HandlerFunc(s.handleGrantStarsAPI)))
|
||||||
|
|
@ -367,6 +373,110 @@ func (s *server) proxyAdminJSON(w http.ResponseWriter, r *http.Request, apiPath
|
||||||
_, _ = w.Write(raw)
|
_, _ = w.Write(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *server) handleModerationCasesAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
apiPath := "/v1/moderation/cases"
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
apiPath += "?" + r.URL.RawQuery
|
||||||
|
}
|
||||||
|
s.proxyAdminJSON(w, r, apiPath, 4<<20)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleModerationCaseAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "invalid moderation case id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/moderation/cases/%d", id), 4<<20)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleModerationReportAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "invalid moderation report id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/moderation/reports/%d", id), 4<<20)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleClaimModerationCaseAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.proxyModerationWrite(w, r, "claim", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleDecideModerationCaseAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.proxyModerationWrite(w, r, "decide", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleReviewModerationAppealAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.proxyModerationWrite(w, r, "appeals/"+r.PathValue("appeal_id")+"/review", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) proxyModerationWrite(w http.ResponseWriter, r *http.Request, suffix string, needsCommand bool) {
|
||||||
|
caseID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil || caseID <= 0 {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "invalid moderation case id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(suffix, "appeals/") {
|
||||||
|
appealID, err := strconv.ParseInt(r.PathValue("appeal_id"), 10, 64)
|
||||||
|
if err != nil || appealID <= 0 {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "invalid moderation appeal id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
var payload map[string]any
|
||||||
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||||
|
if err := decoder.Decode(&payload); err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, "invalid json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload["actor"] = actorFromContext(r.Context())
|
||||||
|
if needsCommand {
|
||||||
|
commandID, _ := payload["command_id"].(string)
|
||||||
|
if strings.TrimSpace(commandID) == "" {
|
||||||
|
payload["command_id"] = newCommandID("moderation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.relayAdminJSON(
|
||||||
|
w, r, http.MethodPost,
|
||||||
|
fmt.Sprintf("/v1/moderation/cases/%d/%s", caseID, suffix),
|
||||||
|
raw, 4<<20,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) relayAdminJSON(w http.ResponseWriter, r *http.Request, method, apiPath string, body []byte, maxBytes int64) {
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
r.Context(), method, s.cfg.AdminAPIURL+apiPath, bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
||||||
|
if err != nil || int64(len(raw)) > maxBytes {
|
||||||
|
writeAPIError(w, http.StatusBadGateway, "invalid admin api response")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
_, _ = w.Write(raw)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) handleAccountsAPI(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleAccountsAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.read == nil {
|
if s.read == nil {
|
||||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
2
cmd/telesrv-admin/web/dist/index.html
vendored
2
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -21,7 +21,7 @@
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-CsfWywUl.js"></script>
|
<script type="module" crossorigin src="/assets/index-CkSmQdTc.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BwxAoLbQ.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BwxAoLbQ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import type {
|
||||||
GroupMessageListResponse,
|
GroupMessageListResponse,
|
||||||
MessageDetail,
|
MessageDetail,
|
||||||
MessageListResponse,
|
MessageListResponse,
|
||||||
|
ModerationCaseDetail,
|
||||||
|
ModerationCaseRow,
|
||||||
|
ModerationReport,
|
||||||
OfficialStarGiftListResponse,
|
OfficialStarGiftListResponse,
|
||||||
StarGiftCollectiblePreview,
|
StarGiftCollectiblePreview,
|
||||||
StarGiftListResponse
|
StarGiftListResponse
|
||||||
|
|
@ -73,6 +76,27 @@ export const api = {
|
||||||
const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) });
|
const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) });
|
||||||
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
|
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
|
||||||
},
|
},
|
||||||
|
moderationCases: (params: URLSearchParams) =>
|
||||||
|
request<{ cases: ModerationCaseRow[] }>(`/api/moderation/cases?${params.toString()}`),
|
||||||
|
moderationCase: (id: number) =>
|
||||||
|
request<ModerationCaseDetail>(`/api/moderation/cases/${id}`),
|
||||||
|
moderationReport: (id: number) =>
|
||||||
|
request<ModerationReport>(`/api/moderation/reports/${id}`),
|
||||||
|
claimModerationCase: (id: number, expectedVersion: number) =>
|
||||||
|
request<ModerationCaseRow>(`/api/moderation/cases/${id}/claim`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ expected_version: expectedVersion })
|
||||||
|
}),
|
||||||
|
decideModerationCase: (id: number, payload: Record<string, unknown>) =>
|
||||||
|
request<{ created: boolean; case: ModerationCaseDetail }>(`/api/moderation/cases/${id}/decide`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}),
|
||||||
|
reviewModerationAppeal: (caseID: number, appealID: number, payload: Record<string, unknown>) =>
|
||||||
|
request<{ created: boolean; case: ModerationCaseDetail }>(`/api/moderation/cases/${caseID}/appeals/${appealID}/review`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}),
|
||||||
gifts: () => request<StarGiftListResponse>("/api/gifts"),
|
gifts: () => request<StarGiftListResponse>("/api/gifts"),
|
||||||
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
|
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
|
||||||
officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),
|
officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
Server,
|
Server,
|
||||||
Shield,
|
Shield,
|
||||||
|
ShieldAlert,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Smile,
|
Smile,
|
||||||
Users,
|
Users,
|
||||||
|
|
@ -80,6 +81,7 @@ export function Shell({
|
||||||
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{t("layout.accounts")}</NavLink>
|
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{t("layout.accounts")}</NavLink>
|
||||||
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
|
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
|
||||||
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{t("layout.bots")}</NavLink>
|
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{t("layout.bots")}</NavLink>
|
||||||
|
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{t("layout.moderation")}</NavLink>
|
||||||
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink>
|
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink>
|
||||||
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{t("layout.giveGifts")}</NavLink>
|
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{t("layout.giveGifts")}</NavLink>
|
||||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{t("layout.emoji")}</NavLink>
|
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{t("layout.emoji")}</NavLink>
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"route.accountsSubtitle": "Console / Accounts",
|
"route.accountsSubtitle": "Console / Accounts",
|
||||||
"route.channels": "Supergroups and Channels",
|
"route.channels": "Supergroups and Channels",
|
||||||
"route.channelsSubtitle": "Console / Channels",
|
"route.channelsSubtitle": "Console / Channels",
|
||||||
|
"route.moderation": "Reports and Moderation",
|
||||||
|
"route.moderationSubtitle": "Console / Moderation",
|
||||||
"route.dashboard": "Operations Console",
|
"route.dashboard": "Operations Console",
|
||||||
"route.dashboardSubtitle": "Console / Overview",
|
"route.dashboardSubtitle": "Console / Overview",
|
||||||
"route.messages": "Message Audit",
|
"route.messages": "Message Audit",
|
||||||
|
|
@ -70,6 +72,7 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"layout.dashboard": "Overview",
|
"layout.dashboard": "Overview",
|
||||||
"layout.accounts": "Accounts",
|
"layout.accounts": "Accounts",
|
||||||
"layout.channels": "Supergroups / Channels",
|
"layout.channels": "Supergroups / Channels",
|
||||||
|
"layout.moderation": "Reports / Moderation",
|
||||||
"layout.messages": "Messages",
|
"layout.messages": "Messages",
|
||||||
"layout.gifts": "Star Gifts",
|
"layout.gifts": "Star Gifts",
|
||||||
"layout.giveGifts": "Give Gifts",
|
"layout.giveGifts": "Give Gifts",
|
||||||
|
|
@ -539,6 +542,8 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"route.accountsSubtitle": "控制台 / 账号",
|
"route.accountsSubtitle": "控制台 / 账号",
|
||||||
"route.channels": "超级群与频道",
|
"route.channels": "超级群与频道",
|
||||||
"route.channelsSubtitle": "控制台 / 频道",
|
"route.channelsSubtitle": "控制台 / 频道",
|
||||||
|
"route.moderation": "举报与审核",
|
||||||
|
"route.moderationSubtitle": "控制台 / 内容安全",
|
||||||
"route.dashboard": "运维控制台",
|
"route.dashboard": "运维控制台",
|
||||||
"route.dashboardSubtitle": "控制台 / 总览",
|
"route.dashboardSubtitle": "控制台 / 总览",
|
||||||
"route.messages": "消息审计",
|
"route.messages": "消息审计",
|
||||||
|
|
@ -552,6 +557,7 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"layout.dashboard": "总览",
|
"layout.dashboard": "总览",
|
||||||
"layout.accounts": "账号",
|
"layout.accounts": "账号",
|
||||||
"layout.channels": "超级群/频道",
|
"layout.channels": "超级群/频道",
|
||||||
|
"layout.moderation": "举报/审核",
|
||||||
"layout.messages": "消息",
|
"layout.messages": "消息",
|
||||||
"layout.gifts": "礼物目录",
|
"layout.gifts": "礼物目录",
|
||||||
"layout.giveGifts": "赠送礼物",
|
"layout.giveGifts": "赠送礼物",
|
||||||
|
|
@ -1021,6 +1027,8 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"route.accountsSubtitle": "Консоль / Аккаунты",
|
"route.accountsSubtitle": "Консоль / Аккаунты",
|
||||||
"route.channels": "Супергруппы и каналы",
|
"route.channels": "Супергруппы и каналы",
|
||||||
"route.channelsSubtitle": "Консоль / Каналы",
|
"route.channelsSubtitle": "Консоль / Каналы",
|
||||||
|
"route.moderation": "Жалобы и модерация",
|
||||||
|
"route.moderationSubtitle": "Консоль / Модерация",
|
||||||
"route.dashboard": "Панель управления",
|
"route.dashboard": "Панель управления",
|
||||||
"route.dashboardSubtitle": "Консоль / Обзор",
|
"route.dashboardSubtitle": "Консоль / Обзор",
|
||||||
"route.messages": "Аудит сообщений",
|
"route.messages": "Аудит сообщений",
|
||||||
|
|
@ -1034,6 +1042,7 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"layout.dashboard": "Обзор",
|
"layout.dashboard": "Обзор",
|
||||||
"layout.accounts": "Аккаунты",
|
"layout.accounts": "Аккаунты",
|
||||||
"layout.channels": "Супергруппы / Каналы",
|
"layout.channels": "Супергруппы / Каналы",
|
||||||
|
"layout.moderation": "Жалобы / Модерация",
|
||||||
"layout.messages": "Сообщения",
|
"layout.messages": "Сообщения",
|
||||||
"layout.gifts": "Звёздные подарки",
|
"layout.gifts": "Звёздные подарки",
|
||||||
"layout.giveGifts": "Выдача подарков",
|
"layout.giveGifts": "Выдача подарков",
|
||||||
|
|
|
||||||
362
cmd/telesrv-admin/web/src/pages/ModerationCaseDetailPage.tsx
Normal file
362
cmd/telesrv-admin/web/src/pages/ModerationCaseDetailPage.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
import { ArrowLeft, CheckCircle2, RefreshCw, ShieldCheck } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { api, errorMessage } from "../api";
|
||||||
|
import { Alert, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||||
|
import { formatDate } from "../lib/format";
|
||||||
|
import type { Navigate } from "../routing";
|
||||||
|
import type { ModerationCaseDetail, ModerationReport } from "../types";
|
||||||
|
import { CaseStatus } from "./ModerationCasesPage";
|
||||||
|
|
||||||
|
type DecisionPreset = "no_violation" | "scam" | "fake" | "freeze" | "scam_freeze" | "fake_freeze" | "delete_messages" | "delete_account";
|
||||||
|
|
||||||
|
export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
|
||||||
|
const [detail, setDetail] = useState<ModerationCaseDetail | null>(null);
|
||||||
|
const [report, setReport] = useState<ModerationReport | null>(null);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [preset, setPreset] = useState<DecisionPreset>("no_violation");
|
||||||
|
const [messageIDs, setMessageIDs] = useState("");
|
||||||
|
const [ownerUserID, setOwnerUserID] = useState("");
|
||||||
|
const [revokeMessages, setRevokeMessages] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
function selectReport(next: ModerationReport | null) {
|
||||||
|
setReport(next);
|
||||||
|
if (!next) return;
|
||||||
|
const ids = next.Items
|
||||||
|
.filter((item) => item.Kind === "message")
|
||||||
|
.map((item) => Number(item.ItemID))
|
||||||
|
.filter((value) => Number.isSafeInteger(value) && value > 0);
|
||||||
|
setMessageIDs(ids.join(", "));
|
||||||
|
setOwnerUserID(String(next.ReporterUserID));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const next = await api.moderationCase(id);
|
||||||
|
setDetail(next);
|
||||||
|
const reportID = next.ReportIDs[0];
|
||||||
|
selectReport(reportID ? await api.moderationReport(reportID) : null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const selectedActions = useMemo(
|
||||||
|
() => actionsForPreset(
|
||||||
|
preset,
|
||||||
|
detail?.Case.Target.Type,
|
||||||
|
parseMessageIDs(messageIDs),
|
||||||
|
Number(ownerUserID),
|
||||||
|
revokeMessages
|
||||||
|
),
|
||||||
|
[preset, detail?.Case.Target.Type, messageIDs, ownerUserID, revokeMessages]
|
||||||
|
);
|
||||||
|
const appealRemedy = useMemo(
|
||||||
|
() => detail ? requiredAppealRemedy(detail) : { actions: [], label: "无", blocked: false },
|
||||||
|
[detail]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function claim() {
|
||||||
|
if (!detail) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await api.claimModerationCase(id, detail.Case.Version);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decide() {
|
||||||
|
if (!detail || !reason.trim()) {
|
||||||
|
setError("必须填写审核理由。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (preset === "delete_messages" && selectedActions.length === 0) {
|
||||||
|
setError(detail.Case.Target.Type === "user"
|
||||||
|
? "私聊删除需要合法的证据消息 ID 和举报人 owner_user_id。"
|
||||||
|
: "频道删除需要至少一个合法的证据消息 ID。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(`确认提交 ${preset} 决定?处置会通过 durable action 队列执行。`)) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await api.decideModerationCase(id, {
|
||||||
|
expected_version: detail.Case.Version,
|
||||||
|
reason: reason.trim(),
|
||||||
|
kind: preset === "no_violation" ? "no_violation" : "violation",
|
||||||
|
actions: selectedActions
|
||||||
|
});
|
||||||
|
setDetail(result.case);
|
||||||
|
setReason("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reviewAppeal(appealID: number, granted: boolean) {
|
||||||
|
if (!detail || !reason.trim()) {
|
||||||
|
setError("必须填写申诉复核理由。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(granted ? "确认通过申诉?" : "确认驳回申诉?")) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await api.reviewModerationAppeal(id, appealID, {
|
||||||
|
expected_version: detail.Case.Version,
|
||||||
|
reason: reason.trim(),
|
||||||
|
granted,
|
||||||
|
actions: granted ? appealRemedy.actions : []
|
||||||
|
});
|
||||||
|
setDetail(result.case);
|
||||||
|
setReason("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !detail) return <Alert>{error}</Alert>;
|
||||||
|
if (!detail) return <LoadingSurface label="正在加载审核案件…" />;
|
||||||
|
const item = detail.Case;
|
||||||
|
const canClaim = item.Status === "open" || item.Status === "in_review" || item.Status === "appeal_review";
|
||||||
|
const canDecide = (item.Status === "in_review" || item.Status === "action_failed") && Boolean(item.AssignedTo);
|
||||||
|
const canSubmitDecision = canDecide && (item.Status !== "action_failed" || preset !== "no_violation");
|
||||||
|
const pendingAppeal = detail.Appeals.find((appeal) => appeal.Status === "pending");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageFrame
|
||||||
|
title={`审核案件 #${item.ID}`}
|
||||||
|
eyebrow="Moderation / Case detail"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<button className="btn icon-text" onClick={() => navigate("/moderation")}><ArrowLeft size={15} /> 返回队列</button>
|
||||||
|
<button className="btn icon-text" onClick={load}><RefreshCw size={15} /> 刷新</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<SplitLayout
|
||||||
|
main={
|
||||||
|
<div className="stacked-sections">
|
||||||
|
<section className="entity-head">
|
||||||
|
<div>
|
||||||
|
<div className="entity-title">{item.Target.Type}:{item.Target.ID}</div>
|
||||||
|
<div className="entity-subtitle">版本 {item.Version} · 最近更新 {formatDate(item.UpdatedAt)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="entity-badges">
|
||||||
|
<CaseStatus status={item.Status} />
|
||||||
|
<Badge tone={item.Severity >= 4 ? "danger" : item.Severity >= 3 ? "warn" : "neutral"}>severity {item.Severity}</Badge>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className="summary-grid">
|
||||||
|
<Summary label="目标" value={`${item.Target.Type}:${item.Target.ID}`} mono />
|
||||||
|
<Summary label="举报数" value={`${item.ReportCount}(${item.DistinctReporterCount} 位举报人)`} />
|
||||||
|
<Summary label="审核人" value={item.AssignedTo || "-"} />
|
||||||
|
<Summary label="首个 / 最近举报" value={`${formatDate(item.FirstReportAt)} / ${formatDate(item.LastReportAt)}`} />
|
||||||
|
</div>
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead title="举报证据" text="最多显示最近 100 条;快照在举报受理时冻结。" />
|
||||||
|
<div className="toolbar">
|
||||||
|
{detail.ReportIDs.map((reportID) => (
|
||||||
|
<button className="btn" key={reportID} onClick={async () => selectReport(await api.moderationReport(reportID))}>
|
||||||
|
#{reportID}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{report && (
|
||||||
|
<>
|
||||||
|
<div className="summary-grid">
|
||||||
|
<Summary label="来源 / 原因" value={`${report.Source} / ${report.Reason}`} />
|
||||||
|
<Summary label="举报人" value={String(report.ReporterUserID)} mono />
|
||||||
|
<Summary label="选项" value={report.Option} mono />
|
||||||
|
<Summary label="时间" value={formatDate(report.CreatedAt)} />
|
||||||
|
</div>
|
||||||
|
{report.Comment && <p className="about-text">{report.Comment}</p>}
|
||||||
|
<JsonBlock value={JSON.stringify(report, null, 2)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead title="决定与处置审计" text="动作由租约 worker 幂等执行;失败保留错误与尝试次数。" />
|
||||||
|
<JsonBlock value={JSON.stringify({ decisions: detail.Decisions, actions: detail.Actions }, null, 2)} />
|
||||||
|
</section>
|
||||||
|
{detail.Appeals.length > 0 && (
|
||||||
|
<section className="section-block">
|
||||||
|
<SectionHead title="申诉" />
|
||||||
|
<JsonBlock value={JSON.stringify(detail.Appeals, null, 2)} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
side={
|
||||||
|
<section className="action-dock">
|
||||||
|
<div className="dock-title">案件操作</div>
|
||||||
|
{canClaim && (
|
||||||
|
<button className="btn primary icon-text" disabled={busy} onClick={claim}>
|
||||||
|
<ShieldCheck size={15} /> {item.AssignedTo ? "续领案件" : "领取案件"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<label className="field">
|
||||||
|
<span>审核理由</span>
|
||||||
|
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={5} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>决定模板</span>
|
||||||
|
<select value={preset} onChange={(event) => setPreset(event.target.value as DecisionPreset)}>
|
||||||
|
<option value="no_violation">无违规(驳回举报)</option>
|
||||||
|
<option value="scam">标记 SCAM</option>
|
||||||
|
<option value="fake">标记 FAKE</option>
|
||||||
|
<option value="freeze">冻结账号</option>
|
||||||
|
<option value="scam_freeze">SCAM + 冻结</option>
|
||||||
|
<option value="fake_freeze">FAKE + 冻结</option>
|
||||||
|
<option value="delete_messages">删除证据覆盖的消息</option>
|
||||||
|
<option value="delete_account">删除账号</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{preset === "delete_messages" && (
|
||||||
|
<>
|
||||||
|
<label className="field">
|
||||||
|
<span>证据消息 ID(逗号分隔)</span>
|
||||||
|
<input value={messageIDs} onChange={(event) => setMessageIDs(event.target.value)} placeholder="101, 102" />
|
||||||
|
</label>
|
||||||
|
{item.Target.Type === "user" && (
|
||||||
|
<>
|
||||||
|
<label className="field">
|
||||||
|
<span>私聊 owner_user_id</span>
|
||||||
|
<input value={ownerUserID} onChange={(event) => setOwnerUserID(event.target.value)} inputMode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label className="field checkbox-field">
|
||||||
|
<input type="checkbox" checked={revokeMessages} onChange={(event) => setRevokeMessages(event.target.checked)} />
|
||||||
|
<span>双方撤回</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Alert>服务端会再次校验每个消息 ID 必须存在于该案件的不可变举报证据中。</Alert>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.Status === "action_failed" && preset === "no_violation" && (
|
||||||
|
<Alert>处置已部分执行,不能直接改为无违规;请选择新的处置动作重新执行并保留旧失败审计。</Alert>
|
||||||
|
)}
|
||||||
|
{canDecide && (
|
||||||
|
<button className="btn danger icon-text" disabled={busy || !canSubmitDecision} onClick={decide}>
|
||||||
|
<CheckCircle2 size={15} /> {item.Status === "action_failed" ? "重新执行处置" : "提交决定"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{pendingAppeal && item.AssignedTo && (
|
||||||
|
<>
|
||||||
|
<div className="dock-title">申诉复核 #{pendingAppeal.ID}</div>
|
||||||
|
<Summary label="通过后自动恢复" value={appealRemedy.label} />
|
||||||
|
{appealRemedy.blocked && (
|
||||||
|
<Alert>案件包含已成功的不可逆删除动作,不能标记为“申诉通过并已恢复”;请驳回或升级人工处理。</Alert>
|
||||||
|
)}
|
||||||
|
<button className="btn" disabled={busy} onClick={() => reviewAppeal(pendingAppeal.ID, false)}>驳回申诉</button>
|
||||||
|
<button className="btn primary" disabled={busy || appealRemedy.blocked} onClick={() => reviewAppeal(pendingAppeal.ID, true)}>通过申诉</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionsForPreset(
|
||||||
|
preset: DecisionPreset,
|
||||||
|
targetType: string | undefined,
|
||||||
|
messageIDs: number[],
|
||||||
|
ownerUserID: number,
|
||||||
|
revoke: boolean
|
||||||
|
): Array<{ kind: string; payload: Record<string, unknown> }> {
|
||||||
|
switch (preset) {
|
||||||
|
case "scam":
|
||||||
|
return [{ kind: "mark_scam", payload: {} }];
|
||||||
|
case "fake":
|
||||||
|
return [{ kind: "mark_fake", payload: {} }];
|
||||||
|
case "freeze":
|
||||||
|
return [{ kind: "freeze_account", payload: {} }];
|
||||||
|
case "scam_freeze":
|
||||||
|
return [{ kind: "mark_scam", payload: {} }, { kind: "freeze_account", payload: {} }];
|
||||||
|
case "fake_freeze":
|
||||||
|
return [{ kind: "mark_fake", payload: {} }, { kind: "freeze_account", payload: {} }];
|
||||||
|
case "delete_messages":
|
||||||
|
if (messageIDs.length === 0) return [];
|
||||||
|
if (targetType === "channel") {
|
||||||
|
return [{ kind: "delete_channel_message", payload: { ids: messageIDs } }];
|
||||||
|
}
|
||||||
|
if (targetType === "user" && Number.isSafeInteger(ownerUserID) && ownerUserID > 0) {
|
||||||
|
return [{ kind: "delete_private_message", payload: { owner_user_id: ownerUserID, ids: messageIDs, revoke } }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
case "delete_account":
|
||||||
|
return [{ kind: "delete_account", payload: {} }];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMessageIDs(raw: string): number[] {
|
||||||
|
const values = raw
|
||||||
|
.split(/[,\s]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(Number);
|
||||||
|
if (values.length === 0 || values.some((value) => !Number.isSafeInteger(value) || value <= 0)) return [];
|
||||||
|
return [...new Set(values)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredAppealRemedy(detail: ModerationCaseDetail): {
|
||||||
|
actions: Array<{ kind: string; payload: Record<string, unknown> }>;
|
||||||
|
label: string;
|
||||||
|
blocked: boolean;
|
||||||
|
} {
|
||||||
|
let flagsActive = false;
|
||||||
|
let freezeActive = false;
|
||||||
|
let irreversible = false;
|
||||||
|
for (const action of [...detail.Actions].sort((left, right) => left.ID - right.ID)) {
|
||||||
|
if (action.Status !== "succeeded") continue;
|
||||||
|
switch (action.Kind) {
|
||||||
|
case "mark_scam":
|
||||||
|
case "mark_fake":
|
||||||
|
flagsActive = true;
|
||||||
|
break;
|
||||||
|
case "clear_peer_flags":
|
||||||
|
flagsActive = false;
|
||||||
|
break;
|
||||||
|
case "freeze_account":
|
||||||
|
freezeActive = true;
|
||||||
|
break;
|
||||||
|
case "unfreeze_account":
|
||||||
|
freezeActive = false;
|
||||||
|
break;
|
||||||
|
case "delete_private_message":
|
||||||
|
case "delete_channel_message":
|
||||||
|
case "delete_account":
|
||||||
|
irreversible = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const actions: Array<{ kind: string; payload: Record<string, unknown> }> = [];
|
||||||
|
const labels: string[] = [];
|
||||||
|
if (flagsActive) {
|
||||||
|
actions.push({ kind: "clear_peer_flags", payload: {} });
|
||||||
|
labels.push("清除 SCAM / FAKE");
|
||||||
|
}
|
||||||
|
if (freezeActive) {
|
||||||
|
actions.push({ kind: "unfreeze_account", payload: {} });
|
||||||
|
labels.push("解除冻结");
|
||||||
|
}
|
||||||
|
return { actions, label: labels.join(" + ") || "无需恢复动作", blocked: irreversible };
|
||||||
|
}
|
||||||
117
cmd/telesrv-admin/web/src/pages/ModerationCasesPage.tsx
Normal file
117
cmd/telesrv-admin/web/src/pages/ModerationCasesPage.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { ChevronRight, RefreshCw, ShieldAlert } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, errorMessage } from "../api";
|
||||||
|
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||||
|
import { formatDate } from "../lib/format";
|
||||||
|
import type { Navigate } from "../routing";
|
||||||
|
import type { ModerationCaseRow } from "../types";
|
||||||
|
|
||||||
|
const defaultStatuses = "open,in_review,action_pending,action_failed,appeal_review";
|
||||||
|
|
||||||
|
export function ModerationCasesPage({ navigate }: { navigate: Navigate }) {
|
||||||
|
const [statuses, setStatuses] = useState(defaultStatuses);
|
||||||
|
const [assignedTo, setAssignedTo] = useState("");
|
||||||
|
const [rows, setRows] = useState<ModerationCaseRow[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ statuses, limit: "100" });
|
||||||
|
if (assignedTo.trim()) params.set("assigned_to", assignedTo.trim());
|
||||||
|
setRows((await api.moderationCases(params)).cases);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pendingActions = rows.filter((row) => row.Status === "action_pending" || row.Status === "action_failed").length;
|
||||||
|
const critical = rows.filter((row) => row.Severity === 4).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageFrame
|
||||||
|
title="举报与审核"
|
||||||
|
eyebrow="Moderation / Cases"
|
||||||
|
actions={
|
||||||
|
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
|
||||||
|
<RefreshCw size={15} className={busy ? "spin" : ""} /> 刷新
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<div className="metric-row">
|
||||||
|
<Metric label="当前队列" value={String(rows.length)} />
|
||||||
|
<Metric label="关键案件" value={String(critical)} tone={critical ? "danger" : "neutral"} />
|
||||||
|
<Metric label="处置待完成/失败" value={String(pendingActions)} tone={pendingActions ? "warn" : "good"} />
|
||||||
|
</div>
|
||||||
|
<QueryPanel>
|
||||||
|
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(); }}>
|
||||||
|
<label className="field-inline">
|
||||||
|
<span>状态</span>
|
||||||
|
<input value={statuses} onChange={(event) => setStatuses(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field-inline">
|
||||||
|
<span>审核人</span>
|
||||||
|
<input value={assignedTo} onChange={(event) => setAssignedTo(event.target.value)} placeholder="留空为全部" />
|
||||||
|
</label>
|
||||||
|
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||||
|
<ShieldAlert size={15} /> 查询
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</QueryPanel>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>案件</th><th>目标</th><th>状态</th><th>等级</th>
|
||||||
|
<th>举报 / 举报人</th><th>审核人</th><th>最近举报</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.ID}>
|
||||||
|
<td className="mono">#{row.ID}</td>
|
||||||
|
<td className="mono">{row.Target.Type}:{row.Target.ID}</td>
|
||||||
|
<td><CaseStatus status={row.Status} /></td>
|
||||||
|
<td><Severity value={row.Severity} /></td>
|
||||||
|
<td>{row.ReportCount} / {row.DistinctReporterCount}</td>
|
||||||
|
<td>{row.AssignedTo || "-"}</td>
|
||||||
|
<td>{formatDate(row.LastReportAt)}</td>
|
||||||
|
<td>
|
||||||
|
<button className="row-link" onClick={() => navigate(`/moderation/${row.ID}`)}>
|
||||||
|
审核 <ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</PageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CaseStatus({ status }: { status: string }) {
|
||||||
|
const tone = status === "resolved" || status === "dismissed"
|
||||||
|
? "good"
|
||||||
|
: status === "action_failed"
|
||||||
|
? "danger"
|
||||||
|
: status === "action_pending"
|
||||||
|
? "warn"
|
||||||
|
: "neutral";
|
||||||
|
return <Badge tone={tone}>{status}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Severity({ value }: { value: number }) {
|
||||||
|
const labels = ["", "低", "中", "高", "关键"];
|
||||||
|
return <Badge tone={value >= 4 ? "danger" : value >= 3 ? "warn" : "neutral"}>{labels[value] || value}</Badge>;
|
||||||
|
}
|
||||||
|
|
@ -13,11 +13,14 @@ import { MessageDetailPage } from "./MessageDetailPage";
|
||||||
import { MessagesPage } from "./MessagesPage";
|
import { MessagesPage } from "./MessagesPage";
|
||||||
import { GiftsPage } from "./GiftsPage";
|
import { GiftsPage } from "./GiftsPage";
|
||||||
import { GiveGiftsPage } from "./GiveGiftsPage";
|
import { GiveGiftsPage } from "./GiveGiftsPage";
|
||||||
|
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
|
||||||
|
import { ModerationCasesPage } from "./ModerationCasesPage";
|
||||||
|
|
||||||
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
|
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
|
||||||
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
|
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
|
||||||
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
|
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
|
||||||
const botID = route.path.match(/^\/bots\/(\d+)$/)?.[1];
|
const botID = route.path.match(/^\/bots\/(\d+)$/)?.[1];
|
||||||
|
const moderationCaseID = route.path.match(/^\/moderation\/(\d+)$/)?.[1];
|
||||||
if (accountID) {
|
if (accountID) {
|
||||||
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
|
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
|
||||||
}
|
}
|
||||||
|
|
@ -27,6 +30,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
||||||
if (botID) {
|
if (botID) {
|
||||||
return <BotDetailPage id={Number(botID)} navigate={navigate} />;
|
return <BotDetailPage id={Number(botID)} navigate={navigate} />;
|
||||||
}
|
}
|
||||||
|
if (moderationCaseID) {
|
||||||
|
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
|
||||||
|
}
|
||||||
if (route.path === "/accounts") {
|
if (route.path === "/accounts") {
|
||||||
return <AccountsPage navigate={navigate} />;
|
return <AccountsPage navigate={navigate} />;
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +42,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
||||||
if (route.path === "/bots") {
|
if (route.path === "/bots") {
|
||||||
return <BotsPage navigate={navigate} />;
|
return <BotsPage navigate={navigate} />;
|
||||||
}
|
}
|
||||||
|
if (route.path === "/moderation") {
|
||||||
|
return <ModerationCasesPage navigate={navigate} />;
|
||||||
|
}
|
||||||
if (route.path === "/emoji") {
|
if (route.path === "/emoji") {
|
||||||
return <EmojiPage />;
|
return <EmojiPage />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export function routeTitle(pathname: string, t: TFunction): string {
|
||||||
if (pathname.startsWith("/accounts")) return t("route.accounts");
|
if (pathname.startsWith("/accounts")) return t("route.accounts");
|
||||||
if (pathname.startsWith("/channels")) return t("route.channels");
|
if (pathname.startsWith("/channels")) return t("route.channels");
|
||||||
if (pathname.startsWith("/bots")) return t("route.bots");
|
if (pathname.startsWith("/bots")) return t("route.bots");
|
||||||
|
if (pathname.startsWith("/moderation")) return t("route.moderation");
|
||||||
if (pathname.startsWith("/emoji")) return t("route.emoji");
|
if (pathname.startsWith("/emoji")) return t("route.emoji");
|
||||||
if (pathname.startsWith("/messages")) return t("route.messages");
|
if (pathname.startsWith("/messages")) return t("route.messages");
|
||||||
if (pathname.startsWith("/give-gifts")) return t("route.giveGifts");
|
if (pathname.startsWith("/give-gifts")) return t("route.giveGifts");
|
||||||
|
|
@ -31,6 +32,7 @@ export function routeSubtitle(pathname: string, t: TFunction): string {
|
||||||
if (pathname.startsWith("/accounts")) return t("route.accountsSubtitle");
|
if (pathname.startsWith("/accounts")) return t("route.accountsSubtitle");
|
||||||
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
|
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
|
||||||
if (pathname.startsWith("/bots")) return t("route.botsSubtitle");
|
if (pathname.startsWith("/bots")) return t("route.botsSubtitle");
|
||||||
|
if (pathname.startsWith("/moderation")) return t("route.moderationSubtitle");
|
||||||
if (pathname.startsWith("/emoji")) return t("route.emojiSubtitle");
|
if (pathname.startsWith("/emoji")) return t("route.emojiSubtitle");
|
||||||
if (pathname.startsWith("/messages")) return t("route.messagesSubtitle");
|
if (pathname.startsWith("/messages")) return t("route.messagesSubtitle");
|
||||||
if (pathname.startsWith("/give-gifts")) return t("route.giveGiftsSubtitle");
|
if (pathname.startsWith("/give-gifts")) return t("route.giveGiftsSubtitle");
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,85 @@ export type OfficialStarGiftRow = {
|
||||||
|
|
||||||
export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] };
|
export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] };
|
||||||
|
|
||||||
|
export type ModerationPeer = {
|
||||||
|
Type: "user" | "channel";
|
||||||
|
ID: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModerationCaseRow = {
|
||||||
|
ID: number;
|
||||||
|
Target: ModerationPeer;
|
||||||
|
Status: string;
|
||||||
|
Severity: number;
|
||||||
|
AssignedTo: string;
|
||||||
|
Version: number;
|
||||||
|
ReportCount: number;
|
||||||
|
DistinctReporterCount: number;
|
||||||
|
FirstReportAt: string;
|
||||||
|
LastReportAt: string;
|
||||||
|
CreatedAt: string;
|
||||||
|
UpdatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModerationDecision = {
|
||||||
|
ID: number;
|
||||||
|
CaseID: number;
|
||||||
|
AppealID: number;
|
||||||
|
Kind: string;
|
||||||
|
Actor: string;
|
||||||
|
Reason: string;
|
||||||
|
CommandID: string;
|
||||||
|
CreatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModerationAction = {
|
||||||
|
ID: number;
|
||||||
|
CaseID: number;
|
||||||
|
DecisionID: number;
|
||||||
|
Kind: string;
|
||||||
|
Payload: Record<string, unknown>;
|
||||||
|
Status: string;
|
||||||
|
Attempts: number;
|
||||||
|
LastError: string;
|
||||||
|
CommandID: string;
|
||||||
|
CreatedAt: string;
|
||||||
|
UpdatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModerationAppeal = {
|
||||||
|
ID: number;
|
||||||
|
CaseID: number;
|
||||||
|
AppellantUserID: number;
|
||||||
|
Text: string;
|
||||||
|
Status: string;
|
||||||
|
PreviousCaseStatus: string;
|
||||||
|
Reviewer: string;
|
||||||
|
ReviewReason: string;
|
||||||
|
CreatedAt: string;
|
||||||
|
ReviewedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModerationCaseDetail = {
|
||||||
|
Case: ModerationCaseRow;
|
||||||
|
ReportIDs: number[];
|
||||||
|
Decisions: ModerationDecision[];
|
||||||
|
Actions: ModerationAction[];
|
||||||
|
Appeals: ModerationAppeal[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModerationReport = {
|
||||||
|
ID: number;
|
||||||
|
ReporterUserID: number;
|
||||||
|
Source: string;
|
||||||
|
Target: ModerationPeer;
|
||||||
|
Reason: string;
|
||||||
|
Option: string;
|
||||||
|
Comment: string;
|
||||||
|
Items: Array<Record<string, unknown>>;
|
||||||
|
MediaHolds: Array<Record<string, unknown>>;
|
||||||
|
CreatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type StarGiftCollectibleAttributeRow = {
|
export type StarGiftCollectibleAttributeRow = {
|
||||||
id: string;
|
id: string;
|
||||||
kind: "model" | "pattern" | "backdrop";
|
kind: "model" | "pattern" | "backdrop";
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,11 @@ import (
|
||||||
"telesrv/internal/app/account"
|
"telesrv/internal/app/account"
|
||||||
aiapp "telesrv/internal/app/ai"
|
aiapp "telesrv/internal/app/ai"
|
||||||
"telesrv/internal/app/auth"
|
"telesrv/internal/app/auth"
|
||||||
|
authdiagnosticsapp "telesrv/internal/app/authdiagnostics"
|
||||||
botsapp "telesrv/internal/app/bots"
|
botsapp "telesrv/internal/app/bots"
|
||||||
channelapp "telesrv/internal/app/channels"
|
channelapp "telesrv/internal/app/channels"
|
||||||
chatlistsapp "telesrv/internal/app/chatlists"
|
chatlistsapp "telesrv/internal/app/chatlists"
|
||||||
|
clienttelemetryapp "telesrv/internal/app/clienttelemetry"
|
||||||
communitiesapp "telesrv/internal/app/communities"
|
communitiesapp "telesrv/internal/app/communities"
|
||||||
"telesrv/internal/app/contacts"
|
"telesrv/internal/app/contacts"
|
||||||
"telesrv/internal/app/dialogs"
|
"telesrv/internal/app/dialogs"
|
||||||
|
|
@ -41,6 +43,7 @@ import (
|
||||||
"telesrv/internal/app/livestream"
|
"telesrv/internal/app/livestream"
|
||||||
"telesrv/internal/app/maintenance"
|
"telesrv/internal/app/maintenance"
|
||||||
messageapp "telesrv/internal/app/messages"
|
messageapp "telesrv/internal/app/messages"
|
||||||
|
moderationapp "telesrv/internal/app/moderation"
|
||||||
passkeyapp "telesrv/internal/app/passkey"
|
passkeyapp "telesrv/internal/app/passkey"
|
||||||
phoneapp "telesrv/internal/app/phone"
|
phoneapp "telesrv/internal/app/phone"
|
||||||
pollsapp "telesrv/internal/app/polls"
|
pollsapp "telesrv/internal/app/polls"
|
||||||
|
|
@ -417,6 +420,9 @@ func run(logger *zap.Logger) error {
|
||||||
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
|
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
|
||||||
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
|
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
|
||||||
ephemeralReportStore := postgres.NewEphemeralReportStore(pool)
|
ephemeralReportStore := postgres.NewEphemeralReportStore(pool)
|
||||||
|
moderationReportStore := postgres.NewModerationReportStore(pool)
|
||||||
|
authDeliveryReportStore := postgres.NewAuthDeliveryReportStore(pool)
|
||||||
|
clientTelemetryStore := postgres.NewClientTelemetryStore(pool)
|
||||||
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
|
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
|
||||||
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
|
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
|
||||||
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
|
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
|
||||||
|
|
@ -521,6 +527,8 @@ func run(logger *zap.Logger) error {
|
||||||
tempAuthKeyStore := postgres.NewTempAuthKeyBindingStore(pool)
|
tempAuthKeyStore := postgres.NewTempAuthKeyBindingStore(pool)
|
||||||
inlineRegistryStore := redisstore.NewInlineRegistryStore(rdb)
|
inlineRegistryStore := redisstore.NewInlineRegistryStore(rdb)
|
||||||
codeStore := redisstore.NewCodeStore(rdb)
|
codeStore := redisstore.NewCodeStore(rdb)
|
||||||
|
authDeliveryReportService := authdiagnosticsapp.NewService(codeStore, authDeliveryReportStore)
|
||||||
|
clientTelemetryService := clienttelemetryapp.NewService(clientTelemetryStore)
|
||||||
rateLimiter := redisstore.NewRateLimiter(rdb)
|
rateLimiter := redisstore.NewRateLimiter(rdb)
|
||||||
activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions"))
|
activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions"))
|
||||||
adminService := adminapp.NewService(adminapp.Dependencies{
|
adminService := adminapp.NewService(adminapp.Dependencies{
|
||||||
|
|
@ -536,6 +544,9 @@ func run(logger *zap.Logger) error {
|
||||||
WithBotAPIUpdateRetention(botAPIUpdateStore, cfg.BotAPIUpdateRetention).
|
WithBotAPIUpdateRetention(botAPIUpdateStore, cfg.BotAPIUpdateRetention).
|
||||||
WithAuthKeySessionLayerRetention(authKeyStore).
|
WithAuthKeySessionLayerRetention(authKeyStore).
|
||||||
WithLoginCodeDeliveryRetention(messageStore).
|
WithLoginCodeDeliveryRetention(messageStore).
|
||||||
|
WithClientTelemetryRetention(clientTelemetryStore, 30*24*time.Hour).
|
||||||
|
WithAuthDeliveryReportRetention(authDeliveryReportStore, 30*24*time.Hour).
|
||||||
|
WithModerationRetention(moderationReportStore).
|
||||||
WithUserUpdateRetention(updateEventStore).
|
WithUserUpdateRetention(updateEventStore).
|
||||||
WithChannelUpdateRetention(channelStore).
|
WithChannelUpdateRetention(channelStore).
|
||||||
WithOrphanAuthKeyRetention(authKeyStore, activeSessions, cfg.OrphanAuthKeyRetention).
|
WithOrphanAuthKeyRetention(authKeyStore, activeSessions, cfg.OrphanAuthKeyRetention).
|
||||||
|
|
@ -573,6 +584,7 @@ func run(logger *zap.Logger) error {
|
||||||
// userCache 与 users 服务共享同一实例:bot 元数据写入(version bump)后必须
|
// userCache 与 users 服务共享同一实例:bot 元数据写入(version bump)后必须
|
||||||
// 失效缓存,否则 TTL 内 getUsers 回旧 first_name/旧 bot_info_version。
|
// 失效缓存,否则 TTL 内 getUsers 回旧 first_name/旧 bot_info_version。
|
||||||
userCache := redisstore.NewUserCache(rdb, redisstore.DefaultUserCacheTTL)
|
userCache := redisstore.NewUserCache(rdb, redisstore.DefaultUserCacheTTL)
|
||||||
|
accountLifecycleStore := postgres.NewAccountLifecycleStore(pool)
|
||||||
accountOptions := []account.ServiceOption{
|
accountOptions := []account.ServiceOption{
|
||||||
account.WithReactionSettings(passwordStore),
|
account.WithReactionSettings(passwordStore),
|
||||||
account.WithAccountSettings(passwordStore),
|
account.WithAccountSettings(passwordStore),
|
||||||
|
|
@ -583,7 +595,7 @@ func run(logger *zap.Logger) error {
|
||||||
account.WithBusinessAutomation(passwordStore),
|
account.WithBusinessAutomation(passwordStore),
|
||||||
account.WithUsers(userStore),
|
account.WithUsers(userStore),
|
||||||
account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts),
|
account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts),
|
||||||
account.WithAccountLifecycle(postgres.NewAccountLifecycleStore(pool)),
|
account.WithAccountLifecycle(accountLifecycleStore),
|
||||||
account.WithPublicBaseURL(cfg.PublicBaseURL),
|
account.WithPublicBaseURL(cfg.PublicBaseURL),
|
||||||
}
|
}
|
||||||
var webhookSender otpdelivery.Sender
|
var webhookSender otpdelivery.Sender
|
||||||
|
|
@ -753,6 +765,7 @@ func run(logger *zap.Logger) error {
|
||||||
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
|
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
|
||||||
themeService := themesapp.NewService(postgres.NewThemeStore(pool))
|
themeService := themesapp.NewService(postgres.NewThemeStore(pool))
|
||||||
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService))
|
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService))
|
||||||
|
privacyService.ConfigureReadModels(usersService, channelStore)
|
||||||
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
|
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
|
||||||
botsService.SetAIChatGenerator(aiComposeService)
|
botsService.SetAIChatGenerator(aiComposeService)
|
||||||
dialogsService := dialogs.NewService(dialogStore, channelStore).Configure(
|
dialogsService := dialogs.NewService(dialogStore, channelStore).Configure(
|
||||||
|
|
@ -773,6 +786,7 @@ func run(logger *zap.Logger) error {
|
||||||
)
|
)
|
||||||
communitiesService := communitiesapp.NewService(communityStore)
|
communitiesService := communitiesapp.NewService(communityStore)
|
||||||
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
|
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
|
||||||
|
storiesService := storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService))
|
||||||
chatlistsService := chatlistsapp.NewService(
|
chatlistsService := chatlistsapp.NewService(
|
||||||
chatlistStore,
|
chatlistStore,
|
||||||
dialogStore,
|
dialogStore,
|
||||||
|
|
@ -790,6 +804,21 @@ func run(logger *zap.Logger) error {
|
||||||
messageapp.WithSendPermissionChecker(adminService),
|
messageapp.WithSendPermissionChecker(adminService),
|
||||||
messageapp.WithBusinessAutomation(passwordStore, businessAutomationOptions...),
|
messageapp.WithBusinessAutomation(passwordStore, businessAutomationOptions...),
|
||||||
)
|
)
|
||||||
|
moderationService := moderationapp.NewService(
|
||||||
|
moderationReportStore,
|
||||||
|
moderationapp.WithMessageReaders(messagesService, channelsService),
|
||||||
|
moderationapp.WithStoryReader(storiesService),
|
||||||
|
moderationapp.WithPeerReaders(usersService, channelsService),
|
||||||
|
moderationapp.WithProfilePhotoReader(filesService),
|
||||||
|
)
|
||||||
|
legacyReportsMigrated, err := moderationService.MigrateLegacyEphemeralReports(ctx, ephemeralReportStore, 500)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("migrate legacy ephemeral reports: %w", err)
|
||||||
|
}
|
||||||
|
if legacyReportsMigrated > 0 {
|
||||||
|
logger.Info("旧 ephemeral 举报已迁移到统一审核管线",
|
||||||
|
zap.Int("reports", legacyReportsMigrated))
|
||||||
|
}
|
||||||
translationService := translationapp.NewService(
|
translationService := translationapp.NewService(
|
||||||
messagesService,
|
messagesService,
|
||||||
channelsService,
|
channelsService,
|
||||||
|
|
@ -820,7 +849,6 @@ func run(logger *zap.Logger) error {
|
||||||
Sender: loginEmailSender,
|
Sender: loginEmailSender,
|
||||||
}))
|
}))
|
||||||
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
|
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
|
||||||
rpc.SetModerationWarnings(cfg.ScamWarning, cfg.FakeWarning)
|
|
||||||
router := rpc.New(rpc.Config{
|
router := rpc.New(rpc.Config{
|
||||||
DC: cfg.DC,
|
DC: cfg.DC,
|
||||||
IP: cfg.AdvertiseIP,
|
IP: cfg.AdvertiseIP,
|
||||||
|
|
@ -847,6 +875,8 @@ func run(logger *zap.Logger) error {
|
||||||
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
|
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
|
||||||
}, rpc.Deps{
|
}, rpc.Deps{
|
||||||
Auth: authService,
|
Auth: authService,
|
||||||
|
AuthDeliveryReports: authDeliveryReportService,
|
||||||
|
ClientTelemetry: clientTelemetryService,
|
||||||
AuthKeySessionLayers: authKeyStore,
|
AuthKeySessionLayers: authKeyStore,
|
||||||
Account: accountService,
|
Account: accountService,
|
||||||
Privacy: privacyService,
|
Privacy: privacyService,
|
||||||
|
|
@ -855,7 +885,7 @@ func run(logger *zap.Logger) error {
|
||||||
AICompose: aiComposeService,
|
AICompose: aiComposeService,
|
||||||
Ephemeral: ephemeralService,
|
Ephemeral: ephemeralService,
|
||||||
EphemeralPush: ephemeralStore,
|
EphemeralPush: ephemeralStore,
|
||||||
EphemeralReports: ephemeralReportStore,
|
Moderation: moderationService,
|
||||||
Users: usersService,
|
Users: usersService,
|
||||||
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
|
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
|
||||||
Updates: updatesService,
|
Updates: updatesService,
|
||||||
|
|
@ -872,7 +902,7 @@ func run(logger *zap.Logger) error {
|
||||||
Files: filesService,
|
Files: filesService,
|
||||||
Bots: botsService,
|
Bots: botsService,
|
||||||
Polls: pollsapp.NewService(pollStore),
|
Polls: pollsapp.NewService(pollStore),
|
||||||
Stories: storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService)),
|
Stories: storiesService,
|
||||||
Phone: phoneService,
|
Phone: phoneService,
|
||||||
SecretChats: secretChatService,
|
SecretChats: secretChatService,
|
||||||
Stars: starsService,
|
Stars: starsService,
|
||||||
|
|
@ -896,7 +926,7 @@ func run(logger *zap.Logger) error {
|
||||||
ChannelBoosts: channelBoostCache,
|
ChannelBoosts: channelBoostCache,
|
||||||
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
|
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
|
||||||
Dialogs: dialogsService,
|
Dialogs: dialogsService,
|
||||||
Privacy: privacyStore,
|
Privacy: privacyService,
|
||||||
ProfilePhotos: cachedPhotos,
|
ProfilePhotos: cachedPhotos,
|
||||||
Stories: router,
|
Stories: router,
|
||||||
ChannelFullBots: router,
|
ChannelFullBots: router,
|
||||||
|
|
@ -925,7 +955,24 @@ func run(logger *zap.Logger) error {
|
||||||
GiftGranter: router,
|
GiftGranter: router,
|
||||||
Bots: botsService,
|
Bots: botsService,
|
||||||
Emoji: filesService,
|
Emoji: filesService,
|
||||||
|
Moderation: moderationService,
|
||||||
})
|
})
|
||||||
|
moderationActionOptions := []moderationapp.ActionExecutorOption{}
|
||||||
|
if cfg.PublicLinkWebAddr != "" {
|
||||||
|
moderationActionOptions = append(
|
||||||
|
moderationActionOptions,
|
||||||
|
moderationapp.WithAppealLinks(moderationService, cfg.PublicBaseURL),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
moderationActionExecutor := moderationapp.NewActionExecutor(
|
||||||
|
adminService, channelsService, router, accountLifecycleStore,
|
||||||
|
moderationActionOptions...,
|
||||||
|
)
|
||||||
|
go moderationapp.NewActionWorker(
|
||||||
|
moderationReportStore,
|
||||||
|
moderationActionExecutor,
|
||||||
|
logger.Named("moderation").Named("actions"),
|
||||||
|
).Run(ctx)
|
||||||
// bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界),
|
// bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界),
|
||||||
// router 创建后注入。
|
// router 创建后注入。
|
||||||
botsService.SetRouterHooks(router)
|
botsService.SetRouterHooks(router)
|
||||||
|
|
@ -989,20 +1036,21 @@ func run(logger *zap.Logger) error {
|
||||||
return fmt.Errorf("start admin api: %w", err)
|
return fmt.Errorf("start admin api: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := web.Start(ctx, web.Config{
|
if _, err := web.Start(ctx, web.Config{
|
||||||
Addr: cfg.PublicLinkWebAddr,
|
Addr: cfg.PublicLinkWebAddr,
|
||||||
PublicBaseURL: cfg.PublicBaseURL,
|
PublicBaseURL: cfg.PublicBaseURL,
|
||||||
AppScheme: cfg.PublicAppScheme,
|
AppScheme: cfg.PublicAppScheme,
|
||||||
AppLinkBase: cfg.PublicAppLinkBase,
|
AppLinkBase: cfg.PublicAppLinkBase,
|
||||||
WebBaseURL: cfg.PublicWebBaseURL,
|
WebBaseURL: cfg.PublicWebBaseURL,
|
||||||
AppName: cfg.PublicAppName,
|
AppName: cfg.PublicAppName,
|
||||||
StickerSets: filesService,
|
StickerSets: filesService,
|
||||||
Users: userStore,
|
Users: userStore,
|
||||||
Channels: channelStore,
|
Channels: channelStore,
|
||||||
Privacy: privacyService,
|
Privacy: privacyService,
|
||||||
Photos: filesService,
|
Photos: filesService,
|
||||||
UniqueGifts: giftsService,
|
UniqueGifts: giftsService,
|
||||||
GiftWithdrawals: giftsService,
|
GiftWithdrawals: giftsService,
|
||||||
TelegramLogin: telegramLoginHTTPHandler,
|
ModerationAppeals: moderationService,
|
||||||
|
TelegramLogin: telegramLoginHTTPHandler,
|
||||||
}, logger.Named("public-web")); err != nil {
|
}, logger.Named("public-web")); err != nil {
|
||||||
return fmt.Errorf("start public Web: %w", err)
|
return fmt.Errorf("start public Web: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
deploy/migrations/0139_moderation_reports.down.sql
Normal file
4
deploy/migrations/0139_moderation_reports.down.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
DROP TABLE IF EXISTS public.moderation_legacy_ephemeral_migrations;
|
||||||
|
DROP TABLE IF EXISTS public.moderation_media_holds;
|
||||||
|
DROP TABLE IF EXISTS public.moderation_report_items;
|
||||||
|
DROP TABLE IF EXISTS public.moderation_reports;
|
||||||
112
deploy/migrations/0139_moderation_reports.up.sql
Normal file
112
deploy/migrations/0139_moderation_reports.up.sql
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
-- Unified immutable abuse-report submissions. Operational delivery/read/music
|
||||||
|
-- telemetry and auth-code delivery diagnostics intentionally use separate
|
||||||
|
-- tables and retention policies.
|
||||||
|
CREATE TABLE public.moderation_reports (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
reporter_user_id bigint NOT NULL CHECK (reporter_user_id > 0),
|
||||||
|
source text NOT NULL CHECK (source IN (
|
||||||
|
'account_peer', 'profile_photo', 'messages_spam', 'messages',
|
||||||
|
'encrypted_spam', 'reaction', 'channel_spam', 'story', 'ephemeral',
|
||||||
|
'sponsored', 'antispam_false_positive'
|
||||||
|
)),
|
||||||
|
target_peer_type text NOT NULL CHECK (target_peer_type IN ('user', 'channel')),
|
||||||
|
target_peer_id bigint NOT NULL CHECK (target_peer_id > 0),
|
||||||
|
reason text NOT NULL CHECK (reason IN (
|
||||||
|
'spam', 'violence', 'pornography', 'child_abuse', 'other',
|
||||||
|
'copyright', 'geo_irrelevant', 'fake', 'illegal_drugs',
|
||||||
|
'personal_details'
|
||||||
|
)),
|
||||||
|
report_option text NOT NULL CHECK (
|
||||||
|
octet_length(report_option) BETWEEN 1 AND 32
|
||||||
|
),
|
||||||
|
report_comment text NOT NULL DEFAULT '' CHECK (
|
||||||
|
char_length(report_comment) <= 512
|
||||||
|
),
|
||||||
|
comment_hash bytea NOT NULL CHECK (octet_length(comment_hash) = 32),
|
||||||
|
fingerprint bytea NOT NULL CHECK (octet_length(fingerprint) = 32),
|
||||||
|
taxonomy_version smallint NOT NULL CHECK (taxonomy_version > 0),
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
CONSTRAINT moderation_reports_idempotency
|
||||||
|
UNIQUE (reporter_user_id, fingerprint)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX moderation_reports_target_created_idx
|
||||||
|
ON public.moderation_reports (
|
||||||
|
target_peer_type, target_peer_id, created_at DESC, id DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX moderation_reports_reporter_created_idx
|
||||||
|
ON public.moderation_reports (
|
||||||
|
reporter_user_id, created_at DESC, id DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE public.moderation_report_items (
|
||||||
|
report_id bigint NOT NULL REFERENCES public.moderation_reports(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
ordinal smallint NOT NULL CHECK (ordinal BETWEEN 0 AND 99),
|
||||||
|
item_kind text NOT NULL CHECK (item_kind IN (
|
||||||
|
'peer', 'message', 'profile_photo', 'reaction', 'story',
|
||||||
|
'encrypted_chat', 'ephemeral', 'sponsored', 'antispam_decision'
|
||||||
|
)),
|
||||||
|
peer_type text NOT NULL CHECK (peer_type IN ('user', 'channel')),
|
||||||
|
peer_id bigint NOT NULL CHECK (peer_id > 0),
|
||||||
|
item_id bigint NOT NULL CHECK (item_id > 0),
|
||||||
|
secondary_id bigint NOT NULL DEFAULT 0 CHECK (secondary_id >= 0),
|
||||||
|
author_user_id bigint NOT NULL DEFAULT 0 CHECK (author_user_id >= 0),
|
||||||
|
evidence_schema_version smallint NOT NULL CHECK (
|
||||||
|
evidence_schema_version > 0
|
||||||
|
),
|
||||||
|
evidence jsonb NOT NULL CHECK (
|
||||||
|
jsonb_typeof(evidence) = 'object'
|
||||||
|
AND octet_length(evidence::text) <= 1048576
|
||||||
|
),
|
||||||
|
evidence_hash bytea NOT NULL CHECK (octet_length(evidence_hash) = 32),
|
||||||
|
PRIMARY KEY (report_id, ordinal),
|
||||||
|
CONSTRAINT moderation_report_items_identity
|
||||||
|
UNIQUE (
|
||||||
|
report_id, item_kind, peer_type, peer_id, item_id, secondary_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX moderation_report_items_lookup_idx
|
||||||
|
ON public.moderation_report_items (
|
||||||
|
item_kind, peer_type, peer_id, item_id, report_id
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX moderation_report_items_author_idx
|
||||||
|
ON public.moderation_report_items (
|
||||||
|
author_user_id, report_id
|
||||||
|
)
|
||||||
|
WHERE author_user_id > 0;
|
||||||
|
|
||||||
|
CREATE TABLE public.moderation_media_holds (
|
||||||
|
report_id bigint NOT NULL,
|
||||||
|
item_ordinal smallint NOT NULL,
|
||||||
|
media_kind text NOT NULL CHECK (media_kind IN ('photo', 'document', 'blob')),
|
||||||
|
storage_key text NOT NULL CHECK (
|
||||||
|
octet_length(storage_key) BETWEEN 1 AND 512
|
||||||
|
),
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
released_at timestamptz,
|
||||||
|
PRIMARY KEY (report_id, item_ordinal, media_kind, storage_key),
|
||||||
|
FOREIGN KEY (report_id, item_ordinal)
|
||||||
|
REFERENCES public.moderation_report_items(report_id, ordinal)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CHECK (released_at IS NULL OR released_at >= created_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX moderation_media_holds_active_key_idx
|
||||||
|
ON public.moderation_media_holds (media_kind, storage_key, report_id)
|
||||||
|
WHERE released_at IS NULL;
|
||||||
|
|
||||||
|
-- Crash-safe, one-way provenance for rows written by the pre-unified
|
||||||
|
-- ephemeral.reportMessage implementation. The legacy table remains immutable
|
||||||
|
-- until every deployed database has completed the application-level evidence
|
||||||
|
-- conversion; all new writes go exclusively to moderation_reports.
|
||||||
|
CREATE TABLE public.moderation_legacy_ephemeral_migrations (
|
||||||
|
legacy_report_id bigint PRIMARY KEY
|
||||||
|
REFERENCES public.ephemeral_abuse_reports(id) ON DELETE RESTRICT,
|
||||||
|
moderation_report_id bigint NOT NULL
|
||||||
|
REFERENCES public.moderation_reports(id) ON DELETE RESTRICT,
|
||||||
|
migrated_at timestamptz NOT NULL
|
||||||
|
);
|
||||||
1
deploy/migrations/0140_auth_delivery_reports.down.sql
Normal file
1
deploy/migrations/0140_auth_delivery_reports.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS public.auth_delivery_reports;
|
||||||
27
deploy/migrations/0140_auth_delivery_reports.up.sql
Normal file
27
deploy/migrations/0140_auth_delivery_reports.up.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
-- Authentication-code delivery diagnostics have a separate privacy and
|
||||||
|
-- retention boundary from abuse moderation. Raw phone numbers, raw
|
||||||
|
-- phone_code_hash values and authentication codes are never stored here.
|
||||||
|
CREATE TABLE public.auth_delivery_reports (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
auth_key_id bytea NOT NULL CHECK (octet_length(auth_key_id) = 8),
|
||||||
|
session_id bigint NOT NULL CHECK (session_id <> 0),
|
||||||
|
client_type text NOT NULL CHECK (octet_length(client_type) <= 32),
|
||||||
|
phone_hash bytea NOT NULL CHECK (octet_length(phone_hash) = 32),
|
||||||
|
code_hash bytea NOT NULL CHECK (octet_length(code_hash) = 32),
|
||||||
|
issued_user_id bigint NOT NULL CHECK (issued_user_id >= 0),
|
||||||
|
delivery_id text NOT NULL CHECK (octet_length(delivery_id) <= 128),
|
||||||
|
channel text NOT NULL CHECK (channel IN ('phone', 'sms')),
|
||||||
|
mnc text NOT NULL CHECK (
|
||||||
|
octet_length(mnc) <= 8 AND mnc !~ '[^0-9]'
|
||||||
|
),
|
||||||
|
fingerprint bytea NOT NULL CHECK (octet_length(fingerprint) = 32),
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
CONSTRAINT auth_delivery_reports_idempotency
|
||||||
|
UNIQUE (auth_key_id, fingerprint)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX auth_delivery_reports_auth_key_created_idx
|
||||||
|
ON public.auth_delivery_reports (auth_key_id, created_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX auth_delivery_reports_phone_created_idx
|
||||||
|
ON public.auth_delivery_reports (phone_hash, created_at DESC, id DESC);
|
||||||
6
deploy/migrations/0141_moderation_cases.down.sql
Normal file
6
deploy/migrations/0141_moderation_cases.down.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
DROP TABLE IF EXISTS public.moderation_actions;
|
||||||
|
DROP TABLE IF EXISTS public.moderation_decisions;
|
||||||
|
DROP TABLE IF EXISTS public.moderation_appeal_links;
|
||||||
|
DROP TABLE IF EXISTS public.moderation_appeals;
|
||||||
|
DROP TABLE IF EXISTS public.moderation_case_reports;
|
||||||
|
DROP TABLE IF EXISTS public.moderation_cases;
|
||||||
202
deploy/migrations/0141_moderation_cases.up.sql
Normal file
202
deploy/migrations/0141_moderation_cases.up.sql
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
-- Target-grouped moderation work queue. Reports stay immutable; cases,
|
||||||
|
-- decisions, actions and appeals form a separate optimistic-concurrency state
|
||||||
|
-- machine.
|
||||||
|
CREATE TABLE public.moderation_cases (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
target_peer_type text NOT NULL CHECK (target_peer_type IN ('user', 'channel')),
|
||||||
|
target_peer_id bigint NOT NULL CHECK (target_peer_id > 0),
|
||||||
|
status text NOT NULL CHECK (status IN (
|
||||||
|
'open', 'in_review', 'action_pending', 'action_failed', 'resolved',
|
||||||
|
'dismissed', 'appeal_review'
|
||||||
|
)),
|
||||||
|
severity smallint NOT NULL CHECK (severity BETWEEN 1 AND 4),
|
||||||
|
assigned_to text NOT NULL DEFAULT '' CHECK (octet_length(assigned_to) <= 128),
|
||||||
|
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||||
|
report_count integer NOT NULL CHECK (report_count > 0),
|
||||||
|
distinct_reporter_count integer NOT NULL CHECK (
|
||||||
|
distinct_reporter_count > 0
|
||||||
|
AND distinct_reporter_count <= report_count
|
||||||
|
),
|
||||||
|
first_report_at timestamptz NOT NULL,
|
||||||
|
last_report_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL,
|
||||||
|
CHECK (last_report_at >= first_report_at),
|
||||||
|
CHECK (updated_at >= created_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX moderation_cases_one_active_target_idx
|
||||||
|
ON public.moderation_cases (target_peer_type, target_peer_id)
|
||||||
|
WHERE status IN ('open', 'in_review');
|
||||||
|
|
||||||
|
CREATE INDEX moderation_cases_queue_idx
|
||||||
|
ON public.moderation_cases (status, severity DESC, updated_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX moderation_cases_assignee_idx
|
||||||
|
ON public.moderation_cases (assigned_to, status, updated_at DESC, id DESC)
|
||||||
|
WHERE assigned_to <> '';
|
||||||
|
|
||||||
|
CREATE TABLE public.moderation_case_reports (
|
||||||
|
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
report_id bigint NOT NULL UNIQUE REFERENCES public.moderation_reports(id)
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
attached_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (case_id, report_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX moderation_case_reports_case_idx
|
||||||
|
ON public.moderation_case_reports (case_id, report_id);
|
||||||
|
|
||||||
|
CREATE TABLE public.moderation_decisions (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
appeal_id bigint,
|
||||||
|
kind text NOT NULL CHECK (kind IN (
|
||||||
|
'no_violation', 'violation', 'appeal_granted', 'appeal_denied'
|
||||||
|
)),
|
||||||
|
actor text NOT NULL CHECK (octet_length(actor) BETWEEN 1 AND 128),
|
||||||
|
reason text NOT NULL CHECK (char_length(reason) BETWEEN 1 AND 2000),
|
||||||
|
command_id text NOT NULL UNIQUE CHECK (octet_length(command_id) BETWEEN 1 AND 120),
|
||||||
|
fingerprint bytea NOT NULL CHECK (octet_length(fingerprint) = 32),
|
||||||
|
created_at timestamptz NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX moderation_decisions_case_idx
|
||||||
|
ON public.moderation_decisions (case_id, created_at, id);
|
||||||
|
|
||||||
|
CREATE TABLE public.moderation_actions (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
decision_id bigint NOT NULL REFERENCES public.moderation_decisions(id)
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
kind text NOT NULL CHECK (kind IN (
|
||||||
|
'mark_scam', 'mark_fake', 'clear_peer_flags', 'freeze_account',
|
||||||
|
'unfreeze_account', 'delete_private_message',
|
||||||
|
'delete_channel_message', 'delete_account'
|
||||||
|
)),
|
||||||
|
payload jsonb NOT NULL CHECK (
|
||||||
|
jsonb_typeof(payload) = 'object'
|
||||||
|
AND octet_length(payload::text) <= 65536
|
||||||
|
),
|
||||||
|
status text NOT NULL CHECK (status IN (
|
||||||
|
'pending', 'processing', 'succeeded', 'superseded', 'retry', 'failed'
|
||||||
|
)),
|
||||||
|
attempts integer NOT NULL DEFAULT 0 CHECK (attempts BETWEEN 0 AND 20),
|
||||||
|
available_at timestamptz NOT NULL,
|
||||||
|
lease_until timestamptz,
|
||||||
|
last_error text NOT NULL DEFAULT '' CHECK (char_length(last_error) <= 4000),
|
||||||
|
command_id text NOT NULL UNIQUE CHECK (octet_length(command_id) BETWEEN 1 AND 160),
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL,
|
||||||
|
CHECK (updated_at >= created_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX moderation_actions_claim_idx
|
||||||
|
ON public.moderation_actions (available_at, id)
|
||||||
|
WHERE status IN ('pending', 'retry', 'processing');
|
||||||
|
|
||||||
|
CREATE INDEX moderation_actions_case_idx
|
||||||
|
ON public.moderation_actions (case_id, id);
|
||||||
|
|
||||||
|
CREATE TABLE public.moderation_appeals (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
appellant_user_id bigint NOT NULL CHECK (appellant_user_id > 0),
|
||||||
|
appeal_text text NOT NULL CHECK (char_length(appeal_text) BETWEEN 1 AND 4000),
|
||||||
|
text_hash bytea NOT NULL CHECK (octet_length(text_hash) = 32),
|
||||||
|
fingerprint bytea NOT NULL UNIQUE CHECK (octet_length(fingerprint) = 32),
|
||||||
|
status text NOT NULL CHECK (status IN ('pending', 'granted', 'rejected')),
|
||||||
|
previous_case_status text NOT NULL CHECK (
|
||||||
|
previous_case_status IN ('resolved', 'dismissed')
|
||||||
|
),
|
||||||
|
reviewer text NOT NULL DEFAULT '' CHECK (octet_length(reviewer) <= 128),
|
||||||
|
review_reason text NOT NULL DEFAULT '' CHECK (char_length(review_reason) <= 2000),
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
reviewed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX moderation_appeals_one_pending_case_actor_idx
|
||||||
|
ON public.moderation_appeals (case_id, appellant_user_id)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
CREATE INDEX moderation_appeals_queue_idx
|
||||||
|
ON public.moderation_appeals (status, created_at, id);
|
||||||
|
|
||||||
|
CREATE TABLE public.moderation_appeal_links (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
appellant_user_id bigint NOT NULL CHECK (appellant_user_id > 0),
|
||||||
|
token_hash bytea NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32),
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
appeal_id bigint REFERENCES public.moderation_appeals(id)
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
consumed_at timestamptz,
|
||||||
|
CHECK (expires_at > created_at),
|
||||||
|
CHECK (expires_at <= created_at + interval '90 days'),
|
||||||
|
CHECK (
|
||||||
|
(appeal_id IS NULL AND consumed_at IS NULL)
|
||||||
|
OR (appeal_id IS NOT NULL AND consumed_at IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX moderation_appeal_links_expiry_idx
|
||||||
|
ON public.moderation_appeal_links (expires_at, id)
|
||||||
|
WHERE consumed_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX moderation_appeal_links_case_idx
|
||||||
|
ON public.moderation_appeal_links (case_id, id);
|
||||||
|
|
||||||
|
ALTER TABLE public.moderation_decisions
|
||||||
|
ADD CONSTRAINT moderation_decisions_appeal_fk
|
||||||
|
FOREIGN KEY (appeal_id) REFERENCES public.moderation_appeals(id)
|
||||||
|
ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX moderation_decisions_one_per_appeal_idx
|
||||||
|
ON public.moderation_decisions (appeal_id)
|
||||||
|
WHERE appeal_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Existing unified reports become one open case per target. This backfill is
|
||||||
|
-- deterministic and keeps every report linked exactly once.
|
||||||
|
INSERT INTO public.moderation_cases (
|
||||||
|
target_peer_type, target_peer_id, status, severity, assigned_to,
|
||||||
|
version, report_count, distinct_reporter_count, first_report_at,
|
||||||
|
last_report_at, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
target_peer_type,
|
||||||
|
target_peer_id,
|
||||||
|
'open',
|
||||||
|
max(CASE reason
|
||||||
|
WHEN 'child_abuse' THEN 4
|
||||||
|
WHEN 'violence' THEN 3
|
||||||
|
WHEN 'pornography' THEN 3
|
||||||
|
WHEN 'illegal_drugs' THEN 3
|
||||||
|
WHEN 'personal_details' THEN 3
|
||||||
|
WHEN 'fake' THEN 2
|
||||||
|
WHEN 'copyright' THEN 2
|
||||||
|
ELSE 1
|
||||||
|
END)::smallint,
|
||||||
|
'',
|
||||||
|
1,
|
||||||
|
count(*)::integer,
|
||||||
|
count(DISTINCT reporter_user_id)::integer,
|
||||||
|
min(created_at),
|
||||||
|
max(created_at),
|
||||||
|
min(created_at),
|
||||||
|
max(created_at)
|
||||||
|
FROM public.moderation_reports
|
||||||
|
GROUP BY target_peer_type, target_peer_id;
|
||||||
|
|
||||||
|
INSERT INTO public.moderation_case_reports (case_id, report_id, attached_at)
|
||||||
|
SELECT c.id, r.id, r.created_at
|
||||||
|
FROM public.moderation_reports r
|
||||||
|
JOIN public.moderation_cases c
|
||||||
|
ON c.target_peer_type = r.target_peer_type
|
||||||
|
AND c.target_peer_id = r.target_peer_id
|
||||||
|
AND c.status = 'open';
|
||||||
1
deploy/migrations/0143_client_telemetry.down.sql
Normal file
1
deploy/migrations/0143_client_telemetry.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS public.client_telemetry_events;
|
||||||
30
deploy/migrations/0143_client_telemetry.up.sql
Normal file
30
deploy/migrations/0143_client_telemetry.up.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- Operational client telemetry is not an abuse-report source. It has its own
|
||||||
|
-- idempotency/rate-limit indexes and TTL retention boundary.
|
||||||
|
CREATE TABLE public.client_telemetry_events (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
user_id bigint NOT NULL CHECK (user_id > 0),
|
||||||
|
kind text NOT NULL CHECK (
|
||||||
|
kind IN ('message_delivery', 'read_metrics', 'music_listen')
|
||||||
|
),
|
||||||
|
peer_type text NOT NULL CHECK (peer_type IN ('', 'user', 'channel')),
|
||||||
|
peer_id bigint NOT NULL CHECK (
|
||||||
|
(peer_type = '' AND peer_id = 0)
|
||||||
|
OR (peer_type <> '' AND peer_id > 0)
|
||||||
|
),
|
||||||
|
subject_ids bigint[] NOT NULL CHECK (
|
||||||
|
cardinality(subject_ids) BETWEEN 1 AND 100
|
||||||
|
),
|
||||||
|
payload jsonb NOT NULL CHECK (
|
||||||
|
jsonb_typeof(payload) = 'object'
|
||||||
|
AND octet_length(payload::text) <= 65536
|
||||||
|
),
|
||||||
|
fingerprint bytea NOT NULL CHECK (octet_length(fingerprint) = 32),
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
CONSTRAINT client_telemetry_idempotency UNIQUE (user_id, fingerprint)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX client_telemetry_user_created_idx
|
||||||
|
ON public.client_telemetry_events (user_id, created_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX client_telemetry_retention_idx
|
||||||
|
ON public.client_telemetry_events (created_at, id);
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP TABLE IF EXISTS public.channel_antispam_decisions;
|
||||||
|
DROP TABLE IF EXISTS public.sponsored_message_impressions;
|
||||||
47
deploy/migrations/0144_moderation_evidence_registries.up.sql
Normal file
47
deploy/migrations/0144_moderation_evidence_registries.up.sql
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
-- Server-issued evidence registries. These prevent arbitrary sponsored IDs or
|
||||||
|
-- ordinary deleted messages from being accepted as human reports.
|
||||||
|
CREATE TABLE public.sponsored_message_impressions (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
user_id bigint NOT NULL CHECK (user_id > 0),
|
||||||
|
random_id_hash bytea NOT NULL CHECK (octet_length(random_id_hash) = 32),
|
||||||
|
target_peer_type text NOT NULL CHECK (target_peer_type IN ('user', 'channel')),
|
||||||
|
target_peer_id bigint NOT NULL CHECK (target_peer_id > 0),
|
||||||
|
author_user_id bigint NOT NULL CHECK (author_user_id >= 0),
|
||||||
|
evidence_schema_version smallint NOT NULL CHECK (evidence_schema_version > 0),
|
||||||
|
evidence jsonb NOT NULL CHECK (
|
||||||
|
jsonb_typeof(evidence) = 'object'
|
||||||
|
AND octet_length(evidence::text) <= 1048576
|
||||||
|
),
|
||||||
|
evidence_hash bytea NOT NULL CHECK (octet_length(evidence_hash) = 32),
|
||||||
|
report_id bigint UNIQUE REFERENCES public.moderation_reports(id) ON DELETE RESTRICT,
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
CHECK (expires_at > created_at),
|
||||||
|
CHECK (expires_at <= created_at + interval '30 days'),
|
||||||
|
CONSTRAINT sponsored_message_impressions_identity
|
||||||
|
UNIQUE (user_id, random_id_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX sponsored_message_impressions_expiry_idx
|
||||||
|
ON public.sponsored_message_impressions (expires_at, id);
|
||||||
|
|
||||||
|
CREATE TABLE public.channel_antispam_decisions (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
channel_id bigint NOT NULL CHECK (channel_id > 0),
|
||||||
|
message_id integer NOT NULL CHECK (message_id > 0),
|
||||||
|
author_user_id bigint NOT NULL CHECK (author_user_id > 0),
|
||||||
|
evidence_schema_version smallint NOT NULL CHECK (evidence_schema_version > 0),
|
||||||
|
evidence jsonb NOT NULL CHECK (
|
||||||
|
jsonb_typeof(evidence) = 'object'
|
||||||
|
AND octet_length(evidence::text) <= 1048576
|
||||||
|
),
|
||||||
|
evidence_hash bytea NOT NULL CHECK (octet_length(evidence_hash) = 32),
|
||||||
|
report_id bigint UNIQUE REFERENCES public.moderation_reports(id) ON DELETE RESTRICT,
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
CONSTRAINT channel_antispam_decisions_identity
|
||||||
|
UNIQUE (channel_id, message_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX channel_antispam_decisions_unreported_idx
|
||||||
|
ON public.channel_antispam_decisions (channel_id, created_at DESC, id DESC)
|
||||||
|
WHERE report_id IS NULL;
|
||||||
17
deploy/migrations/0145_privacy_update_events.down.sql
Normal file
17
deploy/migrations/0145_privacy_update_events.down.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check;
|
||||||
|
ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK (
|
||||||
|
(event_type)::text = ANY (ARRAY[
|
||||||
|
'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents',
|
||||||
|
'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies',
|
||||||
|
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
|
||||||
|
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
|
||||||
|
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'delete_messages',
|
||||||
|
'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
|
||||||
|
'channel_available_messages', 'channel_view_forum_as_messages', 'channel_state',
|
||||||
|
'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
|
||||||
|
'sent_story_reaction', 'new_story_reaction', 'noop',
|
||||||
|
'read_channel_discussion_inbox', 'read_channel_discussion_outbox'
|
||||||
|
]::text[])
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS public.user_update_privacy_payloads;
|
||||||
30
deploy/migrations/0145_privacy_update_events.up.sql
Normal file
30
deploy/migrations/0145_privacy_update_events.up.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- Privacy settings are absolute account state. Persist the immutable rule
|
||||||
|
-- snapshot next to the account pts event so other online sessions and offline
|
||||||
|
-- getDifference replay exactly the committed value without re-querying the
|
||||||
|
-- mutable account_privacy_rules row.
|
||||||
|
CREATE TABLE public.user_update_privacy_payloads (
|
||||||
|
user_id bigint NOT NULL,
|
||||||
|
pts integer NOT NULL CHECK (pts > 0),
|
||||||
|
payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'),
|
||||||
|
PRIMARY KEY (user_id, pts),
|
||||||
|
CONSTRAINT user_update_privacy_payloads_event_fk
|
||||||
|
FOREIGN KEY (user_id, pts)
|
||||||
|
REFERENCES public.user_update_events(user_id, pts)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check;
|
||||||
|
ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK (
|
||||||
|
(event_type)::text = ANY (ARRAY[
|
||||||
|
'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents',
|
||||||
|
'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies',
|
||||||
|
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
|
||||||
|
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
|
||||||
|
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'privacy', 'delete_messages',
|
||||||
|
'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
|
||||||
|
'channel_available_messages', 'channel_view_forum_as_messages', 'channel_state',
|
||||||
|
'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
|
||||||
|
'sent_story_reaction', 'new_story_reaction', 'noop',
|
||||||
|
'read_channel_discussion_inbox', 'read_channel_discussion_outbox'
|
||||||
|
]::text[])
|
||||||
|
);
|
||||||
|
|
@ -63,9 +63,7 @@ This document describes every setting loaded by `internal/config`. Defaults and
|
||||||
| `TELESRV_PUBLIC_APP_LINK_BASE` | nullable custom URL base / empty | Optional host-based root for multi-server clients, for example `owpg://example.com`. When set, links use `owpg://example.com/oauth`, `owpg://example.com/<username>`, and equivalent route paths. Only exact `<custom-scheme>://<host>` values are accepted; ports, paths, queries, and fragments are rejected. `TELESRV_PUBLIC_APP_SCHEME` remains an accepted legacy input. |
|
| `TELESRV_PUBLIC_APP_LINK_BASE` | nullable custom URL base / empty | Optional host-based root for multi-server clients, for example `owpg://example.com`. When set, links use `owpg://example.com/oauth`, `owpg://example.com/<username>`, and equivalent route paths. Only exact `<custom-scheme>://<host>` values are accepted; ports, paths, queries, and fragments are rejected. `TELESRV_PUBLIC_APP_SCHEME` remains an accepted legacy input. |
|
||||||
| `TELESRV_PUBLIC_WEB_BASE_URL` | HTTP(S) URL / `https://web.telesrv.net` | Web-client root used by public username pages. Same URL validation as `TELESRV_PUBLIC_BASE_URL`. |
|
| `TELESRV_PUBLIC_WEB_BASE_URL` | HTTP(S) URL / `https://web.telesrv.net` | Web-client root used by public username pages. Same URL validation as `TELESRV_PUBLIC_BASE_URL`. |
|
||||||
| `TELESRV_PUBLIC_APP_NAME` | string / `telesrv` | Public landing-page product name; trimmed, non-empty, no control characters, maximum 64 Unicode characters. |
|
| `TELESRV_PUBLIC_APP_NAME` | string / `telesrv` | Public landing-page product name; trimmed, non-empty, no control characters, maximum 64 Unicode characters. |
|
||||||
| `TELESRV_SCAM_WARNING` | string / empty | Overrides the profile warning injected into `getFullUser`/`getFullChannel` About for SCAM-flagged peers. Empty keeps the built-in per-peer-type English default. Non-destructive: the stored bio/description is never overwritten and the warning is re-applied from the flag on every read. Clients cannot localize server-provided text. |
|
| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / empty | Username/avatar/sticker/emoji/chatlist/collectible-gift landing pages plus the hash-only moderation appeal form. Empty disables it. Production should bind loopback behind exact nginx routes. Moderation `freeze_account` actions fail closed when this listener is disabled because telesrv cannot issue a reachable appeal URL. `.env.example` enables `127.0.0.1:2401` for development. |
|
||||||
| `TELESRV_FAKE_WARNING` | string / empty | Same as `TELESRV_SCAM_WARNING`, for FAKE-flagged peers. |
|
|
||||||
| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / empty | Read-only username/avatar/sticker/emoji/chatlist/collectible-gift landing-page listener. Empty disables it. Production should bind loopback behind exact nginx routes. `.env.example` enables `127.0.0.1:2401` for development. |
|
|
||||||
| `TELESRV_TELEGRAM_LOGIN_ENABLE` | bool / `false` | Mount the self-hosted Telegram Login/OIDC provider on `TELESRV_PUBLIC_LINK_WEB_ADDR`. Enabling it requires that listener and all key files below. |
|
| `TELESRV_TELEGRAM_LOGIN_ENABLE` | bool / `false` | Mount the self-hosted Telegram Login/OIDC provider on `TELESRV_PUBLIC_LINK_WEB_ADDR`. Enabling it requires that listener and all key files below. |
|
||||||
| `TELESRV_TELEGRAM_LOGIN_ISSUER` | absolute origin URL / `TELESRV_PUBLIC_BASE_URL` | Exact public issuer used in discovery and tokens. HTTPS is required by default; paths, credentials, query, and fragment are rejected. The next setting permits any HTTP host/IP. |
|
| `TELESRV_TELEGRAM_LOGIN_ISSUER` | absolute origin URL / `TELESRV_PUBLIC_BASE_URL` | Exact public issuer used in discovery and tokens. HTTPS is required by default; paths, credentials, query, and fragment are rejected. The next setting permits any HTTP host/IP. |
|
||||||
| `TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP` | bool / `false` | When enabled, permits any valid HTTP issuer, BotFather Web origin, redirect URI, and native HTTP callback, without loopback, subnet, or port restrictions. When disabled, those Web URLs still require HTTPS. |
|
| `TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP` | bool / `false` | When enabled, permits any valid HTTP issuer, BotFather Web origin, redirect URI, and native HTTP callback, without loopback, subnet, or port restrictions. When disabled, those Web URLs still require HTTPS. |
|
||||||
|
|
@ -510,6 +508,13 @@ The following fallback keys are accepted from the **process environment only**.
|
||||||
| `TELESRV_RETENTION_INTERVAL` | duration / `1h` | General retention worker interval. |
|
| `TELESRV_RETENTION_INTERVAL` | duration / `1h` | General retention worker interval. |
|
||||||
| `TELESRV_RETENTION_BATCH` | int / `10000` | Maximum rows deleted by one general retention batch. |
|
| `TELESRV_RETENTION_BATCH` | int / `10000` | Maximum rows deleted by one general retention batch. |
|
||||||
|
|
||||||
|
Moderation report/evidence/case/decision/action/appeal rows are durable audit facts and are not removed by the
|
||||||
|
general retention worker. The same worker deletes expired sponsored impressions and appeal links in bounded seek
|
||||||
|
batches, and deletes auth-delivery diagnostics and client telemetry after their fixed 30-day privacy retention.
|
||||||
|
The raw appeal token is never persisted. Production reverse proxies must expose only `/appeal/<token>` to the
|
||||||
|
public-link listener, preserve HTTPS in `TELESRV_PUBLIC_BASE_URL`, cap request bodies, and must not log the tokenized
|
||||||
|
path. `TELESRV_PUBLIC_BASE_URL` must resolve to that proxy for moderation freeze actions.
|
||||||
|
|
||||||
## 10. Premium and Stars development grants
|
## 10. Premium and Stars development grants
|
||||||
|
|
||||||
| Setting | Type / code default | Description and constraints |
|
| Setting | Type / code default | Description and constraints |
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,16 @@ type EmojiService interface {
|
||||||
DocumentAnimationJSON(ctx context.Context, documentID int64) ([]byte, bool, error)
|
DocumentAnimationJSON(ctx context.Context, documentID int64) ([]byte, bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ModerationService interface {
|
||||||
|
ListCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error)
|
||||||
|
Case(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error)
|
||||||
|
Report(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error)
|
||||||
|
ClaimCase(ctx context.Context, caseID, expectedVersion int64, actor string, now time.Time) (domain.ModerationCase, error)
|
||||||
|
DecideCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
|
||||||
|
SubmitAppeal(ctx context.Context, caseID, appellantUserID int64, text string, now time.Time) (domain.ModerationAppeal, bool, error)
|
||||||
|
ReviewAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
// GiftGranter delivers a catalog gift to a recipient peer on behalf of a sender
|
// GiftGranter delivers a catalog gift to a recipient peer on behalf of a sender
|
||||||
// without charging Stars. Implemented by the RPC router, it reuses the standard
|
// without charging Stars. Implemented by the RPC router, it reuses the standard
|
||||||
// gift-delivery path (service message for users, saved-gift + admin log for
|
// gift-delivery path (service message for users, saved-gift + admin log for
|
||||||
|
|
@ -191,6 +201,7 @@ type Dependencies struct {
|
||||||
OfficialGifts OfficialGiftsSource
|
OfficialGifts OfficialGiftsSource
|
||||||
Bots BotService
|
Bots BotService
|
||||||
Emoji EmojiService
|
Emoji EmojiService
|
||||||
|
Moderation ModerationService
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,6 +223,7 @@ type Service struct {
|
||||||
officialGifts OfficialGiftsSource
|
officialGifts OfficialGiftsSource
|
||||||
bots BotService
|
bots BotService
|
||||||
emoji EmojiService
|
emoji EmojiService
|
||||||
|
moderation ModerationService
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,6 +284,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
||||||
if deps.Emoji != nil {
|
if deps.Emoji != nil {
|
||||||
s.emoji = deps.Emoji
|
s.emoji = deps.Emoji
|
||||||
}
|
}
|
||||||
|
if deps.Moderation != nil {
|
||||||
|
s.moderation = deps.Moderation
|
||||||
|
}
|
||||||
if deps.Now != nil {
|
if deps.Now != nil {
|
||||||
s.now = deps.Now
|
s.now = deps.Now
|
||||||
}
|
}
|
||||||
|
|
@ -281,6 +296,61 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
|
||||||
|
if s == nil || s.moderation == nil {
|
||||||
|
return nil, fmt.Errorf("moderation dependency is not configured")
|
||||||
|
}
|
||||||
|
return s.moderation.ListCases(ctx, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ModerationCase(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error) {
|
||||||
|
if s == nil || s.moderation == nil {
|
||||||
|
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation dependency is not configured")
|
||||||
|
}
|
||||||
|
return s.moderation.Case(ctx, caseID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error) {
|
||||||
|
if s == nil || s.moderation == nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("moderation dependency is not configured")
|
||||||
|
}
|
||||||
|
return s.moderation.Report(ctx, reportID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ClaimModerationCase(ctx context.Context, caseID, expectedVersion int64, actor string) (domain.ModerationCase, error) {
|
||||||
|
if s == nil || s.moderation == nil {
|
||||||
|
return domain.ModerationCase{}, fmt.Errorf("moderation dependency is not configured")
|
||||||
|
}
|
||||||
|
return s.moderation.ClaimCase(ctx, caseID, expectedVersion, actor, s.now().UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DecideModerationCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||||
|
if s == nil || s.moderation == nil {
|
||||||
|
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation dependency is not configured")
|
||||||
|
}
|
||||||
|
if request.CreatedAt.IsZero() {
|
||||||
|
request.CreatedAt = s.now().UTC()
|
||||||
|
}
|
||||||
|
return s.moderation.DecideCase(ctx, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SubmitModerationAppeal(ctx context.Context, caseID, appellantUserID int64, text string) (domain.ModerationAppeal, bool, error) {
|
||||||
|
if s == nil || s.moderation == nil {
|
||||||
|
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation dependency is not configured")
|
||||||
|
}
|
||||||
|
return s.moderation.SubmitAppeal(ctx, caseID, appellantUserID, text, s.now().UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReviewModerationAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||||
|
if s == nil || s.moderation == nil {
|
||||||
|
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation dependency is not configured")
|
||||||
|
}
|
||||||
|
if request.CreatedAt.IsZero() {
|
||||||
|
request.CreatedAt = s.now().UTC()
|
||||||
|
}
|
||||||
|
return s.moderation.ReviewAppeal(ctx, request)
|
||||||
|
}
|
||||||
|
|
||||||
type CommandMeta struct {
|
type CommandMeta struct {
|
||||||
CommandID string `json:"command_id"`
|
CommandID string `json:"command_id"`
|
||||||
Actor string `json:"actor"`
|
Actor string `json:"actor"`
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,13 @@ type Service interface {
|
||||||
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error)
|
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error)
|
||||||
StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
|
StarGiftCollectibles(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
|
||||||
StarGiftCollectibleAnimation(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
|
StarGiftCollectibleAnimation(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error)
|
||||||
|
ModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error)
|
||||||
|
ModerationCase(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error)
|
||||||
|
ModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error)
|
||||||
|
ClaimModerationCase(ctx context.Context, caseID, expectedVersion int64, actor string) (domain.ModerationCase, error)
|
||||||
|
DecideModerationCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
|
||||||
|
SubmitModerationAppeal(ctx context.Context, caseID, appellantUserID int64, text string) (domain.ModerationAppeal, bool, error)
|
||||||
|
ReviewModerationAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http.Server, error) {
|
func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http.Server, error) {
|
||||||
|
|
@ -137,6 +144,13 @@ func (s *Server) routes() http.Handler {
|
||||||
mux.HandleFunc("GET /v1/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation))
|
mux.HandleFunc("GET /v1/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation))
|
||||||
mux.HandleFunc("GET /v1/gifts/{id}/collectibles", s.authenticated(s.handleStarGiftCollectibles))
|
mux.HandleFunc("GET /v1/gifts/{id}/collectibles", s.authenticated(s.handleStarGiftCollectibles))
|
||||||
mux.HandleFunc("GET /v1/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.authenticated(s.handleStarGiftCollectibleAnimation))
|
mux.HandleFunc("GET /v1/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.authenticated(s.handleStarGiftCollectibleAnimation))
|
||||||
|
mux.HandleFunc("GET /v1/moderation/cases", s.authenticated(s.handleModerationCases))
|
||||||
|
mux.HandleFunc("GET /v1/moderation/cases/{id}", s.authenticated(s.handleModerationCase))
|
||||||
|
mux.HandleFunc("GET /v1/moderation/reports/{id}", s.authenticated(s.handleModerationReport))
|
||||||
|
mux.HandleFunc("POST /v1/moderation/cases/{id}/claim", s.authenticated(s.handleClaimModerationCase))
|
||||||
|
mux.HandleFunc("POST /v1/moderation/cases/{id}/decide", s.authenticated(s.handleDecideModerationCase))
|
||||||
|
mux.HandleFunc("POST /v1/moderation/cases/{id}/appeals", s.authenticated(s.handleSubmitModerationAppeal))
|
||||||
|
mux.HandleFunc("POST /v1/moderation/cases/{id}/appeals/{appeal_id}/review", s.authenticated(s.handleReviewModerationAppeal))
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -634,6 +648,267 @@ func (s *Server) handleStarGiftCollectibleAnimation(w http.ResponseWriter, r *ht
|
||||||
_, _ = w.Write(raw)
|
_, _ = w.Write(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type moderationClaimRequest struct {
|
||||||
|
ExpectedVersion int64 `json:"expected_version"`
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type moderationActionRequest struct {
|
||||||
|
Kind domain.ModerationActionKind `json:"kind"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type moderationDecisionRequest struct {
|
||||||
|
ExpectedVersion int64 `json:"expected_version"`
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
Kind domain.ModerationDecisionKind `json:"kind"`
|
||||||
|
Actions []moderationActionRequest `json:"actions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type moderationAppealRequest struct {
|
||||||
|
AppellantUserID int64 `json:"appellant_user_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type moderationAppealReviewRequest struct {
|
||||||
|
ExpectedVersion int64 `json:"expected_version"`
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
Granted bool `json:"granted"`
|
||||||
|
Actions []moderationActionRequest `json:"actions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleModerationCases(w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
limit := 50
|
||||||
|
if raw := query.Get("limit"); raw != "" {
|
||||||
|
parsed, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid limit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit = parsed
|
||||||
|
}
|
||||||
|
filter := domain.ModerationCaseFilter{
|
||||||
|
AssignedTo: query.Get("assigned_to"),
|
||||||
|
Limit: limit,
|
||||||
|
}
|
||||||
|
if raw := query.Get("statuses"); raw != "" {
|
||||||
|
for _, status := range strings.Split(raw, ",") {
|
||||||
|
if status = strings.TrimSpace(status); status != "" {
|
||||||
|
filter.Statuses = append(filter.Statuses, domain.ModerationCaseStatus(status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw := query.Get("target_id"); raw != "" {
|
||||||
|
id, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid target id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter.Target = domain.Peer{
|
||||||
|
Type: domain.PeerType(query.Get("target_type")), ID: id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw := query.Get("before_updated_at"); raw != "" {
|
||||||
|
parsed, err := time.Parse(time.RFC3339Nano, raw)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid before_updated_at")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter.BeforeUpdate = parsed
|
||||||
|
filter.BeforeID, _ = strconv.ParseInt(query.Get("before_id"), 10, 64)
|
||||||
|
}
|
||||||
|
items, err := s.svc.ModerationCases(r.Context(), filter)
|
||||||
|
if err != nil {
|
||||||
|
writeModerationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"cases": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleModerationCase(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caseID, ok := moderationPathID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, found, err := s.svc.ModerationCase(r.Context(), caseID)
|
||||||
|
if err != nil {
|
||||||
|
writeModerationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
writeError(w, http.StatusNotFound, "moderation case not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleModerationReport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reportID, ok := moderationPathID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
report, found, err := s.svc.ModerationReport(r.Context(), reportID)
|
||||||
|
if err != nil {
|
||||||
|
writeModerationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
writeError(w, http.StatusNotFound, "moderation report not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, report)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleClaimModerationCase(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caseID, ok := moderationPathID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request moderationClaimRequest
|
||||||
|
if !decodeJSON(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := s.svc.ClaimModerationCase(
|
||||||
|
r.Context(), caseID, request.ExpectedVersion, request.Actor,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeModerationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDecideModerationCase(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caseID, ok := moderationPathID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request moderationDecisionRequest
|
||||||
|
if !decodeJSON(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, created, err := s.svc.DecideModerationCase(
|
||||||
|
r.Context(), moderationDecisionDomain(caseID, 0, request),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeModerationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"created": created, "case": detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSubmitModerationAppeal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caseID, ok := moderationPathID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request moderationAppealRequest
|
||||||
|
if !decodeJSON(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appeal, created, err := s.svc.SubmitModerationAppeal(
|
||||||
|
r.Context(), caseID, request.AppellantUserID, request.Text,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeModerationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"created": created, "appeal": appeal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleReviewModerationAppeal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caseID, ok := moderationPathID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appealID, ok := moderationPathID(w, r, "appeal_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request moderationAppealReviewRequest
|
||||||
|
if !decodeJSON(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kind := domain.ModerationDecisionAppealDeny
|
||||||
|
if request.Granted {
|
||||||
|
kind = domain.ModerationDecisionAppealGrant
|
||||||
|
}
|
||||||
|
decision := moderationDecisionRequest{
|
||||||
|
ExpectedVersion: request.ExpectedVersion, Actor: request.Actor,
|
||||||
|
Reason: request.Reason, CommandID: request.CommandID,
|
||||||
|
Kind: kind, Actions: request.Actions,
|
||||||
|
}
|
||||||
|
detail, created, err := s.svc.ReviewModerationAppeal(
|
||||||
|
r.Context(), moderationDecisionDomain(caseID, appealID, decision),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeModerationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"created": created, "case": detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func moderationDecisionDomain(caseID, appealID int64, request moderationDecisionRequest) domain.ModerationDecisionRequest {
|
||||||
|
actions := make([]domain.ModerationActionDraft, 0, len(request.Actions))
|
||||||
|
for _, action := range request.Actions {
|
||||||
|
payload := action.Payload
|
||||||
|
if len(payload) == 0 {
|
||||||
|
payload = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
actions = append(actions, domain.ModerationActionDraft{
|
||||||
|
Kind: action.Kind, Payload: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return domain.ModerationDecisionRequest{
|
||||||
|
CaseID: caseID, AppealID: appealID,
|
||||||
|
ExpectedVersion: request.ExpectedVersion, Actor: request.Actor,
|
||||||
|
Reason: request.Reason, CommandID: request.CommandID,
|
||||||
|
Kind: request.Kind, Actions: actions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moderationPathID(w http.ResponseWriter, r *http.Request, name string) (int64, bool) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue(name), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid "+name)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeModerationError(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrModerationCaseNotFound),
|
||||||
|
errors.Is(err, domain.ErrModerationReportNotFound),
|
||||||
|
errors.Is(err, domain.ErrModerationEvidenceNotFound):
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
case errors.Is(err, domain.ErrModerationPermissionDenied):
|
||||||
|
writeError(w, http.StatusForbidden, err.Error())
|
||||||
|
case errors.Is(err, domain.ErrModerationCaseConflict),
|
||||||
|
errors.Is(err, domain.ErrModerationActionConflict):
|
||||||
|
writeError(w, http.StatusConflict, err.Error())
|
||||||
|
case errors.Is(err, domain.ErrModerationRateLimited):
|
||||||
|
writeError(w, http.StatusTooManyRequests, err.Error())
|
||||||
|
case errors.Is(err, domain.ErrModerationCaseInvalid),
|
||||||
|
errors.Is(err, domain.ErrModerationActionInvalid),
|
||||||
|
errors.Is(err, domain.ErrModerationReportInvalid):
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,85 @@ func TestAdminAPISetAccountFrozen(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type captureModerationService struct {
|
||||||
|
fakeService
|
||||||
|
filter domain.ModerationCaseFilter
|
||||||
|
decision domain.ModerationDecisionRequest
|
||||||
|
appealReview domain.ModerationDecisionRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *captureModerationService) ModerationCases(_ context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
|
||||||
|
s.filter = filter
|
||||||
|
return []domain.ModerationCase{{ID: 7}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *captureModerationService) DecideModerationCase(_ context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||||
|
s.decision = request
|
||||||
|
return domain.ModerationCaseDetail{Case: domain.ModerationCase{ID: request.CaseID}}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *captureModerationService) ReviewModerationAppeal(_ context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||||
|
s.appealReview = request
|
||||||
|
return domain.ModerationCaseDetail{Case: domain.ModerationCase{ID: request.CaseID}}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminAPIModerationQueueDecisionAndAppealReview(t *testing.T) {
|
||||||
|
svc := &captureModerationService{}
|
||||||
|
srv := &Server{token: "secret", svc: svc}
|
||||||
|
listRequest := httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/v1/moderation/cases?statuses=open,action_failed&assigned_to=alice&target_type=user&target_id=99&limit=25",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
listRequest.Header.Set("Authorization", "Bearer secret")
|
||||||
|
list := httptest.NewRecorder()
|
||||||
|
srv.routes().ServeHTTP(list, listRequest)
|
||||||
|
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"ID":7`) {
|
||||||
|
t.Fatalf("list status=%d body=%s", list.Code, list.Body.String())
|
||||||
|
}
|
||||||
|
if len(svc.filter.Statuses) != 2 ||
|
||||||
|
svc.filter.Statuses[0] != domain.ModerationCaseOpen ||
|
||||||
|
svc.filter.Statuses[1] != domain.ModerationCaseActionFailed ||
|
||||||
|
svc.filter.AssignedTo != "alice" ||
|
||||||
|
svc.filter.Target != (domain.Peer{Type: domain.PeerTypeUser, ID: 99}) ||
|
||||||
|
svc.filter.Limit != 25 {
|
||||||
|
t.Fatalf("filter=%+v", svc.filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
decisionRequest := httptest.NewRequest(
|
||||||
|
http.MethodPost, "/v1/moderation/cases/7/decide",
|
||||||
|
strings.NewReader(`{"expected_version":3,"actor":"alice","reason":"confirmed","command_id":"decision-7","kind":"violation","actions":[{"kind":"mark_scam","payload":{}}]}`),
|
||||||
|
)
|
||||||
|
decisionRequest.Header.Set("Authorization", "Bearer secret")
|
||||||
|
decision := httptest.NewRecorder()
|
||||||
|
srv.routes().ServeHTTP(decision, decisionRequest)
|
||||||
|
if decision.Code != http.StatusOK ||
|
||||||
|
!strings.Contains(decision.Body.String(), `"created":true`) ||
|
||||||
|
svc.decision.CaseID != 7 || svc.decision.ExpectedVersion != 3 ||
|
||||||
|
svc.decision.Kind != domain.ModerationDecisionViolation ||
|
||||||
|
len(svc.decision.Actions) != 1 ||
|
||||||
|
svc.decision.Actions[0].Kind != domain.ModerationActionMarkScam {
|
||||||
|
t.Fatalf("decision status=%d request=%+v body=%s",
|
||||||
|
decision.Code, svc.decision, decision.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
reviewRequest := httptest.NewRequest(
|
||||||
|
http.MethodPost, "/v1/moderation/cases/7/appeals/8/review",
|
||||||
|
strings.NewReader(`{"expected_version":5,"actor":"bob","reason":"appeal accepted","command_id":"appeal-8","granted":true,"actions":[{"kind":"clear_peer_flags","payload":{}}]}`),
|
||||||
|
)
|
||||||
|
reviewRequest.Header.Set("Authorization", "Bearer secret")
|
||||||
|
review := httptest.NewRecorder()
|
||||||
|
srv.routes().ServeHTTP(review, reviewRequest)
|
||||||
|
if review.Code != http.StatusOK ||
|
||||||
|
svc.appealReview.CaseID != 7 || svc.appealReview.AppealID != 8 ||
|
||||||
|
svc.appealReview.Kind != domain.ModerationDecisionAppealGrant ||
|
||||||
|
len(svc.appealReview.Actions) != 1 ||
|
||||||
|
svc.appealReview.Actions[0].Kind != domain.ModerationActionClearPeerFlags {
|
||||||
|
t.Fatalf("review status=%d request=%+v body=%s",
|
||||||
|
review.Code, svc.appealReview, review.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminAPISetVerified(t *testing.T) {
|
func TestAdminAPISetVerified(t *testing.T) {
|
||||||
srv := &Server{token: "secret", svc: fakeService{}}
|
srv := &Server{token: "secret", svc: fakeService{}}
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/set-verified", strings.NewReader(`{"command_id":"c2","actor":"ops","reason":"official","dry_run":true,"user_id":1001,"verified":true}`))
|
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/set-verified", strings.NewReader(`{"command_id":"c2","actor":"ops","reason":"official","dry_run":true,"user_id":1001,"verified":true}`))
|
||||||
|
|
@ -357,3 +436,31 @@ func (fakeService) StarGiftCollectibles(context.Context, int64) (domain.StarGift
|
||||||
func (fakeService) StarGiftCollectibleAnimation(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
|
func (fakeService) StarGiftCollectibleAnimation(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
|
||||||
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
|
return []byte(`{"v":"5.7","w":512,"h":512}`), true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fakeService) ModerationCases(context.Context, domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeService) ModerationCase(context.Context, int64) (domain.ModerationCaseDetail, bool, error) {
|
||||||
|
return domain.ModerationCaseDetail{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeService) ModerationReport(context.Context, int64) (domain.ModerationReport, bool, error) {
|
||||||
|
return domain.ModerationReport{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeService) ClaimModerationCase(context.Context, int64, int64, string) (domain.ModerationCase, error) {
|
||||||
|
return domain.ModerationCase{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeService) DecideModerationCase(context.Context, domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||||
|
return domain.ModerationCaseDetail{}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeService) SubmitModerationAppeal(context.Context, int64, int64, string) (domain.ModerationAppeal, bool, error) {
|
||||||
|
return domain.ModerationAppeal{}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeService) ReviewModerationAppeal(context.Context, domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||||
|
return domain.ModerationCaseDetail{}, true, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1016,6 +1016,41 @@ func (s *Service) GetAccountSettings(ctx context.Context, userID int64) (domain.
|
||||||
return settings, nil
|
return settings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAccountSettingsBatch is the bounded cold loader behind the RPC read
|
||||||
|
// model. Missing rows are returned as explicit defaults so they are negative
|
||||||
|
// cached instead of being queried again.
|
||||||
|
func (s *Service) GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
|
||||||
|
out := make(map[int64]domain.AccountSettings, len(userIDs))
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
if userID > 0 {
|
||||||
|
out[userID] = domain.DefaultAccountSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s == nil || s.settings == nil || len(out) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if batch, ok := s.settings.(store.AccountSettingsBatchStore); ok {
|
||||||
|
loaded, err := batch.GetAccountSettingsBatch(ctx, userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for userID, settings := range loaded {
|
||||||
|
out[userID] = settings
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
for userID := range out {
|
||||||
|
settings, found, err := s.settings.GetAccountSettings(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
out[userID] = settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetGlobalPrivacy 持久化账号全局隐私开关,返回合并后的完整设置。
|
// SetGlobalPrivacy 持久化账号全局隐私开关,返回合并后的完整设置。
|
||||||
func (s *Service) SetGlobalPrivacy(ctx context.Context, userID int64, privacy domain.GlobalPrivacy) (domain.AccountSettings, error) {
|
func (s *Service) SetGlobalPrivacy(ctx context.Context, userID int64, privacy domain.GlobalPrivacy) (domain.AccountSettings, error) {
|
||||||
settings, err := s.GetAccountSettings(ctx, userID)
|
settings, err := s.GetAccountSettings(ctx, userID)
|
||||||
|
|
|
||||||
53
internal/app/authdiagnostics/service.go
Normal file
53
internal/app/authdiagnostics/service.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package authdiagnostics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
codes store.CodeStore
|
||||||
|
reports store.AuthDeliveryReportStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(codes store.CodeStore, reports store.AuthDeliveryReportStore) *Service {
|
||||||
|
return &Service{codes: codes, reports: reports}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReportMissingCode(ctx context.Context, req domain.AuthMissingCodeReportRequest) (domain.AuthDeliveryReport, bool, error) {
|
||||||
|
phone := domain.NormalizePhone(req.Phone)
|
||||||
|
if s == nil || s.codes == nil || s.reports == nil ||
|
||||||
|
!domain.ValidPhone(phone) || req.PhoneCodeHash == "" {
|
||||||
|
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeInvalid
|
||||||
|
}
|
||||||
|
record, found, err := s.codes.Get(ctx, req.PhoneCodeHash)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AuthDeliveryReport{}, false, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeExpired
|
||||||
|
}
|
||||||
|
if record.Version != store.PhoneCodeVersionCurrent || record.Purpose != "" ||
|
||||||
|
record.Phone != phone || !store.LoginCodeChannelVerifiable(record.Channel) {
|
||||||
|
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeInvalid
|
||||||
|
}
|
||||||
|
var channel domain.AuthCodeDeliveryKind
|
||||||
|
switch record.Channel {
|
||||||
|
case store.PhoneCodeChannelPhone:
|
||||||
|
channel = domain.AuthCodeDeliveryPhone
|
||||||
|
case store.PhoneCodeChannelSMS:
|
||||||
|
channel = domain.AuthCodeDeliverySMS
|
||||||
|
default:
|
||||||
|
return domain.AuthDeliveryReport{}, false, domain.ErrPhoneCodeInvalid
|
||||||
|
}
|
||||||
|
report, err := domain.NewAuthDeliveryReport(
|
||||||
|
req.AuthKeyID, req.SessionID, req.ClientType, phone, req.PhoneCodeHash,
|
||||||
|
record.IssuedUserID, record.DeliveryID, channel, req.MNC, req.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.AuthDeliveryReport{}, false, err
|
||||||
|
}
|
||||||
|
return s.reports.CreateAuthDeliveryReport(ctx, report)
|
||||||
|
}
|
||||||
81
internal/app/authdiagnostics/service_test.go
Normal file
81
internal/app/authdiagnostics/service_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package authdiagnostics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReportMissingCodeValidatesLiveDeliveryAndStoresOnlyHashes(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
codes := memory.NewCodeStore()
|
||||||
|
reports := memory.NewAuthDeliveryReportStore()
|
||||||
|
const (
|
||||||
|
phone = "15550001234"
|
||||||
|
codeHash = "login-code-hash"
|
||||||
|
)
|
||||||
|
if err := codes.Set(ctx, codeHash, store.PhoneCode{
|
||||||
|
Version: store.PhoneCodeVersionCurrent, Phone: phone, Code: "12345",
|
||||||
|
DeliveryID: "delivery-1", Channel: store.PhoneCodeChannelSMS,
|
||||||
|
IssuedUserID: 42,
|
||||||
|
}, time.Hour); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service := NewService(codes, reports)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
req := domain.AuthMissingCodeReportRequest{
|
||||||
|
AuthKeyID: [8]byte{1, 2, 3}, SessionID: 99, ClientType: "tdesktop",
|
||||||
|
Phone: "+1 (555) 000-1234", PhoneCodeHash: codeHash, MNC: "46000",
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
first, created, err := service.ReportMissingCode(ctx, req)
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("first report created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
second, created, err := service.ReportMissingCode(ctx, req)
|
||||||
|
if err != nil || created || second.ID != first.ID {
|
||||||
|
t.Fatalf("retry report=%+v created=%v err=%v", second, created, err)
|
||||||
|
}
|
||||||
|
stored := reports.Reports()
|
||||||
|
if len(stored) != 1 {
|
||||||
|
t.Fatalf("stored reports=%d, want 1", len(stored))
|
||||||
|
}
|
||||||
|
if stored[0].PhoneHash != sha256.Sum256([]byte(phone)) ||
|
||||||
|
stored[0].CodeHash != sha256.Sum256([]byte(codeHash)) {
|
||||||
|
t.Fatalf("stored hashes do not match normalized delivery identity: %+v", stored[0])
|
||||||
|
}
|
||||||
|
if stored[0].DeliveryID != "delivery-1" || stored[0].IssuedUserID != 42 ||
|
||||||
|
stored[0].Channel != domain.AuthCodeDeliverySMS {
|
||||||
|
t.Fatalf("stored delivery metadata=%+v", stored[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportMissingCodeRejectsUnknownOrMismatchedLoginState(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
codes := memory.NewCodeStore()
|
||||||
|
service := NewService(codes, memory.NewAuthDeliveryReportStore())
|
||||||
|
now := time.Now().UTC()
|
||||||
|
base := domain.AuthMissingCodeReportRequest{
|
||||||
|
AuthKeyID: [8]byte{1}, SessionID: 10, Phone: "15550002222",
|
||||||
|
PhoneCodeHash: "missing", CreatedAt: now,
|
||||||
|
}
|
||||||
|
if _, _, err := service.ReportMissingCode(ctx, base); !errors.Is(err, domain.ErrPhoneCodeExpired) {
|
||||||
|
t.Fatalf("missing hash err=%v, want phone-code expired", err)
|
||||||
|
}
|
||||||
|
if err := codes.Set(ctx, "current", store.PhoneCode{
|
||||||
|
Version: store.PhoneCodeVersionCurrent, Phone: "15550003333",
|
||||||
|
Channel: store.PhoneCodeChannelPhone,
|
||||||
|
}, time.Hour); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
base.PhoneCodeHash = "current"
|
||||||
|
if _, _, err := service.ReportMissingCode(ctx, base); !errors.Is(err, domain.ErrPhoneCodeInvalid) {
|
||||||
|
t.Fatalf("mismatched phone err=%v, want phone-code invalid", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1031,6 +1031,29 @@ func (s *Service) ListMessageReactions(ctx context.Context, userID int64, req do
|
||||||
return s.channels.ListChannelMessageReactions(ctx, req)
|
return s.channels.ListChannelMessageReactions(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type messageReactionLookupStore interface {
|
||||||
|
FindChannelMessageReaction(ctx context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) FindMessageReaction(ctx context.Context, userID int64, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) {
|
||||||
|
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 ||
|
||||||
|
req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID ||
|
||||||
|
req.ReactorUserID == 0 {
|
||||||
|
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
if req.ViewerUserID == 0 {
|
||||||
|
req.ViewerUserID = userID
|
||||||
|
}
|
||||||
|
if req.ViewerUserID != userID {
|
||||||
|
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
lookup, ok := s.channels.(messageReactionLookupStore)
|
||||||
|
if !ok {
|
||||||
|
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
return lookup.FindChannelMessageReaction(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
type messageReactionUsageStore interface {
|
type messageReactionUsageStore interface {
|
||||||
RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error
|
RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error
|
||||||
}
|
}
|
||||||
|
|
@ -1445,6 +1468,30 @@ func (s *Service) DeleteMessages(ctx context.Context, userID int64, req domain.D
|
||||||
return s.channels.DeleteChannelMessages(ctx, req)
|
return s.channels.DeleteChannelMessages(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type moderationChannelMessageStore interface {
|
||||||
|
ModerationDeleteChannelMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationDeleteMessages is the explicit server-authority deletion path used
|
||||||
|
// only by the durable moderation action worker. It never accepts a client
|
||||||
|
// identity and therefore cannot be reached by ordinary RPC permission checks.
|
||||||
|
func (s *Service) ModerationDeleteMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error) {
|
||||||
|
if s == nil || s.channels == nil || channelID <= 0 ||
|
||||||
|
len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
|
||||||
|
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||||
|
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store, ok := s.channels.(moderationChannelMessageStore)
|
||||||
|
if !ok {
|
||||||
|
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
return store.ModerationDeleteChannelMessages(ctx, channelID, append([]int(nil), ids...), date)
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteHistory clears the current user's history view or deletes a bounded channel history page for everyone.
|
// DeleteHistory clears the current user's history view or deletes a bounded channel history page for everyone.
|
||||||
func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) {
|
func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) {
|
||||||
if s == nil || s.channels == nil || userID == 0 {
|
if s == nil || s.channels == nil || userID == 0 {
|
||||||
|
|
|
||||||
31
internal/app/clienttelemetry/service.go
Normal file
31
internal/app/clienttelemetry/service.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
package clienttelemetry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
store store.ClientTelemetryStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(telemetryStore store.ClientTelemetryStore) *Service {
|
||||||
|
return &Service{store: telemetryStore}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Record(ctx context.Context, userID int64, kind domain.ClientTelemetryKind, peer domain.Peer, subjectIDs []int64, payload any, createdAt time.Time) (domain.ClientTelemetryEvent, bool, error) {
|
||||||
|
if s == nil || s.store == nil {
|
||||||
|
return domain.ClientTelemetryEvent{}, false, fmt.Errorf("client telemetry store is not configured")
|
||||||
|
}
|
||||||
|
event, err := domain.NewClientTelemetryEvent(
|
||||||
|
userID, kind, peer, subjectIDs, payload, createdAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ClientTelemetryEvent{}, false, err
|
||||||
|
}
|
||||||
|
return s.store.CreateClientTelemetry(ctx, event)
|
||||||
|
}
|
||||||
|
|
@ -149,7 +149,9 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
|
||||||
return s.projectContact(ctx, userID, contact)
|
return s.projectContact(ctx, userID, contact)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AcceptContact shares the current user's phone/profile with an existing one-way contact.
|
// AcceptContact creates the reciprocal contact for an existing one-way contact.
|
||||||
|
// Phone visibility remains governed exclusively by account privacy rules; this
|
||||||
|
// RPC has no protocol flag authorizing a hidden phone-number exception.
|
||||||
func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64) (domain.Contact, error) {
|
func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64) (domain.Contact, error) {
|
||||||
if s == nil || s.contacts == nil || s.users == nil || userID == 0 || contactUserID == 0 || contactUserID == userID {
|
if s == nil || s.contacts == nil || s.users == nil || userID == 0 || contactUserID == 0 || contactUserID == userID {
|
||||||
return domain.Contact{}, ErrContactIDInvalid
|
return domain.Contact{}, ErrContactIDInvalid
|
||||||
|
|
@ -188,11 +190,6 @@ func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64
|
||||||
return domain.Contact{}, err
|
return domain.Contact{}, err
|
||||||
}
|
}
|
||||||
s.InvalidateViewers(userID, contactUserID)
|
s.InvalidateViewers(userID, contactUserID)
|
||||||
if s.privacy != nil {
|
|
||||||
if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, contactUserID); err != nil {
|
|
||||||
return domain.Contact{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
contact, found, err := s.contacts.Get(ctx, userID, target.ID)
|
contact, found, err := s.contacts.Get(ctx, userID, target.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Contact{}, err
|
return domain.Contact{}, err
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,19 @@ type LoginCodeDeliveryRetentionStore interface {
|
||||||
DeleteExpiredLoginCodeDeliveries(ctx context.Context, expiredBefore time.Time, limit int) (int, error)
|
DeleteExpiredLoginCodeDeliveries(ctx context.Context, expiredBefore time.Time, limit int) (int, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClientTelemetryRetentionStore interface {
|
||||||
|
DeleteExpiredClientTelemetry(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthDeliveryReportRetentionStore interface {
|
||||||
|
DeleteExpiredAuthDeliveryReports(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationRetentionStore interface {
|
||||||
|
DeleteExpiredSponsoredMessageImpressions(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||||
|
DeleteExpiredModerationAppealLinks(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
// botAPIConfirmedGrace 是已确认 Bot API update 行的删除宽限:确认水位之下的行不会再被
|
// botAPIConfirmedGrace 是已确认 Bot API update 行的删除宽限:确认水位之下的行不会再被
|
||||||
// getUpdates 读取(fromID 恒 > confirmed),宽限仅防御 offset 回拨调试;回收目标是清堆积。
|
// getUpdates 读取(fromID 恒 > confirmed),宽限仅防御 offset 回拨调试;回收目标是清堆积。
|
||||||
const botAPIConfirmedGrace = 15 * time.Minute
|
const botAPIConfirmedGrace = 15 * time.Minute
|
||||||
|
|
@ -92,24 +105,29 @@ const (
|
||||||
// 前缀;落后或缺 state 的任一设备都会把 floor 压回 0。客户端偶然带回已确认前的旧 pts 时,
|
// 前缀;落后或缺 state 的任一设备都会把 floor 压回 0。客户端偶然带回已确认前的旧 pts 时,
|
||||||
// updates 服务通过普通 differenceSlice checkpoint 推进,不发送 differenceTooLong。
|
// updates 服务通过普通 differenceSlice checkpoint 推进,不发送 differenceTooLong。
|
||||||
type RetentionWorker struct {
|
type RetentionWorker struct {
|
||||||
outbox DispatchOutboxRetentionStore
|
outbox DispatchOutboxRetentionStore
|
||||||
tempKeys TempAuthKeyRetentionStore // 可为 nil(不回收 temp key 绑定)
|
tempKeys TempAuthKeyRetentionStore // 可为 nil(不回收 temp key 绑定)
|
||||||
authKeySessionLayers AuthKeySessionLayerRetentionStore
|
authKeySessionLayers AuthKeySessionLayerRetentionStore
|
||||||
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil(不回收 Bot API 队列)
|
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil(不回收 Bot API 队列)
|
||||||
userUpdates UserUpdateEventRetentionStore
|
userUpdates UserUpdateEventRetentionStore
|
||||||
channelUpdates ChannelUpdateEventRetentionStore
|
channelUpdates ChannelUpdateEventRetentionStore
|
||||||
loginCodeDeliveries LoginCodeDeliveryRetentionStore
|
loginCodeDeliveries LoginCodeDeliveryRetentionStore
|
||||||
orphanAuthKeys OrphanAuthKeyRetentionStore
|
clientTelemetry ClientTelemetryRetentionStore
|
||||||
activeAuthKeys ActiveRawAuthKeyProvider
|
authDeliveryReports AuthDeliveryReportRetentionStore
|
||||||
activeAuthKeyHeartbeat ActiveAuthKeyHeartbeatStore
|
moderation ModerationRetentionStore
|
||||||
logger *zap.Logger
|
orphanAuthKeys OrphanAuthKeyRetentionStore
|
||||||
retention time.Duration
|
activeAuthKeys ActiveRawAuthKeyProvider
|
||||||
botAPIRetention time.Duration
|
activeAuthKeyHeartbeat ActiveAuthKeyHeartbeatStore
|
||||||
orphanRetention time.Duration
|
logger *zap.Logger
|
||||||
outboxPoisonRetention time.Duration
|
retention time.Duration
|
||||||
outboxPoisonInterval time.Duration
|
botAPIRetention time.Duration
|
||||||
interval time.Duration
|
orphanRetention time.Duration
|
||||||
batch int
|
clientTelemetryRetention time.Duration
|
||||||
|
authDeliveryReportRetention time.Duration
|
||||||
|
outboxPoisonRetention time.Duration
|
||||||
|
outboxPoisonInterval time.Duration
|
||||||
|
interval time.Duration
|
||||||
|
batch int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRetentionWorker(outbox DispatchOutboxRetentionStore, tempKeys TempAuthKeyRetentionStore, logger *zap.Logger, retention, interval time.Duration, batch int) *RetentionWorker {
|
func NewRetentionWorker(outbox DispatchOutboxRetentionStore, tempKeys TempAuthKeyRetentionStore, logger *zap.Logger, retention, interval time.Duration, batch int) *RetentionWorker {
|
||||||
|
|
@ -191,6 +209,29 @@ func (w *RetentionWorker) WithAuthKeySessionLayerRetention(store AuthKeySessionL
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *RetentionWorker) WithClientTelemetryRetention(store ClientTelemetryRetentionStore, retention time.Duration) *RetentionWorker {
|
||||||
|
if retention <= 0 {
|
||||||
|
retention = 30 * 24 * time.Hour
|
||||||
|
}
|
||||||
|
w.clientTelemetry = store
|
||||||
|
w.clientTelemetryRetention = retention
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *RetentionWorker) WithAuthDeliveryReportRetention(store AuthDeliveryReportRetentionStore, retention time.Duration) *RetentionWorker {
|
||||||
|
if retention <= 0 {
|
||||||
|
retention = 30 * 24 * time.Hour
|
||||||
|
}
|
||||||
|
w.authDeliveryReports = store
|
||||||
|
w.authDeliveryReportRetention = retention
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *RetentionWorker) WithModerationRetention(store ModerationRetentionStore) *RetentionWorker {
|
||||||
|
w.moderation = store
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
// WithOrphanAuthKeyRetention 启用未授权握手 key 的有界回收。active 必须提供 raw key,
|
// WithOrphanAuthKeyRetention 启用未授权握手 key 的有界回收。active 必须提供 raw key,
|
||||||
// 不能提供 temp→perm business key;否则未登录或 PFS 连接会被误判为 orphan。
|
// 不能提供 temp→perm business key;否则未登录或 PFS 连接会被误判为 orphan。
|
||||||
func (w *RetentionWorker) WithOrphanAuthKeyRetention(store OrphanAuthKeyRetentionStore, active ActiveRawAuthKeyProvider, retention time.Duration) *RetentionWorker {
|
func (w *RetentionWorker) WithOrphanAuthKeyRetention(store OrphanAuthKeyRetentionStore, active ActiveRawAuthKeyProvider, retention time.Duration) *RetentionWorker {
|
||||||
|
|
@ -274,6 +315,45 @@ func (w *RetentionWorker) runRetentionOnce(ctx context.Context) {
|
||||||
w.logger.Info("回收过期 login-code delivery 回执完成", zap.Int("deleted", deleted))
|
w.logger.Info("回收过期 login-code delivery 回执完成", zap.Int("deleted", deleted))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if w.clientTelemetry != nil {
|
||||||
|
deleted, err := w.clientTelemetry.DeleteExpiredClientTelemetry(
|
||||||
|
ctx, time.Now().Add(-w.clientTelemetryRetention), w.batch,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Warn("回收过期客户端 telemetry 失败", zap.Error(err))
|
||||||
|
} else if deleted > 0 {
|
||||||
|
w.logger.Info("回收过期客户端 telemetry 完成", zap.Int("deleted", deleted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w.authDeliveryReports != nil {
|
||||||
|
deleted, err := w.authDeliveryReports.DeleteExpiredAuthDeliveryReports(
|
||||||
|
ctx, time.Now().Add(-w.authDeliveryReportRetention), w.batch,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Warn("回收过期验证码投递诊断失败", zap.Error(err))
|
||||||
|
} else if deleted > 0 {
|
||||||
|
w.logger.Info("回收过期验证码投递诊断完成", zap.Int("deleted", deleted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w.moderation != nil {
|
||||||
|
now := time.Now()
|
||||||
|
impressions, err := w.moderation.DeleteExpiredSponsoredMessageImpressions(
|
||||||
|
ctx, now, w.batch,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Warn("回收过期 sponsored impression 失败", zap.Error(err))
|
||||||
|
} else if impressions > 0 {
|
||||||
|
w.logger.Info("回收过期 sponsored impression 完成", zap.Int("deleted", impressions))
|
||||||
|
}
|
||||||
|
links, err := w.moderation.DeleteExpiredModerationAppealLinks(
|
||||||
|
ctx, now, w.batch,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Warn("回收过期审核申诉链接失败", zap.Error(err))
|
||||||
|
} else if links > 0 {
|
||||||
|
w.logger.Info("回收过期审核申诉链接完成", zap.Int("deleted", links))
|
||||||
|
}
|
||||||
|
}
|
||||||
if w.tempKeys != nil {
|
if w.tempKeys != nil {
|
||||||
expiredBefore := time.Now().Add(-tempAuthKeyExpiryGrace).Unix()
|
expiredBefore := time.Now().Add(-tempAuthKeyExpiryGrace).Unix()
|
||||||
tempDeleted, err := w.tempKeys.DeleteExpired(ctx, expiredBefore, w.batch)
|
tempDeleted, err := w.tempKeys.DeleteExpired(ctx, expiredBefore, w.batch)
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,86 @@ func TestRetentionWorkerReclaimsExpiredLoginCodeDeliveryReceipts(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeReportRetention struct {
|
||||||
|
telemetryBefore time.Time
|
||||||
|
authBefore time.Time
|
||||||
|
sponsoredBefore time.Time
|
||||||
|
appealBefore time.Time
|
||||||
|
telemetryCalls int
|
||||||
|
authCalls int
|
||||||
|
sponsoredCalls int
|
||||||
|
appealCalls int
|
||||||
|
limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReportRetention) DeleteExpiredClientTelemetry(_ context.Context, before time.Time, limit int) (int, error) {
|
||||||
|
f.telemetryCalls++
|
||||||
|
f.telemetryBefore = before
|
||||||
|
f.limit = limit
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReportRetention) DeleteExpiredAuthDeliveryReports(_ context.Context, before time.Time, limit int) (int, error) {
|
||||||
|
f.authCalls++
|
||||||
|
f.authBefore = before
|
||||||
|
f.limit = limit
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReportRetention) DeleteExpiredSponsoredMessageImpressions(_ context.Context, before time.Time, limit int) (int, error) {
|
||||||
|
f.sponsoredCalls++
|
||||||
|
f.sponsoredBefore = before
|
||||||
|
f.limit = limit
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReportRetention) DeleteExpiredModerationAppealLinks(_ context.Context, before time.Time, limit int) (int, error) {
|
||||||
|
f.appealCalls++
|
||||||
|
f.appealBefore = before
|
||||||
|
f.limit = limit
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetentionWorkerSeparatesTelemetryDiagnosticsAndModerationCapabilities(t *testing.T) {
|
||||||
|
const (
|
||||||
|
telemetryTTL = 7 * 24 * time.Hour
|
||||||
|
authTTL = 14 * 24 * time.Hour
|
||||||
|
batch = 47
|
||||||
|
)
|
||||||
|
store := &fakeReportRetention{}
|
||||||
|
w := NewRetentionWorker(
|
||||||
|
&fakeOutboxRetention{}, nil, zap.NewNop(),
|
||||||
|
168*time.Hour, time.Hour, batch,
|
||||||
|
).WithClientTelemetryRetention(store, telemetryTTL).
|
||||||
|
WithAuthDeliveryReportRetention(store, authTTL).
|
||||||
|
WithModerationRetention(store)
|
||||||
|
before := time.Now()
|
||||||
|
w.runRetentionOnce(context.Background())
|
||||||
|
after := time.Now()
|
||||||
|
if store.telemetryCalls != 1 || store.authCalls != 1 ||
|
||||||
|
store.sponsoredCalls != 1 || store.appealCalls != 1 ||
|
||||||
|
store.limit != batch {
|
||||||
|
t.Fatalf("calls telemetry/auth/sponsored/appeal=%d/%d/%d/%d limit=%d",
|
||||||
|
store.telemetryCalls, store.authCalls,
|
||||||
|
store.sponsoredCalls, store.appealCalls, store.limit)
|
||||||
|
}
|
||||||
|
if store.telemetryBefore.Before(before.Add(-telemetryTTL)) ||
|
||||||
|
store.telemetryBefore.After(after.Add(-telemetryTTL)) {
|
||||||
|
t.Fatalf("telemetry boundary=%v", store.telemetryBefore)
|
||||||
|
}
|
||||||
|
if store.authBefore.Before(before.Add(-authTTL)) ||
|
||||||
|
store.authBefore.After(after.Add(-authTTL)) {
|
||||||
|
t.Fatalf("auth boundary=%v", store.authBefore)
|
||||||
|
}
|
||||||
|
if store.sponsoredBefore.Before(before) ||
|
||||||
|
store.sponsoredBefore.After(after) ||
|
||||||
|
store.appealBefore.Before(before) ||
|
||||||
|
store.appealBefore.After(after) {
|
||||||
|
t.Fatalf("moderation capability boundaries sponsored=%v appeal=%v",
|
||||||
|
store.sponsoredBefore, store.appealBefore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeBotAPIRetention) DeleteDeliveredOrExpired(_ context.Context, confirmedGrace, maxAge time.Duration, limit int) (int, error) {
|
func (f *fakeBotAPIRetention) DeleteDeliveredOrExpired(_ context.Context, confirmedGrace, maxAge time.Duration, limit int) (int, error) {
|
||||||
f.calls++
|
f.calls++
|
||||||
f.confirmedGrace = confirmedGrace
|
f.confirmedGrace = confirmedGrace
|
||||||
|
|
|
||||||
512
internal/app/moderation/actions.go
Normal file
512
internal/app/moderation/actions.go
Normal file
|
|
@ -0,0 +1,512 @@
|
||||||
|
package moderation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"telesrv/internal/admin"
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type moderationAdminActions interface {
|
||||||
|
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
|
||||||
|
SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error)
|
||||||
|
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
|
||||||
|
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type moderationChannelDeleter interface {
|
||||||
|
ModerationDeleteMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type moderationChannelDeleteNotifier interface {
|
||||||
|
NotifyModerationChannelDeletion(ctx context.Context, result domain.DeleteChannelMessagesResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
type moderationAccountDeleter interface {
|
||||||
|
ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type moderationAppealLinkIssuer interface {
|
||||||
|
IssueAppealLink(ctx context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionExecutor struct {
|
||||||
|
admin moderationAdminActions
|
||||||
|
channels moderationChannelDeleter
|
||||||
|
channelNotifier moderationChannelDeleteNotifier
|
||||||
|
accounts moderationAccountDeleter
|
||||||
|
appealLinks moderationAppealLinkIssuer
|
||||||
|
publicBaseURL string
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionExecutorOption func(*ActionExecutor)
|
||||||
|
|
||||||
|
func WithAppealLinks(issuer moderationAppealLinkIssuer, publicBaseURL string) ActionExecutorOption {
|
||||||
|
return func(executor *ActionExecutor) {
|
||||||
|
executor.appealLinks = issuer
|
||||||
|
executor.publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithActionClock(now func() time.Time) ActionExecutorOption {
|
||||||
|
return func(executor *ActionExecutor) {
|
||||||
|
if now != nil {
|
||||||
|
executor.now = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewActionExecutor(adminActions moderationAdminActions, channels moderationChannelDeleter, channelNotifier moderationChannelDeleteNotifier, accounts moderationAccountDeleter, opts ...ActionExecutorOption) *ActionExecutor {
|
||||||
|
executor := &ActionExecutor{
|
||||||
|
admin: adminActions, channels: channels,
|
||||||
|
channelNotifier: channelNotifier, accounts: accounts,
|
||||||
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
if opt != nil {
|
||||||
|
opt(executor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return executor
|
||||||
|
}
|
||||||
|
|
||||||
|
type freezeAccountActionPayload struct {
|
||||||
|
Until time.Time `json:"until,omitempty"`
|
||||||
|
AppealURL string `json:"appeal_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deletePrivateMessageActionPayload struct {
|
||||||
|
OwnerUserID int64 `json:"owner_user_id"`
|
||||||
|
IDs []int `json:"ids"`
|
||||||
|
Revoke bool `json:"revoke"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteChannelMessageActionPayload struct {
|
||||||
|
IDs []int `json:"ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ActionExecutor) Execute(ctx context.Context, detail domain.ModerationCaseDetail, action domain.ModerationAction) error {
|
||||||
|
if e == nil || action.CaseID != detail.Case.ID {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
actor, _, ok := decisionAuditContext(detail.Decisions, action.DecisionID)
|
||||||
|
if !ok {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
meta := admin.CommandMeta{
|
||||||
|
CommandID: action.CommandID, Actor: actor,
|
||||||
|
Reason: fmt.Sprintf("moderation case %d decision %d", detail.Case.ID, action.DecisionID),
|
||||||
|
}
|
||||||
|
switch action.Kind {
|
||||||
|
case domain.ModerationActionMarkScam:
|
||||||
|
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.setPeerFlags(ctx, detail.Case.Target, true, false, meta)
|
||||||
|
case domain.ModerationActionMarkFake:
|
||||||
|
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.setPeerFlags(ctx, detail.Case.Target, false, true, meta)
|
||||||
|
case domain.ModerationActionClearPeerFlags:
|
||||||
|
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.setPeerFlags(ctx, detail.Case.Target, false, false, meta)
|
||||||
|
case domain.ModerationActionFreezeAccount, domain.ModerationActionUnfreezeAccount:
|
||||||
|
if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
var payload freezeAccountActionPayload
|
||||||
|
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
frozen := action.Kind == domain.ModerationActionFreezeAccount
|
||||||
|
if !frozen && (!payload.Until.IsZero() || payload.AppealURL != "") {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
if frozen {
|
||||||
|
now := e.now().UTC()
|
||||||
|
if payload.Until.IsZero() {
|
||||||
|
payload.Until = now.Add(30 * 24 * time.Hour)
|
||||||
|
}
|
||||||
|
if !payload.Until.After(now) {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
if payload.AppealURL == "" {
|
||||||
|
if e.appealLinks == nil || e.publicBaseURL == "" {
|
||||||
|
return fmt.Errorf("moderation appeal link issuer is not configured")
|
||||||
|
}
|
||||||
|
linkExpiresAt := payload.Until
|
||||||
|
maxLinkExpiry := now.Add(domain.MaxModerationAppealLinkLifetime)
|
||||||
|
if linkExpiresAt.After(maxLinkExpiry) {
|
||||||
|
linkExpiresAt = maxLinkExpiry
|
||||||
|
}
|
||||||
|
token, err := e.appealLinks.IssueAppealLink(
|
||||||
|
ctx, detail.Case.ID, detail.Case.Target.ID,
|
||||||
|
linkExpiresAt, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload.AppealURL = e.publicBaseURL + "/appeal/" + token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := e.admin.SetAccountFrozen(ctx, admin.SetAccountFrozenRequest{
|
||||||
|
CommandMeta: meta, UserID: detail.Case.Target.ID, Frozen: frozen,
|
||||||
|
Until: payload.Until, AppealURL: payload.AppealURL,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
case domain.ModerationActionDeletePrivateMessage:
|
||||||
|
if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
var payload deletePrivateMessageActionPayload
|
||||||
|
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if payload.OwnerUserID <= 0 || len(payload.IDs) == 0 ||
|
||||||
|
len(payload.IDs) > domain.MaxDeleteMessageIDs {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
_, err := e.admin.DeletePrivateMessages(ctx, admin.DeletePrivateMessagesRequest{
|
||||||
|
CommandMeta: meta, OwnerUserID: payload.OwnerUserID,
|
||||||
|
Peer: detail.Case.Target, IDs: payload.IDs, Revoke: payload.Revoke,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
case domain.ModerationActionDeleteChannelMessage:
|
||||||
|
if e.channels == nil || detail.Case.Target.Type != domain.PeerTypeChannel {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
var payload deleteChannelMessageActionPayload
|
||||||
|
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(payload.IDs) == 0 || len(payload.IDs) > domain.MaxDeleteMessageIDs {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
result, err := e.channels.ModerationDeleteMessages(
|
||||||
|
ctx, detail.Case.Target.ID, payload.IDs, int(e.now().Unix()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if e.channelNotifier != nil {
|
||||||
|
e.channelNotifier.NotifyModerationChannelDeletion(ctx, result)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case domain.ModerationActionDeleteAccount:
|
||||||
|
if e.accounts == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := e.accounts.ExecuteAccountDeletion(
|
||||||
|
ctx, detail.Case.Target.ID, domain.AccountDeletionManual,
|
||||||
|
fmt.Sprintf("moderation case %d", detail.Case.ID), e.now().UTC(),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validateDecisionActions(ctx context.Context, detail domain.ModerationCaseDetail, actions []domain.ModerationActionDraft) error {
|
||||||
|
if len(actions) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[domain.ModerationActionKind]struct{}, len(actions))
|
||||||
|
flagActions := 0
|
||||||
|
freezeActions := 0
|
||||||
|
hasDeleteAccount := false
|
||||||
|
for _, action := range actions {
|
||||||
|
if _, duplicate := seen[action.Kind]; duplicate {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
seen[action.Kind] = struct{}{}
|
||||||
|
switch action.Kind {
|
||||||
|
case domain.ModerationActionMarkScam, domain.ModerationActionMarkFake,
|
||||||
|
domain.ModerationActionClearPeerFlags:
|
||||||
|
flagActions++
|
||||||
|
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case domain.ModerationActionFreezeAccount, domain.ModerationActionUnfreezeAccount:
|
||||||
|
freezeActions++
|
||||||
|
if detail.Case.Target.Type != domain.PeerTypeUser {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
var payload freezeAccountActionPayload
|
||||||
|
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if action.Kind == domain.ModerationActionUnfreezeAccount &&
|
||||||
|
(!payload.Until.IsZero() || payload.AppealURL != "") {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
case domain.ModerationActionDeletePrivateMessage:
|
||||||
|
var payload deletePrivateMessageActionPayload
|
||||||
|
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if detail.Case.Target.Type != domain.PeerTypeUser ||
|
||||||
|
payload.OwnerUserID <= 0 ||
|
||||||
|
!validModerationMessageIDs(payload.IDs) ||
|
||||||
|
!s.privateDeletionCoveredByEvidence(ctx, detail, payload) {
|
||||||
|
return domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
case domain.ModerationActionDeleteChannelMessage:
|
||||||
|
var payload deleteChannelMessageActionPayload
|
||||||
|
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if detail.Case.Target.Type != domain.PeerTypeChannel ||
|
||||||
|
!validModerationMessageIDs(payload.IDs) ||
|
||||||
|
!s.channelDeletionCoveredByEvidence(ctx, detail, payload.IDs) {
|
||||||
|
return domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
case domain.ModerationActionDeleteAccount:
|
||||||
|
hasDeleteAccount = true
|
||||||
|
if detail.Case.Target.Type != domain.PeerTypeUser {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if flagActions > 1 || freezeActions > 1 ||
|
||||||
|
(hasDeleteAccount && len(actions) != 1) {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) privateDeletionCoveredByEvidence(ctx context.Context, detail domain.ModerationCaseDetail, payload deletePrivateMessageActionPayload) bool {
|
||||||
|
needed := make(map[int]struct{}, len(payload.IDs))
|
||||||
|
for _, id := range payload.IDs {
|
||||||
|
needed[id] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, reportID := range detail.ReportIDs {
|
||||||
|
report, found, err := s.Report(ctx, reportID)
|
||||||
|
if err != nil || !found || report.ReporterUserID != payload.OwnerUserID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range report.Items {
|
||||||
|
if item.Kind == domain.ModerationItemMessage &&
|
||||||
|
item.Peer == detail.Case.Target {
|
||||||
|
delete(needed, int(item.ItemID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(needed) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) channelDeletionCoveredByEvidence(ctx context.Context, detail domain.ModerationCaseDetail, ids []int) bool {
|
||||||
|
needed := make(map[int]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
needed[id] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, reportID := range detail.ReportIDs {
|
||||||
|
report, found, err := s.Report(ctx, reportID)
|
||||||
|
if err != nil || !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range report.Items {
|
||||||
|
if item.Kind == domain.ModerationItemMessage &&
|
||||||
|
item.Peer == detail.Case.Target {
|
||||||
|
delete(needed, int(item.ItemID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(needed) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func validModerationMessageIDs(ids []int) bool {
|
||||||
|
if len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen := make(map[int]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[id]; duplicate {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ActionExecutor) setPeerFlags(ctx context.Context, target domain.Peer, scam, fake bool, meta admin.CommandMeta) error {
|
||||||
|
if e.admin == nil {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
switch target.Type {
|
||||||
|
case domain.PeerTypeUser:
|
||||||
|
_, err := e.admin.SetUserFlags(ctx, admin.SetUserFlagsRequest{
|
||||||
|
CommandMeta: meta, UserID: target.ID, Scam: scam, Fake: fake,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
case domain.PeerTypeChannel:
|
||||||
|
_, err := e.admin.SetChannelFlags(ctx, admin.SetChannelFlagsRequest{
|
||||||
|
CommandMeta: meta, ChannelID: target.ID, Scam: scam, Fake: fake,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decisionAuditContext(decisions []domain.ModerationDecision, decisionID int64) (string, string, bool) {
|
||||||
|
for _, decision := range decisions {
|
||||||
|
if decision.ID == decisionID {
|
||||||
|
return decision.Actor, decision.Reason, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStrictActionPayload(raw json.RawMessage, target any) error {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(target); err != nil {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionWorker struct {
|
||||||
|
store store.ModerationCaseStore
|
||||||
|
executor *ActionExecutor
|
||||||
|
interval time.Duration
|
||||||
|
lease time.Duration
|
||||||
|
batch int
|
||||||
|
log *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewActionWorker(caseStore store.ModerationCaseStore, executor *ActionExecutor, log *zap.Logger) *ActionWorker {
|
||||||
|
if log == nil {
|
||||||
|
log = zap.NewNop()
|
||||||
|
}
|
||||||
|
return &ActionWorker{
|
||||||
|
store: caseStore, executor: executor,
|
||||||
|
interval: time.Second, lease: 30 * time.Second, batch: 20, log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ActionWorker) Run(ctx context.Context) {
|
||||||
|
if w == nil || w.store == nil || w.executor == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(w.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
if err := w.runOnce(ctx); err != nil && ctx.Err() == nil {
|
||||||
|
w.log.Warn("审核处置任务执行失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ActionWorker) runOnce(ctx context.Context) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
actions, err := w.store.ClaimModerationActions(ctx, now, w.batch, w.lease)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, action := range actions {
|
||||||
|
current, currentErr := w.store.IsModerationActionCurrent(ctx, action)
|
||||||
|
if currentErr == nil && !current {
|
||||||
|
if err := w.store.SupersedeModerationAction(
|
||||||
|
ctx, action.ID, action.Attempts, time.Now().UTC(),
|
||||||
|
); err != nil {
|
||||||
|
w.log.Warn("提交已被新案件取代的审核处置失败",
|
||||||
|
zap.Int64("case_id", action.CaseID),
|
||||||
|
zap.Int64("action_id", action.ID),
|
||||||
|
zap.String("kind", string(action.Kind)),
|
||||||
|
zap.Error(err))
|
||||||
|
} else {
|
||||||
|
w.log.Info("审核处置已被同目标的更新处置取代",
|
||||||
|
zap.Int64("case_id", action.CaseID),
|
||||||
|
zap.Int64("action_id", action.ID),
|
||||||
|
zap.String("kind", string(action.Kind)))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
detail, found, getErr := w.store.GetModerationCase(ctx, action.CaseID)
|
||||||
|
execErr := currentErr
|
||||||
|
if execErr == nil {
|
||||||
|
execErr = getErr
|
||||||
|
}
|
||||||
|
if execErr == nil && !found {
|
||||||
|
execErr = domain.ErrModerationCaseNotFound
|
||||||
|
}
|
||||||
|
if execErr == nil {
|
||||||
|
execErr = w.executor.Execute(ctx, detail, action)
|
||||||
|
}
|
||||||
|
finishedAt := time.Now().UTC()
|
||||||
|
retryAt := finishedAt
|
||||||
|
errorText := ""
|
||||||
|
if execErr != nil {
|
||||||
|
errorText = execErr.Error()
|
||||||
|
retryAt = finishedAt.Add(moderationActionRetryDelay(action.Attempts))
|
||||||
|
}
|
||||||
|
if err := w.store.CompleteModerationAction(
|
||||||
|
ctx, action.ID, action.Attempts, execErr == nil,
|
||||||
|
errorText, retryAt, finishedAt,
|
||||||
|
); err != nil {
|
||||||
|
w.log.Warn("提交审核处置结果失败",
|
||||||
|
zap.Int64("action_id", action.ID),
|
||||||
|
zap.Int("attempts", action.Attempts),
|
||||||
|
zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if execErr != nil {
|
||||||
|
w.log.Warn("审核处置等待重试",
|
||||||
|
zap.Int64("case_id", action.CaseID),
|
||||||
|
zap.Int64("action_id", action.ID),
|
||||||
|
zap.String("kind", string(action.Kind)),
|
||||||
|
zap.Int("attempts", action.Attempts),
|
||||||
|
zap.Error(execErr))
|
||||||
|
} else {
|
||||||
|
w.log.Info("审核处置完成",
|
||||||
|
zap.Int64("case_id", action.CaseID),
|
||||||
|
zap.Int64("action_id", action.ID),
|
||||||
|
zap.String("kind", string(action.Kind)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func moderationActionRetryDelay(attempt int) time.Duration {
|
||||||
|
if attempt < 1 {
|
||||||
|
attempt = 1
|
||||||
|
}
|
||||||
|
delay := time.Second << min(attempt-1, 10)
|
||||||
|
if delay > time.Hour {
|
||||||
|
return time.Hour
|
||||||
|
}
|
||||||
|
return delay
|
||||||
|
}
|
||||||
325
internal/app/moderation/actions_test.go
Normal file
325
internal/app/moderation/actions_test.go
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
package moderation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"telesrv/internal/admin"
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
type captureModerationAdmin struct {
|
||||||
|
userFlags []admin.SetUserFlagsRequest
|
||||||
|
frozen []admin.SetAccountFrozenRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *captureModerationAdmin) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
|
||||||
|
a.frozen = append(a.frozen, req)
|
||||||
|
return admin.CommandResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *captureModerationAdmin) SetUserFlags(_ context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) {
|
||||||
|
a.userFlags = append(a.userFlags, req)
|
||||||
|
return admin.CommandResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*captureModerationAdmin) SetChannelFlags(context.Context, admin.SetChannelFlagsRequest) (admin.CommandResult, error) {
|
||||||
|
return admin.CommandResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*captureModerationAdmin) DeletePrivateMessages(context.Context, admin.DeletePrivateMessagesRequest) (admin.CommandResult, error) {
|
||||||
|
return admin.CommandResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionWorkerAppliesFakeFlagAndResolvesCase(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UTC().Add(-10 * time.Second)
|
||||||
|
reports := memory.NewModerationReportStore()
|
||||||
|
service := NewService(reports)
|
||||||
|
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||||
|
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: 101, Source: domain.ModerationSourceAccountPeer,
|
||||||
|
Target: target, Reason: domain.ModerationReasonFake, Option: "fake",
|
||||||
|
Items: []domain.ModerationReportItem{{
|
||||||
|
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||||
|
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||||
|
Evidence: []byte(`{"schema_version":1}`),
|
||||||
|
}},
|
||||||
|
CreatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{Limit: 10})
|
||||||
|
if err != nil || len(cases) != 1 {
|
||||||
|
t.Fatalf("cases=%+v err=%v", cases, err)
|
||||||
|
}
|
||||||
|
claimed, err := service.ClaimCase(ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
decision, created, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||||
|
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||||
|
Actor: "reviewer", Reason: "impersonation confirmed",
|
||||||
|
CommandID: "mod-fake-1", Kind: domain.ModerationDecisionViolation,
|
||||||
|
Actions: []domain.ModerationActionDraft{{
|
||||||
|
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
|
||||||
|
}},
|
||||||
|
CreatedAt: now.Add(2 * time.Second),
|
||||||
|
})
|
||||||
|
if err != nil || !created || decision.Case.Status != domain.ModerationCaseActionPending {
|
||||||
|
t.Fatalf("decision=%+v created=%v err=%v", decision, created, err)
|
||||||
|
}
|
||||||
|
adminActions := &captureModerationAdmin{}
|
||||||
|
worker := NewActionWorker(
|
||||||
|
reports,
|
||||||
|
NewActionExecutor(adminActions, nil, nil, nil),
|
||||||
|
zap.NewNop(),
|
||||||
|
)
|
||||||
|
if err := worker.runOnce(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(adminActions.userFlags) != 1 {
|
||||||
|
t.Fatalf("flag actions=%d, want 1", len(adminActions.userFlags))
|
||||||
|
}
|
||||||
|
flag := adminActions.userFlags[0]
|
||||||
|
if flag.UserID != target.ID || flag.Scam || !flag.Fake ||
|
||||||
|
flag.CommandID != "mod-fake-1:000" || flag.Actor != "reviewer" {
|
||||||
|
t.Fatalf("flag request=%+v", flag)
|
||||||
|
}
|
||||||
|
resolved, found, err := service.Case(ctx, claimed.ID)
|
||||||
|
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved ||
|
||||||
|
resolved.Actions[0].Status != domain.ModerationActionSucceeded {
|
||||||
|
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionWorkerSupersedesOlderTargetSanction(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Unix(1_750_000_000, 0).UTC()
|
||||||
|
reports := memory.NewModerationReportStore()
|
||||||
|
service := NewService(reports)
|
||||||
|
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||||
|
createDecision := func(reporter int64, command string, kind domain.ModerationActionKind, at time.Time) int64 {
|
||||||
|
t.Helper()
|
||||||
|
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
|
||||||
|
Target: target, Reason: domain.ModerationReasonFake, Option: command,
|
||||||
|
Items: []domain.ModerationReportItem{{
|
||||||
|
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||||
|
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||||
|
Evidence: []byte(`{"schema_version":1}`),
|
||||||
|
}},
|
||||||
|
CreatedAt: at,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{
|
||||||
|
Statuses: []domain.ModerationCaseStatus{domain.ModerationCaseOpen},
|
||||||
|
Target: target, Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil || len(cases) != 1 {
|
||||||
|
t.Fatalf("open cases=%+v err=%v", cases, err)
|
||||||
|
}
|
||||||
|
claimed, err := service.ClaimCase(ctx, cases[0].ID, cases[0].Version, "reviewer", at.Add(time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||||
|
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||||
|
Actor: "reviewer", Reason: "confirmed", CommandID: command,
|
||||||
|
Kind: domain.ModerationDecisionViolation,
|
||||||
|
Actions: []domain.ModerationActionDraft{{Kind: kind, Payload: []byte(`{}`)}},
|
||||||
|
CreatedAt: at.Add(2 * time.Second),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return claimed.ID
|
||||||
|
}
|
||||||
|
oldCaseID := createDecision(101, "old-scam", domain.ModerationActionMarkScam, now)
|
||||||
|
newCaseID := createDecision(102, "new-fake", domain.ModerationActionMarkFake, now.Add(3*time.Second))
|
||||||
|
|
||||||
|
adminActions := &captureModerationAdmin{}
|
||||||
|
worker := NewActionWorker(
|
||||||
|
reports,
|
||||||
|
NewActionExecutor(adminActions, nil, nil, nil),
|
||||||
|
zap.NewNop(),
|
||||||
|
)
|
||||||
|
if err := worker.runOnce(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(adminActions.userFlags) != 1 || adminActions.userFlags[0].Scam ||
|
||||||
|
!adminActions.userFlags[0].Fake {
|
||||||
|
t.Fatalf("flag actions=%+v", adminActions.userFlags)
|
||||||
|
}
|
||||||
|
oldDetail, _, err := service.Case(ctx, oldCaseID)
|
||||||
|
if err != nil || oldDetail.Case.Status != domain.ModerationCaseResolved ||
|
||||||
|
len(oldDetail.Actions) != 1 ||
|
||||||
|
oldDetail.Actions[0].Status != domain.ModerationActionSuperseded {
|
||||||
|
t.Fatalf("old detail=%+v err=%v", oldDetail, err)
|
||||||
|
}
|
||||||
|
newDetail, _, err := service.Case(ctx, newCaseID)
|
||||||
|
if err != nil || newDetail.Case.Status != domain.ModerationCaseResolved ||
|
||||||
|
len(newDetail.Actions) != 1 ||
|
||||||
|
newDetail.Actions[0].Status != domain.ModerationActionSucceeded {
|
||||||
|
t.Fatalf("new detail=%+v err=%v", newDetail, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppealCannotClearSanctionOwnedByNewerCase(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Unix(1_750_000_000, 0).UTC()
|
||||||
|
reports := memory.NewModerationReportStore()
|
||||||
|
service := NewService(reports)
|
||||||
|
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||||
|
createCase := func(reporter int64, option, command string, at time.Time) domain.ModerationCase {
|
||||||
|
t.Helper()
|
||||||
|
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
|
||||||
|
Target: target, Reason: domain.ModerationReasonFake, Option: option,
|
||||||
|
Items: []domain.ModerationReportItem{{
|
||||||
|
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||||
|
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||||
|
Evidence: []byte(`{"schema_version":1}`),
|
||||||
|
}},
|
||||||
|
CreatedAt: at,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, err := service.ListCases(ctx, domain.ModerationCaseFilter{
|
||||||
|
Statuses: []domain.ModerationCaseStatus{domain.ModerationCaseOpen},
|
||||||
|
Target: target, Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil || len(items) != 1 {
|
||||||
|
t.Fatalf("open cases=%+v err=%v", items, err)
|
||||||
|
}
|
||||||
|
claimed, err := service.ClaimCase(ctx, items[0].ID, items[0].Version, "reviewer", at.Add(time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||||
|
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||||
|
Actor: "reviewer", Reason: "confirmed", CommandID: command,
|
||||||
|
Kind: domain.ModerationDecisionViolation,
|
||||||
|
Actions: []domain.ModerationActionDraft{{
|
||||||
|
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
|
||||||
|
}},
|
||||||
|
CreatedAt: at.Add(2 * time.Second),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
detail, _, err := service.Case(ctx, claimed.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return detail.Case
|
||||||
|
}
|
||||||
|
oldCase := createCase(101, "old", "old", now)
|
||||||
|
adminActions := &captureModerationAdmin{}
|
||||||
|
worker := NewActionWorker(reports, NewActionExecutor(adminActions, nil, nil, nil), zap.NewNop())
|
||||||
|
if err := worker.runOnce(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
appeal, _, err := service.SubmitAppeal(ctx, oldCase.ID, target.ID, "mistake", now.Add(3*time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
newCase := createCase(102, "new", "new", now.Add(4*time.Second))
|
||||||
|
if newCase.ID == oldCase.ID {
|
||||||
|
t.Fatal("new report reused decided case")
|
||||||
|
}
|
||||||
|
oldDetail, _, err := service.Case(ctx, oldCase.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
claimed, err := service.ClaimCase(ctx, oldCase.ID, oldDetail.Case.Version, "reviewer", now.Add(8*time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _, err = service.ReviewAppeal(ctx, domain.ModerationDecisionRequest{
|
||||||
|
CaseID: oldCase.ID, AppealID: appeal.ID,
|
||||||
|
ExpectedVersion: claimed.Version, Actor: "reviewer",
|
||||||
|
Reason: "grant", CommandID: "stale-appeal",
|
||||||
|
Kind: domain.ModerationDecisionAppealGrant,
|
||||||
|
Actions: []domain.ModerationActionDraft{{
|
||||||
|
Kind: domain.ModerationActionClearPeerFlags, Payload: []byte(`{}`),
|
||||||
|
}},
|
||||||
|
CreatedAt: now.Add(9 * time.Second),
|
||||||
|
})
|
||||||
|
if !errors.Is(err, domain.ErrModerationActionConflict) {
|
||||||
|
t.Fatalf("ReviewAppeal error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type captureAppealLinkIssuer struct {
|
||||||
|
caseID int64
|
||||||
|
appellantID int64
|
||||||
|
expiresAt time.Time
|
||||||
|
issuedAt time.Time
|
||||||
|
returnedToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *captureAppealLinkIssuer) IssueAppealLink(_ context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error) {
|
||||||
|
i.caseID = caseID
|
||||||
|
i.appellantID = appellantUserID
|
||||||
|
i.expiresAt = expiresAt
|
||||||
|
i.issuedAt = now
|
||||||
|
return i.returnedToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionExecutorFreezeDefaultsAndBoundsAppealLink(t *testing.T) {
|
||||||
|
now := time.Unix(1_750_000_000, 0).UTC()
|
||||||
|
adminActions := &captureModerationAdmin{}
|
||||||
|
issuer := &captureAppealLinkIssuer{returnedToken: "token"}
|
||||||
|
executor := NewActionExecutor(
|
||||||
|
adminActions, nil, nil, nil,
|
||||||
|
WithActionClock(func() time.Time { return now }),
|
||||||
|
WithAppealLinks(issuer, "https://example.test/"),
|
||||||
|
)
|
||||||
|
detail := domain.ModerationCaseDetail{
|
||||||
|
Case: domain.ModerationCase{
|
||||||
|
ID: 10, Target: domain.Peer{Type: domain.PeerTypeUser, ID: 20},
|
||||||
|
},
|
||||||
|
Decisions: []domain.ModerationDecision{{
|
||||||
|
ID: 30, Actor: "reviewer",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
action := domain.ModerationAction{
|
||||||
|
CaseID: 10, DecisionID: 30,
|
||||||
|
Kind: domain.ModerationActionFreezeAccount,
|
||||||
|
Payload: []byte(`{}`), CommandID: "freeze:000",
|
||||||
|
}
|
||||||
|
if err := executor.Execute(context.Background(), detail, action); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(adminActions.frozen) != 1 {
|
||||||
|
t.Fatalf("freeze calls=%d", len(adminActions.frozen))
|
||||||
|
}
|
||||||
|
req := adminActions.frozen[0]
|
||||||
|
wantUntil := now.Add(30 * 24 * time.Hour)
|
||||||
|
if !req.Frozen || req.UserID != 20 || !req.Until.Equal(wantUntil) ||
|
||||||
|
req.AppealURL != "https://example.test/appeal/token" {
|
||||||
|
t.Fatalf("freeze request=%+v", req)
|
||||||
|
}
|
||||||
|
if issuer.caseID != 10 || issuer.appellantID != 20 ||
|
||||||
|
!issuer.expiresAt.Equal(wantUntil) || !issuer.issuedAt.Equal(now) {
|
||||||
|
t.Fatalf("appeal issue=%+v", issuer)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminActions.frozen = nil
|
||||||
|
longUntil := now.Add(365 * 24 * time.Hour)
|
||||||
|
action.Payload = []byte(`{"until":"` + longUntil.Format(time.RFC3339Nano) + `"}`)
|
||||||
|
if err := executor.Execute(context.Background(), detail, action); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !adminActions.frozen[0].Until.Equal(longUntil) {
|
||||||
|
t.Fatalf("long freeze until=%v", adminActions.frozen[0].Until)
|
||||||
|
}
|
||||||
|
if want := now.Add(domain.MaxModerationAppealLinkLifetime); !issuer.expiresAt.Equal(want) {
|
||||||
|
t.Fatalf("link expiry=%v want=%v", issuer.expiresAt, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
83
internal/app/moderation/appeal_links.go
Normal file
83
internal/app/moderation/appeal_links.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package moderation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const moderationAppealTokenBytes = 32
|
||||||
|
|
||||||
|
// IssueAppealLink creates a hash-only, time-bounded bearer capability. The raw
|
||||||
|
// token is returned once and must only be embedded in the affected user's
|
||||||
|
// client-visible appeal URL.
|
||||||
|
func (s *Service) IssueAppealLink(ctx context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error) {
|
||||||
|
if s == nil || s.cases == nil {
|
||||||
|
return "", fmt.Errorf("moderation case store is not configured")
|
||||||
|
}
|
||||||
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
raw := make([]byte, moderationAppealTokenBytes)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", fmt.Errorf("generate moderation appeal token: %w", err)
|
||||||
|
}
|
||||||
|
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||||
|
link := domain.ModerationAppealLink{
|
||||||
|
CaseID: caseID, AppellantUserID: appellantUserID,
|
||||||
|
TokenHash: sha256.Sum256(raw), ExpiresAt: expiresAt.UTC(),
|
||||||
|
CreatedAt: now.UTC(),
|
||||||
|
}
|
||||||
|
if _, err := s.cases.IssueModerationAppealLink(ctx, link); err == nil {
|
||||||
|
return token, nil
|
||||||
|
} else if !errors.Is(err, domain.ErrModerationActionConflict) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", domain.ErrModerationActionConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ResolveAppealLink(ctx context.Context, token string, now time.Time) (domain.ModerationAppealLink, bool, error) {
|
||||||
|
if s == nil || s.cases == nil {
|
||||||
|
return domain.ModerationAppealLink{}, false, fmt.Errorf("moderation case store is not configured")
|
||||||
|
}
|
||||||
|
hash, err := moderationAppealTokenHash(token)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationAppealLink{}, false, err
|
||||||
|
}
|
||||||
|
return s.cases.GetModerationAppealLink(ctx, hash, now.UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Appeal(ctx context.Context, appealID int64) (domain.ModerationAppeal, bool, error) {
|
||||||
|
if s == nil || s.cases == nil {
|
||||||
|
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation case store is not configured")
|
||||||
|
}
|
||||||
|
return s.cases.GetModerationAppeal(ctx, appealID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SubmitAppealLink(ctx context.Context, token, text string, now time.Time) (domain.ModerationAppeal, bool, error) {
|
||||||
|
if s == nil || s.cases == nil {
|
||||||
|
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation case store is not configured")
|
||||||
|
}
|
||||||
|
hash, err := moderationAppealTokenHash(token)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationAppeal{}, false, err
|
||||||
|
}
|
||||||
|
return s.cases.SubmitModerationAppealByLink(ctx, hash, text, now.UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
func moderationAppealTokenHash(token string) ([sha256.Size]byte, error) {
|
||||||
|
if len(token) != base64.RawURLEncoding.EncodedLen(moderationAppealTokenBytes) {
|
||||||
|
return [sha256.Size]byte{}, domain.ErrModerationAppealLinkInvalid
|
||||||
|
}
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||||
|
if err != nil || len(raw) != moderationAppealTokenBytes ||
|
||||||
|
base64.RawURLEncoding.EncodeToString(raw) != token {
|
||||||
|
return [sha256.Size]byte{}, domain.ErrModerationAppealLinkInvalid
|
||||||
|
}
|
||||||
|
return sha256.Sum256(raw), nil
|
||||||
|
}
|
||||||
178
internal/app/moderation/appeal_links_test.go
Normal file
178
internal/app/moderation/appeal_links_test.go
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
package moderation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppealLinkSubmissionIsHashOnlyIdempotentAndExpires(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Unix(1_750_000_000, 0).UTC()
|
||||||
|
store := memory.NewModerationReportStore()
|
||||||
|
service := NewService(store)
|
||||||
|
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
||||||
|
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: 101, Source: domain.ModerationSourceAccountPeer,
|
||||||
|
Target: target, Reason: domain.ModerationReasonFake, Option: "fake",
|
||||||
|
Items: []domain.ModerationReportItem{{
|
||||||
|
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||||
|
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
||||||
|
Evidence: []byte(`{"schema_version":1}`),
|
||||||
|
}},
|
||||||
|
CreatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{Limit: 10})
|
||||||
|
if err != nil || len(cases) != 1 {
|
||||||
|
t.Fatalf("cases=%+v err=%v", cases, err)
|
||||||
|
}
|
||||||
|
claimed, err := service.ClaimCase(
|
||||||
|
ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
detail, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
||||||
|
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
||||||
|
Actor: "reviewer", Reason: "confirmed", CommandID: "appeal-link-decision",
|
||||||
|
Kind: domain.ModerationDecisionViolation,
|
||||||
|
Actions: []domain.ModerationActionDraft{{
|
||||||
|
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
|
||||||
|
}},
|
||||||
|
CreatedAt: now.Add(2 * time.Second),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token, err := service.IssueAppealLink(
|
||||||
|
ctx, claimed.ID, target.ID, now.Add(24*time.Hour), now.Add(3*time.Second),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(token) != 43 {
|
||||||
|
t.Fatalf("token length=%d", len(token))
|
||||||
|
}
|
||||||
|
link, found, err := service.ResolveAppealLink(ctx, token, now.Add(4*time.Second))
|
||||||
|
if err != nil || !found || link.TokenHash == ([32]byte{}) {
|
||||||
|
t.Fatalf("link=%+v found=%v err=%v", link, found, err)
|
||||||
|
}
|
||||||
|
expiredToken, err := service.IssueAppealLink(
|
||||||
|
ctx, claimed.ID, target.ID, now.Add(10*time.Second), now.Add(4*time.Second),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < domain.MaxModerationAppealLinksPerCase-2; i++ {
|
||||||
|
issuedAt := now.Add(time.Duration(20+i) * time.Second)
|
||||||
|
if _, err := service.IssueAppealLink(
|
||||||
|
ctx, claimed.ID, target.ID, now.Add(time.Hour), issuedAt,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("issue bounded link %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := service.IssueAppealLink(
|
||||||
|
ctx, claimed.ID, target.ID, now.Add(time.Hour), now.Add(time.Minute),
|
||||||
|
); !errors.Is(err, domain.ErrModerationActionConflict) {
|
||||||
|
t.Fatalf("appeal link overflow err=%v", err)
|
||||||
|
}
|
||||||
|
if actions, err := store.ClaimModerationActions(
|
||||||
|
ctx, now.Add(5*time.Second), 10, time.Minute,
|
||||||
|
); err != nil || len(actions) != 1 {
|
||||||
|
t.Fatalf("actions=%+v err=%v", actions, err)
|
||||||
|
} else if err := store.CompleteModerationAction(
|
||||||
|
ctx, actions[0].ID, actions[0].Attempts, true, "",
|
||||||
|
time.Time{}, now.Add(6*time.Second),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
appeal, created, err := service.SubmitAppealLink(
|
||||||
|
ctx, token, "The account was impersonated.", now.Add(7*time.Second),
|
||||||
|
)
|
||||||
|
if err != nil || !created || appeal.CaseID != claimed.ID ||
|
||||||
|
appeal.AppellantUserID != target.ID {
|
||||||
|
t.Fatalf("appeal=%+v created=%v err=%v", appeal, created, err)
|
||||||
|
}
|
||||||
|
retry, created, err := service.SubmitAppealLink(
|
||||||
|
ctx, token, "A different retry body must not create another appeal.",
|
||||||
|
now.Add(8*time.Second),
|
||||||
|
)
|
||||||
|
if err != nil || created || retry.ID != appeal.ID || retry.Text != appeal.Text {
|
||||||
|
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||||
|
}
|
||||||
|
appealed, found, err := service.Case(ctx, detail.Case.ID)
|
||||||
|
if err != nil || !found ||
|
||||||
|
appealed.Case.Status != domain.ModerationCaseAppealReview ||
|
||||||
|
len(appealed.Appeals) != 1 {
|
||||||
|
t.Fatalf("appealed=%+v found=%v err=%v", appealed, found, err)
|
||||||
|
}
|
||||||
|
appealClaim, err := service.ClaimCase(
|
||||||
|
ctx, appealed.Case.ID, appealed.Case.Version,
|
||||||
|
"appeal-reviewer", now.Add(8*time.Second),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
grant := domain.ModerationDecisionRequest{
|
||||||
|
CaseID: appealClaim.ID, AppealID: appeal.ID,
|
||||||
|
ExpectedVersion: appealClaim.Version, Actor: "appeal-reviewer",
|
||||||
|
Reason: "original evidence was insufficient",
|
||||||
|
CommandID: "appeal-grant-without-remedy",
|
||||||
|
Kind: domain.ModerationDecisionAppealGrant,
|
||||||
|
CreatedAt: now.Add(9 * time.Second),
|
||||||
|
}
|
||||||
|
if _, _, err := service.ReviewAppeal(ctx, grant); !errors.Is(
|
||||||
|
err, domain.ErrModerationActionInvalid,
|
||||||
|
) {
|
||||||
|
t.Fatalf("grant without required flag remedy err=%v", err)
|
||||||
|
}
|
||||||
|
grant.CommandID = "appeal-grant-with-remedy"
|
||||||
|
grant.Actions = []domain.ModerationActionDraft{{
|
||||||
|
Kind: domain.ModerationActionClearPeerFlags, Payload: []byte(`{}`),
|
||||||
|
}}
|
||||||
|
granted, created, err := service.ReviewAppeal(ctx, grant)
|
||||||
|
if err != nil || !created ||
|
||||||
|
granted.Case.Status != domain.ModerationCaseActionPending {
|
||||||
|
t.Fatalf("granted=%+v created=%v err=%v", granted, created, err)
|
||||||
|
}
|
||||||
|
remedies, err := store.ClaimModerationActions(
|
||||||
|
ctx, now.Add(10*time.Second), 10, time.Minute,
|
||||||
|
)
|
||||||
|
if err != nil || len(remedies) != 1 ||
|
||||||
|
remedies[0].Kind != domain.ModerationActionClearPeerFlags {
|
||||||
|
t.Fatalf("remedies=%+v err=%v", remedies, err)
|
||||||
|
}
|
||||||
|
if err := store.CompleteModerationAction(
|
||||||
|
ctx, remedies[0].ID, remedies[0].Attempts, true, "",
|
||||||
|
time.Time{}, now.Add(11*time.Second),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dismissed, found, err := service.Case(ctx, appealed.Case.ID)
|
||||||
|
if err != nil || !found ||
|
||||||
|
dismissed.Case.Status != domain.ModerationCaseDismissed {
|
||||||
|
t.Fatalf("dismissed=%+v found=%v err=%v", dismissed, found, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, found, err := service.ResolveAppealLink(
|
||||||
|
ctx, expiredToken, now.Add(10*time.Second),
|
||||||
|
); err != nil || found {
|
||||||
|
t.Fatalf("expired resolve found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if _, _, err := service.SubmitAppealLink(
|
||||||
|
ctx, expiredToken, "too late", now.Add(10*time.Second),
|
||||||
|
); !errors.Is(err, domain.ErrModerationAppealLinkInvalid) {
|
||||||
|
t.Fatalf("expired submit err=%v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := service.ResolveAppealLink(ctx, "not-a-token", now); !errors.Is(
|
||||||
|
err, domain.ErrModerationAppealLinkInvalid,
|
||||||
|
) {
|
||||||
|
t.Fatalf("invalid token err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
180
internal/app/moderation/cases.go
Normal file
180
internal/app/moderation/cases.go
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
package moderation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) ListCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error) {
|
||||||
|
if s == nil || s.cases == nil {
|
||||||
|
return nil, fmt.Errorf("moderation case store is not configured")
|
||||||
|
}
|
||||||
|
return s.cases.ListModerationCases(ctx, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Case(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error) {
|
||||||
|
if s == nil || s.cases == nil {
|
||||||
|
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation case store is not configured")
|
||||||
|
}
|
||||||
|
return s.cases.GetModerationCase(ctx, caseID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ClaimCase(ctx context.Context, caseID, expectedVersion int64, actor string, now time.Time) (domain.ModerationCase, error) {
|
||||||
|
if s == nil || s.cases == nil {
|
||||||
|
return domain.ModerationCase{}, fmt.Errorf("moderation case store is not configured")
|
||||||
|
}
|
||||||
|
return s.cases.ClaimModerationCase(ctx, caseID, expectedVersion, actor, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DecideCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||||
|
if s == nil || s.cases == nil {
|
||||||
|
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation case store is not configured")
|
||||||
|
}
|
||||||
|
prepared, err := domain.NewModerationDecisionRequest(request)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationCaseDetail{}, false, err
|
||||||
|
}
|
||||||
|
detail, found, err := s.cases.GetModerationCase(ctx, prepared.CaseID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationCaseDetail{}, false, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
|
||||||
|
}
|
||||||
|
if err := s.validateDecisionActions(ctx, detail, prepared.Actions); err != nil {
|
||||||
|
return domain.ModerationCaseDetail{}, false, err
|
||||||
|
}
|
||||||
|
return s.cases.DecideModerationCase(ctx, prepared)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SubmitAppeal(ctx context.Context, caseID, appellantUserID int64, text string, now time.Time) (domain.ModerationAppeal, bool, error) {
|
||||||
|
if s == nil || s.cases == nil {
|
||||||
|
return domain.ModerationAppeal{}, false, fmt.Errorf("moderation case store is not configured")
|
||||||
|
}
|
||||||
|
detail, found, err := s.cases.GetModerationCase(ctx, caseID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationAppeal{}, false, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseNotFound
|
||||||
|
}
|
||||||
|
switch detail.Case.Target.Type {
|
||||||
|
case domain.PeerTypeUser:
|
||||||
|
if detail.Case.Target.ID != appellantUserID {
|
||||||
|
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
|
||||||
|
}
|
||||||
|
case domain.PeerTypeChannel:
|
||||||
|
if s.channels == nil {
|
||||||
|
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
|
||||||
|
}
|
||||||
|
view, err := s.channels.ResolveChannel(ctx, appellantUserID, detail.Case.Target.ID)
|
||||||
|
if err != nil || view.Forbidden ||
|
||||||
|
(view.Self.Role != domain.ChannelRoleCreator &&
|
||||||
|
view.Self.Role != domain.ChannelRoleAdmin) {
|
||||||
|
return domain.ModerationAppeal{}, false, domain.ErrModerationPermissionDenied
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return domain.ModerationAppeal{}, false, domain.ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
appeal, err := domain.NewModerationAppeal(
|
||||||
|
caseID, appellantUserID, detail.Case.Status, text, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationAppeal{}, false, err
|
||||||
|
}
|
||||||
|
return s.cases.CreateModerationAppeal(ctx, appeal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReviewAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
|
||||||
|
if s == nil || s.cases == nil {
|
||||||
|
return domain.ModerationCaseDetail{}, false, fmt.Errorf("moderation case store is not configured")
|
||||||
|
}
|
||||||
|
prepared, err := domain.NewModerationDecisionRequest(request)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationCaseDetail{}, false, err
|
||||||
|
}
|
||||||
|
if prepared.AppealID <= 0 ||
|
||||||
|
(prepared.Kind != domain.ModerationDecisionAppealGrant &&
|
||||||
|
prepared.Kind != domain.ModerationDecisionAppealDeny) {
|
||||||
|
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
detail, found, err := s.cases.GetModerationCase(ctx, prepared.CaseID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationCaseDetail{}, false, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
|
||||||
|
}
|
||||||
|
appealFound := false
|
||||||
|
for _, appeal := range detail.Appeals {
|
||||||
|
if appeal.ID == prepared.AppealID &&
|
||||||
|
appeal.Status == domain.ModerationAppealPending {
|
||||||
|
appealFound = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !appealFound {
|
||||||
|
return domain.ModerationCaseDetail{}, false, domain.ErrModerationCaseNotFound
|
||||||
|
}
|
||||||
|
if err := s.validateDecisionActions(ctx, detail, prepared.Actions); err != nil {
|
||||||
|
return domain.ModerationCaseDetail{}, false, err
|
||||||
|
}
|
||||||
|
if prepared.Kind == domain.ModerationDecisionAppealGrant {
|
||||||
|
if err := validateAppealRemedyActions(detail, prepared.Actions); err != nil {
|
||||||
|
return domain.ModerationCaseDetail{}, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.cases.ReviewModerationAppeal(ctx, prepared)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAppealRemedyActions(detail domain.ModerationCaseDetail, actions []domain.ModerationActionDraft) error {
|
||||||
|
history := append([]domain.ModerationAction(nil), detail.Actions...)
|
||||||
|
sort.Slice(history, func(i, j int) bool { return history[i].ID < history[j].ID })
|
||||||
|
var flagsActive, freezeActive, irreversible bool
|
||||||
|
for _, action := range history {
|
||||||
|
if action.Status != domain.ModerationActionSucceeded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch action.Kind {
|
||||||
|
case domain.ModerationActionMarkScam, domain.ModerationActionMarkFake:
|
||||||
|
flagsActive = true
|
||||||
|
case domain.ModerationActionClearPeerFlags:
|
||||||
|
flagsActive = false
|
||||||
|
case domain.ModerationActionFreezeAccount:
|
||||||
|
freezeActive = true
|
||||||
|
case domain.ModerationActionUnfreezeAccount:
|
||||||
|
freezeActive = false
|
||||||
|
case domain.ModerationActionDeletePrivateMessage,
|
||||||
|
domain.ModerationActionDeleteChannelMessage,
|
||||||
|
domain.ModerationActionDeleteAccount:
|
||||||
|
irreversible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if irreversible {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
expected := make(map[domain.ModerationActionKind]bool, 2)
|
||||||
|
if flagsActive {
|
||||||
|
expected[domain.ModerationActionClearPeerFlags] = true
|
||||||
|
}
|
||||||
|
if freezeActive {
|
||||||
|
expected[domain.ModerationActionUnfreezeAccount] = true
|
||||||
|
}
|
||||||
|
if len(actions) != len(expected) {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
for _, action := range actions {
|
||||||
|
if !expected[action.Kind] {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
delete(expected, action.Kind)
|
||||||
|
}
|
||||||
|
if len(expected) != 0 {
|
||||||
|
return domain.ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
43
internal/app/moderation/cases_test.go
Normal file
43
internal/app/moderation/cases_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
package moderation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateAppealRemedyActionsMatchesOnlyAppliedReversibleState(t *testing.T) {
|
||||||
|
detail := domain.ModerationCaseDetail{Actions: []domain.ModerationAction{
|
||||||
|
{ID: 2, Kind: domain.ModerationActionFreezeAccount, Status: domain.ModerationActionSucceeded},
|
||||||
|
{ID: 1, Kind: domain.ModerationActionMarkScam, Status: domain.ModerationActionSucceeded},
|
||||||
|
{ID: 3, Kind: domain.ModerationActionDeletePrivateMessage, Status: domain.ModerationActionFailed},
|
||||||
|
}}
|
||||||
|
remedies := []domain.ModerationActionDraft{
|
||||||
|
{Kind: domain.ModerationActionClearPeerFlags},
|
||||||
|
{Kind: domain.ModerationActionUnfreezeAccount},
|
||||||
|
}
|
||||||
|
if err := validateAppealRemedyActions(detail, remedies); err != nil {
|
||||||
|
t.Fatalf("valid remedies err=%v", err)
|
||||||
|
}
|
||||||
|
if err := validateAppealRemedyActions(
|
||||||
|
detail, remedies[:1],
|
||||||
|
); !errors.Is(err, domain.ErrModerationActionInvalid) {
|
||||||
|
t.Fatalf("missing unfreeze err=%v", err)
|
||||||
|
}
|
||||||
|
if err := validateAppealRemedyActions(detail, []domain.ModerationActionDraft{
|
||||||
|
{Kind: domain.ModerationActionMarkFake},
|
||||||
|
{Kind: domain.ModerationActionUnfreezeAccount},
|
||||||
|
}); !errors.Is(err, domain.ErrModerationActionInvalid) {
|
||||||
|
t.Fatalf("new punishment in appeal err=%v", err)
|
||||||
|
}
|
||||||
|
detail.Actions = append(detail.Actions, domain.ModerationAction{
|
||||||
|
ID: 4, Kind: domain.ModerationActionDeletePrivateMessage,
|
||||||
|
Status: domain.ModerationActionSucceeded,
|
||||||
|
})
|
||||||
|
if err := validateAppealRemedyActions(
|
||||||
|
detail, remedies,
|
||||||
|
); !errors.Is(err, domain.ErrModerationActionInvalid) {
|
||||||
|
t.Fatalf("irreversible grant err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
739
internal/app/moderation/evidence.go
Normal file
739
internal/app/moderation/evidence.go
Normal file
|
|
@ -0,0 +1,739 @@
|
||||||
|
package moderation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type privateMessageReader interface {
|
||||||
|
GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error)
|
||||||
|
GetMessageReactions(ctx context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelMessageReader interface {
|
||||||
|
GetMessages(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelHistory, error)
|
||||||
|
FindMessageReaction(ctx context.Context, userID int64, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type storyReader interface {
|
||||||
|
GetStoriesByID(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, now int) (domain.StoryList, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type userReader interface {
|
||||||
|
ByID(ctx context.Context, viewerUserID, userID int64) (domain.User, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelPeerReader interface {
|
||||||
|
ResolveChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type profilePhotoReader interface {
|
||||||
|
GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]domain.Photo, int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReportMessages(ctx context.Context, req domain.ModerationMessageReportRequest) (domain.ModerationReport, bool, error) {
|
||||||
|
ids, err := canonicalPositiveIDs(req.MessageIDs, domain.MaxMessageBoxID)
|
||||||
|
if err != nil || req.ReporterUserID <= 0 || req.Target.ID <= 0 {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
items := make([]domain.ModerationReportItem, 0, len(ids))
|
||||||
|
holds := make([]domain.ModerationMediaHold, 0)
|
||||||
|
switch req.Target.Type {
|
||||||
|
case domain.PeerTypeUser:
|
||||||
|
if s == nil || s.privateMessages == nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("moderation private message reader is not configured")
|
||||||
|
}
|
||||||
|
list, err := s.privateMessages.GetMessages(ctx, req.ReporterUserID, ids)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
byID := make(map[int]domain.Message, len(list.Messages))
|
||||||
|
for _, message := range list.Messages {
|
||||||
|
if message.Peer == req.Target {
|
||||||
|
byID[message.ID] = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
message, found := byID[id]
|
||||||
|
if !found {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
evidence, err := json.Marshal(privateMessageEvidence(message))
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("marshal private message evidence: %w", err)
|
||||||
|
}
|
||||||
|
items = append(items, domain.ModerationReportItem{
|
||||||
|
Kind: domain.ModerationItemMessage, Peer: req.Target,
|
||||||
|
ItemID: int64(message.ID), AuthorUserID: message.From.ID,
|
||||||
|
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||||
|
})
|
||||||
|
holds = append(holds, mediaHolds(len(items)-1, message.Media)...)
|
||||||
|
}
|
||||||
|
case domain.PeerTypeChannel:
|
||||||
|
if s == nil || s.channelMessages == nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("moderation channel message reader is not configured")
|
||||||
|
}
|
||||||
|
history, err := s.channelMessages.GetMessages(ctx, req.ReporterUserID, req.Target.ID, ids)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
byID := make(map[int]domain.ChannelMessage, len(history.Messages))
|
||||||
|
for _, message := range history.Messages {
|
||||||
|
if message.ChannelID == req.Target.ID && !message.Deleted {
|
||||||
|
byID[message.ID] = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
message, found := byID[id]
|
||||||
|
if !found {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
evidence, err := marshalChannelMessageEvidence(message)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("marshal channel message evidence: %w", err)
|
||||||
|
}
|
||||||
|
items = append(items, domain.ModerationReportItem{
|
||||||
|
Kind: domain.ModerationItemMessage, Peer: req.Target,
|
||||||
|
ItemID: int64(message.ID), AuthorUserID: message.SenderUserID,
|
||||||
|
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||||
|
})
|
||||||
|
holds = append(holds, mediaHolds(len(items)-1, message.Media)...)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: req.ReporterUserID,
|
||||||
|
Source: domain.ModerationSourceMessages,
|
||||||
|
Target: req.Target,
|
||||||
|
Reason: req.Reason,
|
||||||
|
Option: req.Option,
|
||||||
|
Comment: req.Comment,
|
||||||
|
Items: items,
|
||||||
|
MediaHolds: dedupeMediaHolds(holds),
|
||||||
|
CreatedAt: req.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReportPeer(ctx context.Context, reporterUserID int64, source domain.ModerationReportSource, target domain.Peer, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error) {
|
||||||
|
if source != domain.ModerationSourceAccountPeer && source != domain.ModerationSourceMessagesSpam {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
snapshot := peerEvidenceV1{SchemaVersion: 1, Target: target}
|
||||||
|
switch target.Type {
|
||||||
|
case domain.PeerTypeUser:
|
||||||
|
if s == nil || s.users == nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("moderation user reader is not configured")
|
||||||
|
}
|
||||||
|
user, found, err := s.users.ByID(ctx, reporterUserID, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
if !found || user.Deleted {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
snapshot.User = &peerUserEvidenceV1{
|
||||||
|
ID: user.ID, FirstName: user.FirstName, LastName: user.LastName,
|
||||||
|
Username: user.Username, About: user.About, Bot: user.Bot,
|
||||||
|
Verified: user.Verified, Scam: user.Scam, Fake: user.Fake,
|
||||||
|
PhotoID: user.PhotoID,
|
||||||
|
}
|
||||||
|
case domain.PeerTypeChannel:
|
||||||
|
if s == nil || s.channels == nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("moderation channel reader is not configured")
|
||||||
|
}
|
||||||
|
view, err := s.channels.ResolveChannel(ctx, reporterUserID, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
channel := view.Channel
|
||||||
|
snapshot.Channel = &peerChannelEvidenceV1{
|
||||||
|
ID: channel.ID, Title: channel.Title, About: channel.About,
|
||||||
|
Username: channel.Username, Broadcast: channel.Broadcast,
|
||||||
|
Megagroup: channel.Megagroup, Verified: channel.Verified,
|
||||||
|
Scam: channel.Scam, Fake: channel.Fake, PhotoID: channel.PhotoID,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
evidence, err := json.Marshal(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("marshal peer evidence: %w", err)
|
||||||
|
}
|
||||||
|
authorUserID := int64(0)
|
||||||
|
if target.Type == domain.PeerTypeUser {
|
||||||
|
authorUserID = target.ID
|
||||||
|
}
|
||||||
|
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: reporterUserID, Source: source, Target: target,
|
||||||
|
Reason: reason, Option: option, Comment: comment,
|
||||||
|
Items: []domain.ModerationReportItem{{
|
||||||
|
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
||||||
|
AuthorUserID: authorUserID, EvidenceSchemaVersion: 1,
|
||||||
|
Evidence: evidence,
|
||||||
|
}},
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReportProfilePhoto(ctx context.Context, req domain.ModerationProfilePhotoReportRequest) (domain.ModerationReport, bool, error) {
|
||||||
|
if req.ReporterUserID <= 0 || req.Target.ID <= 0 || req.PhotoID <= 0 ||
|
||||||
|
!req.Reason.Valid() || s == nil || s.photos == nil {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
photos, _, err := s.photos.GetProfilePhotos(ctx, req.Target.Type, req.Target.ID, -1, 1, req.PhotoID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
if len(photos) != 1 || photos[0].ID != req.PhotoID ||
|
||||||
|
photos[0].AccessHash != req.AccessHash {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
photo := photos[0]
|
||||||
|
if len(req.FileReference) > 0 && len(photo.FileReference) > 0 &&
|
||||||
|
!bytes.Equal(req.FileReference, photo.FileReference) {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
evidence, err := json.Marshal(profilePhotoEvidenceV1{
|
||||||
|
SchemaVersion: 1, Owner: req.Target, Photo: photo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("marshal profile photo evidence: %w", err)
|
||||||
|
}
|
||||||
|
authorUserID := int64(0)
|
||||||
|
if req.Target.Type == domain.PeerTypeUser {
|
||||||
|
authorUserID = req.Target.ID
|
||||||
|
}
|
||||||
|
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceProfilePhoto,
|
||||||
|
Target: req.Target, Reason: req.Reason, Option: string(req.Reason),
|
||||||
|
Comment: req.Comment, CreatedAt: req.CreatedAt,
|
||||||
|
Items: []domain.ModerationReportItem{{
|
||||||
|
Kind: domain.ModerationItemProfilePhoto, Peer: req.Target,
|
||||||
|
ItemID: req.PhotoID, AuthorUserID: authorUserID,
|
||||||
|
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||||
|
}},
|
||||||
|
MediaHolds: photoHolds(0, photo),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReportChannelSpam(ctx context.Context, req domain.ModerationChannelSpamReportRequest) (domain.ModerationReport, bool, error) {
|
||||||
|
if req.ReporterUserID <= 0 || req.ChannelID <= 0 || req.ParticipantUserID <= 0 ||
|
||||||
|
s == nil || s.channelMessages == nil {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
ids, err := canonicalPositiveIDs(req.MessageIDs, domain.MaxMessageBoxID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
history, err := s.channelMessages.GetMessages(ctx, req.ReporterUserID, req.ChannelID, ids)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
byID := make(map[int]domain.ChannelMessage, len(history.Messages))
|
||||||
|
for _, message := range history.Messages {
|
||||||
|
if message.ChannelID == req.ChannelID && !message.Deleted {
|
||||||
|
byID[message.ID] = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items := make([]domain.ModerationReportItem, 0, len(ids))
|
||||||
|
holds := make([]domain.ModerationMediaHold, 0)
|
||||||
|
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
||||||
|
for _, id := range ids {
|
||||||
|
message, found := byID[id]
|
||||||
|
if !found || message.SenderUserID != req.ParticipantUserID {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
evidence, err := marshalChannelMessageEvidence(message)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
items = append(items, domain.ModerationReportItem{
|
||||||
|
Kind: domain.ModerationItemMessage, Peer: target,
|
||||||
|
ItemID: int64(message.ID), AuthorUserID: req.ParticipantUserID,
|
||||||
|
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||||
|
})
|
||||||
|
holds = append(holds, mediaHolds(len(items)-1, message.Media)...)
|
||||||
|
}
|
||||||
|
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceChannelSpam,
|
||||||
|
Target: target, Reason: domain.ModerationReasonSpam, Option: "spam",
|
||||||
|
Items: items, MediaHolds: dedupeMediaHolds(holds), CreatedAt: req.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReportReaction(ctx context.Context, req domain.ModerationReactionReportRequest) (domain.ModerationReport, bool, error) {
|
||||||
|
if req.ReporterUserID <= 0 || req.Target.ID <= 0 || req.MessageID <= 0 ||
|
||||||
|
req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID <= 0 {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
var evidence []byte
|
||||||
|
var err error
|
||||||
|
switch req.Target.Type {
|
||||||
|
case domain.PeerTypeUser:
|
||||||
|
if s == nil || s.privateMessages == nil {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
result, err := s.privateMessages.GetMessageReactions(ctx, req.ReporterUserID, domain.PrivateMessageReactionsRequest{
|
||||||
|
OwnerUserID: req.ReporterUserID, Peer: req.Target, IDs: []int{req.MessageID},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
if len(result.Messages) != 1 || result.Messages[0].ID != req.MessageID ||
|
||||||
|
result.Messages[0].Peer != req.Target {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
reactions := reactionRowsForUser(result.Messages[0].Reactions, req.ReactorUserID)
|
||||||
|
if len(reactions) == 0 {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
evidence, err = json.Marshal(privateReactionEvidenceV1{
|
||||||
|
SchemaVersion: 1, Message: privateMessageEvidence(result.Messages[0]),
|
||||||
|
ReactorUserID: req.ReactorUserID, Reactions: reactionEvidenceRows(reactions),
|
||||||
|
})
|
||||||
|
case domain.PeerTypeChannel:
|
||||||
|
if s == nil || s.channelMessages == nil {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
lookup, found, lookupErr := s.channelMessages.FindMessageReaction(ctx, req.ReporterUserID, domain.ChannelMessageReactionLookupRequest{
|
||||||
|
ViewerUserID: req.ReporterUserID, ChannelID: req.Target.ID,
|
||||||
|
MessageID: req.MessageID, ReactorUserID: req.ReactorUserID,
|
||||||
|
})
|
||||||
|
if lookupErr != nil {
|
||||||
|
return domain.ModerationReport{}, false, lookupErr
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
evidence, err = json.Marshal(channelReactionEvidenceV1{
|
||||||
|
SchemaVersion: 1, Message: channelMessageEvidence(lookup.Message),
|
||||||
|
ReactorUserID: req.ReactorUserID, Reactions: reactionEvidenceRows(lookup.Reactions),
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("marshal reaction evidence: %w", err)
|
||||||
|
}
|
||||||
|
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceReaction,
|
||||||
|
Target: req.Target, Reason: domain.ModerationReasonOther,
|
||||||
|
Option: "reaction", CreatedAt: req.CreatedAt,
|
||||||
|
Items: []domain.ModerationReportItem{{
|
||||||
|
Kind: domain.ModerationItemReaction, Peer: req.Target,
|
||||||
|
ItemID: int64(req.MessageID), SecondaryID: req.ReactorUserID,
|
||||||
|
AuthorUserID: req.ReactorUserID, EvidenceSchemaVersion: 1,
|
||||||
|
Evidence: evidence,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReportEncryptedSpam(ctx context.Context, reporterUserID int64, chat domain.SecretChat, createdAt time.Time) (domain.ModerationReport, bool, error) {
|
||||||
|
if reporterUserID <= 0 || !chat.HasParticipant(reporterUserID) || chat.ID <= 0 {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationPermissionDenied
|
||||||
|
}
|
||||||
|
offenderUserID := chat.PeerOf(reporterUserID)
|
||||||
|
if offenderUserID <= 0 {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
target := domain.Peer{Type: domain.PeerTypeUser, ID: offenderUserID}
|
||||||
|
evidence, err := json.Marshal(encryptedChatEvidenceV1{
|
||||||
|
SchemaVersion: 1, ChatID: chat.ID, State: chat.State,
|
||||||
|
AdminUserID: chat.AdminUserID, ParticipantUserID: chat.ParticipantUserID,
|
||||||
|
Date: chat.Date,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("marshal encrypted chat evidence: %w", err)
|
||||||
|
}
|
||||||
|
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: reporterUserID, Source: domain.ModerationSourceEncryptedSpam,
|
||||||
|
Target: target, Reason: domain.ModerationReasonSpam, Option: "spam",
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
Items: []domain.ModerationReportItem{{
|
||||||
|
Kind: domain.ModerationItemEncryptedChat, Peer: target,
|
||||||
|
ItemID: int64(chat.ID), AuthorUserID: offenderUserID,
|
||||||
|
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReportStories(ctx context.Context, req domain.ModerationStoryReportRequest) (domain.ModerationReport, bool, error) {
|
||||||
|
ids, err := canonicalPositiveIDs(req.StoryIDs, domain.MaxStoryID)
|
||||||
|
if err != nil || req.ReporterUserID <= 0 || req.Target.ID <= 0 {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
if s == nil || s.stories == nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("moderation story reader is not configured")
|
||||||
|
}
|
||||||
|
list, err := s.stories.GetStoriesByID(ctx, req.ReporterUserID, req.Target, ids, int(req.CreatedAt.Unix()))
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
byID := make(map[int]domain.Story, len(list.Stories))
|
||||||
|
for _, story := range list.Stories {
|
||||||
|
if story.Owner == req.Target && !story.Deleted {
|
||||||
|
byID[story.ID] = story
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items := make([]domain.ModerationReportItem, 0, len(ids))
|
||||||
|
holds := make([]domain.ModerationMediaHold, 0)
|
||||||
|
for _, id := range ids {
|
||||||
|
story, found := byID[id]
|
||||||
|
if !found {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
evidence, err := json.Marshal(storyEvidenceV1{
|
||||||
|
SchemaVersion: 1, Owner: story.Owner, StoryID: story.ID,
|
||||||
|
Date: story.Date, ExpireDate: story.ExpireDate, Pinned: story.Pinned,
|
||||||
|
Public: story.Public, CloseFriends: story.CloseFriends,
|
||||||
|
Contacts: story.Contacts, SelectedContacts: story.SelectedContacts,
|
||||||
|
NoForwards: story.NoForwards, Edited: story.Edited,
|
||||||
|
Caption: story.Caption, Entities: story.Entities, Media: story.Media,
|
||||||
|
MediaAreas: story.MediaAreas, Forward: story.Forward,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("marshal story evidence: %w", err)
|
||||||
|
}
|
||||||
|
authorUserID := int64(0)
|
||||||
|
if story.Owner.Type == domain.PeerTypeUser {
|
||||||
|
authorUserID = story.Owner.ID
|
||||||
|
}
|
||||||
|
items = append(items, domain.ModerationReportItem{
|
||||||
|
Kind: domain.ModerationItemStory, Peer: story.Owner,
|
||||||
|
ItemID: int64(story.ID), AuthorUserID: authorUserID,
|
||||||
|
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||||
|
})
|
||||||
|
holds = append(holds, mediaHolds(len(items)-1, story.Media)...)
|
||||||
|
}
|
||||||
|
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: req.ReporterUserID, Source: domain.ModerationSourceStory,
|
||||||
|
Target: req.Target, Reason: req.Reason, Option: req.Option,
|
||||||
|
Comment: req.Comment, Items: items,
|
||||||
|
MediaHolds: dedupeMediaHolds(holds), CreatedAt: req.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReportEphemeral(ctx context.Context, reporterUserID int64, target domain.EphemeralMessage, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error) {
|
||||||
|
legacy := domain.NewEphemeralAbuseReport(reporterUserID, option, comment, target, createdAt)
|
||||||
|
if err := legacy.Validate(); err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
evidence, err := json.Marshal(struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Evidence domain.EphemeralReportEvidence `json:"evidence"`
|
||||||
|
}{SchemaVersion: 1, Evidence: legacy.Evidence})
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("marshal ephemeral report evidence: %w", err)
|
||||||
|
}
|
||||||
|
holds := mediaHolds(0, target.Content.Media)
|
||||||
|
return s.AcceptReport(ctx, domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: reporterUserID, Source: domain.ModerationSourceEphemeral,
|
||||||
|
Target: target.Peer, Reason: reason, Option: option, Comment: comment,
|
||||||
|
Items: []domain.ModerationReportItem{{
|
||||||
|
Kind: domain.ModerationItemEphemeral, Peer: target.Peer,
|
||||||
|
ItemID: int64(target.ID), AuthorUserID: target.SenderUserID,
|
||||||
|
EvidenceSchemaVersion: 1, Evidence: evidence,
|
||||||
|
}},
|
||||||
|
MediaHolds: holds, CreatedAt: createdAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type peerEvidenceV1 struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Target domain.Peer `json:"target"`
|
||||||
|
User *peerUserEvidenceV1 `json:"user,omitempty"`
|
||||||
|
Channel *peerChannelEvidenceV1 `json:"channel,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type peerUserEvidenceV1 struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
LastName string `json:"last_name"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
About string `json:"about"`
|
||||||
|
Bot bool `json:"bot,omitempty"`
|
||||||
|
Verified bool `json:"verified,omitempty"`
|
||||||
|
Scam bool `json:"scam,omitempty"`
|
||||||
|
Fake bool `json:"fake,omitempty"`
|
||||||
|
PhotoID int64 `json:"photo_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type peerChannelEvidenceV1 struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
About string `json:"about"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Broadcast bool `json:"broadcast,omitempty"`
|
||||||
|
Megagroup bool `json:"megagroup,omitempty"`
|
||||||
|
Verified bool `json:"verified,omitempty"`
|
||||||
|
Scam bool `json:"scam,omitempty"`
|
||||||
|
Fake bool `json:"fake,omitempty"`
|
||||||
|
PhotoID int64 `json:"photo_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type profilePhotoEvidenceV1 struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Owner domain.Peer `json:"owner"`
|
||||||
|
Photo domain.Photo `json:"photo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type privateReactionEvidenceV1 struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Message privateMessageEvidenceV1 `json:"message"`
|
||||||
|
ReactorUserID int64 `json:"reactor_user_id"`
|
||||||
|
Reactions []messageReactionEvidenceV1 `json:"reactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelReactionEvidenceV1 struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Message channelMessageEvidenceV1 `json:"message"`
|
||||||
|
ReactorUserID int64 `json:"reactor_user_id"`
|
||||||
|
Reactions []messageReactionEvidenceV1 `json:"reactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type messageReactionEvidenceV1 struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Type domain.MessageReactionType `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Big bool `json:"big,omitempty"`
|
||||||
|
Unread bool `json:"unread,omitempty"`
|
||||||
|
ChosenOrder int `json:"chosen_order,omitempty"`
|
||||||
|
Date int `json:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encryptedChatEvidenceV1 struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
ChatID int `json:"chat_id"`
|
||||||
|
State domain.SecretChatState `json:"state"`
|
||||||
|
AdminUserID int64 `json:"admin_user_id"`
|
||||||
|
ParticipantUserID int64 `json:"participant_user_id"`
|
||||||
|
Date int `json:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type privateMessageEvidenceV1 struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
MessageID int `json:"message_id"`
|
||||||
|
UID int64 `json:"uid"`
|
||||||
|
Peer domain.Peer `json:"peer"`
|
||||||
|
From domain.Peer `json:"from"`
|
||||||
|
Date int `json:"date"`
|
||||||
|
EditDate int `json:"edit_date,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Entities []domain.MessageEntity `json:"entities,omitempty"`
|
||||||
|
ReplyTo *domain.MessageReply `json:"reply_to,omitempty"`
|
||||||
|
Forward *domain.MessageForward `json:"forward,omitempty"`
|
||||||
|
Reactions *domain.ChannelMessageReactions `json:"reactions,omitempty"`
|
||||||
|
Media *domain.MessageMedia `json:"media,omitempty"`
|
||||||
|
RichMessage *domain.MessageRichMessage `json:"rich_message,omitempty"`
|
||||||
|
GroupedID int64 `json:"grouped_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelMessageEvidenceV1 struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
ChannelID int64 `json:"channel_id"`
|
||||||
|
MessageID int `json:"message_id"`
|
||||||
|
SenderUserID int64 `json:"sender_user_id"`
|
||||||
|
From domain.Peer `json:"from"`
|
||||||
|
SendAs *domain.Peer `json:"send_as,omitempty"`
|
||||||
|
Date int `json:"date"`
|
||||||
|
EditDate int `json:"edit_date,omitempty"`
|
||||||
|
Post bool `json:"post,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Entities []domain.MessageEntity `json:"entities,omitempty"`
|
||||||
|
ReplyTo *domain.MessageReply `json:"reply_to,omitempty"`
|
||||||
|
Forward *domain.MessageForward `json:"forward,omitempty"`
|
||||||
|
Reactions *domain.ChannelMessageReactions `json:"reactions,omitempty"`
|
||||||
|
Action *domain.ChannelMessageAction `json:"action,omitempty"`
|
||||||
|
Media *domain.MessageMedia `json:"media,omitempty"`
|
||||||
|
RichMessage *domain.MessageRichMessage `json:"rich_message,omitempty"`
|
||||||
|
GroupedID int64 `json:"grouped_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type storyEvidenceV1 struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Owner domain.Peer `json:"owner"`
|
||||||
|
StoryID int `json:"story_id"`
|
||||||
|
Date int `json:"date"`
|
||||||
|
ExpireDate int `json:"expire_date"`
|
||||||
|
Pinned bool `json:"pinned,omitempty"`
|
||||||
|
Public bool `json:"public,omitempty"`
|
||||||
|
CloseFriends bool `json:"close_friends,omitempty"`
|
||||||
|
Contacts bool `json:"contacts,omitempty"`
|
||||||
|
SelectedContacts bool `json:"selected_contacts,omitempty"`
|
||||||
|
NoForwards bool `json:"no_forwards,omitempty"`
|
||||||
|
Edited bool `json:"edited,omitempty"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
Entities []domain.MessageEntity `json:"entities,omitempty"`
|
||||||
|
Media *domain.MessageMedia `json:"media,omitempty"`
|
||||||
|
MediaAreas []domain.StoryMediaArea `json:"media_areas,omitempty"`
|
||||||
|
Forward *domain.StoryForward `json:"forward,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func privateMessageEvidence(message domain.Message) privateMessageEvidenceV1 {
|
||||||
|
return privateMessageEvidenceV1{
|
||||||
|
SchemaVersion: 1, MessageID: message.ID, UID: message.UID,
|
||||||
|
Peer: message.Peer, From: message.From, Date: message.Date,
|
||||||
|
EditDate: message.EditDate, Body: message.Body,
|
||||||
|
Entities: message.Entities, ReplyTo: message.ReplyTo,
|
||||||
|
Forward: message.Forward, Reactions: message.Reactions,
|
||||||
|
Media: message.Media, RichMessage: message.RichMessage,
|
||||||
|
GroupedID: message.GroupedID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelMessageEvidence(message domain.ChannelMessage) channelMessageEvidenceV1 {
|
||||||
|
return channelMessageEvidenceV1{
|
||||||
|
SchemaVersion: 1, ChannelID: message.ChannelID,
|
||||||
|
MessageID: message.ID, SenderUserID: message.SenderUserID,
|
||||||
|
From: message.From, SendAs: message.SendAs, Date: message.Date,
|
||||||
|
EditDate: message.EditDate, Post: message.Post, Body: message.Body,
|
||||||
|
Entities: message.Entities, ReplyTo: message.ReplyTo,
|
||||||
|
Forward: message.Forward, Reactions: message.Reactions,
|
||||||
|
Action: message.Action, Media: message.Media,
|
||||||
|
RichMessage: message.RichMessage, GroupedID: message.GroupedID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalChannelMessageEvidence(message domain.ChannelMessage) ([]byte, error) {
|
||||||
|
evidence, err := json.Marshal(channelMessageEvidence(message))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal channel message evidence: %w", err)
|
||||||
|
}
|
||||||
|
return evidence, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reactionRowsForUser(reactions *domain.ChannelMessageReactions, userID int64) []domain.ChannelMessagePeerReaction {
|
||||||
|
if reactions == nil || userID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows := make([]domain.ChannelMessagePeerReaction, 0, len(reactions.Recent))
|
||||||
|
for _, reaction := range reactions.Recent {
|
||||||
|
if reaction.UserID == userID {
|
||||||
|
rows = append(rows, reaction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func reactionEvidenceRows(rows []domain.ChannelMessagePeerReaction) []messageReactionEvidenceV1 {
|
||||||
|
out := make([]messageReactionEvidenceV1, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, messageReactionEvidenceV1{
|
||||||
|
UserID: row.UserID, Type: row.Reaction.Type,
|
||||||
|
Value: row.Reaction.Value(), Big: row.Big, Unread: row.Unread,
|
||||||
|
ChosenOrder: row.ChosenOrder, Date: row.Date,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].ChosenOrder != out[j].ChosenOrder {
|
||||||
|
return out[i].ChosenOrder < out[j].ChosenOrder
|
||||||
|
}
|
||||||
|
if out[i].Type != out[j].Type {
|
||||||
|
return out[i].Type < out[j].Type
|
||||||
|
}
|
||||||
|
return out[i].Value < out[j].Value
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalPositiveIDs(ids []int, max int) ([]int, error) {
|
||||||
|
if len(ids) == 0 || len(ids) > domain.MaxModerationReportItems {
|
||||||
|
return nil, domain.ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
seen := make(map[int]struct{}, len(ids))
|
||||||
|
out := make([]int, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id <= 0 || id > max {
|
||||||
|
return nil, domain.ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[id]; !duplicate {
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Ints(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaHolds(itemIndex int, media *domain.MessageMedia) []domain.ModerationMediaHold {
|
||||||
|
if media == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
holds := make([]domain.ModerationMediaHold, 0, 8)
|
||||||
|
addPhoto := func(photo *domain.Photo) {
|
||||||
|
if photo == nil || photo.ID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, size := range photo.Sizes {
|
||||||
|
if size.Type != "" {
|
||||||
|
holds = append(holds, domain.ModerationMediaHold{
|
||||||
|
ItemIndex: itemIndex, Kind: domain.ModerationMediaPhoto,
|
||||||
|
StorageKey: "photo:" + strconv.FormatInt(photo.ID, 10) + ":" + size.Type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addDocument := func(document *domain.Document) {
|
||||||
|
if document == nil || document.ID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefix := "doc:" + strconv.FormatInt(document.ID, 10)
|
||||||
|
holds = append(holds, domain.ModerationMediaHold{
|
||||||
|
ItemIndex: itemIndex, Kind: domain.ModerationMediaDocument,
|
||||||
|
StorageKey: prefix,
|
||||||
|
})
|
||||||
|
for _, thumb := range document.Thumbs {
|
||||||
|
if thumb.Type != "" {
|
||||||
|
holds = append(holds, domain.ModerationMediaHold{
|
||||||
|
ItemIndex: itemIndex, Kind: domain.ModerationMediaDocument,
|
||||||
|
StorageKey: prefix + ":" + thumb.Type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addPhoto(media.Photo)
|
||||||
|
addDocument(media.Document)
|
||||||
|
addDocument(media.LivePhotoVideo)
|
||||||
|
return dedupeMediaHolds(holds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func photoHolds(itemIndex int, photo domain.Photo) []domain.ModerationMediaHold {
|
||||||
|
if photo.ID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
holds := make([]domain.ModerationMediaHold, 0, len(photo.Sizes))
|
||||||
|
for _, size := range photo.Sizes {
|
||||||
|
if size.Type == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
holds = append(holds, domain.ModerationMediaHold{
|
||||||
|
ItemIndex: itemIndex, Kind: domain.ModerationMediaPhoto,
|
||||||
|
StorageKey: "photo:" + strconv.FormatInt(photo.ID, 10) + ":" + size.Type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return dedupeMediaHolds(holds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeMediaHolds(holds []domain.ModerationMediaHold) []domain.ModerationMediaHold {
|
||||||
|
if len(holds) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[domain.ModerationMediaHold]struct{}, len(holds))
|
||||||
|
out := make([]domain.ModerationMediaHold, 0, len(holds))
|
||||||
|
for _, hold := range holds {
|
||||||
|
if _, duplicate := seen[hold]; duplicate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[hold] = struct{}{}
|
||||||
|
out = append(out, hold)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
106
internal/app/moderation/legacy.go
Normal file
106
internal/app/moderation/legacy.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package moderation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MigrateLegacyEphemeralReports converts every pre-unified durable report into
|
||||||
|
// the canonical moderation shape. The store commits the new report and its
|
||||||
|
// legacy provenance mapping atomically; rerunning after a crash is safe.
|
||||||
|
func (s *Service) MigrateLegacyEphemeralReports(ctx context.Context, source store.LegacyEphemeralReportReader, batchSize int) (int, error) {
|
||||||
|
if s == nil || s.reports == nil || source == nil {
|
||||||
|
return 0, fmt.Errorf("legacy ephemeral report migration is not configured")
|
||||||
|
}
|
||||||
|
if batchSize <= 0 || batchSize > 1000 {
|
||||||
|
return 0, fmt.Errorf("legacy ephemeral report batch limit out of range")
|
||||||
|
}
|
||||||
|
importer, ok := s.reports.(store.LegacyEphemeralReportImporter)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("moderation report store does not support legacy imports")
|
||||||
|
}
|
||||||
|
migrated := 0
|
||||||
|
for {
|
||||||
|
rows, err := source.ListUnmigratedEphemeralReports(ctx, batchSize)
|
||||||
|
if err != nil {
|
||||||
|
return migrated, err
|
||||||
|
}
|
||||||
|
for _, legacy := range rows {
|
||||||
|
report, err := legacyEphemeralModerationReport(legacy.Report)
|
||||||
|
if err != nil {
|
||||||
|
return migrated, fmt.Errorf("convert legacy ephemeral report %d: %w", legacy.ID, err)
|
||||||
|
}
|
||||||
|
if _, _, err := importer.ImportLegacyEphemeralReport(ctx, legacy.ID, report); err != nil {
|
||||||
|
return migrated, fmt.Errorf("import legacy ephemeral report %d: %w", legacy.ID, err)
|
||||||
|
}
|
||||||
|
migrated++
|
||||||
|
}
|
||||||
|
if len(rows) < batchSize {
|
||||||
|
return migrated, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyEphemeralModerationReport(legacy domain.EphemeralAbuseReport) (domain.ModerationReport, error) {
|
||||||
|
if err := legacy.Validate(); err != nil {
|
||||||
|
return domain.ModerationReport{}, err
|
||||||
|
}
|
||||||
|
reason, ok := legacyEphemeralModerationReason(legacy.Option)
|
||||||
|
if !ok {
|
||||||
|
return domain.ModerationReport{}, fmt.Errorf("%w: unsupported legacy option %q", domain.ErrModerationReportInvalid, legacy.Option)
|
||||||
|
}
|
||||||
|
evidence, err := json.Marshal(struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Evidence domain.EphemeralReportEvidence `json:"evidence"`
|
||||||
|
}{SchemaVersion: 1, Evidence: legacy.Evidence})
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, fmt.Errorf("marshal legacy ephemeral evidence: %w", err)
|
||||||
|
}
|
||||||
|
return domain.NewModerationReport(domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: legacy.ReporterUserID,
|
||||||
|
Source: domain.ModerationSourceEphemeral,
|
||||||
|
Target: legacy.Evidence.Peer,
|
||||||
|
Reason: reason,
|
||||||
|
Option: legacy.Option,
|
||||||
|
Comment: legacy.Comment,
|
||||||
|
Items: []domain.ModerationReportItem{{
|
||||||
|
Kind: domain.ModerationItemEphemeral,
|
||||||
|
Peer: legacy.Evidence.Peer,
|
||||||
|
ItemID: int64(legacy.Evidence.MessageID),
|
||||||
|
AuthorUserID: legacy.Evidence.SenderUserID,
|
||||||
|
EvidenceSchemaVersion: 1,
|
||||||
|
Evidence: evidence,
|
||||||
|
}},
|
||||||
|
MediaHolds: mediaHolds(0, legacy.Evidence.Content.Media),
|
||||||
|
CreatedAt: legacy.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyEphemeralModerationReason(option string) (domain.ModerationReason, bool) {
|
||||||
|
switch option {
|
||||||
|
case "spam":
|
||||||
|
return domain.ModerationReasonSpam, true
|
||||||
|
case "violence":
|
||||||
|
return domain.ModerationReasonViolence, true
|
||||||
|
case "pornography":
|
||||||
|
return domain.ModerationReasonPornography, true
|
||||||
|
case "child_abuse":
|
||||||
|
return domain.ModerationReasonChildAbuse, true
|
||||||
|
case "illegal_drugs":
|
||||||
|
return domain.ModerationReasonIllegalDrugs, true
|
||||||
|
case "personal_details":
|
||||||
|
return domain.ModerationReasonPersonalDetails, true
|
||||||
|
case "copyright":
|
||||||
|
return domain.ModerationReasonCopyright, true
|
||||||
|
case "fake":
|
||||||
|
return domain.ModerationReasonFake, true
|
||||||
|
case "other", "other:comment":
|
||||||
|
return domain.ModerationReasonOther, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
88
internal/app/moderation/legacy_test.go
Normal file
88
internal/app/moderation/legacy_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
package moderation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
type legacyEphemeralReader struct {
|
||||||
|
rows []store.LegacyEphemeralReport
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *legacyEphemeralReader) ListUnmigratedEphemeralReports(_ context.Context, limit int) ([]store.LegacyEphemeralReport, error) {
|
||||||
|
if len(r.rows) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if limit > len(r.rows) {
|
||||||
|
limit = len(r.rows)
|
||||||
|
}
|
||||||
|
out := append([]store.LegacyEphemeralReport(nil), r.rows[:limit]...)
|
||||||
|
r.rows = r.rows[limit:]
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacyModerationImporter struct {
|
||||||
|
*memory.ModerationReportStore
|
||||||
|
mappings map[int64]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *legacyModerationImporter) ImportLegacyEphemeralReport(ctx context.Context, legacyID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
|
||||||
|
if reportID, ok := s.mappings[legacyID]; ok {
|
||||||
|
existing, _, err := s.GetModerationReport(ctx, reportID)
|
||||||
|
return existing, false, err
|
||||||
|
}
|
||||||
|
stored, created, err := s.CreateModerationReport(ctx, report)
|
||||||
|
if err == nil {
|
||||||
|
s.mappings[legacyID] = stored.ID
|
||||||
|
}
|
||||||
|
return stored, created, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateLegacyEphemeralReportsPreservesEvidenceAndMediaHolds(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
reporter := int64(101)
|
||||||
|
message := domain.EphemeralMessage{
|
||||||
|
ID: 44, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 303},
|
||||||
|
SenderUserID: 202, ReceiverUserID: reporter, Date: int(now.Unix()),
|
||||||
|
Content: domain.EphemeralContent{
|
||||||
|
Message: "evidence",
|
||||||
|
Media: &domain.MessageMedia{
|
||||||
|
Kind: domain.MessageMediaKindDocument,
|
||||||
|
Document: &domain.Document{
|
||||||
|
ID: 909, AccessHash: 1, MimeType: "text/plain", Size: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Version: 1, CreatedAt: now, ExpiresAt: now.Add(time.Hour),
|
||||||
|
}
|
||||||
|
legacy := domain.NewEphemeralAbuseReport(reporter, "spam", "review", message, now)
|
||||||
|
source := &legacyEphemeralReader{rows: []store.LegacyEphemeralReport{{ID: 7, Report: legacy}}}
|
||||||
|
target := &legacyModerationImporter{
|
||||||
|
ModerationReportStore: memory.NewModerationReportStore(),
|
||||||
|
mappings: make(map[int64]int64),
|
||||||
|
}
|
||||||
|
service := NewService(target)
|
||||||
|
count, err := service.MigrateLegacyEphemeralReports(context.Background(), source, 10)
|
||||||
|
if err != nil || count != 1 {
|
||||||
|
t.Fatalf("migrate count=%d err=%v", count, err)
|
||||||
|
}
|
||||||
|
reports := target.Reports()
|
||||||
|
if len(reports) != 1 {
|
||||||
|
t.Fatalf("reports=%d, want 1", len(reports))
|
||||||
|
}
|
||||||
|
got := reports[0]
|
||||||
|
if got.Source != domain.ModerationSourceEphemeral ||
|
||||||
|
got.Target != message.Peer || len(got.Items) != 1 ||
|
||||||
|
got.Items[0].AuthorUserID != message.SenderUserID {
|
||||||
|
t.Fatalf("migrated report=%+v", got)
|
||||||
|
}
|
||||||
|
if len(got.MediaHolds) != 1 ||
|
||||||
|
got.MediaHolds[0].StorageKey != "doc:909" {
|
||||||
|
t.Fatalf("media holds=%+v", got.MediaHolds)
|
||||||
|
}
|
||||||
|
}
|
||||||
102
internal/app/moderation/registry_reports.go
Normal file
102
internal/app/moderation/registry_reports.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
package moderation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) SponsoredImpression(ctx context.Context, userID int64, randomID []byte, now time.Time) (domain.SponsoredMessageImpression, error) {
|
||||||
|
if s == nil || s.registry == nil || len(randomID) == 0 {
|
||||||
|
return domain.SponsoredMessageImpression{}, domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
impression, found, err := s.registry.GetSponsoredMessageImpression(
|
||||||
|
ctx, userID, sha256.Sum256(randomID), now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.SponsoredMessageImpression{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.SponsoredMessageImpression{}, domain.ErrModerationImpressionExpired
|
||||||
|
}
|
||||||
|
return impression, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReportSponsored(ctx context.Context, userID int64, randomID []byte, reason domain.ModerationReason, option string, now time.Time) (domain.ModerationReport, bool, error) {
|
||||||
|
impression, err := s.SponsoredImpression(ctx, userID, randomID, now)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
if impression.ReportID > 0 {
|
||||||
|
report, found, err := s.Report(ctx, impression.ReportID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
|
||||||
|
}
|
||||||
|
return report, false, nil
|
||||||
|
}
|
||||||
|
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: userID, Source: domain.ModerationSourceSponsored,
|
||||||
|
Target: impression.Target, Reason: reason, Option: option,
|
||||||
|
Items: []domain.ModerationReportItem{{
|
||||||
|
Kind: domain.ModerationItemSponsored, Peer: impression.Target,
|
||||||
|
ItemID: impression.ID, AuthorUserID: impression.AuthorUserID,
|
||||||
|
EvidenceSchemaVersion: impression.EvidenceSchemaVersion,
|
||||||
|
Evidence: impression.Evidence,
|
||||||
|
}},
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
return s.registry.CreateSponsoredModerationReport(ctx, impression.ID, report)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReportAntiSpamFalsePositive(ctx context.Context, reporterUserID, channelID int64, messageID int, now time.Time) (domain.ModerationReport, bool, error) {
|
||||||
|
if s == nil || s.registry == nil {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
decision, found, err := s.registry.GetChannelAntiSpamDecision(
|
||||||
|
ctx, channelID, messageID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationEvidenceNotFound
|
||||||
|
}
|
||||||
|
if decision.ReportID > 0 {
|
||||||
|
report, found, err := s.Report(ctx, decision.ReportID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationReportNotFound
|
||||||
|
}
|
||||||
|
return report, false, nil
|
||||||
|
}
|
||||||
|
target := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
|
||||||
|
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: reporterUserID,
|
||||||
|
Source: domain.ModerationSourceAntiSpamFalsePositive,
|
||||||
|
Target: target,
|
||||||
|
Reason: domain.ModerationReasonOther,
|
||||||
|
Option: "false_positive",
|
||||||
|
Items: []domain.ModerationReportItem{{
|
||||||
|
Kind: domain.ModerationItemAntiSpamDecision, Peer: target,
|
||||||
|
ItemID: decision.ID, SecondaryID: int64(messageID),
|
||||||
|
AuthorUserID: decision.AuthorUserID,
|
||||||
|
EvidenceSchemaVersion: decision.EvidenceSchemaVersion,
|
||||||
|
Evidence: decision.Evidence,
|
||||||
|
}},
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
return s.registry.CreateAntiSpamFalsePositiveReport(ctx, decision.ID, report)
|
||||||
|
}
|
||||||
98
internal/app/moderation/registry_reports_test.go
Normal file
98
internal/app/moderation/registry_reports_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package moderation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSponsoredReportRequiresIssuedImpressionAndLinksAtomically(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Unix(1_750_000_000, 0).UTC()
|
||||||
|
store := memory.NewModerationReportStore()
|
||||||
|
service := NewService(store)
|
||||||
|
randomID := []byte("server-issued-random-id")
|
||||||
|
if _, _, err := service.ReportSponsored(
|
||||||
|
ctx, 11, randomID, domain.ModerationReasonSpam, "spam", now,
|
||||||
|
); !errors.Is(err, domain.ErrModerationImpressionExpired) {
|
||||||
|
t.Fatalf("unseen impression err=%v", err)
|
||||||
|
}
|
||||||
|
impression, err := domain.NewSponsoredMessageImpression(
|
||||||
|
11, randomID, domain.Peer{Type: domain.PeerTypeChannel, ID: 22},
|
||||||
|
33, []byte(`{"author_id":33,"creative_id":"creative-1"}`),
|
||||||
|
now, now.Add(time.Hour),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
impression, created, err := store.CreateSponsoredMessageImpression(ctx, impression)
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("impression=%+v created=%v err=%v", impression, created, err)
|
||||||
|
}
|
||||||
|
report, created, err := service.ReportSponsored(
|
||||||
|
ctx, 11, randomID, domain.ModerationReasonSpam, "spam", now.Add(time.Second),
|
||||||
|
)
|
||||||
|
if err != nil || !created || report.ID <= 0 {
|
||||||
|
t.Fatalf("report=%+v created=%v err=%v", report, created, err)
|
||||||
|
}
|
||||||
|
retry, created, err := service.ReportSponsored(
|
||||||
|
ctx, 11, randomID, domain.ModerationReasonFake, "fake", now.Add(2*time.Second),
|
||||||
|
)
|
||||||
|
if err != nil || created || retry.ID != report.ID {
|
||||||
|
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||||
|
}
|
||||||
|
if reports := store.Reports(); len(reports) != 1 ||
|
||||||
|
reports[0].Items[0].EvidenceHash != impression.EvidenceHash {
|
||||||
|
t.Fatalf("reports=%+v", reports)
|
||||||
|
}
|
||||||
|
if _, _, err := service.ReportSponsored(
|
||||||
|
ctx, 11, []byte("expired"),
|
||||||
|
domain.ModerationReasonSpam, "spam", now.Add(2*time.Hour),
|
||||||
|
); !errors.Is(err, domain.ErrModerationImpressionExpired) {
|
||||||
|
t.Fatalf("expired/unseen err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntiSpamFalsePositiveRequiresNativeDecisionAndIsIdempotent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Unix(1_750_000_000, 0).UTC()
|
||||||
|
store := memory.NewModerationReportStore()
|
||||||
|
service := NewService(store)
|
||||||
|
if _, _, err := service.ReportAntiSpamFalsePositive(
|
||||||
|
ctx, 11, 22, 33, now,
|
||||||
|
); !errors.Is(err, domain.ErrModerationEvidenceNotFound) {
|
||||||
|
t.Fatalf("missing decision err=%v", err)
|
||||||
|
}
|
||||||
|
decision, err := domain.NewChannelAntiSpamDecision(
|
||||||
|
22, 33, 44,
|
||||||
|
[]byte(`{"engine":"native-v1","score":0.99}`), now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
decision, created, err := store.CreateChannelAntiSpamDecision(ctx, decision)
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("decision=%+v created=%v err=%v", decision, created, err)
|
||||||
|
}
|
||||||
|
report, created, err := service.ReportAntiSpamFalsePositive(
|
||||||
|
ctx, 11, 22, 33, now.Add(time.Second),
|
||||||
|
)
|
||||||
|
if err != nil || !created || report.ID <= 0 {
|
||||||
|
t.Fatalf("report=%+v created=%v err=%v", report, created, err)
|
||||||
|
}
|
||||||
|
retry, created, err := service.ReportAntiSpamFalsePositive(
|
||||||
|
ctx, 11, 22, 33, now.Add(2*time.Second),
|
||||||
|
)
|
||||||
|
if err != nil || created || retry.ID != report.ID {
|
||||||
|
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||||
|
}
|
||||||
|
if reports := store.Reports(); len(reports) != 1 ||
|
||||||
|
reports[0].Items[0].EvidenceHash != decision.EvidenceHash ||
|
||||||
|
reports[0].Items[0].SecondaryID != 33 {
|
||||||
|
t.Fatalf("reports=%+v", reports)
|
||||||
|
}
|
||||||
|
}
|
||||||
89
internal/app/moderation/service.go
Normal file
89
internal/app/moderation/service.go
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
package moderation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service owns moderation submission invariants. RPC handlers provide
|
||||||
|
// domain-only snapshots; the service canonicalizes and persists them before a
|
||||||
|
// client may observe a successful report response.
|
||||||
|
type Service struct {
|
||||||
|
reports store.ModerationReportStore
|
||||||
|
cases store.ModerationCaseStore
|
||||||
|
registry store.ModerationEvidenceRegistryStore
|
||||||
|
privateMessages privateMessageReader
|
||||||
|
channelMessages channelMessageReader
|
||||||
|
stories storyReader
|
||||||
|
users userReader
|
||||||
|
channels channelPeerReader
|
||||||
|
photos profilePhotoReader
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(*Service)
|
||||||
|
|
||||||
|
func WithMessageReaders(private privateMessageReader, channels channelMessageReader) Option {
|
||||||
|
return func(service *Service) {
|
||||||
|
service.privateMessages = private
|
||||||
|
service.channelMessages = channels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithStoryReader(stories storyReader) Option {
|
||||||
|
return func(service *Service) {
|
||||||
|
service.stories = stories
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithPeerReaders(users userReader, channels channelPeerReader) Option {
|
||||||
|
return func(service *Service) {
|
||||||
|
service.users = users
|
||||||
|
service.channels = channels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithProfilePhotoReader(photos profilePhotoReader) Option {
|
||||||
|
return func(service *Service) {
|
||||||
|
service.photos = photos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(reports store.ModerationReportStore, opts ...Option) *Service {
|
||||||
|
service := &Service{reports: reports}
|
||||||
|
if cases, ok := reports.(store.ModerationCaseStore); ok {
|
||||||
|
service.cases = cases
|
||||||
|
}
|
||||||
|
if registry, ok := reports.(store.ModerationEvidenceRegistryStore); ok {
|
||||||
|
service.registry = registry
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
if opt != nil {
|
||||||
|
opt(service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AcceptReport(ctx context.Context, draft domain.ModerationReportDraft) (domain.ModerationReport, bool, error) {
|
||||||
|
if s == nil || s.reports == nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
|
||||||
|
}
|
||||||
|
report, err := domain.NewModerationReport(draft)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ModerationReport{}, false, err
|
||||||
|
}
|
||||||
|
return s.reports.CreateModerationReport(ctx, report)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Report(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error) {
|
||||||
|
if s == nil || s.reports == nil {
|
||||||
|
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
|
||||||
|
}
|
||||||
|
if reportID <= 0 {
|
||||||
|
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
return s.reports.GetModerationReport(ctx, reportID)
|
||||||
|
}
|
||||||
36
internal/app/moderation/service_test.go
Normal file
36
internal/app/moderation/service_test.go
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
package moderation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAcceptReportReturnsDurableRetry(t *testing.T) {
|
||||||
|
reports := memory.NewModerationReportStore()
|
||||||
|
service := NewService(reports)
|
||||||
|
draft := domain.ModerationReportDraft{
|
||||||
|
ReporterUserID: 100, Source: domain.ModerationSourceMessagesSpam,
|
||||||
|
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 200},
|
||||||
|
Reason: domain.ModerationReasonSpam, Option: "v1/spam",
|
||||||
|
Items: []domain.ModerationReportItem{{
|
||||||
|
Kind: domain.ModerationItemPeer,
|
||||||
|
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 200},
|
||||||
|
ItemID: 200, AuthorUserID: 200, EvidenceSchemaVersion: 1,
|
||||||
|
Evidence: []byte(`{"snapshot":"peer"}`),
|
||||||
|
}},
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
first, created, err := service.AcceptReport(context.Background(), draft)
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("first created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
draft.CreatedAt = draft.CreatedAt.Add(time.Minute)
|
||||||
|
retry, created, err := service.AcceptReport(context.Background(), draft)
|
||||||
|
if err != nil || created || retry.ID != first.ID {
|
||||||
|
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -87,6 +87,34 @@ func (c *CachedPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.P
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *CachedPrivacyStore) SetPrivacyRulesWithUpdate(
|
||||||
|
ctx context.Context,
|
||||||
|
rules domain.PrivacyRules,
|
||||||
|
event domain.UpdateEvent,
|
||||||
|
excludeAuthKeyID [8]byte,
|
||||||
|
excludeSessionID int64,
|
||||||
|
) (domain.UpdateEvent, error) {
|
||||||
|
writer, ok := c.inner.(store.PrivacyUpdateStore)
|
||||||
|
if !ok {
|
||||||
|
return domain.UpdateEvent{}, domain.ErrPrivacyRuleInvalid
|
||||||
|
}
|
||||||
|
recorded, err := writer.SetPrivacyRulesWithUpdate(ctx, rules, event, excludeAuthKeyID, excludeSessionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.UpdateEvent{}, err
|
||||||
|
}
|
||||||
|
c.InvalidateOwners(rules.OwnerUserID)
|
||||||
|
_ = c.WarmOwners(ctx, rules.OwnerUserID)
|
||||||
|
return recorded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedPrivacyStore) SupportsDurablePrivacyUpdates() bool {
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
capability, ok := c.inner.(interface{ SupportsDurablePrivacyUpdates() bool })
|
||||||
|
return ok && capability.SupportsDurablePrivacyUpdates()
|
||||||
|
}
|
||||||
|
|
||||||
// WarmOwners 在低频写/变更通知路径一次性装入 owner 的完整规则集。调用方必须先
|
// WarmOwners 在低频写/变更通知路径一次性装入 owner 的完整规则集。调用方必须先
|
||||||
// InvalidateOwners;epoch 保证预热期间若又发生失效,不会把旧快照写回。
|
// InvalidateOwners;epoch 保证预热期间若又发生失效,不会把旧快照写回。
|
||||||
func (c *CachedPrivacyStore) WarmOwners(ctx context.Context, ownerUserIDs ...int64) error {
|
func (c *CachedPrivacyStore) WarmOwners(ctx context.Context, ownerUserIDs ...int64) error {
|
||||||
|
|
@ -185,6 +213,40 @@ func (c *CachedPrivacyStore) FlushReadModelCache() {
|
||||||
c.cache.Flush()
|
c.cache.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvalidateOwners lets Service be registered as the single privacy read-model
|
||||||
|
// cache group: rule snapshots and relationship facts then share one invalidation
|
||||||
|
// lifecycle.
|
||||||
|
func (s *Service) InvalidateOwners(ids ...int64) {
|
||||||
|
if s == nil || s.rules == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cache, ok := s.rules.(interface{ InvalidateOwners(...int64) }); ok {
|
||||||
|
cache.InvalidateOwners(ids...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) WarmOwners(ctx context.Context, ids ...int64) error {
|
||||||
|
if s == nil || s.rules == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cache, ok := s.rules.(interface {
|
||||||
|
WarmOwners(context.Context, ...int64) error
|
||||||
|
}); ok {
|
||||||
|
return cache.WarmOwners(ctx, ids...)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) FlushReadModelCache() {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cache, ok := s.rules.(interface{ FlushReadModelCache() }); ok {
|
||||||
|
cache.FlushReadModelCache()
|
||||||
|
}
|
||||||
|
s.flushFactCaches()
|
||||||
|
}
|
||||||
|
|
||||||
// buildPrivacyRulesByOwner 把扁平规则按 owner 归组;每个 owner 都建一个条目(无规则即空 map),
|
// buildPrivacyRulesByOwner 把扁平规则按 owner 归组;每个 owner 都建一个条目(无规则即空 map),
|
||||||
// 这样「查过且无规则」的 owner 也被负缓存,不会反复打后端。
|
// 这样「查过且无规则」的 owner 也被负缓存,不会反复打后端。
|
||||||
func buildPrivacyRulesByOwner(list []domain.PrivacyRules, owners []int64) map[int64]privacyRulesMap {
|
func buildPrivacyRulesByOwner(list []domain.PrivacyRules, owners []int64) map[int64]privacyRulesMap {
|
||||||
|
|
|
||||||
245
internal/app/privacy/facts.go
Normal file
245
internal/app/privacy/facts.go
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
package privacy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/readmodelcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPrivacyViewerFactsTTL = 10 * time.Minute
|
||||||
|
defaultPrivacyMembershipTTL = 24 * time.Hour
|
||||||
|
|
||||||
|
privacyViewerFactsMaxEntries = 8192
|
||||||
|
privacyMembershipMaxEntries = 65536
|
||||||
|
)
|
||||||
|
|
||||||
|
// baseUserProvider returns viewer-independent user facts through the users read
|
||||||
|
// model. Implementations must batch cold misses rather than issue one query per
|
||||||
|
// user.
|
||||||
|
type baseUserProvider interface {
|
||||||
|
PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// channelMembershipProvider is the cold loader behind the bounded membership
|
||||||
|
// read model. Privacy evaluation never calls it for a warm (chat,user) pair.
|
||||||
|
type channelMembershipProvider interface {
|
||||||
|
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type viewerFacts struct {
|
||||||
|
Found bool
|
||||||
|
Bot bool
|
||||||
|
PremiumUntil int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type membershipKey struct {
|
||||||
|
ChatID int64
|
||||||
|
UserID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type evaluationNeeds struct {
|
||||||
|
viewerBase bool
|
||||||
|
chatIDs []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newViewerFactsCache() *readmodelcache.Cache[int64, viewerFacts] {
|
||||||
|
return readmodelcache.New[int64, viewerFacts](readmodelcache.Config[int64, viewerFacts]{
|
||||||
|
MaxEntries: privacyViewerFactsMaxEntries,
|
||||||
|
TTL: defaultPrivacyViewerFactsTTL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMembershipCache() *readmodelcache.Cache[membershipKey, bool] {
|
||||||
|
return readmodelcache.New[membershipKey, bool](readmodelcache.Config[membershipKey, bool]{
|
||||||
|
MaxEntries: privacyMembershipMaxEntries,
|
||||||
|
TTL: defaultPrivacyMembershipTTL,
|
||||||
|
KeyString: func(key membershipKey) string {
|
||||||
|
return strconv.FormatInt(key.ChatID, 10) + ":" + strconv.FormatInt(key.UserID, 10)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsForRules(rules domain.PrivacyRules) evaluationNeeds {
|
||||||
|
var needs evaluationNeeds
|
||||||
|
seenChats := make(map[int64]struct{})
|
||||||
|
for _, rule := range rules.Rules {
|
||||||
|
switch rule.Kind {
|
||||||
|
case domain.PrivacyRuleAllowPremium,
|
||||||
|
domain.PrivacyRuleAllowBots,
|
||||||
|
domain.PrivacyRuleDisallowBots:
|
||||||
|
needs.viewerBase = true
|
||||||
|
case domain.PrivacyRuleAllowChatParticipants,
|
||||||
|
domain.PrivacyRuleDisallowChatParticipants:
|
||||||
|
for _, chatID := range rule.ChatIDs {
|
||||||
|
if chatID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seenChats[chatID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenChats[chatID] = struct{}{}
|
||||||
|
needs.chatIDs = append(needs.chatIDs, chatID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return needs
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeNeeds(dst *evaluationNeeds, src evaluationNeeds) {
|
||||||
|
if src.viewerBase {
|
||||||
|
dst.viewerBase = true
|
||||||
|
}
|
||||||
|
if len(src.chatIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen := make(map[int64]struct{}, len(dst.chatIDs)+len(src.chatIDs))
|
||||||
|
for _, id := range dst.chatIDs {
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, id := range src.chatIDs {
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
dst.chatIDs = append(dst.chatIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadViewerFacts(ctx context.Context, viewerUserIDs []int64) (map[int64]viewerFacts, error) {
|
||||||
|
ids := dedupNonZero(viewerUserIDs)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return map[int64]viewerFacts{}, nil
|
||||||
|
}
|
||||||
|
loadMissing := func(ctx context.Context, missing []int64) (map[int64]viewerFacts, error) {
|
||||||
|
out := make(map[int64]viewerFacts, len(missing))
|
||||||
|
for _, id := range missing {
|
||||||
|
out[id] = viewerFacts{} // negative cache: user was not found.
|
||||||
|
}
|
||||||
|
if s == nil || s.baseUsers == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
users, err := s.baseUsers.PrivacyBaseUsers(ctx, missing)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
if user.ID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[user.ID] = viewerFacts{
|
||||||
|
Found: true,
|
||||||
|
Bot: user.Bot,
|
||||||
|
PremiumUntil: int64(user.PremiumUntil),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if s == nil || s.viewerFacts == nil {
|
||||||
|
return loadMissing(ctx, ids)
|
||||||
|
}
|
||||||
|
return s.viewerFacts.GetOrLoadBatch(ctx, ids,
|
||||||
|
func(int64) (int64, bool) { return 0, true },
|
||||||
|
loadMissing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadMembershipFacts(ctx context.Context, chatIDs, viewerUserIDs []int64) (map[membershipKey]bool, error) {
|
||||||
|
chats := dedupNonZero(chatIDs)
|
||||||
|
viewers := dedupNonZero(viewerUserIDs)
|
||||||
|
if len(chats) == 0 || len(viewers) == 0 {
|
||||||
|
return map[membershipKey]bool{}, nil
|
||||||
|
}
|
||||||
|
keys := make([]membershipKey, 0, len(chats)*len(viewers))
|
||||||
|
for _, chatID := range chats {
|
||||||
|
for _, viewerID := range viewers {
|
||||||
|
keys = append(keys, membershipKey{ChatID: chatID, UserID: viewerID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadMissing := func(ctx context.Context, missing []membershipKey) (map[membershipKey]bool, error) {
|
||||||
|
out := make(map[membershipKey]bool, len(missing))
|
||||||
|
byChat := make(map[int64][]int64)
|
||||||
|
for _, key := range missing {
|
||||||
|
out[key] = false // negative cache: not an active member.
|
||||||
|
byChat[key.ChatID] = append(byChat[key.ChatID], key.UserID)
|
||||||
|
}
|
||||||
|
if s == nil || s.memberships == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
for chatID, userIDs := range byChat {
|
||||||
|
active, err := s.memberships.FilterActiveChannelMemberIDs(ctx, chatID, userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, userID := range active {
|
||||||
|
out[membershipKey{ChatID: chatID, UserID: userID}] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if s == nil || s.membershipFacts == nil {
|
||||||
|
return loadMissing(ctx, keys)
|
||||||
|
}
|
||||||
|
return s.membershipFacts.GetOrLoadBatch(ctx, keys,
|
||||||
|
func(membershipKey) (int64, bool) { return 0, true },
|
||||||
|
loadMissing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyViewerFacts(ctx *domain.PrivacyContext, facts viewerFacts, now int64) {
|
||||||
|
if ctx == nil || !facts.Found {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.ViewerIsBot = facts.Bot
|
||||||
|
ctx.ViewerIsPremium = !facts.Bot && facts.PremiumUntil > now
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyMembershipFacts(ctx *domain.PrivacyContext, chatIDs []int64, facts map[membershipKey]bool) {
|
||||||
|
if ctx == nil || len(chatIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, chatID := range chatIDs {
|
||||||
|
if facts[membershipKey{ChatID: chatID, UserID: ctx.ViewerUserID}] {
|
||||||
|
ctx.SharedChatIDs = append(ctx.SharedChatIDs, chatID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateViewerFacts invalidates bot/premium facts after a user-base change.
|
||||||
|
func (s *Service) InvalidateViewerFacts(userIDs ...int64) {
|
||||||
|
if s == nil || s.viewerFacts == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.viewerFacts.Invalidate(dedupNonZero(userIDs)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateMembership invalidates one membership pair after a channel-member change.
|
||||||
|
func (s *Service) InvalidateMembership(channelID, userID int64) {
|
||||||
|
if s == nil || s.membershipFacts == nil || channelID == 0 || userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.membershipFacts.Invalidate(membershipKey{ChatID: channelID, UserID: userID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateChannelMemberships invalidates all cached pairs for a changed/deleted channel.
|
||||||
|
func (s *Service) InvalidateChannelMemberships(channelID int64) {
|
||||||
|
if s == nil || s.membershipFacts == nil || channelID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.membershipFacts.InvalidateWhere(func(key membershipKey) bool { return key.ChatID == channelID })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) flushFactCaches() {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.viewerFacts != nil {
|
||||||
|
s.viewerFacts.Flush()
|
||||||
|
}
|
||||||
|
if s.membershipFacts != nil {
|
||||||
|
s.membershipFacts.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,21 +3,49 @@ package privacy
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"slices"
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/readmodelcache"
|
||||||
"telesrv/internal/store"
|
"telesrv/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxPrivacyRules = 100
|
const (
|
||||||
|
maxPrivacyRules = 100
|
||||||
|
maxPrivacyRuleIDs = 5000
|
||||||
|
)
|
||||||
|
|
||||||
// Service owns account privacy rules and viewer-specific evaluation.
|
// Service owns account privacy rules and viewer-specific evaluation.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
rules store.PrivacyStore
|
rules store.PrivacyStore
|
||||||
contacts store.ContactStore
|
contacts store.ContactStore
|
||||||
|
baseUsers baseUserProvider
|
||||||
|
memberships channelMembershipProvider
|
||||||
|
viewerFacts *readmodelcache.Cache[int64, viewerFacts]
|
||||||
|
membershipFacts *readmodelcache.Cache[membershipKey, bool]
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(rules store.PrivacyStore, contacts store.ContactStore) *Service {
|
func NewService(rules store.PrivacyStore, contacts store.ContactStore) *Service {
|
||||||
return &Service{rules: rules, contacts: contacts}
|
return &Service{
|
||||||
|
rules: rules,
|
||||||
|
contacts: contacts,
|
||||||
|
viewerFacts: newViewerFactsCache(),
|
||||||
|
membershipFacts: newMembershipCache(),
|
||||||
|
now: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigureReadModels wires the cold loaders behind the bounded in-memory
|
||||||
|
// privacy fact caches. It is called after users/channels services are built to
|
||||||
|
// avoid a package dependency cycle.
|
||||||
|
func (s *Service) ConfigureReadModels(users baseUserProvider, memberships channelMembershipProvider) *Service {
|
||||||
|
if s == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s.baseUsers = users
|
||||||
|
s.memberships = memberships
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, error) {
|
func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, error) {
|
||||||
|
|
@ -43,6 +71,52 @@ func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.Pr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
|
func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
|
||||||
|
out, err := normalizedRules(ownerUserID, key, rules)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PrivacyRules{}, err
|
||||||
|
}
|
||||||
|
if s != nil && s.rules != nil {
|
||||||
|
if err := s.rules.SetPrivacyRules(ctx, out); err != nil {
|
||||||
|
return domain.PrivacyRules{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRulesWithUpdate uses the production atomic write boundary when available.
|
||||||
|
// durable=false means no write was attempted; the RPC layer may then use the
|
||||||
|
// ordinary SetRules + Updates.RecordPrivacy fallback used by memory tests.
|
||||||
|
func (s *Service) SetRulesWithUpdate(
|
||||||
|
ctx context.Context,
|
||||||
|
ownerUserID int64,
|
||||||
|
key domain.PrivacyKey,
|
||||||
|
rules []domain.PrivacyRule,
|
||||||
|
date int,
|
||||||
|
excludeAuthKeyID [8]byte,
|
||||||
|
excludeSessionID int64,
|
||||||
|
) (domain.PrivacyRules, domain.UpdateEvent, bool, error) {
|
||||||
|
out, err := normalizedRules(ownerUserID, key, rules)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, err
|
||||||
|
}
|
||||||
|
capability, ok := s.rules.(interface{ SupportsDurablePrivacyUpdates() bool })
|
||||||
|
if !ok || !capability.SupportsDurablePrivacyUpdates() {
|
||||||
|
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, nil
|
||||||
|
}
|
||||||
|
writer := s.rules.(store.PrivacyUpdateStore)
|
||||||
|
event, err := writer.SetPrivacyRulesWithUpdate(ctx, out, domain.UpdateEvent{
|
||||||
|
Type: domain.UpdateEventPrivacy,
|
||||||
|
Date: date,
|
||||||
|
Privacy: cloneRules(out),
|
||||||
|
PtsCount: 1,
|
||||||
|
}, excludeAuthKeyID, excludeSessionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, err
|
||||||
|
}
|
||||||
|
return out, event, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedRules(ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
|
||||||
if !ValidKey(key) {
|
if !ValidKey(key) {
|
||||||
return domain.PrivacyRules{}, domain.ErrPrivacyKeyInvalid
|
return domain.PrivacyRules{}, domain.ErrPrivacyKeyInvalid
|
||||||
}
|
}
|
||||||
|
|
@ -52,13 +126,7 @@ func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.Pr
|
||||||
if err := validateRules(rules); err != nil {
|
if err := validateRules(rules); err != nil {
|
||||||
return domain.PrivacyRules{}, err
|
return domain.PrivacyRules{}, err
|
||||||
}
|
}
|
||||||
out := domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}
|
return domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}, nil
|
||||||
if s != nil && s.rules != nil {
|
|
||||||
if err := s.rules.SetPrivacyRules(ctx, out); err != nil {
|
|
||||||
return domain.PrivacyRules{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error) {
|
func (s *Service) AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error) {
|
||||||
|
|
@ -96,17 +164,33 @@ func (s *Service) CanSee(ctx context.Context, ownerUserID, viewerUserID int64, k
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
needs := needsForRules(rules)
|
||||||
evalCtx := domain.PrivacyContext{
|
evalCtx := domain.PrivacyContext{
|
||||||
OwnerUserID: ownerUserID,
|
OwnerUserID: ownerUserID,
|
||||||
ViewerUserID: viewerUserID,
|
ViewerUserID: viewerUserID,
|
||||||
}
|
}
|
||||||
if s != nil && s.contacts != nil {
|
if s != nil && s.contacts != nil {
|
||||||
if _, found, err := s.contacts.Get(ctx, ownerUserID, viewerUserID); err != nil {
|
if contact, found, err := s.contacts.Get(ctx, ownerUserID, viewerUserID); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
} else if found {
|
} else if found {
|
||||||
evalCtx.ViewerIsContact = true
|
evalCtx.ViewerIsContact = true
|
||||||
|
evalCtx.ViewerCloseFriend = contact.CloseFriend
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if needs.viewerBase {
|
||||||
|
facts, err := s.loadViewerFacts(ctx, []int64{viewerUserID})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
applyViewerFacts(&evalCtx, facts[viewerUserID], s.now().Unix())
|
||||||
|
}
|
||||||
|
if len(needs.chatIDs) > 0 {
|
||||||
|
facts, err := s.loadMembershipFacts(ctx, needs.chatIDs, []int64{viewerUserID})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
applyMembershipFacts(&evalCtx, needs.chatIDs, facts)
|
||||||
|
}
|
||||||
return Evaluate(rules, evalCtx), nil
|
return Evaluate(rules, evalCtx), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,6 +267,16 @@ func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerU
|
||||||
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
|
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var needs evaluationNeeds
|
||||||
|
for _, owner := range owners {
|
||||||
|
for _, key := range keys {
|
||||||
|
rules, ok := rulesByOwner[owner][key]
|
||||||
|
if !ok {
|
||||||
|
rules = defaultRules(owner, key)
|
||||||
|
}
|
||||||
|
mergeNeeds(&needs, needsForRules(rules))
|
||||||
|
}
|
||||||
|
}
|
||||||
// 批量取「viewer 是否在 owner 的联系人里」(owner→viewer 方向,对应 CanSee 的
|
// 批量取「viewer 是否在 owner 的联系人里」(owner→viewer 方向,对应 CanSee 的
|
||||||
// contacts.Get(owner, viewer))。
|
// contacts.Get(owner, viewer))。
|
||||||
var reverse map[int64]domain.Contact
|
var reverse map[int64]domain.Contact
|
||||||
|
|
@ -193,25 +287,82 @@ func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerU
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var baseFacts map[int64]viewerFacts
|
||||||
|
if needs.viewerBase {
|
||||||
|
var err error
|
||||||
|
baseFacts, err = s.loadViewerFacts(ctx, []int64{viewerUserID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var membershipFacts map[membershipKey]bool
|
||||||
|
if len(needs.chatIDs) > 0 {
|
||||||
|
var err error
|
||||||
|
membershipFacts, err = s.loadMembershipFacts(ctx, needs.chatIDs, []int64{viewerUserID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := s.now().Unix()
|
||||||
for _, owner := range owners {
|
for _, owner := range owners {
|
||||||
_, isContact := reverse[owner]
|
contact, isContact := reverse[owner]
|
||||||
m := make(map[domain.PrivacyKey]bool, len(keys))
|
m := make(map[domain.PrivacyKey]bool, len(keys))
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
rules, ok := rulesByOwner[owner][k]
|
rules, ok := rulesByOwner[owner][k]
|
||||||
if !ok {
|
if !ok {
|
||||||
rules = defaultRules(owner, k)
|
rules = defaultRules(owner, k)
|
||||||
}
|
}
|
||||||
m[k] = Evaluate(rules, domain.PrivacyContext{
|
evalCtx := domain.PrivacyContext{
|
||||||
OwnerUserID: owner,
|
OwnerUserID: owner,
|
||||||
ViewerUserID: viewerUserID,
|
ViewerUserID: viewerUserID,
|
||||||
ViewerIsContact: isContact,
|
ViewerIsContact: isContact,
|
||||||
})
|
ViewerCloseFriend: isContact && contact.CloseFriend,
|
||||||
|
}
|
||||||
|
applyViewerFacts(&evalCtx, baseFacts[viewerUserID], now)
|
||||||
|
applyMembershipFacts(&evalCtx, needs.chatIDs, membershipFacts)
|
||||||
|
m[k] = Evaluate(rules, evalCtx)
|
||||||
}
|
}
|
||||||
out[owner] = m
|
out[owner] = m
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanContactForFreeBatch evaluates the complete exception predicate for
|
||||||
|
// per-user contact requirements. Contacts are always free because the global
|
||||||
|
// setting is explicitly "noncontact peers"; privacyKeyNoPaidMessages adds
|
||||||
|
// exceptions beyond that relationship. Both facts come from the in-memory
|
||||||
|
// privacy/contact read models after their bounded cold loads.
|
||||||
|
func (s *Service) CanContactForFreeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64) (map[int64]bool, error) {
|
||||||
|
owners := dedupNonZero(ownerUserIDs)
|
||||||
|
out := make(map[int64]bool, len(owners))
|
||||||
|
if viewerUserID == 0 || len(owners) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
visibility, err := s.CanSeeBatch(
|
||||||
|
ctx,
|
||||||
|
owners,
|
||||||
|
viewerUserID,
|
||||||
|
[]domain.PrivacyKey{domain.PrivacyKeyNoPaidMessages},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var contacts map[int64]domain.Contact
|
||||||
|
if s != nil && s.contacts != nil {
|
||||||
|
contacts, err = s.contacts.GetReverseContacts(ctx, viewerUserID, owners)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ownerUserID := range owners {
|
||||||
|
_, isContact := contacts[ownerUserID]
|
||||||
|
out[ownerUserID] = ownerUserID == viewerUserID ||
|
||||||
|
isContact ||
|
||||||
|
visibility[ownerUserID][domain.PrivacyKeyNoPaidMessages]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CanSeeMatrix 批量评估 owners × viewers × keys 的可见性矩阵,结果等价于逐 (owner,viewer,key)
|
// CanSeeMatrix 批量评估 owners × viewers × keys 的可见性矩阵,结果等价于逐 (owner,viewer,key)
|
||||||
// 调 CanSee,但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate
|
// 调 CanSee,但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate
|
||||||
// (把 fan-out 投影从 O(viewer) 次 privacy 查询降到 O(owner))。返回 map[owner]map[viewer]map[key]bool。
|
// (把 fan-out 投影从 O(viewer) 次 privacy 查询降到 O(owner))。返回 map[owner]map[viewer]map[key]bool。
|
||||||
|
|
@ -249,6 +400,33 @@ func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs
|
||||||
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
|
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var needs evaluationNeeds
|
||||||
|
for _, owner := range owners {
|
||||||
|
for _, key := range keys {
|
||||||
|
rules, ok := rulesByOwner[owner][key]
|
||||||
|
if !ok {
|
||||||
|
rules = defaultRules(owner, key)
|
||||||
|
}
|
||||||
|
mergeNeeds(&needs, needsForRules(rules))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var baseFacts map[int64]viewerFacts
|
||||||
|
if needs.viewerBase {
|
||||||
|
var err error
|
||||||
|
baseFacts, err = s.loadViewerFacts(ctx, viewers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var membershipFacts map[membershipKey]bool
|
||||||
|
if len(needs.chatIDs) > 0 {
|
||||||
|
var err error
|
||||||
|
membershipFacts, err = s.loadMembershipFacts(ctx, needs.chatIDs, viewers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := s.now().Unix()
|
||||||
for _, owner := range owners {
|
for _, owner := range owners {
|
||||||
// owner 的联系人中哪些是本批 viewer(= privacy 的 ViewerIsContact,对应 contacts.Get(owner,viewer))。
|
// owner 的联系人中哪些是本批 viewer(= privacy 的 ViewerIsContact,对应 contacts.Get(owner,viewer))。
|
||||||
var ownerContacts map[int64]domain.Contact
|
var ownerContacts map[int64]domain.Contact
|
||||||
|
|
@ -269,17 +447,21 @@ func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs
|
||||||
perViewer[viewer] = m
|
perViewer[viewer] = m
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_, isContact := ownerContacts[viewer]
|
contact, isContact := ownerContacts[viewer]
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
rules, ok := rulesByOwner[owner][k]
|
rules, ok := rulesByOwner[owner][k]
|
||||||
if !ok {
|
if !ok {
|
||||||
rules = defaultRules(owner, k)
|
rules = defaultRules(owner, k)
|
||||||
}
|
}
|
||||||
m[k] = Evaluate(rules, domain.PrivacyContext{
|
evalCtx := domain.PrivacyContext{
|
||||||
OwnerUserID: owner,
|
OwnerUserID: owner,
|
||||||
ViewerUserID: viewer,
|
ViewerUserID: viewer,
|
||||||
ViewerIsContact: isContact,
|
ViewerIsContact: isContact,
|
||||||
})
|
ViewerCloseFriend: isContact && contact.CloseFriend,
|
||||||
|
}
|
||||||
|
applyViewerFacts(&evalCtx, baseFacts[viewer], now)
|
||||||
|
applyMembershipFacts(&evalCtx, needs.chatIDs, membershipFacts)
|
||||||
|
m[k] = Evaluate(rules, evalCtx)
|
||||||
}
|
}
|
||||||
perViewer[viewer] = m
|
perViewer[viewer] = m
|
||||||
}
|
}
|
||||||
|
|
@ -370,6 +552,7 @@ func validateRules(rules []domain.PrivacyRule) error {
|
||||||
if len(rules) > maxPrivacyRules {
|
if len(rules) > maxPrivacyRules {
|
||||||
return domain.ErrPrivacyRuleInvalid
|
return domain.ErrPrivacyRuleInvalid
|
||||||
}
|
}
|
||||||
|
totalIDs := 0
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
switch rule.Kind {
|
switch rule.Kind {
|
||||||
case domain.PrivacyRuleAllowContacts,
|
case domain.PrivacyRuleAllowContacts,
|
||||||
|
|
@ -387,6 +570,20 @@ func validateRules(rules []domain.PrivacyRule) error {
|
||||||
default:
|
default:
|
||||||
return domain.ErrPrivacyRuleInvalid
|
return domain.ErrPrivacyRuleInvalid
|
||||||
}
|
}
|
||||||
|
totalIDs += len(rule.UserIDs) + len(rule.ChatIDs)
|
||||||
|
if totalIDs > maxPrivacyRuleIDs {
|
||||||
|
return domain.ErrPrivacyRuleInvalid
|
||||||
|
}
|
||||||
|
for _, id := range rule.UserIDs {
|
||||||
|
if id <= 0 {
|
||||||
|
return domain.ErrPrivacyRuleInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range rule.ChatIDs {
|
||||||
|
if id <= 0 {
|
||||||
|
return domain.ErrPrivacyRuleInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -607,6 +607,19 @@ func (s *Service) RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]b
|
||||||
}, true, excludeSessionID)
|
}, true, excludeSessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordPrivacy durably synchronizes the exact immutable privacy snapshot to
|
||||||
|
// the account's other sessions and offline difference stream.
|
||||||
|
func (s *Service) RecordPrivacy(ctx context.Context, stateAuthKeyID [8]byte, userID int64, rules domain.PrivacyRules, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
|
||||||
|
if rules.OwnerUserID != userID || rules.Key == "" || len(rules.Rules) == 0 {
|
||||||
|
return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrPrivacyRuleInvalid
|
||||||
|
}
|
||||||
|
return s.recordEvent(ctx, stateAuthKeyID, excludeAuthKeyID, userID, domain.UpdateEvent{
|
||||||
|
Type: domain.UpdateEventPrivacy,
|
||||||
|
Privacy: rules,
|
||||||
|
PtsCount: 1,
|
||||||
|
}, true, excludeSessionID)
|
||||||
|
}
|
||||||
|
|
||||||
// RecordDraftMessage 记录某会话云草稿变化(保存/清空都是同一事件——草稿是绝对
|
// RecordDraftMessage 记录某会话云草稿变化(保存/清空都是同一事件——草稿是绝对
|
||||||
// 状态,重放时按 peer 重载当前值)。updateDraftMessage 无 pts 字段,走 LacksWirePts
|
// 状态,重放时按 peer 重载当前值)。updateDraftMessage 无 pts 字段,走 LacksWirePts
|
||||||
// aux 簿记;topMsgID 是 forum 话题草稿键(复用 MaxID 列持久化)。
|
// aux 簿记;topMsgID 是 forum 话题草稿键(复用 MaxID 列持久化)。
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package userprojection
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
|
|
@ -660,10 +661,8 @@ func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID in
|
||||||
return domain.User{}, err
|
return domain.User{}, err
|
||||||
}
|
}
|
||||||
if !statusAllowed {
|
if !statusAllowed {
|
||||||
|
user.Status = domain.ApproximateUserStatus(user.LastSeenAt, int(time.Now().Unix()))
|
||||||
user.LastSeenAt = 0
|
user.LastSeenAt = 0
|
||||||
if user.Status.Kind == domain.UserStatusOnline || user.Status.Kind == domain.UserStatusOffline {
|
|
||||||
user.Status = domain.UserStatus{Kind: domain.UserStatusRecently}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if ref, ok := personalRefs[user.ID]; ok && ref.PhotoID != 0 {
|
if ref, ok := personalRefs[user.ID]; ok && ref.PhotoID != 0 {
|
||||||
ref.Personal = true
|
ref.Personal = true
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,14 @@ func (s *Service) AdminUser(ctx context.Context, userID int64) (domain.User, boo
|
||||||
return s.loadBaseUserByID(ctx, userID)
|
return s.loadBaseUserByID(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrivacyBaseUsers returns viewer-independent bot/premium facts through the
|
||||||
|
// shared base-user read model. Privacy uses this as a batched cold loader behind
|
||||||
|
// its bounded process cache; no viewer projection is performed, avoiding a
|
||||||
|
// privacy -> users -> privacy recursion.
|
||||||
|
func (s *Service) PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error) {
|
||||||
|
return s.loadBaseUsersByIDs(ctx, userIDs)
|
||||||
|
}
|
||||||
|
|
||||||
// ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。
|
// ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。
|
||||||
func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
|
func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
|
||||||
if currentUserID == 0 {
|
if currentUserID == 0 {
|
||||||
|
|
|
||||||
|
|
@ -90,14 +90,9 @@ type Config struct {
|
||||||
PublicWebBaseURL string
|
PublicWebBaseURL string
|
||||||
// PublicAppName 是公开落地页展示的产品名,不参与协议路由。
|
// PublicAppName 是公开落地页展示的产品名,不参与协议路由。
|
||||||
PublicAppName string
|
PublicAppName string
|
||||||
// ScamWarning / FakeWarning override the profile warning text injected into
|
|
||||||
// getFullUser/getFullChannel About for scam/fake peers. Empty keeps the
|
|
||||||
// built-in per-peer-type English defaults. Clients cannot localize
|
|
||||||
// server-provided text, so operators set these to their audience language.
|
|
||||||
ScamWarning string
|
|
||||||
FakeWarning string
|
|
||||||
// PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。
|
// PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。
|
||||||
// 生产应只监听 loopback,并由 nginx 将 /<username>、/addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。
|
// 生产应只监听 loopback,并由 nginx 将 /<username>、/addstickers/、/addemoji/、
|
||||||
|
// /addlist/ 与 hash-only /appeal/ 路由反代到该地址。
|
||||||
PublicLinkWebAddr string
|
PublicLinkWebAddr string
|
||||||
// TelegramLoginEnabled mounts the self-hosted Telegram Login/OIDC provider
|
// TelegramLoginEnabled mounts the self-hosted Telegram Login/OIDC provider
|
||||||
// on PublicLinkWebAddr. Secrets are file-backed so they are not exposed in
|
// on PublicLinkWebAddr. Secrets are file-backed so they are not exposed in
|
||||||
|
|
@ -504,8 +499,6 @@ func Load() (Config, error) {
|
||||||
PublicAppLinkBase: publicAppLinkBase,
|
PublicAppLinkBase: publicAppLinkBase,
|
||||||
PublicWebBaseURL: publicWebBaseURL,
|
PublicWebBaseURL: publicWebBaseURL,
|
||||||
PublicAppName: publicAppName,
|
PublicAppName: publicAppName,
|
||||||
ScamWarning: envAllowEmptyOr("TELESRV_SCAM_WARNING", ""),
|
|
||||||
FakeWarning: envAllowEmptyOr("TELESRV_FAKE_WARNING", ""),
|
|
||||||
PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""),
|
PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""),
|
||||||
TelegramLoginEnabled: envBoolOr("TELESRV_TELEGRAM_LOGIN_ENABLE", false),
|
TelegramLoginEnabled: envBoolOr("TELESRV_TELEGRAM_LOGIN_ENABLE", false),
|
||||||
TelegramLoginIssuer: strings.TrimSuffix(envOr("TELESRV_TELEGRAM_LOGIN_ISSUER", publicBaseURL), "/"),
|
TelegramLoginIssuer: strings.TrimSuffix(envOr("TELESRV_TELEGRAM_LOGIN_ISSUER", publicBaseURL), "/"),
|
||||||
|
|
|
||||||
114
internal/domain/auth_delivery_report.go
Normal file
114
internal/domain/auth_delivery_report.go
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxAuthDeliveryMNCBytes = 8
|
||||||
|
MaxAuthDeliveryClientTypeBytes = 32
|
||||||
|
MaxAuthDeliveryIDBytes = 128
|
||||||
|
MaxAuthDeliveryReportsPerHour = 10
|
||||||
|
MaxAuthDeliveryReportsPerPhoneDay = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAuthDeliveryReportInvalid = errors.New("auth delivery report invalid")
|
||||||
|
ErrAuthDeliveryRateLimited = errors.New("auth delivery report rate limited")
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthDeliveryReport is operational delivery telemetry, not an abuse report.
|
||||||
|
// It deliberately stores only hashes of the phone and phone_code_hash and
|
||||||
|
// never stores the authentication code.
|
||||||
|
type AuthDeliveryReport struct {
|
||||||
|
ID int64
|
||||||
|
AuthKeyID [8]byte
|
||||||
|
SessionID int64
|
||||||
|
ClientType string
|
||||||
|
PhoneHash [sha256.Size]byte
|
||||||
|
CodeHash [sha256.Size]byte
|
||||||
|
IssuedUserID int64
|
||||||
|
DeliveryID string
|
||||||
|
Channel AuthCodeDeliveryKind
|
||||||
|
MNC string
|
||||||
|
Fingerprint [sha256.Size]byte
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthMissingCodeReportRequest struct {
|
||||||
|
AuthKeyID [8]byte
|
||||||
|
SessionID int64
|
||||||
|
ClientType string
|
||||||
|
Phone string
|
||||||
|
PhoneCodeHash string
|
||||||
|
MNC string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthDeliveryReport(authKeyID [8]byte, sessionID int64, clientType, phone, phoneCodeHash string, issuedUserID int64, deliveryID string, channel AuthCodeDeliveryKind, mnc string, createdAt time.Time) (AuthDeliveryReport, error) {
|
||||||
|
report := AuthDeliveryReport{
|
||||||
|
AuthKeyID: authKeyID, SessionID: sessionID, ClientType: clientType,
|
||||||
|
PhoneHash: sha256.Sum256([]byte(phone)), CodeHash: sha256.Sum256([]byte(phoneCodeHash)),
|
||||||
|
IssuedUserID: issuedUserID, DeliveryID: deliveryID,
|
||||||
|
Channel: channel, MNC: mnc, CreatedAt: createdAt,
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(struct {
|
||||||
|
Version int
|
||||||
|
AuthKeyID [8]byte
|
||||||
|
SessionID int64
|
||||||
|
PhoneHash [sha256.Size]byte
|
||||||
|
CodeHash [sha256.Size]byte
|
||||||
|
DeliveryID string
|
||||||
|
Channel AuthCodeDeliveryKind
|
||||||
|
MNC string
|
||||||
|
}{
|
||||||
|
Version: 1, AuthKeyID: authKeyID, SessionID: sessionID,
|
||||||
|
PhoneHash: report.PhoneHash, CodeHash: report.CodeHash,
|
||||||
|
DeliveryID: deliveryID, Channel: channel, MNC: mnc,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return AuthDeliveryReport{}, ErrAuthDeliveryReportInvalid
|
||||||
|
}
|
||||||
|
report.Fingerprint = sha256.Sum256(raw)
|
||||||
|
if err := report.Validate(); err != nil {
|
||||||
|
return AuthDeliveryReport{}, err
|
||||||
|
}
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r AuthDeliveryReport) Validate() error {
|
||||||
|
if r.ID < 0 || r.AuthKeyID == ([8]byte{}) || r.SessionID == 0 ||
|
||||||
|
r.PhoneHash == ([sha256.Size]byte{}) || r.CodeHash == ([sha256.Size]byte{}) ||
|
||||||
|
r.Fingerprint == ([sha256.Size]byte{}) || r.IssuedUserID < 0 ||
|
||||||
|
len(r.ClientType) > MaxAuthDeliveryClientTypeBytes || !utf8.ValidString(r.ClientType) ||
|
||||||
|
len(r.DeliveryID) > MaxAuthDeliveryIDBytes || !utf8.ValidString(r.DeliveryID) ||
|
||||||
|
!validAuthDeliveryChannel(r.Channel) || !validMNC(r.MNC) || r.CreatedAt.IsZero() {
|
||||||
|
return ErrAuthDeliveryReportInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validAuthDeliveryChannel(channel AuthCodeDeliveryKind) bool {
|
||||||
|
switch channel {
|
||||||
|
case AuthCodeDeliveryPhone, AuthCodeDeliverySMS:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validMNC(mnc string) bool {
|
||||||
|
if len(mnc) > MaxAuthDeliveryMNCBytes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range mnc {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
@ -508,6 +508,21 @@ type ChannelMember struct {
|
||||||
Guest bool
|
Guest bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanInviteUsers reports whether this active member may directly add users.
|
||||||
|
// Keep this predicate aligned with both memory/postgres write boundaries so an
|
||||||
|
// RPC cannot expose another user's privacy decision before authorizing the
|
||||||
|
// actor.
|
||||||
|
func (m ChannelMember) CanInviteUsers(channel Channel) bool {
|
||||||
|
if m.Status != ChannelMemberActive {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if m.Role == ChannelRoleCreator ||
|
||||||
|
(m.Role == ChannelRoleAdmin && (m.AdminRights.InviteUsers || m.AdminRights.ChangeInfo)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return channel.Megagroup && !channel.DefaultBannedRights.InviteUsers && !m.BannedRights.InviteUsers
|
||||||
|
}
|
||||||
|
|
||||||
// CanManageDirectMessages reports whether this active parent-channel member may
|
// CanManageDirectMessages reports whether this active parent-channel member may
|
||||||
// see and address every subscriber topic in the linked direct-messages
|
// see and address every subscriber topic in the linked direct-messages
|
||||||
// monoforum. Telegram deliberately does not grant this capability to an
|
// monoforum. Telegram deliberately does not grant this capability to an
|
||||||
|
|
@ -869,6 +884,22 @@ type ChannelMessageReactionsList struct {
|
||||||
NextOffset string
|
NextOffset string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChannelMessageReactionLookupRequest is the bounded exact lookup used by
|
||||||
|
// moderation evidence capture. It avoids paging an arbitrarily large reactor
|
||||||
|
// list merely to prove that one named participant reacted.
|
||||||
|
type ChannelMessageReactionLookupRequest struct {
|
||||||
|
ViewerUserID int64
|
||||||
|
ChannelID int64
|
||||||
|
MessageID int
|
||||||
|
ReactorUserID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChannelMessageReactionLookup struct {
|
||||||
|
Channel Channel
|
||||||
|
Message ChannelMessage
|
||||||
|
Reactions []ChannelMessagePeerReaction
|
||||||
|
}
|
||||||
|
|
||||||
// RecentMessageReaction is one account-level recently used message reaction.
|
// RecentMessageReaction is one account-level recently used message reaction.
|
||||||
type RecentMessageReaction struct {
|
type RecentMessageReaction struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
|
|
|
||||||
131
internal/domain/client_telemetry.go
Normal file
131
internal/domain/client_telemetry.go
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxClientTelemetrySubjects = 100
|
||||||
|
MaxClientTelemetryPayloadBytes = 64 << 10
|
||||||
|
MaxClientTelemetryEventsPerHour = 1000
|
||||||
|
MaxClientTelemetryEventsPerDay = 10000
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrClientTelemetryInvalid = errors.New("client telemetry invalid")
|
||||||
|
ErrClientTelemetryRateLimited = errors.New("client telemetry rate limited")
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClientTelemetryKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ClientTelemetryMessageDelivery ClientTelemetryKind = "message_delivery"
|
||||||
|
ClientTelemetryReadMetrics ClientTelemetryKind = "read_metrics"
|
||||||
|
ClientTelemetryMusicListen ClientTelemetryKind = "music_listen"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (k ClientTelemetryKind) Valid() bool {
|
||||||
|
switch k {
|
||||||
|
case ClientTelemetryMessageDelivery, ClientTelemetryReadMetrics,
|
||||||
|
ClientTelemetryMusicListen:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientTelemetryEvent is operational product telemetry. It is deliberately
|
||||||
|
// isolated from moderation reports/cases and has TTL-based retention.
|
||||||
|
type ClientTelemetryEvent struct {
|
||||||
|
ID int64
|
||||||
|
UserID int64
|
||||||
|
Kind ClientTelemetryKind
|
||||||
|
Peer Peer
|
||||||
|
SubjectIDs []int64
|
||||||
|
Payload json.RawMessage
|
||||||
|
Fingerprint [sha256.Size]byte
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClientTelemetryEvent(userID int64, kind ClientTelemetryKind, peer Peer, subjectIDs []int64, payload any, createdAt time.Time) (ClientTelemetryEvent, error) {
|
||||||
|
canonicalIDs := append([]int64(nil), subjectIDs...)
|
||||||
|
sort.Slice(canonicalIDs, func(i, j int) bool { return canonicalIDs[i] < canonicalIDs[j] })
|
||||||
|
for i, id := range canonicalIDs {
|
||||||
|
if id <= 0 || (i > 0 && canonicalIDs[i-1] == id) {
|
||||||
|
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
|
||||||
|
}
|
||||||
|
var object map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &object); err != nil || object == nil {
|
||||||
|
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
|
||||||
|
}
|
||||||
|
raw, err = json.Marshal(object)
|
||||||
|
if err != nil || len(raw) > MaxClientTelemetryPayloadBytes {
|
||||||
|
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
|
||||||
|
}
|
||||||
|
event := ClientTelemetryEvent{
|
||||||
|
UserID: userID, Kind: kind, Peer: peer,
|
||||||
|
SubjectIDs: canonicalIDs, Payload: raw, CreatedAt: createdAt.UTC(),
|
||||||
|
}
|
||||||
|
fingerprintInput, err := json.Marshal(struct {
|
||||||
|
Version int
|
||||||
|
UserID int64
|
||||||
|
Kind ClientTelemetryKind
|
||||||
|
Peer Peer
|
||||||
|
SubjectIDs []int64
|
||||||
|
Payload json.RawMessage
|
||||||
|
Minute int64
|
||||||
|
}{
|
||||||
|
Version: 1, UserID: event.UserID, Kind: event.Kind, Peer: event.Peer,
|
||||||
|
SubjectIDs: event.SubjectIDs, Payload: event.Payload,
|
||||||
|
Minute: event.CreatedAt.Truncate(time.Minute).Unix(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
|
||||||
|
}
|
||||||
|
event.Fingerprint = sha256.Sum256(fingerprintInput)
|
||||||
|
if err := event.Validate(); err != nil {
|
||||||
|
return ClientTelemetryEvent{}, err
|
||||||
|
}
|
||||||
|
return event, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ClientTelemetryEvent) Validate() error {
|
||||||
|
if e.ID < 0 || e.UserID <= 0 || !e.Kind.Valid() ||
|
||||||
|
len(e.SubjectIDs) == 0 ||
|
||||||
|
len(e.SubjectIDs) > MaxClientTelemetrySubjects ||
|
||||||
|
len(e.Payload) == 0 || len(e.Payload) > MaxClientTelemetryPayloadBytes ||
|
||||||
|
e.Fingerprint == ([sha256.Size]byte{}) || e.CreatedAt.IsZero() {
|
||||||
|
return ErrClientTelemetryInvalid
|
||||||
|
}
|
||||||
|
if e.Peer.ID == 0 {
|
||||||
|
if e.Peer.Type != "" || e.Kind != ClientTelemetryMusicListen {
|
||||||
|
return ErrClientTelemetryInvalid
|
||||||
|
}
|
||||||
|
} else if !moderationPeerValid(e.Peer) {
|
||||||
|
return ErrClientTelemetryInvalid
|
||||||
|
}
|
||||||
|
for i, id := range e.SubjectIDs {
|
||||||
|
if id <= 0 || (i > 0 && e.SubjectIDs[i-1] >= id) {
|
||||||
|
return ErrClientTelemetryInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var object map[string]any
|
||||||
|
if err := json.Unmarshal(e.Payload, &object); err != nil || object == nil {
|
||||||
|
return ErrClientTelemetryInvalid
|
||||||
|
}
|
||||||
|
canonical, err := json.Marshal(object)
|
||||||
|
if err != nil || !bytes.Equal(canonical, e.Payload) {
|
||||||
|
return ErrClientTelemetryInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
65
internal/domain/client_telemetry_test.go
Normal file
65
internal/domain/client_telemetry_test.go
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewClientTelemetryEventCanonicalizesSubjectsAndMinuteIdempotency(t *testing.T) {
|
||||||
|
now := time.Unix(1_750_000_000, 0).UTC().Truncate(time.Minute).Add(time.Second)
|
||||||
|
peer := Peer{Type: PeerTypeUser, ID: 22}
|
||||||
|
first, err := NewClientTelemetryEvent(
|
||||||
|
11, ClientTelemetryMessageDelivery, peer, []int64{3, 1, 2},
|
||||||
|
map[string]any{"push": true}, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
retry, err := NewClientTelemetryEvent(
|
||||||
|
11, ClientTelemetryMessageDelivery, peer, []int64{2, 3, 1},
|
||||||
|
struct {
|
||||||
|
Push bool `json:"push"`
|
||||||
|
}{Push: true},
|
||||||
|
now.Add(30*time.Second),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := first.SubjectIDs; len(got) != 3 ||
|
||||||
|
got[0] != 1 || got[1] != 2 || got[2] != 3 {
|
||||||
|
t.Fatalf("canonical subjects = %v", got)
|
||||||
|
}
|
||||||
|
if first.Fingerprint != retry.Fingerprint {
|
||||||
|
t.Fatal("same telemetry inside one minute must have one fingerprint")
|
||||||
|
}
|
||||||
|
nextMinute, err := NewClientTelemetryEvent(
|
||||||
|
11, ClientTelemetryMessageDelivery, peer, []int64{1, 2, 3},
|
||||||
|
map[string]any{"push": true}, now.Add(time.Minute),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if nextMinute.Fingerprint == first.Fingerprint {
|
||||||
|
t.Fatal("a new minute bucket must produce a new fingerprint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClientTelemetryEventRejectsDuplicateSubjectsAndInvalidPeer(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err := NewClientTelemetryEvent(
|
||||||
|
11, ClientTelemetryReadMetrics,
|
||||||
|
Peer{Type: PeerTypeUser, ID: 22},
|
||||||
|
[]int64{1, 1}, map[string]any{"metrics": []int{1}}, now,
|
||||||
|
)
|
||||||
|
if !errors.Is(err, ErrClientTelemetryInvalid) {
|
||||||
|
t.Fatalf("duplicate subjects err=%v", err)
|
||||||
|
}
|
||||||
|
_, err = NewClientTelemetryEvent(
|
||||||
|
11, ClientTelemetryMessageDelivery, Peer{},
|
||||||
|
[]int64{1}, map[string]any{"push": true}, now,
|
||||||
|
)
|
||||||
|
if !errors.Is(err, ErrClientTelemetryInvalid) {
|
||||||
|
t.Fatalf("missing message peer err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
486
internal/domain/moderation.go
Normal file
486
internal/domain/moderation.go
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModerationTaxonomyVersion = 1
|
||||||
|
MaxModerationReportItems = 100
|
||||||
|
MaxModerationMediaHolds = 1000
|
||||||
|
MaxModerationOptionBytes = 32
|
||||||
|
MaxModerationCommentRunes = 512
|
||||||
|
MaxModerationEvidenceBytes = 1 << 20
|
||||||
|
MaxModerationTotalEvidenceBytes = 4 << 20
|
||||||
|
MaxModerationMediaStorageKeyBytes = 512
|
||||||
|
MaxModerationReportsPerHour = 20
|
||||||
|
MaxModerationReportsPerDay = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrModerationReportInvalid = errors.New("moderation report invalid")
|
||||||
|
ErrModerationReportNotFound = errors.New("moderation report not found")
|
||||||
|
ErrModerationCaseInvalid = errors.New("moderation case invalid")
|
||||||
|
ErrModerationCaseNotFound = errors.New("moderation case not found")
|
||||||
|
ErrModerationCaseConflict = errors.New("moderation case conflict")
|
||||||
|
ErrModerationActionInvalid = errors.New("moderation action invalid")
|
||||||
|
ErrModerationActionConflict = errors.New("moderation action conflict")
|
||||||
|
ErrModerationPermissionDenied = errors.New("moderation permission denied")
|
||||||
|
ErrModerationRateLimited = errors.New("moderation rate limited")
|
||||||
|
ErrModerationEvidenceNotFound = errors.New("moderation evidence not found")
|
||||||
|
ErrModerationDecisionNotFound = errors.New("moderation decision not found")
|
||||||
|
ErrModerationImpressionExpired = errors.New("moderation impression expired")
|
||||||
|
ErrModerationAppealLinkInvalid = errors.New("moderation appeal link invalid")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ModerationReportSource identifies the client RPC and evidence admission path.
|
||||||
|
// Operational telemetry and authentication delivery diagnostics deliberately do
|
||||||
|
// not use this type or the moderation report tables.
|
||||||
|
type ModerationReportSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModerationSourceAccountPeer ModerationReportSource = "account_peer"
|
||||||
|
ModerationSourceProfilePhoto ModerationReportSource = "profile_photo"
|
||||||
|
ModerationSourceMessagesSpam ModerationReportSource = "messages_spam"
|
||||||
|
ModerationSourceMessages ModerationReportSource = "messages"
|
||||||
|
ModerationSourceEncryptedSpam ModerationReportSource = "encrypted_spam"
|
||||||
|
ModerationSourceReaction ModerationReportSource = "reaction"
|
||||||
|
ModerationSourceChannelSpam ModerationReportSource = "channel_spam"
|
||||||
|
ModerationSourceStory ModerationReportSource = "story"
|
||||||
|
ModerationSourceEphemeral ModerationReportSource = "ephemeral"
|
||||||
|
ModerationSourceSponsored ModerationReportSource = "sponsored"
|
||||||
|
ModerationSourceAntiSpamFalsePositive ModerationReportSource = "antispam_false_positive"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s ModerationReportSource) Valid() bool {
|
||||||
|
switch s {
|
||||||
|
case ModerationSourceAccountPeer, ModerationSourceProfilePhoto,
|
||||||
|
ModerationSourceMessagesSpam, ModerationSourceMessages,
|
||||||
|
ModerationSourceEncryptedSpam, ModerationSourceReaction,
|
||||||
|
ModerationSourceChannelSpam, ModerationSourceStory,
|
||||||
|
ModerationSourceEphemeral, ModerationSourceSponsored,
|
||||||
|
ModerationSourceAntiSpamFalsePositive:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationReason is the canonical domain taxonomy shared by ReportReason
|
||||||
|
// constructors and the opaque multi-step report option flow.
|
||||||
|
type ModerationReason string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModerationReasonSpam ModerationReason = "spam"
|
||||||
|
ModerationReasonViolence ModerationReason = "violence"
|
||||||
|
ModerationReasonPornography ModerationReason = "pornography"
|
||||||
|
ModerationReasonChildAbuse ModerationReason = "child_abuse"
|
||||||
|
ModerationReasonOther ModerationReason = "other"
|
||||||
|
ModerationReasonCopyright ModerationReason = "copyright"
|
||||||
|
ModerationReasonGeoIrrelevant ModerationReason = "geo_irrelevant"
|
||||||
|
ModerationReasonFake ModerationReason = "fake"
|
||||||
|
ModerationReasonIllegalDrugs ModerationReason = "illegal_drugs"
|
||||||
|
ModerationReasonPersonalDetails ModerationReason = "personal_details"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r ModerationReason) Valid() bool {
|
||||||
|
switch r {
|
||||||
|
case ModerationReasonSpam, ModerationReasonViolence,
|
||||||
|
ModerationReasonPornography, ModerationReasonChildAbuse,
|
||||||
|
ModerationReasonOther, ModerationReasonCopyright,
|
||||||
|
ModerationReasonGeoIrrelevant, ModerationReasonFake,
|
||||||
|
ModerationReasonIllegalDrugs, ModerationReasonPersonalDetails:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationReportItemKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModerationItemPeer ModerationReportItemKind = "peer"
|
||||||
|
ModerationItemMessage ModerationReportItemKind = "message"
|
||||||
|
ModerationItemProfilePhoto ModerationReportItemKind = "profile_photo"
|
||||||
|
ModerationItemReaction ModerationReportItemKind = "reaction"
|
||||||
|
ModerationItemStory ModerationReportItemKind = "story"
|
||||||
|
ModerationItemEncryptedChat ModerationReportItemKind = "encrypted_chat"
|
||||||
|
ModerationItemEphemeral ModerationReportItemKind = "ephemeral"
|
||||||
|
ModerationItemSponsored ModerationReportItemKind = "sponsored"
|
||||||
|
ModerationItemAntiSpamDecision ModerationReportItemKind = "antispam_decision"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (k ModerationReportItemKind) Valid() bool {
|
||||||
|
switch k {
|
||||||
|
case ModerationItemPeer, ModerationItemMessage,
|
||||||
|
ModerationItemProfilePhoto, ModerationItemReaction,
|
||||||
|
ModerationItemStory, ModerationItemEncryptedChat,
|
||||||
|
ModerationItemEphemeral, ModerationItemSponsored,
|
||||||
|
ModerationItemAntiSpamDecision:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationMediaKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModerationMediaPhoto ModerationMediaKind = "photo"
|
||||||
|
ModerationMediaDocument ModerationMediaKind = "document"
|
||||||
|
ModerationMediaBlob ModerationMediaKind = "blob"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (k ModerationMediaKind) Valid() bool {
|
||||||
|
switch k {
|
||||||
|
case ModerationMediaPhoto, ModerationMediaDocument, ModerationMediaBlob:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationReportItem is a stable reference plus a privacy-bounded evidence
|
||||||
|
// snapshot. Evidence must be a versioned JSON object produced by the owning app
|
||||||
|
// service; moderation never repairs malformed historical snapshots on read.
|
||||||
|
type ModerationReportItem struct {
|
||||||
|
Kind ModerationReportItemKind
|
||||||
|
Peer Peer
|
||||||
|
ItemID int64
|
||||||
|
SecondaryID int64
|
||||||
|
AuthorUserID int64
|
||||||
|
EvidenceSchemaVersion int
|
||||||
|
Evidence json.RawMessage
|
||||||
|
EvidenceHash [sha256.Size]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationMediaHold struct {
|
||||||
|
ItemIndex int
|
||||||
|
Kind ModerationMediaKind
|
||||||
|
StorageKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationReport is immutable after acceptance. ID is assigned by the store;
|
||||||
|
// Fingerprint is a deterministic SHA-256 of the immutable client intent and
|
||||||
|
// evidence identity, excluding CreatedAt.
|
||||||
|
type ModerationReport struct {
|
||||||
|
ID int64
|
||||||
|
ReporterUserID int64
|
||||||
|
Source ModerationReportSource
|
||||||
|
Target Peer
|
||||||
|
Reason ModerationReason
|
||||||
|
Option string
|
||||||
|
Comment string
|
||||||
|
CommentHash [sha256.Size]byte
|
||||||
|
Fingerprint [sha256.Size]byte
|
||||||
|
TaxonomyVersion int
|
||||||
|
Items []ModerationReportItem
|
||||||
|
MediaHolds []ModerationMediaHold
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationReportDraft struct {
|
||||||
|
ReporterUserID int64
|
||||||
|
Source ModerationReportSource
|
||||||
|
Target Peer
|
||||||
|
Reason ModerationReason
|
||||||
|
Option string
|
||||||
|
Comment string
|
||||||
|
TaxonomyVersion int
|
||||||
|
Items []ModerationReportItem
|
||||||
|
MediaHolds []ModerationMediaHold
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationMessageReportRequest struct {
|
||||||
|
ReporterUserID int64
|
||||||
|
Target Peer
|
||||||
|
MessageIDs []int
|
||||||
|
Reason ModerationReason
|
||||||
|
Option string
|
||||||
|
Comment string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationStoryReportRequest struct {
|
||||||
|
ReporterUserID int64
|
||||||
|
Target Peer
|
||||||
|
StoryIDs []int
|
||||||
|
Reason ModerationReason
|
||||||
|
Option string
|
||||||
|
Comment string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationProfilePhotoReportRequest struct {
|
||||||
|
ReporterUserID int64
|
||||||
|
Target Peer
|
||||||
|
PhotoID int64
|
||||||
|
AccessHash int64
|
||||||
|
FileReference []byte
|
||||||
|
Reason ModerationReason
|
||||||
|
Comment string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationChannelSpamReportRequest struct {
|
||||||
|
ReporterUserID int64
|
||||||
|
ChannelID int64
|
||||||
|
ParticipantUserID int64
|
||||||
|
MessageIDs []int
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationReactionReportRequest struct {
|
||||||
|
ReporterUserID int64
|
||||||
|
Target Peer
|
||||||
|
MessageID int
|
||||||
|
ReactorUserID int64
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewModerationReport canonicalizes item order and computes all content hashes.
|
||||||
|
// Callers must pass snapshots, not mutable domain objects.
|
||||||
|
func NewModerationReport(draft ModerationReportDraft) (ModerationReport, error) {
|
||||||
|
originalItems := cloneModerationItems(draft.Items)
|
||||||
|
report := ModerationReport{
|
||||||
|
ReporterUserID: draft.ReporterUserID,
|
||||||
|
Source: draft.Source,
|
||||||
|
Target: draft.Target,
|
||||||
|
Reason: draft.Reason,
|
||||||
|
Option: draft.Option,
|
||||||
|
Comment: draft.Comment,
|
||||||
|
TaxonomyVersion: draft.TaxonomyVersion,
|
||||||
|
Items: cloneModerationItems(originalItems),
|
||||||
|
MediaHolds: append([]ModerationMediaHold(nil), draft.MediaHolds...),
|
||||||
|
CreatedAt: draft.CreatedAt,
|
||||||
|
}
|
||||||
|
if report.TaxonomyVersion == 0 {
|
||||||
|
report.TaxonomyVersion = ModerationTaxonomyVersion
|
||||||
|
}
|
||||||
|
report.CommentHash = sha256.Sum256([]byte(report.Comment))
|
||||||
|
for i := range report.Items {
|
||||||
|
evidence, err := CanonicalModerationEvidence(report.Items[i].Evidence)
|
||||||
|
if err != nil {
|
||||||
|
return ModerationReport{}, err
|
||||||
|
}
|
||||||
|
report.Items[i].Evidence = evidence
|
||||||
|
report.Items[i].EvidenceHash = sha256.Sum256(report.Items[i].Evidence)
|
||||||
|
}
|
||||||
|
sort.Slice(report.Items, func(i, j int) bool {
|
||||||
|
return moderationItemLess(report.Items[i], report.Items[j])
|
||||||
|
})
|
||||||
|
canonicalIndexes := make(map[moderationItemIdentity]int, len(report.Items))
|
||||||
|
for i, item := range report.Items {
|
||||||
|
canonicalIndexes[moderationItemIdentityOf(item)] = i
|
||||||
|
}
|
||||||
|
for i := range report.MediaHolds {
|
||||||
|
oldIndex := report.MediaHolds[i].ItemIndex
|
||||||
|
if oldIndex >= 0 && oldIndex < len(originalItems) {
|
||||||
|
if canonicalIndex, ok := canonicalIndexes[moderationItemIdentityOf(originalItems[oldIndex])]; ok {
|
||||||
|
report.MediaHolds[i].ItemIndex = canonicalIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(report.MediaHolds, func(i, j int) bool {
|
||||||
|
a, b := report.MediaHolds[i], report.MediaHolds[j]
|
||||||
|
if a.ItemIndex != b.ItemIndex {
|
||||||
|
return a.ItemIndex < b.ItemIndex
|
||||||
|
}
|
||||||
|
if a.Kind != b.Kind {
|
||||||
|
return a.Kind < b.Kind
|
||||||
|
}
|
||||||
|
return a.StorageKey < b.StorageKey
|
||||||
|
})
|
||||||
|
fingerprint, err := moderationReportFingerprint(report)
|
||||||
|
if err != nil {
|
||||||
|
return ModerationReport{}, err
|
||||||
|
}
|
||||||
|
report.Fingerprint = fingerprint
|
||||||
|
if err := report.Validate(); err != nil {
|
||||||
|
return ModerationReport{}, err
|
||||||
|
}
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ModerationReport) Validate() error {
|
||||||
|
if r.ID < 0 || r.ReporterUserID <= 0 || !r.Source.Valid() ||
|
||||||
|
!moderationPeerValid(r.Target) || !r.Reason.Valid() ||
|
||||||
|
r.Option == "" || len(r.Option) > MaxModerationOptionBytes ||
|
||||||
|
!utf8.ValidString(r.Option) || !utf8.ValidString(r.Comment) ||
|
||||||
|
utf8.RuneCountInString(r.Comment) > MaxModerationCommentRunes ||
|
||||||
|
r.TaxonomyVersion <= 0 || r.TaxonomyVersion > 32767 ||
|
||||||
|
len(r.Items) == 0 || len(r.Items) > MaxModerationReportItems ||
|
||||||
|
len(r.MediaHolds) > MaxModerationMediaHolds || r.CreatedAt.IsZero() ||
|
||||||
|
r.CommentHash != sha256.Sum256([]byte(r.Comment)) {
|
||||||
|
return ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
totalEvidence := 0
|
||||||
|
seenItems := make(map[moderationItemIdentity]struct{}, len(r.Items))
|
||||||
|
for i, item := range r.Items {
|
||||||
|
if !item.Kind.Valid() || !moderationPeerValid(item.Peer) ||
|
||||||
|
item.ItemID <= 0 || item.SecondaryID < 0 || item.AuthorUserID < 0 ||
|
||||||
|
item.EvidenceSchemaVersion <= 0 || item.EvidenceSchemaVersion > 32767 ||
|
||||||
|
len(item.Evidence) == 0 || len(item.Evidence) > MaxModerationEvidenceBytes ||
|
||||||
|
!json.Valid(item.Evidence) ||
|
||||||
|
item.EvidenceHash != sha256.Sum256(item.Evidence) {
|
||||||
|
return ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
canonical, err := CanonicalModerationEvidence(item.Evidence)
|
||||||
|
if err != nil || !bytes.Equal(canonical, item.Evidence) {
|
||||||
|
return ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
if i > 0 && moderationItemLess(item, r.Items[i-1]) {
|
||||||
|
return ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
identity := moderationItemIdentityOf(item)
|
||||||
|
if _, duplicate := seenItems[identity]; duplicate {
|
||||||
|
return ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
seenItems[identity] = struct{}{}
|
||||||
|
totalEvidence += len(item.Evidence)
|
||||||
|
if totalEvidence > MaxModerationTotalEvidenceBytes {
|
||||||
|
return ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seenHolds := make(map[ModerationMediaHold]struct{}, len(r.MediaHolds))
|
||||||
|
for _, hold := range r.MediaHolds {
|
||||||
|
if hold.ItemIndex < 0 || hold.ItemIndex >= len(r.Items) ||
|
||||||
|
!hold.Kind.Valid() || hold.StorageKey == "" ||
|
||||||
|
len(hold.StorageKey) > MaxModerationMediaStorageKeyBytes ||
|
||||||
|
!utf8.ValidString(hold.StorageKey) {
|
||||||
|
return ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
if _, duplicate := seenHolds[hold]; duplicate {
|
||||||
|
return ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
seenHolds[hold] = struct{}{}
|
||||||
|
}
|
||||||
|
fingerprint, err := moderationReportFingerprint(r)
|
||||||
|
if err != nil || fingerprint != r.Fingerprint {
|
||||||
|
return ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type moderationItemIdentity struct {
|
||||||
|
Kind ModerationReportItemKind
|
||||||
|
PeerType PeerType
|
||||||
|
PeerID int64
|
||||||
|
ItemID int64
|
||||||
|
SecondaryID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func moderationItemIdentityOf(item ModerationReportItem) moderationItemIdentity {
|
||||||
|
return moderationItemIdentity{
|
||||||
|
Kind: item.Kind, PeerType: item.Peer.Type, PeerID: item.Peer.ID,
|
||||||
|
ItemID: item.ItemID, SecondaryID: item.SecondaryID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moderationItemLess(a, b ModerationReportItem) bool {
|
||||||
|
if a.Kind != b.Kind {
|
||||||
|
return a.Kind < b.Kind
|
||||||
|
}
|
||||||
|
if a.Peer.Type != b.Peer.Type {
|
||||||
|
return a.Peer.Type < b.Peer.Type
|
||||||
|
}
|
||||||
|
if a.Peer.ID != b.Peer.ID {
|
||||||
|
return a.Peer.ID < b.Peer.ID
|
||||||
|
}
|
||||||
|
if a.ItemID != b.ItemID {
|
||||||
|
return a.ItemID < b.ItemID
|
||||||
|
}
|
||||||
|
return a.SecondaryID < b.SecondaryID
|
||||||
|
}
|
||||||
|
|
||||||
|
func moderationPeerValid(peer Peer) bool {
|
||||||
|
return peer.ID > 0 && (peer.Type == PeerTypeUser || peer.Type == PeerTypeChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanonicalModerationEvidence normalizes a JSON object with Go's deterministic
|
||||||
|
// map-key ordering. This keeps evidence hashes stable after PostgreSQL jsonb
|
||||||
|
// normalizes whitespace and object key order.
|
||||||
|
func CanonicalModerationEvidence(raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var value any
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
return nil, ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
if _, ok := value.(map[string]any); !ok {
|
||||||
|
return nil, ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
canonical, err := json.Marshal(value)
|
||||||
|
if err != nil || len(canonical) == 0 || len(canonical) > MaxModerationEvidenceBytes {
|
||||||
|
return nil, ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
return canonical, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type moderationFingerprintItem struct {
|
||||||
|
Kind ModerationReportItemKind `json:"kind"`
|
||||||
|
PeerType PeerType `json:"peer_type"`
|
||||||
|
PeerID int64 `json:"peer_id"`
|
||||||
|
ItemID int64 `json:"item_id"`
|
||||||
|
SecondaryID int64 `json:"secondary_id"`
|
||||||
|
AuthorUserID int64 `json:"author_user_id"`
|
||||||
|
EvidenceSchemaVersion int `json:"evidence_schema_version"`
|
||||||
|
EvidenceHash [sha256.Size]byte `json:"evidence_hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type moderationFingerprintPayload struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
ReporterUserID int64 `json:"reporter_user_id"`
|
||||||
|
Source ModerationReportSource `json:"source"`
|
||||||
|
TargetType PeerType `json:"target_type"`
|
||||||
|
TargetID int64 `json:"target_id"`
|
||||||
|
Reason ModerationReason `json:"reason"`
|
||||||
|
Option string `json:"option"`
|
||||||
|
CommentHash [sha256.Size]byte `json:"comment_hash"`
|
||||||
|
TaxonomyVersion int `json:"taxonomy_version"`
|
||||||
|
Items []moderationFingerprintItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func moderationReportFingerprint(report ModerationReport) ([sha256.Size]byte, error) {
|
||||||
|
items := make([]moderationFingerprintItem, 0, len(report.Items))
|
||||||
|
for _, item := range report.Items {
|
||||||
|
items = append(items, moderationFingerprintItem{
|
||||||
|
Kind: item.Kind, PeerType: item.Peer.Type, PeerID: item.Peer.ID,
|
||||||
|
ItemID: item.ItemID, SecondaryID: item.SecondaryID,
|
||||||
|
AuthorUserID: item.AuthorUserID,
|
||||||
|
EvidenceSchemaVersion: item.EvidenceSchemaVersion,
|
||||||
|
EvidenceHash: item.EvidenceHash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(moderationFingerprintPayload{
|
||||||
|
Version: 1, ReporterUserID: report.ReporterUserID, Source: report.Source,
|
||||||
|
TargetType: report.Target.Type, TargetID: report.Target.ID,
|
||||||
|
Reason: report.Reason, Option: report.Option,
|
||||||
|
CommentHash: report.CommentHash, TaxonomyVersion: report.TaxonomyVersion,
|
||||||
|
Items: items,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return [sha256.Size]byte{}, ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
return sha256.Sum256(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneModerationItems(items []ModerationReportItem) []ModerationReportItem {
|
||||||
|
out := make([]ModerationReportItem, len(items))
|
||||||
|
copy(out, items)
|
||||||
|
for i := range out {
|
||||||
|
out[i].Evidence = append(json.RawMessage(nil), items[i].Evidence...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func CloneModerationReport(report ModerationReport) ModerationReport {
|
||||||
|
report.Items = cloneModerationItems(report.Items)
|
||||||
|
report.MediaHolds = append([]ModerationMediaHold(nil), report.MediaHolds...)
|
||||||
|
return report
|
||||||
|
}
|
||||||
523
internal/domain/moderation_case.go
Normal file
523
internal/domain/moderation_case.go
Normal file
|
|
@ -0,0 +1,523 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxModerationActorBytes = 128
|
||||||
|
MaxModerationDecisionCommandBytes = 120
|
||||||
|
MaxModerationDecisionTextRunes = 2000
|
||||||
|
MaxModerationActionPayload = 64 << 10
|
||||||
|
MaxModerationCasePage = 100
|
||||||
|
MaxModerationCaseDetailEntries = 100
|
||||||
|
MaxModerationActionsPerCase = 100
|
||||||
|
MaxModerationAppealTextRunes = 4000
|
||||||
|
MaxModerationActionAttempts = 20
|
||||||
|
MaxModerationAppealLinksPerCase = 20
|
||||||
|
MaxModerationAppealLinkLifetime = 90 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModerationSeverity int16
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModerationSeverityLow ModerationSeverity = iota + 1
|
||||||
|
ModerationSeverityMedium
|
||||||
|
ModerationSeverityHigh
|
||||||
|
ModerationSeverityCritical
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s ModerationSeverity) Valid() bool {
|
||||||
|
return s >= ModerationSeverityLow && s <= ModerationSeverityCritical
|
||||||
|
}
|
||||||
|
|
||||||
|
func ModerationSeverityForReason(reason ModerationReason) ModerationSeverity {
|
||||||
|
switch reason {
|
||||||
|
case ModerationReasonChildAbuse:
|
||||||
|
return ModerationSeverityCritical
|
||||||
|
case ModerationReasonViolence, ModerationReasonPornography,
|
||||||
|
ModerationReasonIllegalDrugs, ModerationReasonPersonalDetails:
|
||||||
|
return ModerationSeverityHigh
|
||||||
|
case ModerationReasonFake, ModerationReasonCopyright:
|
||||||
|
return ModerationSeverityMedium
|
||||||
|
default:
|
||||||
|
return ModerationSeverityLow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationCaseStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModerationCaseOpen ModerationCaseStatus = "open"
|
||||||
|
ModerationCaseInReview ModerationCaseStatus = "in_review"
|
||||||
|
ModerationCaseActionPending ModerationCaseStatus = "action_pending"
|
||||||
|
ModerationCaseActionFailed ModerationCaseStatus = "action_failed"
|
||||||
|
ModerationCaseResolved ModerationCaseStatus = "resolved"
|
||||||
|
ModerationCaseDismissed ModerationCaseStatus = "dismissed"
|
||||||
|
ModerationCaseAppealReview ModerationCaseStatus = "appeal_review"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s ModerationCaseStatus) Valid() bool {
|
||||||
|
switch s {
|
||||||
|
case ModerationCaseOpen, ModerationCaseInReview,
|
||||||
|
ModerationCaseActionPending, ModerationCaseResolved,
|
||||||
|
ModerationCaseActionFailed, ModerationCaseDismissed,
|
||||||
|
ModerationCaseAppealReview:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ModerationCaseStatus) Active() bool {
|
||||||
|
switch s {
|
||||||
|
case ModerationCaseOpen, ModerationCaseInReview,
|
||||||
|
ModerationCaseActionPending, ModerationCaseActionFailed,
|
||||||
|
ModerationCaseAppealReview:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationDecisionKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModerationDecisionNoViolation ModerationDecisionKind = "no_violation"
|
||||||
|
ModerationDecisionViolation ModerationDecisionKind = "violation"
|
||||||
|
ModerationDecisionAppealGrant ModerationDecisionKind = "appeal_granted"
|
||||||
|
ModerationDecisionAppealDeny ModerationDecisionKind = "appeal_denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (k ModerationDecisionKind) Valid() bool {
|
||||||
|
switch k {
|
||||||
|
case ModerationDecisionNoViolation, ModerationDecisionViolation,
|
||||||
|
ModerationDecisionAppealGrant, ModerationDecisionAppealDeny:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationActionKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModerationActionMarkScam ModerationActionKind = "mark_scam"
|
||||||
|
ModerationActionMarkFake ModerationActionKind = "mark_fake"
|
||||||
|
ModerationActionClearPeerFlags ModerationActionKind = "clear_peer_flags"
|
||||||
|
ModerationActionFreezeAccount ModerationActionKind = "freeze_account"
|
||||||
|
ModerationActionUnfreezeAccount ModerationActionKind = "unfreeze_account"
|
||||||
|
ModerationActionDeletePrivateMessage ModerationActionKind = "delete_private_message"
|
||||||
|
ModerationActionDeleteChannelMessage ModerationActionKind = "delete_channel_message"
|
||||||
|
ModerationActionDeleteAccount ModerationActionKind = "delete_account"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (k ModerationActionKind) Valid() bool {
|
||||||
|
switch k {
|
||||||
|
case ModerationActionMarkScam, ModerationActionMarkFake,
|
||||||
|
ModerationActionClearPeerFlags, ModerationActionFreezeAccount,
|
||||||
|
ModerationActionUnfreezeAccount,
|
||||||
|
ModerationActionDeletePrivateMessage,
|
||||||
|
ModerationActionDeleteChannelMessage,
|
||||||
|
ModerationActionDeleteAccount:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationActionStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModerationActionPending ModerationActionStatus = "pending"
|
||||||
|
ModerationActionProcessing ModerationActionStatus = "processing"
|
||||||
|
ModerationActionSucceeded ModerationActionStatus = "succeeded"
|
||||||
|
ModerationActionSuperseded ModerationActionStatus = "superseded"
|
||||||
|
ModerationActionRetry ModerationActionStatus = "retry"
|
||||||
|
ModerationActionFailed ModerationActionStatus = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s ModerationActionStatus) Valid() bool {
|
||||||
|
switch s {
|
||||||
|
case ModerationActionPending, ModerationActionProcessing,
|
||||||
|
ModerationActionSucceeded, ModerationActionSuperseded,
|
||||||
|
ModerationActionRetry,
|
||||||
|
ModerationActionFailed:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationSanctionFamily groups reversible actions that mutate the same
|
||||||
|
// target-scoped state. Only the latest desired action in a family may execute;
|
||||||
|
// older queued work is retained as superseded audit history.
|
||||||
|
type ModerationSanctionFamily string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModerationSanctionPeerFlags ModerationSanctionFamily = "peer_flags"
|
||||||
|
ModerationSanctionAccountFreeze ModerationSanctionFamily = "account_freeze"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (k ModerationActionKind) SanctionFamily() (ModerationSanctionFamily, bool) {
|
||||||
|
switch k {
|
||||||
|
case ModerationActionMarkScam, ModerationActionMarkFake,
|
||||||
|
ModerationActionClearPeerFlags:
|
||||||
|
return ModerationSanctionPeerFlags, true
|
||||||
|
case ModerationActionFreezeAccount, ModerationActionUnfreezeAccount:
|
||||||
|
return ModerationSanctionAccountFreeze, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationAppealStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModerationAppealPending ModerationAppealStatus = "pending"
|
||||||
|
ModerationAppealGranted ModerationAppealStatus = "granted"
|
||||||
|
ModerationAppealRejected ModerationAppealStatus = "rejected"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s ModerationAppealStatus) Valid() bool {
|
||||||
|
switch s {
|
||||||
|
case ModerationAppealPending, ModerationAppealGranted, ModerationAppealRejected:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationCase struct {
|
||||||
|
ID int64
|
||||||
|
Target Peer
|
||||||
|
Status ModerationCaseStatus
|
||||||
|
Severity ModerationSeverity
|
||||||
|
AssignedTo string
|
||||||
|
Version int64
|
||||||
|
ReportCount int
|
||||||
|
DistinctReporterCount int
|
||||||
|
FirstReportAt time.Time
|
||||||
|
LastReportAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ModerationCase) Validate() error {
|
||||||
|
if c.ID <= 0 || !moderationPeerValid(c.Target) || !c.Status.Valid() ||
|
||||||
|
!c.Severity.Valid() || c.Version <= 0 || c.ReportCount <= 0 ||
|
||||||
|
c.DistinctReporterCount <= 0 ||
|
||||||
|
c.DistinctReporterCount > c.ReportCount ||
|
||||||
|
len(c.AssignedTo) > MaxModerationActorBytes ||
|
||||||
|
!utf8.ValidString(c.AssignedTo) || c.FirstReportAt.IsZero() ||
|
||||||
|
c.LastReportAt.Before(c.FirstReportAt) || c.CreatedAt.IsZero() ||
|
||||||
|
c.UpdatedAt.Before(c.CreatedAt) {
|
||||||
|
return ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
if (c.Status == ModerationCaseInReview ||
|
||||||
|
c.Status == ModerationCaseActionPending ||
|
||||||
|
c.Status == ModerationCaseActionFailed) && c.AssignedTo == "" {
|
||||||
|
return ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationCaseFilter struct {
|
||||||
|
Statuses []ModerationCaseStatus
|
||||||
|
AssignedTo string
|
||||||
|
Target Peer
|
||||||
|
BeforeUpdate time.Time
|
||||||
|
BeforeID int64
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f ModerationCaseFilter) Validate() error {
|
||||||
|
if f.Limit <= 0 || f.Limit > MaxModerationCasePage ||
|
||||||
|
len(f.AssignedTo) > MaxModerationActorBytes ||
|
||||||
|
!utf8.ValidString(f.AssignedTo) || f.BeforeID < 0 {
|
||||||
|
return ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
if f.Target.ID != 0 && !moderationPeerValid(f.Target) {
|
||||||
|
return ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
if f.Target.ID == 0 && f.Target.Type != "" {
|
||||||
|
return ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
for _, status := range f.Statuses {
|
||||||
|
if !status.Valid() {
|
||||||
|
return ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationCaseDetail struct {
|
||||||
|
Case ModerationCase
|
||||||
|
ReportIDs []int64
|
||||||
|
Decisions []ModerationDecision
|
||||||
|
Actions []ModerationAction
|
||||||
|
Appeals []ModerationAppeal
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationDecision struct {
|
||||||
|
ID int64
|
||||||
|
CaseID int64
|
||||||
|
AppealID int64
|
||||||
|
Kind ModerationDecisionKind
|
||||||
|
Actor string
|
||||||
|
Reason string
|
||||||
|
CommandID string
|
||||||
|
Fingerprint [sha256.Size]byte
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationActionDraft struct {
|
||||||
|
Kind ModerationActionKind
|
||||||
|
Payload json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationDecisionRequest struct {
|
||||||
|
CaseID int64
|
||||||
|
AppealID int64
|
||||||
|
ExpectedVersion int64
|
||||||
|
Actor string
|
||||||
|
Reason string
|
||||||
|
CommandID string
|
||||||
|
Kind ModerationDecisionKind
|
||||||
|
Actions []ModerationActionDraft
|
||||||
|
Fingerprint [sha256.Size]byte
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModerationDecisionRequest(request ModerationDecisionRequest) (ModerationDecisionRequest, error) {
|
||||||
|
out := request
|
||||||
|
out.Actions = append([]ModerationActionDraft(nil), request.Actions...)
|
||||||
|
for i := range out.Actions {
|
||||||
|
canonical, err := CanonicalModerationActionPayload(out.Actions[i].Payload)
|
||||||
|
if err != nil {
|
||||||
|
return ModerationDecisionRequest{}, err
|
||||||
|
}
|
||||||
|
out.Actions[i].Payload = canonical
|
||||||
|
}
|
||||||
|
fingerprint, err := moderationDecisionFingerprint(out)
|
||||||
|
if err != nil {
|
||||||
|
return ModerationDecisionRequest{}, err
|
||||||
|
}
|
||||||
|
out.Fingerprint = fingerprint
|
||||||
|
if err := out.Validate(); err != nil {
|
||||||
|
return ModerationDecisionRequest{}, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ModerationDecisionRequest) Validate() error {
|
||||||
|
if r.CaseID <= 0 || r.ExpectedVersion <= 0 || !r.Kind.Valid() ||
|
||||||
|
r.Actor == "" || len(r.Actor) > MaxModerationActorBytes ||
|
||||||
|
!utf8.ValidString(r.Actor) || r.CommandID == "" ||
|
||||||
|
len(r.CommandID) > MaxModerationDecisionCommandBytes ||
|
||||||
|
!utf8.ValidString(r.CommandID) ||
|
||||||
|
r.Reason == "" || !utf8.ValidString(r.Reason) ||
|
||||||
|
utf8.RuneCountInString(r.Reason) > MaxModerationDecisionTextRunes ||
|
||||||
|
len(r.Actions) > MaxModerationActionsPerCase ||
|
||||||
|
r.Fingerprint == ([sha256.Size]byte{}) || r.CreatedAt.IsZero() {
|
||||||
|
return ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
if r.Kind == ModerationDecisionNoViolation && len(r.Actions) != 0 {
|
||||||
|
return ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
if r.Kind == ModerationDecisionViolation && len(r.Actions) == 0 {
|
||||||
|
return ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
if (r.Kind == ModerationDecisionAppealGrant ||
|
||||||
|
r.Kind == ModerationDecisionAppealDeny) != (r.AppealID > 0) {
|
||||||
|
return ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
if r.Kind == ModerationDecisionAppealDeny && len(r.Actions) != 0 {
|
||||||
|
return ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
if (r.Kind == ModerationDecisionNoViolation ||
|
||||||
|
r.Kind == ModerationDecisionViolation) && r.AppealID != 0 {
|
||||||
|
return ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
for i := range r.Actions {
|
||||||
|
canonical, err := CanonicalModerationActionPayload(r.Actions[i].Payload)
|
||||||
|
if !r.Actions[i].Kind.Valid() || err != nil ||
|
||||||
|
!bytes.Equal(canonical, r.Actions[i].Payload) {
|
||||||
|
return ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fingerprint, err := moderationDecisionFingerprint(r)
|
||||||
|
if err != nil || fingerprint != r.Fingerprint {
|
||||||
|
return ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationAction struct {
|
||||||
|
ID int64
|
||||||
|
CaseID int64
|
||||||
|
DecisionID int64
|
||||||
|
Kind ModerationActionKind
|
||||||
|
Payload json.RawMessage
|
||||||
|
Status ModerationActionStatus
|
||||||
|
Attempts int
|
||||||
|
AvailableAt time.Time
|
||||||
|
LeaseUntil time.Time
|
||||||
|
LastError string
|
||||||
|
CommandID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a ModerationAction) Validate() error {
|
||||||
|
canonical, err := CanonicalModerationActionPayload(a.Payload)
|
||||||
|
if a.ID <= 0 || a.CaseID <= 0 || a.DecisionID <= 0 ||
|
||||||
|
!a.Kind.Valid() || !a.Status.Valid() || a.Attempts < 0 ||
|
||||||
|
a.Attempts > MaxModerationActionAttempts || a.AvailableAt.IsZero() ||
|
||||||
|
a.CommandID == "" || len(a.CommandID) > 160 ||
|
||||||
|
!utf8.ValidString(a.CommandID) || a.CreatedAt.IsZero() ||
|
||||||
|
a.UpdatedAt.Before(a.CreatedAt) || err != nil ||
|
||||||
|
!bytes.Equal(canonical, a.Payload) {
|
||||||
|
return ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModerationAppeal struct {
|
||||||
|
ID int64
|
||||||
|
CaseID int64
|
||||||
|
AppellantUserID int64
|
||||||
|
Text string
|
||||||
|
TextHash [sha256.Size]byte
|
||||||
|
Fingerprint [sha256.Size]byte
|
||||||
|
Status ModerationAppealStatus
|
||||||
|
PreviousCaseStatus ModerationCaseStatus
|
||||||
|
Reviewer string
|
||||||
|
ReviewReason string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ReviewedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationAppealLink is a hash-only bearer capability issued for the user
|
||||||
|
// targeted by a moderation case. The raw token is never persisted.
|
||||||
|
type ModerationAppealLink struct {
|
||||||
|
ID int64
|
||||||
|
CaseID int64
|
||||||
|
AppellantUserID int64
|
||||||
|
TokenHash [sha256.Size]byte
|
||||||
|
ExpiresAt time.Time
|
||||||
|
AppealID int64
|
||||||
|
CreatedAt time.Time
|
||||||
|
ConsumedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l ModerationAppealLink) Validate() error {
|
||||||
|
if l.ID < 0 || l.CaseID <= 0 || l.AppellantUserID <= 0 ||
|
||||||
|
l.TokenHash == ([sha256.Size]byte{}) || l.CreatedAt.IsZero() ||
|
||||||
|
!l.ExpiresAt.After(l.CreatedAt) ||
|
||||||
|
l.ExpiresAt.Sub(l.CreatedAt) > MaxModerationAppealLinkLifetime ||
|
||||||
|
l.AppealID < 0 {
|
||||||
|
return ErrModerationAppealLinkInvalid
|
||||||
|
}
|
||||||
|
if l.AppealID == 0 {
|
||||||
|
if !l.ConsumedAt.IsZero() {
|
||||||
|
return ErrModerationAppealLinkInvalid
|
||||||
|
}
|
||||||
|
} else if l.ConsumedAt.IsZero() {
|
||||||
|
return ErrModerationAppealLinkInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModerationAppeal(caseID, appellantUserID int64, previousStatus ModerationCaseStatus, text string, createdAt time.Time) (ModerationAppeal, error) {
|
||||||
|
appeal := ModerationAppeal{
|
||||||
|
CaseID: caseID, AppellantUserID: appellantUserID, Text: text,
|
||||||
|
TextHash: sha256.Sum256([]byte(text)), Status: ModerationAppealPending,
|
||||||
|
PreviousCaseStatus: previousStatus, CreatedAt: createdAt,
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(struct {
|
||||||
|
Version int
|
||||||
|
CaseID int64
|
||||||
|
AppellantUserID int64
|
||||||
|
PreviousStatus ModerationCaseStatus
|
||||||
|
TextHash [sha256.Size]byte
|
||||||
|
}{1, caseID, appellantUserID, previousStatus, appeal.TextHash})
|
||||||
|
if err != nil {
|
||||||
|
return ModerationAppeal{}, ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
appeal.Fingerprint = sha256.Sum256(raw)
|
||||||
|
if err := appeal.Validate(); err != nil {
|
||||||
|
return ModerationAppeal{}, err
|
||||||
|
}
|
||||||
|
return appeal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a ModerationAppeal) Validate() error {
|
||||||
|
if a.ID < 0 || a.CaseID <= 0 || a.AppellantUserID <= 0 ||
|
||||||
|
a.Text == "" || !utf8.ValidString(a.Text) ||
|
||||||
|
utf8.RuneCountInString(a.Text) > MaxModerationAppealTextRunes ||
|
||||||
|
a.TextHash != sha256.Sum256([]byte(a.Text)) ||
|
||||||
|
a.Fingerprint == ([sha256.Size]byte{}) || !a.Status.Valid() ||
|
||||||
|
(a.PreviousCaseStatus != ModerationCaseResolved &&
|
||||||
|
a.PreviousCaseStatus != ModerationCaseDismissed) ||
|
||||||
|
len(a.Reviewer) > MaxModerationActorBytes ||
|
||||||
|
!utf8.ValidString(a.Reviewer) || !utf8.ValidString(a.ReviewReason) ||
|
||||||
|
utf8.RuneCountInString(a.ReviewReason) > MaxModerationDecisionTextRunes ||
|
||||||
|
a.CreatedAt.IsZero() {
|
||||||
|
return ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
if a.Status == ModerationAppealPending {
|
||||||
|
if a.Reviewer != "" || a.ReviewReason != "" || !a.ReviewedAt.IsZero() {
|
||||||
|
return ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
} else if a.Reviewer == "" || a.ReviewReason == "" || a.ReviewedAt.IsZero() {
|
||||||
|
return ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CanonicalModerationActionPayload(raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var value any
|
||||||
|
if len(raw) == 0 {
|
||||||
|
raw = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
return nil, ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
if _, ok := value.(map[string]any); !ok {
|
||||||
|
return nil, ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
canonical, err := json.Marshal(value)
|
||||||
|
if err != nil || len(canonical) > MaxModerationActionPayload {
|
||||||
|
return nil, ErrModerationActionInvalid
|
||||||
|
}
|
||||||
|
return canonical, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func moderationDecisionFingerprint(request ModerationDecisionRequest) ([sha256.Size]byte, error) {
|
||||||
|
raw, err := json.Marshal(struct {
|
||||||
|
Version int
|
||||||
|
CaseID int64
|
||||||
|
AppealID int64
|
||||||
|
ExpectedVersion int64
|
||||||
|
Actor string
|
||||||
|
Reason string
|
||||||
|
CommandID string
|
||||||
|
Kind ModerationDecisionKind
|
||||||
|
Actions []ModerationActionDraft
|
||||||
|
}{
|
||||||
|
Version: 1, CaseID: request.CaseID,
|
||||||
|
AppealID: request.AppealID,
|
||||||
|
ExpectedVersion: request.ExpectedVersion, Actor: request.Actor,
|
||||||
|
Reason: request.Reason, CommandID: request.CommandID,
|
||||||
|
Kind: request.Kind, Actions: request.Actions,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return [sha256.Size]byte{}, ErrModerationCaseInvalid
|
||||||
|
}
|
||||||
|
return sha256.Sum256(raw), nil
|
||||||
|
}
|
||||||
156
internal/domain/moderation_registry.go
Normal file
156
internal/domain/moderation_registry.go
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxSponsoredImpressionLifetime = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// SponsoredMessageImpression is the server-issued fact required before a
|
||||||
|
// random_id may enter the human moderation pipeline.
|
||||||
|
type SponsoredMessageImpression struct {
|
||||||
|
ID int64
|
||||||
|
UserID int64
|
||||||
|
RandomIDHash [sha256.Size]byte
|
||||||
|
Target Peer
|
||||||
|
AuthorUserID int64
|
||||||
|
EvidenceSchemaVersion int
|
||||||
|
Evidence json.RawMessage
|
||||||
|
EvidenceHash [sha256.Size]byte
|
||||||
|
ReportID int64
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSponsoredMessageImpression(userID int64, randomID []byte, target Peer, authorUserID int64, evidence json.RawMessage, createdAt, expiresAt time.Time) (SponsoredMessageImpression, error) {
|
||||||
|
canonical, err := CanonicalModerationEvidence(evidence)
|
||||||
|
if err != nil {
|
||||||
|
return SponsoredMessageImpression{}, ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
impression := SponsoredMessageImpression{
|
||||||
|
UserID: userID, RandomIDHash: sha256.Sum256(randomID),
|
||||||
|
Target: target, AuthorUserID: authorUserID,
|
||||||
|
EvidenceSchemaVersion: 1, Evidence: canonical,
|
||||||
|
EvidenceHash: sha256.Sum256(canonical),
|
||||||
|
CreatedAt: createdAt.UTC(), ExpiresAt: expiresAt.UTC(),
|
||||||
|
}
|
||||||
|
if err := impression.Validate(); err != nil {
|
||||||
|
return SponsoredMessageImpression{}, err
|
||||||
|
}
|
||||||
|
return impression, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i SponsoredMessageImpression) Validate() error {
|
||||||
|
canonical, err := CanonicalModerationEvidence(i.Evidence)
|
||||||
|
if i.ID < 0 || i.UserID <= 0 ||
|
||||||
|
i.RandomIDHash == ([sha256.Size]byte{}) ||
|
||||||
|
!moderationPeerValid(i.Target) || i.AuthorUserID < 0 ||
|
||||||
|
i.EvidenceSchemaVersion <= 0 ||
|
||||||
|
i.EvidenceHash != sha256.Sum256(i.Evidence) ||
|
||||||
|
err != nil || !bytes.Equal(canonical, i.Evidence) ||
|
||||||
|
i.ReportID < 0 || i.CreatedAt.IsZero() ||
|
||||||
|
!i.ExpiresAt.After(i.CreatedAt) ||
|
||||||
|
i.ExpiresAt.Sub(i.CreatedAt) > MaxSponsoredImpressionLifetime {
|
||||||
|
return ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateSponsoredModerationReport(impression SponsoredMessageImpression, report ModerationReport) error {
|
||||||
|
if err := impression.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := report.Validate(); err != nil ||
|
||||||
|
report.ReporterUserID != impression.UserID ||
|
||||||
|
report.Source != ModerationSourceSponsored ||
|
||||||
|
report.Target != impression.Target ||
|
||||||
|
report.CreatedAt.Before(impression.CreatedAt) ||
|
||||||
|
!report.CreatedAt.Before(impression.ExpiresAt) ||
|
||||||
|
len(report.Items) != 1 || len(report.MediaHolds) != 0 {
|
||||||
|
return ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
item := report.Items[0]
|
||||||
|
if item.Kind != ModerationItemSponsored ||
|
||||||
|
item.Peer != impression.Target ||
|
||||||
|
item.ItemID != impression.ID || item.SecondaryID != 0 ||
|
||||||
|
item.AuthorUserID != impression.AuthorUserID ||
|
||||||
|
item.EvidenceSchemaVersion != impression.EvidenceSchemaVersion ||
|
||||||
|
item.EvidenceHash != impression.EvidenceHash ||
|
||||||
|
!bytes.Equal(item.Evidence, impression.Evidence) {
|
||||||
|
return ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelAntiSpamDecision is immutable evidence that native anti-spam
|
||||||
|
// actually removed the referenced message. A false-positive report without
|
||||||
|
// this fact must fail closed.
|
||||||
|
type ChannelAntiSpamDecision struct {
|
||||||
|
ID int64
|
||||||
|
ChannelID int64
|
||||||
|
MessageID int
|
||||||
|
AuthorUserID int64
|
||||||
|
EvidenceSchemaVersion int
|
||||||
|
Evidence json.RawMessage
|
||||||
|
EvidenceHash [sha256.Size]byte
|
||||||
|
ReportID int64
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChannelAntiSpamDecision(channelID int64, messageID int, authorUserID int64, evidence json.RawMessage, createdAt time.Time) (ChannelAntiSpamDecision, error) {
|
||||||
|
canonical, err := CanonicalModerationEvidence(evidence)
|
||||||
|
if err != nil {
|
||||||
|
return ChannelAntiSpamDecision{}, ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
decision := ChannelAntiSpamDecision{
|
||||||
|
ChannelID: channelID, MessageID: messageID,
|
||||||
|
AuthorUserID: authorUserID, EvidenceSchemaVersion: 1,
|
||||||
|
Evidence: canonical, EvidenceHash: sha256.Sum256(canonical),
|
||||||
|
CreatedAt: createdAt.UTC(),
|
||||||
|
}
|
||||||
|
if err := decision.Validate(); err != nil {
|
||||||
|
return ChannelAntiSpamDecision{}, err
|
||||||
|
}
|
||||||
|
return decision, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d ChannelAntiSpamDecision) Validate() error {
|
||||||
|
canonical, err := CanonicalModerationEvidence(d.Evidence)
|
||||||
|
if d.ID < 0 || d.ChannelID <= 0 || d.MessageID <= 0 ||
|
||||||
|
d.MessageID > MaxMessageBoxID || d.AuthorUserID <= 0 ||
|
||||||
|
d.EvidenceSchemaVersion <= 0 ||
|
||||||
|
d.EvidenceHash != sha256.Sum256(d.Evidence) ||
|
||||||
|
err != nil || !bytes.Equal(canonical, d.Evidence) ||
|
||||||
|
d.ReportID < 0 || d.CreatedAt.IsZero() {
|
||||||
|
return ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateAntiSpamFalsePositiveReport(decision ChannelAntiSpamDecision, report ModerationReport) error {
|
||||||
|
if err := decision.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := Peer{Type: PeerTypeChannel, ID: decision.ChannelID}
|
||||||
|
if err := report.Validate(); err != nil ||
|
||||||
|
report.Source != ModerationSourceAntiSpamFalsePositive ||
|
||||||
|
report.Target != target ||
|
||||||
|
report.CreatedAt.Before(decision.CreatedAt) ||
|
||||||
|
len(report.Items) != 1 || len(report.MediaHolds) != 0 {
|
||||||
|
return ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
item := report.Items[0]
|
||||||
|
if item.Kind != ModerationItemAntiSpamDecision ||
|
||||||
|
item.Peer != target || item.ItemID != decision.ID ||
|
||||||
|
item.SecondaryID != int64(decision.MessageID) ||
|
||||||
|
item.AuthorUserID != decision.AuthorUserID ||
|
||||||
|
item.EvidenceSchemaVersion != decision.EvidenceSchemaVersion ||
|
||||||
|
item.EvidenceHash != decision.EvidenceHash ||
|
||||||
|
!bytes.Equal(item.Evidence, decision.Evidence) {
|
||||||
|
return ErrModerationReportInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
87
internal/domain/moderation_test.go
Normal file
87
internal/domain/moderation_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewModerationReportCanonicalizesEvidenceItemsAndHolds(t *testing.T) {
|
||||||
|
now := time.Unix(1_750_000_000, 0).UTC()
|
||||||
|
draft := ModerationReportDraft{
|
||||||
|
ReporterUserID: 101,
|
||||||
|
Source: ModerationSourceMessages,
|
||||||
|
Target: Peer{Type: PeerTypeChannel, ID: 202},
|
||||||
|
Reason: ModerationReasonSpam,
|
||||||
|
Option: "v1/spam",
|
||||||
|
Comment: "review",
|
||||||
|
CreatedAt: now,
|
||||||
|
Items: []ModerationReportItem{
|
||||||
|
{
|
||||||
|
Kind: ModerationItemStory, Peer: Peer{Type: PeerTypeChannel, ID: 202},
|
||||||
|
ItemID: 20, AuthorUserID: 303, EvidenceSchemaVersion: 1,
|
||||||
|
Evidence: []byte(`{ "z": 1, "a": {"two": 2, "one": 1} }`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Kind: ModerationItemMessage, Peer: Peer{Type: PeerTypeChannel, ID: 202},
|
||||||
|
ItemID: 10, AuthorUserID: 303, EvidenceSchemaVersion: 1,
|
||||||
|
Evidence: []byte(`{"message":"spam"}`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MediaHolds: []ModerationMediaHold{{
|
||||||
|
ItemIndex: 0, Kind: ModerationMediaPhoto, StorageKey: "photo/20",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
report, err := NewModerationReport(draft)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModerationReport: %v", err)
|
||||||
|
}
|
||||||
|
if report.Items[0].Kind != ModerationItemMessage || report.Items[1].Kind != ModerationItemStory {
|
||||||
|
t.Fatalf("items not canonicalized: %+v", report.Items)
|
||||||
|
}
|
||||||
|
if report.MediaHolds[0].ItemIndex != 1 {
|
||||||
|
t.Fatalf("media hold item index = %d, want 1 after canonical sort", report.MediaHolds[0].ItemIndex)
|
||||||
|
}
|
||||||
|
if got, want := report.Items[1].Evidence, []byte(`{"a":{"one":1,"two":2},"z":1}`); !bytes.Equal(got, want) {
|
||||||
|
t.Fatalf("canonical evidence = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
if err := report.Validate(); err != nil {
|
||||||
|
t.Fatalf("Validate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retry := draft
|
||||||
|
retry.CreatedAt = now.Add(time.Hour)
|
||||||
|
retryReport, err := NewModerationReport(retry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("retry NewModerationReport: %v", err)
|
||||||
|
}
|
||||||
|
if retryReport.Fingerprint != report.Fingerprint {
|
||||||
|
t.Fatalf("retry fingerprint changed with CreatedAt")
|
||||||
|
}
|
||||||
|
retry.Items = append([]ModerationReportItem(nil), retry.Items...)
|
||||||
|
retry.Items[0].Evidence = []byte(`{"z":2}`)
|
||||||
|
changed, err := NewModerationReport(retry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("changed NewModerationReport: %v", err)
|
||||||
|
}
|
||||||
|
if changed.Fingerprint == report.Fingerprint {
|
||||||
|
t.Fatalf("evidence change did not change fingerprint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModerationReportRejectsDuplicateItemIdentity(t *testing.T) {
|
||||||
|
item := ModerationReportItem{
|
||||||
|
Kind: ModerationItemMessage, Peer: Peer{Type: PeerTypeUser, ID: 2},
|
||||||
|
ItemID: 7, AuthorUserID: 2, EvidenceSchemaVersion: 1,
|
||||||
|
Evidence: []byte(`{"message":"bad"}`),
|
||||||
|
}
|
||||||
|
_, err := NewModerationReport(ModerationReportDraft{
|
||||||
|
ReporterUserID: 1, Source: ModerationSourceMessages,
|
||||||
|
Target: Peer{Type: PeerTypeUser, ID: 2},
|
||||||
|
Reason: ModerationReasonSpam, Option: "v1/spam",
|
||||||
|
Items: []ModerationReportItem{item, item}, CreatedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
if err != ErrModerationReportInvalid {
|
||||||
|
t.Fatalf("error = %v, want ErrModerationReportInvalid", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -75,6 +75,10 @@ func DefaultPrivacyRules(key PrivacyKey) []PrivacyRule {
|
||||||
switch key {
|
switch key {
|
||||||
case PrivacyKeyPhoneNumber:
|
case PrivacyKeyPhoneNumber:
|
||||||
return []PrivacyRule{{Kind: PrivacyRuleDisallowAll}}
|
return []PrivacyRule{{Kind: PrivacyRuleDisallowAll}}
|
||||||
|
case PrivacyKeyNoPaidMessages:
|
||||||
|
// This key is an allow-list of peers exempt from paid private
|
||||||
|
// messages, not the base visibility of a profile field.
|
||||||
|
return []PrivacyRule{{Kind: PrivacyRuleDisallowAll}}
|
||||||
case PrivacyKeyBirthday:
|
case PrivacyKeyBirthday:
|
||||||
return []PrivacyRule{{Kind: PrivacyRuleAllowContacts}}
|
return []PrivacyRule{{Kind: PrivacyRuleAllowContacts}}
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,7 @@ type AdminStarGiftGrant struct {
|
||||||
CommandKey string
|
CommandKey string
|
||||||
Date int
|
Date int
|
||||||
RecipientBlocked bool
|
RecipientBlocked bool
|
||||||
|
RecipientUnsaved bool
|
||||||
ModelAttributeID int64
|
ModelAttributeID int64
|
||||||
PatternAttributeID int64
|
PatternAttributeID int64
|
||||||
BackdropAttributeID int64
|
BackdropAttributeID int64
|
||||||
|
|
@ -382,6 +383,7 @@ type StarGiftPurchaseRequest struct {
|
||||||
CommandKey string
|
CommandKey string
|
||||||
Date int
|
Date int
|
||||||
RecipientBlocked bool
|
RecipientBlocked bool
|
||||||
|
RecipientUnsaved bool
|
||||||
OriginAuthKeyID [8]byte
|
OriginAuthKeyID [8]byte
|
||||||
OriginSessionID int64
|
OriginSessionID int64
|
||||||
}
|
}
|
||||||
|
|
@ -555,15 +557,16 @@ type StarGiftValueInfo struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type StarGiftTransferRequest struct {
|
type StarGiftTransferRequest struct {
|
||||||
ActorUserID int64
|
ActorUserID int64
|
||||||
Ref SavedStarGiftRef
|
Ref SavedStarGiftRef
|
||||||
To Peer
|
To Peer
|
||||||
ChargeStars int64
|
ChargeStars int64
|
||||||
FormID int64
|
FormID int64
|
||||||
CommandKey string
|
CommandKey string
|
||||||
Date int
|
Date int
|
||||||
OriginAuthKeyID [8]byte
|
RecipientUnsaved bool
|
||||||
OriginSessionID int64
|
OriginAuthKeyID [8]byte
|
||||||
|
OriginSessionID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type StarGiftTransferResult struct {
|
type StarGiftTransferResult struct {
|
||||||
|
|
@ -582,15 +585,16 @@ type StarGiftListingRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type StarGiftResalePurchaseRequest struct {
|
type StarGiftResalePurchaseRequest struct {
|
||||||
BuyerUserID int64
|
BuyerUserID int64
|
||||||
Slug string
|
Slug string
|
||||||
To Peer
|
To Peer
|
||||||
Amount StarGiftAmount
|
Amount StarGiftAmount
|
||||||
FormID int64
|
FormID int64
|
||||||
CommandKey string
|
CommandKey string
|
||||||
Date int
|
Date int
|
||||||
OriginAuthKeyID [8]byte
|
RecipientUnsaved bool
|
||||||
OriginSessionID int64
|
OriginAuthKeyID [8]byte
|
||||||
|
OriginSessionID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type StarGiftOfferRequest struct {
|
type StarGiftOfferRequest struct {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,10 @@ const (
|
||||||
// UpdateEventUserEmojiStatus carries the exact immutable status snapshot.
|
// UpdateEventUserEmojiStatus carries the exact immutable status snapshot.
|
||||||
// It consumes account pts even though updateUserEmojiStatus has no pts.
|
// It consumes account pts even though updateUserEmojiStatus has no pts.
|
||||||
UpdateEventUserEmojiStatus UpdateEventType = "user_emoji_status"
|
UpdateEventUserEmojiStatus UpdateEventType = "user_emoji_status"
|
||||||
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
|
// UpdateEventPrivacy carries the immutable account privacy key/rule
|
||||||
|
// snapshot committed at this pts. updatePrivacy has no wire pts.
|
||||||
|
UpdateEventPrivacy UpdateEventType = "privacy"
|
||||||
|
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
|
||||||
// UpdateEventPinnedMessages 映射 updatePinnedMessages(私聊置顶/取消
|
// UpdateEventPinnedMessages 映射 updatePinnedMessages(私聊置顶/取消
|
||||||
// 置顶;MessageIDs 是该 owner 自己视角的 box id,Bool 为 pinned)。
|
// 置顶;MessageIDs 是该 owner 自己视角的 box id,Bool 为 pinned)。
|
||||||
// TL 构造器自带账号 pts/pts_count,不属于 LacksWirePts。
|
// TL 构造器自带账号 pts/pts_count,不属于 LacksWirePts。
|
||||||
|
|
@ -88,6 +91,7 @@ type UpdateEvent struct {
|
||||||
Bool bool
|
Bool bool
|
||||||
Phone string
|
Phone string
|
||||||
EmojiStatus UserEmojiStatus
|
EmojiStatus UserEmojiStatus
|
||||||
|
Privacy PrivacyRules
|
||||||
Settings PeerSettings
|
Settings PeerSettings
|
||||||
MessageIDs []int
|
MessageIDs []int
|
||||||
MaxID int
|
MaxID int
|
||||||
|
|
@ -140,6 +144,7 @@ func (e UpdateEvent) LacksWirePts() bool {
|
||||||
UpdateEventPeerStoryBlocked,
|
UpdateEventPeerStoryBlocked,
|
||||||
UpdateEventUserPhone,
|
UpdateEventUserPhone,
|
||||||
UpdateEventUserEmojiStatus,
|
UpdateEventUserEmojiStatus,
|
||||||
|
UpdateEventPrivacy,
|
||||||
UpdateEventDialogFilter,
|
UpdateEventDialogFilter,
|
||||||
UpdateEventDialogFilterOrder,
|
UpdateEventDialogFilterOrder,
|
||||||
UpdateEventDialogFilters,
|
UpdateEventDialogFilters,
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,26 @@ type UserStatus struct {
|
||||||
WasOnline int
|
WasOnline int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApproximateUserStatus returns Telegram's coarse privacy-preserving last-seen
|
||||||
|
// buckets. Exact online/offline timestamps must never be reattached after this
|
||||||
|
// projection.
|
||||||
|
func ApproximateUserStatus(lastSeenAt, now int) UserStatus {
|
||||||
|
if lastSeenAt <= 0 || now <= 0 || lastSeenAt >= now {
|
||||||
|
return UserStatus{Kind: UserStatusRecently}
|
||||||
|
}
|
||||||
|
age := now - lastSeenAt
|
||||||
|
switch {
|
||||||
|
case age <= 3*24*60*60:
|
||||||
|
return UserStatus{Kind: UserStatusRecently}
|
||||||
|
case age <= 7*24*60*60:
|
||||||
|
return UserStatus{Kind: UserStatusLastWeek}
|
||||||
|
case age <= 30*24*60*60:
|
||||||
|
return UserStatus{Kind: UserStatusLastMonth}
|
||||||
|
default:
|
||||||
|
return UserStatus{Kind: UserStatusEmpty}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Birthday 是用户公开生日。Day/Month 为 0 表示未设置;Year 为 0 表示只填了月日不含年份。
|
// Birthday 是用户公开生日。Day/Month 为 0 表示未设置;Year 为 0 表示只填了月日不含年份。
|
||||||
type Birthday struct {
|
type Birthday struct {
|
||||||
Day int
|
Day int
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ import (
|
||||||
|
|
||||||
// registerAccount 注册 account.* RPC handler。
|
// registerAccount 注册 account.* RPC handler。
|
||||||
func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
|
func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
|
||||||
|
registerRPC[*tg.AccountReportPeerRequest](d, tlprofile.SemanticMethodAccountReportPeer, func(ctx context.Context, req *tg.AccountReportPeerRequest) (any, error) {
|
||||||
|
return r.onAccountReportPeer(ctx, req)
|
||||||
|
})
|
||||||
|
registerRPC[*tg.AccountReportProfilePhotoRequest](d, tlprofile.SemanticMethodAccountReportProfilePhoto, func(ctx context.Context, req *tg.AccountReportProfilePhotoRequest) (any, error) {
|
||||||
|
return r.onAccountReportProfilePhoto(ctx, req)
|
||||||
|
})
|
||||||
registerRPC[*tg.AccountDeleteAccountRequest](d, tlprofile.SemanticMethodAccountDeleteAccount, func(ctx context.Context, req *tg.AccountDeleteAccountRequest) (any, error) {
|
registerRPC[*tg.AccountDeleteAccountRequest](d, tlprofile.SemanticMethodAccountDeleteAccount, func(ctx context.Context, req *tg.AccountDeleteAccountRequest) (any, error) {
|
||||||
return r.onAccountDeleteAccount(ctx, req)
|
return r.onAccountDeleteAccount(ctx, req)
|
||||||
})
|
})
|
||||||
|
|
@ -867,7 +873,27 @@ func (r *Router) onAccountSetPrivacy(ctx context.Context, req *tg.AccountSetPriv
|
||||||
if r.deps.Privacy == nil {
|
if r.deps.Privacy == nil {
|
||||||
return &tg.AccountPrivacyRules{Rules: tgPrivacyRules(rules), Users: []tg.UserClass{}, Chats: []tg.ChatClass{}}, nil
|
return &tg.AccountPrivacyRules{Rules: tgPrivacyRules(rules), Users: []tg.UserClass{}, Chats: []tg.ChatClass{}}, nil
|
||||||
}
|
}
|
||||||
saved, err := r.deps.Privacy.SetRules(ctx, userID, domainKey, rules)
|
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||||
|
sessionID, _ := SessionIDFrom(ctx)
|
||||||
|
var (
|
||||||
|
saved domain.PrivacyRules
|
||||||
|
event domain.UpdateEvent
|
||||||
|
durableWrite bool
|
||||||
|
)
|
||||||
|
if durable, ok := r.deps.Privacy.(PrivacyDurableService); ok {
|
||||||
|
saved, event, durableWrite, err = durable.SetRulesWithUpdate(
|
||||||
|
ctx,
|
||||||
|
userID,
|
||||||
|
domainKey,
|
||||||
|
rules,
|
||||||
|
int(r.clock.Now().Unix()),
|
||||||
|
rawAuthKeyIDForOrigin(ctx),
|
||||||
|
sessionID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err == nil && !durableWrite {
|
||||||
|
saved, err = r.deps.Privacy.SetRules(ctx, userID, domainKey, rules)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, privacyErr(err)
|
return nil, privacyErr(err)
|
||||||
}
|
}
|
||||||
|
|
@ -876,14 +902,36 @@ func (r *Router) onAccountSetPrivacy(ctx context.Context, req *tg.AccountSetPriv
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
r.invalidateRPCProjectionForUser(userID)
|
r.invalidateRPCProjectionForUser(userID)
|
||||||
r.pushUserUpdates(ctx, userID, &tg.Updates{
|
if durableWrite {
|
||||||
Updates: []tg.UpdateClass{&tg.UpdatePrivacy{
|
if sessionID != 0 {
|
||||||
Key: tgPrivacyKey(saved.Key),
|
r.bookkeepAuxPtsForCurrentSession(ctx, event)
|
||||||
Rules: tgPrivacyRules(saved.Rules),
|
}
|
||||||
}},
|
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event))
|
||||||
Users: []tg.UserClass{},
|
} else if updates, ok := r.deps.Updates.(PrivacyUpdatesService); ok {
|
||||||
Chats: []tg.ChatClass{},
|
event, _, recordErr := updates.RecordPrivacy(
|
||||||
})
|
ctx, authKeyID, userID, saved, rawAuthKeyIDForOrigin(ctx), sessionID,
|
||||||
|
)
|
||||||
|
if recordErr != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
if sessionID != 0 {
|
||||||
|
r.bookkeepAuxPtsForCurrentSession(ctx, event)
|
||||||
|
}
|
||||||
|
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event))
|
||||||
|
} else {
|
||||||
|
r.pushUserUpdates(ctx, userID, &tg.Updates{
|
||||||
|
Updates: []tg.UpdateClass{&tg.UpdatePrivacy{
|
||||||
|
Key: tgPrivacyKey(saved.Key),
|
||||||
|
Rules: tgPrivacyRules(saved.Rules),
|
||||||
|
}},
|
||||||
|
Users: []tg.UserClass{},
|
||||||
|
Chats: []tg.ChatClass{},
|
||||||
|
Date: int(r.clock.Now().Unix()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if domainKey == domain.PrivacyKeyStatusTimestamp {
|
||||||
|
r.pushStatusPrivacyRefresh(ctx, userID)
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -908,10 +956,11 @@ func (r *Router) onAccountSetAccountTTL(ctx context.Context, ttl tg.AccountDaysT
|
||||||
return false, tgerr400("TTL_DAYS_INVALID")
|
return false, tgerr400("TTL_DAYS_INVALID")
|
||||||
}
|
}
|
||||||
if svc, ok := r.accountSettingsSvc(); ok {
|
if svc, ok := r.accountSettingsSvc(); ok {
|
||||||
if _, err := svc.SetAccountTTL(ctx, userID, ttl.Days); err != nil {
|
saved, err := svc.SetAccountTTL(ctx, userID, ttl.Days)
|
||||||
|
if err != nil {
|
||||||
return false, internalErr()
|
return false, internalErr()
|
||||||
}
|
}
|
||||||
r.accountSettings.Delete(userID)
|
r.accountSettings.Store(userID, saved)
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
@ -938,7 +987,7 @@ func (r *Router) onAccountSetGlobalPrivacySettings(ctx context.Context, settings
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
}
|
}
|
||||||
r.accountSettings.Delete(userID)
|
r.accountSettings.Store(userID, saved)
|
||||||
return tgGlobalPrivacySettings(saved.GlobalPrivacy), nil
|
return tgGlobalPrivacySettings(saved.GlobalPrivacy), nil
|
||||||
}
|
}
|
||||||
return &settings, nil
|
return &settings, nil
|
||||||
|
|
@ -965,10 +1014,11 @@ func (r *Router) onAccountSetContentSettings(ctx context.Context, req *tg.Accoun
|
||||||
return false, inputRequestInvalidErr()
|
return false, inputRequestInvalidErr()
|
||||||
}
|
}
|
||||||
if svc, ok := r.accountSettingsSvc(); ok {
|
if svc, ok := r.accountSettingsSvc(); ok {
|
||||||
if _, err := svc.SetSensitiveContent(ctx, userID, req.SensitiveEnabled); err != nil {
|
saved, err := svc.SetSensitiveContent(ctx, userID, req.SensitiveEnabled)
|
||||||
|
if err != nil {
|
||||||
return false, internalErr()
|
return false, internalErr()
|
||||||
}
|
}
|
||||||
r.accountSettings.Delete(userID)
|
r.accountSettings.Store(userID, saved)
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
@ -991,10 +1041,11 @@ func (r *Router) onAccountSetContactSignUpNotification(ctx context.Context, sile
|
||||||
return false, internalErr()
|
return false, internalErr()
|
||||||
}
|
}
|
||||||
if svc, ok := r.accountSettingsSvc(); ok {
|
if svc, ok := r.accountSettingsSvc(); ok {
|
||||||
if _, err := svc.SetContactSignUpSilent(ctx, userID, silent); err != nil {
|
saved, err := svc.SetContactSignUpSilent(ctx, userID, silent)
|
||||||
|
if err != nil {
|
||||||
return false, internalErr()
|
return false, internalErr()
|
||||||
}
|
}
|
||||||
r.accountSettings.Delete(userID)
|
r.accountSettings.Store(userID, saved)
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
108
internal/rpc/account_reports.go
Normal file
108
internal/rpc/account_reports.go
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/iamxvbaba/td/tg"
|
||||||
|
"github.com/iamxvbaba/td/tgerr"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Router) onAccountReportPeer(ctx context.Context, req *tg.AccountReportPeerRequest) (bool, error) {
|
||||||
|
if req == nil || req.Reason == nil {
|
||||||
|
return false, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(req.Message) || utf8.RuneCountInString(req.Message) > domain.MaxModerationCommentRunes {
|
||||||
|
return false, limitInvalidErr()
|
||||||
|
}
|
||||||
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
reason, ok := moderationReasonFromReportReason(req.Reason)
|
||||||
|
if !ok {
|
||||||
|
return false, tgerr.New(400, "REASON_INVALID")
|
||||||
|
}
|
||||||
|
if r.deps.Moderation == nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
if _, _, err := r.deps.Moderation.ReportPeer(
|
||||||
|
ctx, userID, domain.ModerationSourceAccountPeer, target,
|
||||||
|
reason, string(reason), req.Message, r.clock.Now(),
|
||||||
|
); err != nil {
|
||||||
|
return false, moderationReportError(err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onAccountReportProfilePhoto(ctx context.Context, req *tg.AccountReportProfilePhotoRequest) (bool, error) {
|
||||||
|
if req == nil || req.Reason == nil {
|
||||||
|
return false, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(req.Message) || utf8.RuneCountInString(req.Message) > domain.MaxModerationCommentRunes {
|
||||||
|
return false, limitInvalidErr()
|
||||||
|
}
|
||||||
|
photo, ok := req.PhotoID.(*tg.InputPhoto)
|
||||||
|
if !ok || photo == nil || photo.ID <= 0 {
|
||||||
|
return false, photoInvalidErr()
|
||||||
|
}
|
||||||
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
reason, ok := moderationReasonFromReportReason(req.Reason)
|
||||||
|
if !ok {
|
||||||
|
return false, tgerr.New(400, "REASON_INVALID")
|
||||||
|
}
|
||||||
|
if r.deps.Moderation == nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
if _, _, err := r.deps.Moderation.ReportProfilePhoto(ctx, domain.ModerationProfilePhotoReportRequest{
|
||||||
|
ReporterUserID: userID, Target: target, PhotoID: photo.ID,
|
||||||
|
AccessHash: photo.AccessHash, FileReference: append([]byte(nil), photo.FileReference...),
|
||||||
|
Reason: reason, Comment: req.Message, CreatedAt: r.clock.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
if err == domain.ErrModerationEvidenceNotFound {
|
||||||
|
return false, photoInvalidErr()
|
||||||
|
}
|
||||||
|
return false, moderationReportError(err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func moderationReasonFromReportReason(reason tg.ReportReasonClass) (domain.ModerationReason, bool) {
|
||||||
|
switch reason.(type) {
|
||||||
|
case *tg.InputReportReasonSpam:
|
||||||
|
return domain.ModerationReasonSpam, true
|
||||||
|
case *tg.InputReportReasonViolence:
|
||||||
|
return domain.ModerationReasonViolence, true
|
||||||
|
case *tg.InputReportReasonPornography:
|
||||||
|
return domain.ModerationReasonPornography, true
|
||||||
|
case *tg.InputReportReasonChildAbuse:
|
||||||
|
return domain.ModerationReasonChildAbuse, true
|
||||||
|
case *tg.InputReportReasonOther:
|
||||||
|
return domain.ModerationReasonOther, true
|
||||||
|
case *tg.InputReportReasonCopyright:
|
||||||
|
return domain.ModerationReasonCopyright, true
|
||||||
|
case *tg.InputReportReasonGeoIrrelevant:
|
||||||
|
return domain.ModerationReasonGeoIrrelevant, true
|
||||||
|
case *tg.InputReportReasonFake:
|
||||||
|
return domain.ModerationReasonFake, true
|
||||||
|
case *tg.InputReportReasonIllegalDrugs:
|
||||||
|
return domain.ModerationReasonIllegalDrugs, true
|
||||||
|
case *tg.InputReportReasonPersonalDetails:
|
||||||
|
return domain.ModerationReasonPersonalDetails, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
71
internal/rpc/account_reports_rpc_test.go
Normal file
71
internal/rpc/account_reports_rpc_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/iamxvbaba/td/clock"
|
||||||
|
"github.com/iamxvbaba/td/tg"
|
||||||
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
|
appmoderation "telesrv/internal/app/moderation"
|
||||||
|
appusers "telesrv/internal/app/users"
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccountReportPeerPersistsImmutableSnapshot(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
users := memory.NewUserStore()
|
||||||
|
reporter, err := users.Create(ctx, domain.User{
|
||||||
|
AccessHash: 101, Phone: "15550005001", FirstName: "Reporter",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target, err := users.Create(ctx, domain.User{
|
||||||
|
AccessHash: 202, Phone: "15550005002", FirstName: "Target",
|
||||||
|
Username: "reported_target", About: "original bio",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
userService := appusers.NewService(users)
|
||||||
|
reports := memory.NewModerationReportStore()
|
||||||
|
router := New(Config{}, Deps{
|
||||||
|
Users: userService,
|
||||||
|
Moderation: appmoderation.NewService(
|
||||||
|
reports, appmoderation.WithPeerReaders(userService, nil),
|
||||||
|
),
|
||||||
|
}, zaptest.NewLogger(t), clock.System)
|
||||||
|
ok, err := router.onAccountReportPeer(
|
||||||
|
WithUserID(ctx, reporter.ID),
|
||||||
|
&tg.AccountReportPeerRequest{
|
||||||
|
Peer: &tg.InputPeerUser{
|
||||||
|
UserID: target.ID, AccessHash: target.AccessHash,
|
||||||
|
},
|
||||||
|
Reason: &tg.InputReportReasonFake{},
|
||||||
|
Message: "This profile impersonates someone.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("report peer ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
stored := reports.Reports()
|
||||||
|
if len(stored) != 1 ||
|
||||||
|
stored[0].ReporterUserID != reporter.ID ||
|
||||||
|
stored[0].Target != (domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}) ||
|
||||||
|
stored[0].Reason != domain.ModerationReasonFake ||
|
||||||
|
len(stored[0].Items) != 1 ||
|
||||||
|
stored[0].Items[0].Kind != domain.ModerationItemPeer {
|
||||||
|
t.Fatalf("stored report=%+v", stored)
|
||||||
|
}
|
||||||
|
if _, err := users.UpdateProfile(ctx, target.ID, "Changed", "", "changed later"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, found, err := reports.GetModerationReport(ctx, stored[0].ID)
|
||||||
|
if err != nil || !found ||
|
||||||
|
string(again.Items[0].Evidence) != string(stored[0].Items[0].Evidence) {
|
||||||
|
t.Fatalf("immutable snapshot=%+v found=%v err=%v", again, found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,46 @@ func (c *accountSettingsCache) Delete(userID int64) {
|
||||||
c.cache.Invalidate(userID)
|
c.cache.Invalidate(userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *accountSettingsCache) Store(userID int64, settings domain.AccountSettings) {
|
||||||
|
if c == nil || userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.cache.Store(userID, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountSettingsBatchReader interface {
|
||||||
|
GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *accountSettingsCache) getOrLoadBatch(
|
||||||
|
ctx context.Context,
|
||||||
|
userIDs []int64,
|
||||||
|
svc accountSettingsService,
|
||||||
|
) (map[int64]domain.AccountSettings, error) {
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return map[int64]domain.AccountSettings{}, nil
|
||||||
|
}
|
||||||
|
return c.cache.GetOrLoadBatch(
|
||||||
|
ctx,
|
||||||
|
userIDs,
|
||||||
|
func(int64) (int64, bool) { return 0, true },
|
||||||
|
func(ctx context.Context, missing []int64) (map[int64]domain.AccountSettings, error) {
|
||||||
|
if batch, ok := svc.(accountSettingsBatchReader); ok {
|
||||||
|
return batch.GetAccountSettingsBatch(ctx, missing)
|
||||||
|
}
|
||||||
|
out := make(map[int64]domain.AccountSettings, len(missing))
|
||||||
|
for _, userID := range missing {
|
||||||
|
settings, err := svc.GetAccountSettings(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[userID] = settings
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// cachedAccountSettings 取(缓存的)账号单例设置;服务未接通返回默认。
|
// cachedAccountSettings 取(缓存的)账号单例设置;服务未接通返回默认。
|
||||||
func (r *Router) cachedAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, error) {
|
func (r *Router) cachedAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, error) {
|
||||||
svc, ok := r.accountSettingsSvc()
|
svc, ok := r.accountSettingsSvc()
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,9 @@ func (r *Router) registerAuth(d *tlprofile.Dispatcher) {
|
||||||
registerRPC[*tg.AuthSendCodeRequest](d, tlprofile.SemanticMethodAuthSendCode, func(ctx context.Context, layerRequest *tg.AuthSendCodeRequest) (any, error) {
|
registerRPC[*tg.AuthSendCodeRequest](d, tlprofile.SemanticMethodAuthSendCode, func(ctx context.Context, layerRequest *tg.AuthSendCodeRequest) (any, error) {
|
||||||
return r.onAuthSendCode(ctx, layerRequest)
|
return r.onAuthSendCode(ctx, layerRequest)
|
||||||
})
|
})
|
||||||
|
registerRPC[*tg.AuthReportMissingCodeRequest](d, tlprofile.SemanticMethodAuthReportMissingCode, func(ctx context.Context, req *tg.AuthReportMissingCodeRequest) (any, error) {
|
||||||
|
return r.onAuthReportMissingCode(ctx, req)
|
||||||
|
})
|
||||||
registerRPC[*tg.AuthResendCodeRequest](d, tlprofile.SemanticMethodAuthResendCode, func(ctx context.Context, layerRequest *tg.AuthResendCodeRequest) (any, error) {
|
registerRPC[*tg.AuthResendCodeRequest](d, tlprofile.SemanticMethodAuthResendCode, func(ctx context.Context, layerRequest *tg.AuthResendCodeRequest) (any, error) {
|
||||||
return r.onAuthResendCode(ctx, layerRequest)
|
return r.onAuthResendCode(ctx, layerRequest)
|
||||||
})
|
})
|
||||||
|
|
@ -376,6 +379,36 @@ func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest
|
||||||
return r.tgSentCodeForHash(ctx, hash)
|
return r.tgSentCodeForHash(ctx, hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Router) onAuthReportMissingCode(ctx context.Context, req *tg.AuthReportMissingCodeRequest) (bool, error) {
|
||||||
|
if req == nil || r.deps.AuthDeliveryReports == nil {
|
||||||
|
return false, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
authKeyID, authKeyOK := AuthKeyIDFrom(ctx)
|
||||||
|
sessionID, sessionOK := SessionIDFrom(ctx)
|
||||||
|
if !authKeyOK || authKeyID == ([8]byte{}) || !sessionOK || sessionID == 0 {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
clientType := string(ClientTypeFrom(ctx))
|
||||||
|
if _, _, err := r.deps.AuthDeliveryReports.ReportMissingCode(ctx, domain.AuthMissingCodeReportRequest{
|
||||||
|
AuthKeyID: authKeyID, SessionID: sessionID, ClientType: clientType,
|
||||||
|
Phone: req.PhoneNumber, PhoneCodeHash: req.PhoneCodeHash,
|
||||||
|
MNC: req.Mnc, CreatedAt: r.clock.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrPhoneCodeExpired):
|
||||||
|
return false, phoneCodeExpiredErr()
|
||||||
|
case errors.Is(err, domain.ErrPhoneCodeInvalid),
|
||||||
|
errors.Is(err, domain.ErrAuthDeliveryReportInvalid):
|
||||||
|
return false, phoneCodeInvalidErr()
|
||||||
|
case errors.Is(err, domain.ErrAuthDeliveryRateLimited):
|
||||||
|
return false, floodWaitErr(60)
|
||||||
|
default:
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func tgSentCode(hash string) tg.AuthSentCodeClass {
|
func tgSentCode(hash string) tg.AuthSentCodeClass {
|
||||||
return tgSentCodeWithLength(hash, devCodeLength)
|
return tgSentCodeWithLength(hash, devCodeLength)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@ func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreat
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
memberIDs = createChatInviteMemberIDs(memberIDs, userID)
|
memberIDs = createChatInviteMemberIDs(memberIDs, userID)
|
||||||
|
memberIDs, missingInvitees, err := r.filterChatInvitePrivacy(ctx, userID, memberIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
date := int(r.clock.Now().Unix())
|
date := int(r.clock.Now().Unix())
|
||||||
r.log.Debug("messages.createChat resolved users",
|
r.log.Debug("messages.createChat resolved users",
|
||||||
zap.Int("input_users", len(req.Users)),
|
zap.Int("input_users", len(req.Users)),
|
||||||
|
|
@ -84,7 +88,7 @@ func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreat
|
||||||
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, inviteRes, cache)
|
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, inviteRes, cache)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: []tg.MissingInvitee{}}, nil
|
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: missingInvitees}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onMessagesMigrateChat(ctx context.Context, chatID int64) (tg.UpdatesClass, error) {
|
func (r *Router) onMessagesMigrateChat(ctx context.Context, chatID int64) (tg.UpdatesClass, error) {
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,27 @@ func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.Channels
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
res, err := r.deps.Channels.InviteToChannel(ctx, userID, channelID, userIDs, int(r.clock.Now().Unix()))
|
// Authorize before evaluating target privacy, otherwise a non-admin could
|
||||||
|
// probe whether a target permits invites.
|
||||||
|
view, err := r.deps.Channels.ResolveChannel(ctx, userID, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, channelInviteErr(err)
|
||||||
|
}
|
||||||
|
if !view.Self.CanInviteUsers(view.Channel) {
|
||||||
|
return nil, channelInviteErr(domain.ErrChannelAdminRequired)
|
||||||
|
}
|
||||||
|
userIDs, missingInvitees, err := r.filterChatInvitePrivacy(ctx, userID, userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
date := int(r.clock.Now().Unix())
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return &tg.MessagesInvitedUsers{
|
||||||
|
Updates: emptyInvitedUsersUpdates(date),
|
||||||
|
MissingInvitees: missingInvitees,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
res, err := r.deps.Channels.InviteToChannel(ctx, userID, channelID, userIDs, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, channelInviteErr(err)
|
return nil, channelInviteErr(err)
|
||||||
}
|
}
|
||||||
|
|
@ -322,7 +342,7 @@ func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.Channels
|
||||||
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
|
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
|
||||||
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, res, cache)
|
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, res, cache)
|
||||||
})
|
})
|
||||||
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: []tg.MissingInvitee{}}, nil
|
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: missingInvitees}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onChannelsJoinChannel(ctx context.Context, input tg.InputChannelClass) (tg.MessagesChatInviteJoinResultClass, error) {
|
func (r *Router) onChannelsJoinChannel(ctx context.Context, input tg.InputChannelClass) (tg.MessagesChatInviteJoinResultClass, error) {
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,24 @@ func (r *Router) onChannelsDeleteMessages(ctx context.Context, req *tg.ChannelsD
|
||||||
return &tg.MessagesAffectedMessages{Pts: res.Channel.Pts, PtsCount: 0}, nil
|
return &tg.MessagesAffectedMessages{Pts: res.Channel.Pts, PtsCount: 0}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NotifyModerationChannelDeletion performs the online accelerator for a
|
||||||
|
// server-authority deletion already committed by the moderation action worker.
|
||||||
|
// Durable channel update events remain the offline recovery source.
|
||||||
|
func (r *Router) NotifyModerationChannelDeletion(ctx context.Context, res domain.DeleteChannelMessagesResult) {
|
||||||
|
if r == nil || res.Event.Pts == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.enqueueChannelFanout(ctx, channelFanoutMembers, domain.OfficialSystemUserID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
|
||||||
|
return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event)
|
||||||
|
})
|
||||||
|
for _, cascade := range res.DiscussionDeletes {
|
||||||
|
cascade := cascade
|
||||||
|
r.enqueueChannelFanout(ctx, channelFanoutMembers, domain.OfficialSystemUserID, cascade.Channel.ID, cascade.Event.Pts, cascade.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
|
||||||
|
return r.channelDeleteMessagesUpdates(viewerUserID, cascade.Channel, cascade.Event)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Router) onChannelsDeleteHistory(ctx context.Context, req *tg.ChannelsDeleteHistoryRequest) (tg.UpdatesClass, error) {
|
func (r *Router) onChannelsDeleteHistory(ctx context.Context, req *tg.ChannelsDeleteHistoryRequest) (tg.UpdatesClass, error) {
|
||||||
if r.deps.Channels == nil {
|
if r.deps.Channels == nil {
|
||||||
return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil
|
return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ package rpc
|
||||||
import (
|
import (
|
||||||
"github.com/iamxvbaba/td/tg"
|
"github.com/iamxvbaba/td/tg"
|
||||||
"strings"
|
"strings"
|
||||||
|
apptelemetry "telesrv/internal/app/clienttelemetry"
|
||||||
|
appmoderation "telesrv/internal/app/moderation"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -11,6 +14,14 @@ import (
|
||||||
func TestTDesktopPassiveChannelStubs(t *testing.T) {
|
func TestTDesktopPassiveChannelStubs(t *testing.T) {
|
||||||
f := newRPCChannelFixture(t)
|
f := newRPCChannelFixture(t)
|
||||||
r := f.router
|
r := f.router
|
||||||
|
moderationReports := memory.NewModerationReportStore()
|
||||||
|
telemetryEvents := memory.NewClientTelemetryStore()
|
||||||
|
r.deps.ClientTelemetry = apptelemetry.NewService(telemetryEvents)
|
||||||
|
r.deps.Moderation = appmoderation.NewService(
|
||||||
|
moderationReports,
|
||||||
|
appmoderation.WithMessageReaders(nil, r.deps.Channels),
|
||||||
|
appmoderation.WithPeerReaders(r.deps.Users, r.deps.Channels),
|
||||||
|
)
|
||||||
owner := f.user(41, "15550002101", "Owner")
|
owner := f.user(41, "15550002101", "Owner")
|
||||||
friend := f.user(42, "15550002102", "Friend")
|
friend := f.user(42, "15550002102", "Friend")
|
||||||
invited := f.user(43, "15550002103", "Invited")
|
invited := f.user(43, "15550002103", "Invited")
|
||||||
|
|
@ -470,6 +481,9 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
|
||||||
if _, ok := reported.(*tg.ReportResultReported); !ok {
|
if _, ok := reported.(*tg.ReportResultReported); !ok {
|
||||||
t.Fatalf("messages.report spam = %#v, want reported", reported)
|
t.Fatalf("messages.report spam = %#v, want reported", reported)
|
||||||
}
|
}
|
||||||
|
if got := moderationReports.Reports(); len(got) != 2 {
|
||||||
|
t.Fatalf("moderation reports = %+v, want peer-spam and message reports", got)
|
||||||
|
}
|
||||||
if _, err := r.onMessagesReport(ownerCtx, &tg.MessagesReportRequest{
|
if _, err := r.onMessagesReport(ownerCtx, &tg.MessagesReportRequest{
|
||||||
Peer: inputPeerChannel(channel),
|
Peer: inputPeerChannel(channel),
|
||||||
ID: []int{1},
|
ID: []int{1},
|
||||||
|
|
@ -477,13 +491,6 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
|
||||||
}); err == nil || !strings.Contains(err.Error(), "OPTION_INVALID") {
|
}); err == nil || !strings.Contains(err.Error(), "OPTION_INVALID") {
|
||||||
t.Fatalf("messages.report invalid option err = %v, want OPTION_INVALID", err)
|
t.Fatalf("messages.report invalid option err = %v, want OPTION_INVALID", err)
|
||||||
}
|
}
|
||||||
if ok, err := r.onMessagesReportReaction(ownerCtx, &tg.MessagesReportReactionRequest{
|
|
||||||
Peer: inputPeerChannel(channel),
|
|
||||||
ID: 1,
|
|
||||||
ReactionPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
|
||||||
}); err != nil || !ok {
|
|
||||||
t.Fatalf("messages.reportReaction = ok %v err %v, want true nil", ok, err)
|
|
||||||
}
|
|
||||||
if ok, err := r.onMessagesReportMessagesDelivery(ownerCtx, &tg.MessagesReportMessagesDeliveryRequest{
|
if ok, err := r.onMessagesReportMessagesDelivery(ownerCtx, &tg.MessagesReportMessagesDeliveryRequest{
|
||||||
Peer: inputPeerChannel(channel),
|
Peer: inputPeerChannel(channel),
|
||||||
ID: []int{1},
|
ID: []int{1},
|
||||||
|
|
@ -503,11 +510,45 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
|
||||||
}); err != nil || !ok {
|
}); err != nil || !ok {
|
||||||
t.Fatalf("messages.reportReadMetrics = ok %v err %v, want true nil", ok, err)
|
t.Fatalf("messages.reportReadMetrics = ok %v err %v, want true nil", ok, err)
|
||||||
}
|
}
|
||||||
|
if events := telemetryEvents.Events(); len(events) != 2 ||
|
||||||
|
events[0].Kind != domain.ClientTelemetryMessageDelivery ||
|
||||||
|
events[1].Kind != domain.ClientTelemetryReadMetrics ||
|
||||||
|
len(moderationReports.Reports()) != 2 {
|
||||||
|
t.Fatalf("telemetry=%+v moderation=%+v, want separate durable streams",
|
||||||
|
events, moderationReports.Reports())
|
||||||
|
}
|
||||||
if ok, err := r.onMessagesReportMusicListen(ownerCtx, &tg.MessagesReportMusicListenRequest{
|
if ok, err := r.onMessagesReportMusicListen(ownerCtx, &tg.MessagesReportMusicListenRequest{
|
||||||
ID: &tg.InputDocument{ID: 1, AccessHash: 2},
|
ID: &tg.InputDocument{ID: 1, AccessHash: 2},
|
||||||
ListenedDuration: 1,
|
ListenedDuration: 1,
|
||||||
}); err != nil || !ok {
|
}); err == nil || ok || !strings.Contains(err.Error(), "DOCUMENT_INVALID") {
|
||||||
t.Fatalf("messages.reportMusicListen = ok %v err %v, want true nil", ok, err)
|
t.Fatalf("messages.reportMusicListen = ok %v err %v, want DOCUMENT_INVALID", ok, err)
|
||||||
|
}
|
||||||
|
if _, err := r.onMessagesReportSponsoredMessage(ownerCtx, &tg.MessagesReportSponsoredMessageRequest{
|
||||||
|
RandomID: []byte("unseen-ad"),
|
||||||
|
}); err == nil || !strings.Contains(err.Error(), "RANDOM_ID_INVALID") {
|
||||||
|
t.Fatalf("unseen sponsored report err=%v, want RANDOM_ID_INVALID", err)
|
||||||
|
}
|
||||||
|
impression, err := domain.NewSponsoredMessageImpression(
|
||||||
|
owner.ID, []byte("ad"),
|
||||||
|
domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID},
|
||||||
|
0, []byte(`{"schema_version":1,"text":"test sponsored message"}`),
|
||||||
|
time.Now().UTC(), time.Now().UTC().Add(time.Hour),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := moderationReports.CreateSponsoredMessageImpression(ownerCtx, impression); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sponsoredOptions, err := r.onMessagesReportSponsoredMessage(ownerCtx, &tg.MessagesReportSponsoredMessageRequest{
|
||||||
|
RandomID: []byte("ad"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("messages.reportSponsoredMessage options: %v", err)
|
||||||
|
}
|
||||||
|
if choices, ok := sponsoredOptions.(*tg.ChannelsSponsoredMessageReportResultChooseOption); !ok ||
|
||||||
|
len(choices.Options) == 0 {
|
||||||
|
t.Fatalf("sponsored options=%#v, want chooseOption", sponsoredOptions)
|
||||||
}
|
}
|
||||||
sponsoredReport, err := r.onMessagesReportSponsoredMessage(ownerCtx, &tg.MessagesReportSponsoredMessageRequest{
|
sponsoredReport, err := r.onMessagesReportSponsoredMessage(ownerCtx, &tg.MessagesReportSponsoredMessageRequest{
|
||||||
RandomID: []byte("ad"),
|
RandomID: []byte("ad"),
|
||||||
|
|
@ -577,6 +618,13 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
|
||||||
if _, err := r.onMessagesSendReaction(friendCtx, friendReactionReq); err != nil {
|
if _, err := r.onMessagesSendReaction(friendCtx, friendReactionReq); err != nil {
|
||||||
t.Fatalf("messages.sendReaction by friend: %v", err)
|
t.Fatalf("messages.sendReaction by friend: %v", err)
|
||||||
}
|
}
|
||||||
|
if ok, err := r.onMessagesReportReaction(ownerCtx, &tg.MessagesReportReactionRequest{
|
||||||
|
Peer: inputPeerChannel(channel),
|
||||||
|
ID: viewedID,
|
||||||
|
ReactionPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||||
|
}); err != nil || !ok {
|
||||||
|
t.Fatalf("messages.reportReaction = ok %v err %v, want true nil", ok, err)
|
||||||
|
}
|
||||||
unreadReactions, err := r.onMessagesGetUnreadReactions(ownerCtx, &tg.MessagesGetUnreadReactionsRequest{
|
unreadReactions, err := r.onMessagesGetUnreadReactions(ownerCtx, &tg.MessagesGetUnreadReactionsRequest{
|
||||||
Peer: inputPeerChannel(channel),
|
Peer: inputPeerChannel(channel),
|
||||||
Limit: 10,
|
Limit: 10,
|
||||||
|
|
|
||||||
|
|
@ -206,9 +206,21 @@ func (r *Router) onChannelsReportAntiSpamFalsePositive(ctx context.Context, req
|
||||||
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
||||||
return false, messageIDInvalidErr()
|
return false, messageIDInvalidErr()
|
||||||
}
|
}
|
||||||
if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil {
|
userID, view, err := r.channelChangeInfoView(ctx, req.Channel)
|
||||||
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if !view.Channel.Megagroup || view.Channel.Broadcast {
|
||||||
|
return false, channelInvalidErr(domain.ErrChannelInvalid)
|
||||||
|
}
|
||||||
|
if r.deps.Moderation == nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
if _, _, err := r.deps.Moderation.ReportAntiSpamFalsePositive(
|
||||||
|
ctx, userID, view.Channel.ID, req.MsgID, r.clock.Now(),
|
||||||
|
); err != nil {
|
||||||
|
return false, moderationReportError(err)
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/iamxvbaba/td/clock"
|
"github.com/iamxvbaba/td/clock"
|
||||||
"github.com/iamxvbaba/td/tg"
|
"github.com/iamxvbaba/td/tg"
|
||||||
|
|
@ -12,6 +13,7 @@ import (
|
||||||
|
|
||||||
appchannels "telesrv/internal/app/channels"
|
appchannels "telesrv/internal/app/channels"
|
||||||
appdialogs "telesrv/internal/app/dialogs"
|
appdialogs "telesrv/internal/app/dialogs"
|
||||||
|
appmoderation "telesrv/internal/app/moderation"
|
||||||
"telesrv/internal/app/readmodel"
|
"telesrv/internal/app/readmodel"
|
||||||
appusers "telesrv/internal/app/users"
|
appusers "telesrv/internal/app/users"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
|
@ -598,9 +600,17 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
|
||||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002301", FirstName: "Owner"})
|
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002301", FirstName: "Owner"})
|
||||||
requester, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002302", FirstName: "Requester"})
|
requester, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002302", FirstName: "Requester"})
|
||||||
channelStore := memory.NewChannelStore()
|
channelStore := memory.NewChannelStore()
|
||||||
|
moderationStore := memory.NewModerationReportStore()
|
||||||
|
userService := appusers.NewService(userStore)
|
||||||
|
channelService := appchannels.NewService(channelStore)
|
||||||
r := New(Config{}, Deps{
|
r := New(Config{}, Deps{
|
||||||
Users: appusers.NewService(userStore),
|
Users: userService,
|
||||||
Channels: appchannels.NewService(channelStore),
|
Channels: channelService,
|
||||||
|
Moderation: appmoderation.NewService(
|
||||||
|
moderationStore,
|
||||||
|
appmoderation.WithMessageReaders(nil, channelService),
|
||||||
|
appmoderation.WithPeerReaders(userService, channelService),
|
||||||
|
),
|
||||||
}, zaptest.NewLogger(t), clock.System)
|
}, zaptest.NewLogger(t), clock.System)
|
||||||
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
|
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
|
||||||
Title: "Public Team",
|
Title: "Public Team",
|
||||||
|
|
@ -621,6 +631,23 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
|
||||||
t.Fatalf("send seed message: %v", err)
|
t.Fatalf("send seed message: %v", err)
|
||||||
}
|
}
|
||||||
msgID := sent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID
|
msgID := sent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID
|
||||||
|
if ok, err := r.onChannelsReportAntiSpamFalsePositive(
|
||||||
|
WithUserID(ctx, owner.ID),
|
||||||
|
&tg.ChannelsReportAntiSpamFalsePositiveRequest{Channel: input, MsgID: msgID},
|
||||||
|
); err == nil || ok || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
|
||||||
|
t.Fatalf("report anti-spam without native decision = ok %v err %v, want MESSAGE_ID_INVALID", ok, err)
|
||||||
|
}
|
||||||
|
antiSpamDecision, err := domain.NewChannelAntiSpamDecision(
|
||||||
|
channel.ID, msgID, owner.ID,
|
||||||
|
[]byte(`{"schema_version":1,"source":"native_antispam"}`),
|
||||||
|
time.Now().UTC(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := moderationStore.CreateChannelAntiSpamDecision(ctx, antiSpamDecision); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
okUsername, err := r.onChannelsCheckUsername(WithUserID(ctx, owner.ID), &tg.ChannelsCheckUsernameRequest{
|
okUsername, err := r.onChannelsCheckUsername(WithUserID(ctx, owner.ID), &tg.ChannelsCheckUsernameRequest{
|
||||||
Channel: input,
|
Channel: input,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@ func validateEmptyChannelStickerSet(stickerset tg.InputStickerSetClass) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onChannelsReportSpam(ctx context.Context, req *tg.ChannelsReportSpamRequest) (bool, error) {
|
func (r *Router) onChannelsReportSpam(ctx context.Context, req *tg.ChannelsReportSpamRequest) (bool, error) {
|
||||||
|
if req == nil {
|
||||||
|
return false, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
if len(req.ID) == 0 {
|
||||||
|
return false, tgerr.New(400, "MESSAGE_ID_REQUIRED")
|
||||||
|
}
|
||||||
if len(req.ID) > maxChannelReportMessageIDs {
|
if len(req.ID) > maxChannelReportMessageIDs {
|
||||||
return false, limitInvalidErr()
|
return false, limitInvalidErr()
|
||||||
}
|
}
|
||||||
|
|
@ -26,12 +32,30 @@ func (r *Router) onChannelsReportSpam(ctx context.Context, req *tg.ChannelsRepor
|
||||||
return false, messageIDInvalidErr()
|
return false, messageIDInvalidErr()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, _, err := r.channelView(ctx, req.Channel); err != nil {
|
userID, view, err := r.channelChangeInfoView(ctx, req.Channel)
|
||||||
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if peer, ok := r.domainPeerFromInputPeer(0, req.Participant); !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 {
|
if !view.Channel.Megagroup {
|
||||||
|
return false, channelInvalidErr(domain.ErrChannelInvalid)
|
||||||
|
}
|
||||||
|
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Participant)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if peer.Type != domain.PeerTypeUser || peer.ID == 0 {
|
||||||
return false, peerIDInvalidErr()
|
return false, peerIDInvalidErr()
|
||||||
}
|
}
|
||||||
|
if r.deps.Moderation == nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
if _, _, err := r.deps.Moderation.ReportChannelSpam(ctx, domain.ModerationChannelSpamReportRequest{
|
||||||
|
ReporterUserID: userID, ChannelID: view.Channel.ID,
|
||||||
|
ParticipantUserID: peer.ID, MessageIDs: req.ID,
|
||||||
|
CreatedAt: r.clock.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
return false, moderationReportError(err)
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -540,6 +540,7 @@ func (r *Router) onContactsGetStatuses(ctx context.Context) ([]tg.ContactStatus,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
statusVisible := r.statusTimestampVisibleToViewer(ctx, contactUserIDs, userID)
|
||||||
seen = make(map[int64]struct{}, len(list.Contacts))
|
seen = make(map[int64]struct{}, len(list.Contacts))
|
||||||
for _, contact := range list.Contacts {
|
for _, contact := range list.Contacts {
|
||||||
id := contact.User.ID
|
id := contact.User.ID
|
||||||
|
|
@ -555,9 +556,19 @@ func (r *Router) onContactsGetStatuses(ctx context.Context) ([]tg.ContactStatus,
|
||||||
u.LastSeenAt = current.LastSeenAt
|
u.LastSeenAt = current.LastSeenAt
|
||||||
u.Status = current.Status
|
u.Status = current.Status
|
||||||
}
|
}
|
||||||
|
status := u.Status
|
||||||
|
if statusVisible[id] {
|
||||||
|
status = r.userPresenceStatusForUser(u)
|
||||||
|
} else {
|
||||||
|
switch status.Kind {
|
||||||
|
case domain.UserStatusRecently, domain.UserStatusLastWeek, domain.UserStatusLastMonth, domain.UserStatusEmpty:
|
||||||
|
default:
|
||||||
|
status = domain.ApproximateUserStatus(u.LastSeenAt, int(r.clock.Now().Unix()))
|
||||||
|
}
|
||||||
|
}
|
||||||
out = append(out, tg.ContactStatus{
|
out = append(out, tg.ContactStatus{
|
||||||
UserID: id,
|
UserID: id,
|
||||||
Status: tgUserStatus(r.userPresenceStatusForUser(u)),
|
Status: tgUserStatus(status),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|
|
||||||
|
|
@ -543,16 +543,6 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// channelAboutWithModerationWarning decorates the projected channel/supergroup
|
|
||||||
// About with the scam/fake warning when set (group vs channel wording).
|
|
||||||
func channelAboutWithModerationWarning(ch domain.Channel) string {
|
|
||||||
scamText, fakeText := defaultScamWarningChannel, defaultFakeWarningChannel
|
|
||||||
if ch.Megagroup && !ch.Broadcast {
|
|
||||||
scamText, fakeText = defaultScamWarningGroup, defaultFakeWarningGroup
|
|
||||||
}
|
|
||||||
return aboutWithModerationWarning(ch.About, scamText, fakeText, ch.Scam, ch.Fake)
|
|
||||||
}
|
|
||||||
|
|
||||||
func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.ChannelFull {
|
func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.ChannelFull {
|
||||||
ch := view.Channel
|
ch := view.Channel
|
||||||
full := &tg.ChannelFull{
|
full := &tg.ChannelFull{
|
||||||
|
|
@ -563,13 +553,15 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
|
||||||
CanSetUsername: view.Self.Role == domain.ChannelRoleCreator,
|
CanSetUsername: view.Self.Role == domain.ChannelRoleCreator,
|
||||||
CanDeleteChannel: view.Self.Role == domain.ChannelRoleCreator,
|
CanDeleteChannel: view.Self.Role == domain.ChannelRoleCreator,
|
||||||
ID: ch.ID,
|
ID: ch.ID,
|
||||||
About: channelAboutWithModerationWarning(ch),
|
// Official clients render localized warnings from scam/fake flags.
|
||||||
ReadInboxMaxID: view.Dialog.ReadInboxMaxID,
|
// About remains the owner's unmodified description.
|
||||||
ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID,
|
About: ch.About,
|
||||||
UnreadCount: view.Dialog.UnreadCount,
|
ReadInboxMaxID: view.Dialog.ReadInboxMaxID,
|
||||||
ChatPhoto: tgChannelChatPhotoFull(ch),
|
ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID,
|
||||||
NotifySettings: *tdesktop.NotifySettings(),
|
UnreadCount: view.Dialog.UnreadCount,
|
||||||
Pts: ch.Pts,
|
ChatPhoto: tgChannelChatPhotoFull(ch),
|
||||||
|
NotifySettings: *tdesktop.NotifySettings(),
|
||||||
|
Pts: ch.Pts,
|
||||||
}
|
}
|
||||||
if ch.ParticipantsCount > 0 {
|
if ch.ParticipantsCount > 0 {
|
||||||
full.SetParticipantsCount(ch.ParticipantsCount)
|
full.SetParticipantsCount(ch.ParticipantsCount)
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
package rpc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Scam/fake profile warnings surfaced in the full-profile About text.
|
|
||||||
//
|
|
||||||
// Telegram Desktop only ships the SCAM/FAKE badge strings and renders no
|
|
||||||
// warning paragraph, while iOS/Android show a localized warning. To make the
|
|
||||||
// warning visible on every client, the server injects it into the projected
|
|
||||||
// getFullUser/getFullChannel About field. Injection is non-destructive: the
|
|
||||||
// stored bio/description is never overwritten, only the response is decorated,
|
|
||||||
// so clearing the flag restores the original text and the warning survives the
|
|
||||||
// owner editing their bio/description (it is re-applied from the flag on every
|
|
||||||
// read).
|
|
||||||
//
|
|
||||||
// The text is server-provided (clients cannot localize it). Operators override
|
|
||||||
// it via TELESRV_SCAM_WARNING / TELESRV_FAKE_WARNING; when unset the built-in
|
|
||||||
// per-peer-type English defaults are used. scam takes precedence over fake.
|
|
||||||
const (
|
|
||||||
defaultScamWarningUser = "\u26A0\uFE0F Warning: Many users reported this account as a scam. Please be careful, especially if it asks you for money."
|
|
||||||
defaultFakeWarningUser = "\u26A0\uFE0F Warning: Many users reported that this account impersonates a famous person or organization."
|
|
||||||
defaultScamWarningChannel = "\u26A0\uFE0F Warning: Many users reported this channel as a scam. Please be careful, especially if it asks you for money."
|
|
||||||
defaultFakeWarningChannel = "\u26A0\uFE0F Warning: Many users reported that this channel impersonates a famous person or organization."
|
|
||||||
defaultScamWarningGroup = "\u26A0\uFE0F Warning: Many users reported this group as a scam. Please be careful, especially if it asks you for money."
|
|
||||||
defaultFakeWarningGroup = "\u26A0\uFE0F Warning: Many users reported that this group impersonates a famous person or organization."
|
|
||||||
)
|
|
||||||
|
|
||||||
// moderationWarningOverrides holds the operator-configured texts. They are set
|
|
||||||
// once at startup (SetModerationWarnings) before any request is served, and
|
|
||||||
// read on the hot path; atomic.Pointer keeps that race-free without locking.
|
|
||||||
var moderationWarningOverrides atomic.Pointer[moderationWarningConfig]
|
|
||||||
|
|
||||||
type moderationWarningConfig struct {
|
|
||||||
scam string
|
|
||||||
fake string
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetModerationWarnings installs operator overrides for the scam/fake profile
|
|
||||||
// warnings. Empty strings keep the built-in per-peer-type defaults. A single
|
|
||||||
// override applies to every peer type (user/channel/group).
|
|
||||||
func SetModerationWarnings(scam, fake string) {
|
|
||||||
moderationWarningOverrides.Store(&moderationWarningConfig{
|
|
||||||
scam: strings.TrimSpace(scam),
|
|
||||||
fake: strings.TrimSpace(fake),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func moderationOverride() moderationWarningConfig {
|
|
||||||
if cfg := moderationWarningOverrides.Load(); cfg != nil {
|
|
||||||
return *cfg
|
|
||||||
}
|
|
||||||
return moderationWarningConfig{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// aboutWithModerationWarning prepends the scam/fake warning to a profile About.
|
|
||||||
// It returns about unchanged when neither flag is set. The operator override
|
|
||||||
// wins over the per-type default; scam wins over fake when both are set.
|
|
||||||
func aboutWithModerationWarning(about, scamDefault, fakeDefault string, scam, fake bool) string {
|
|
||||||
override := moderationOverride()
|
|
||||||
warning := ""
|
|
||||||
switch {
|
|
||||||
case scam:
|
|
||||||
if warning = override.scam; warning == "" {
|
|
||||||
warning = scamDefault
|
|
||||||
}
|
|
||||||
case fake:
|
|
||||||
if warning = override.fake; warning == "" {
|
|
||||||
warning = fakeDefault
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if warning == "" {
|
|
||||||
return about
|
|
||||||
}
|
|
||||||
if about = strings.TrimSpace(about); about == "" {
|
|
||||||
return warning
|
|
||||||
}
|
|
||||||
return warning + "\n\n" + about
|
|
||||||
}
|
|
||||||
|
|
@ -242,6 +242,14 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &tg.UpdateUserEmojiStatus{UserID: event.UserID, EmojiStatus: tgUserEmojiStatusValue(event.EmojiStatus)}
|
return &tg.UpdateUserEmojiStatus{UserID: event.UserID, EmojiStatus: tgUserEmojiStatusValue(event.EmojiStatus)}
|
||||||
|
case domain.UpdateEventPrivacy:
|
||||||
|
if event.Privacy.OwnerUserID == 0 || event.Privacy.Key == "" || len(event.Privacy.Rules) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &tg.UpdatePrivacy{
|
||||||
|
Key: tgPrivacyKey(event.Privacy.Key),
|
||||||
|
Rules: tgPrivacyRules(event.Privacy.Rules),
|
||||||
|
}
|
||||||
case domain.UpdateEventChannelState:
|
case domain.UpdateEventChannelState:
|
||||||
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 {
|
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ func tgSelfUser(u domain.User) *tg.User {
|
||||||
Phone: u.Phone,
|
Phone: u.Phone,
|
||||||
Self: true,
|
Self: true,
|
||||||
Verified: u.Verified,
|
Verified: u.Verified,
|
||||||
|
Scam: u.Scam,
|
||||||
|
Fake: u.Fake,
|
||||||
Support: u.Support,
|
Support: u.Support,
|
||||||
Contact: u.Contact,
|
Contact: u.Contact,
|
||||||
MutualContact: u.Mutual,
|
MutualContact: u.Mutual,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,14 @@ type AuthService interface {
|
||||||
ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
|
ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthDeliveryReportService interface {
|
||||||
|
ReportMissingCode(ctx context.Context, req domain.AuthMissingCodeReportRequest) (domain.AuthDeliveryReport, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientTelemetryService interface {
|
||||||
|
Record(ctx context.Context, userID int64, kind domain.ClientTelemetryKind, peer domain.Peer, subjectIDs []int64, payload any, createdAt time.Time) (domain.ClientTelemetryEvent, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
// SessionBinder 抽象登录后 session 与 user 的在线绑定。
|
// SessionBinder 抽象登录后 session 与 user 的在线绑定。
|
||||||
//
|
//
|
||||||
// MTProto session 的完整身份是 raw auth_key_id + session_id。所有定位单个 session
|
// MTProto session 的完整身份是 raw auth_key_id + session_id。所有定位单个 session
|
||||||
|
|
@ -470,6 +478,26 @@ type UserEmojiStatusUpdatesService interface {
|
||||||
RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]byte, userID int64, status domain.UserEmojiStatus, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
|
RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]byte, userID int64, status domain.UserEmojiStatus, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrivacyUpdatesService is the fallback durable extension for stores that do
|
||||||
|
// not support the atomic privacy+event write boundary (mainly memory tests).
|
||||||
|
type PrivacyUpdatesService interface {
|
||||||
|
RecordPrivacy(ctx context.Context, stateAuthKeyID [8]byte, userID int64, rules domain.PrivacyRules, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrivacyDurableService is implemented by the production privacy service. Its
|
||||||
|
// successful path commits rules+pts+event+dispatch in one transaction.
|
||||||
|
type PrivacyDurableService interface {
|
||||||
|
SetRulesWithUpdate(
|
||||||
|
ctx context.Context,
|
||||||
|
ownerUserID int64,
|
||||||
|
key domain.PrivacyKey,
|
||||||
|
rules []domain.PrivacyRule,
|
||||||
|
date int,
|
||||||
|
excludeAuthKeyID [8]byte,
|
||||||
|
excludeSessionID int64,
|
||||||
|
) (saved domain.PrivacyRules, event domain.UpdateEvent, durable bool, err error)
|
||||||
|
}
|
||||||
|
|
||||||
// ContactsService 抽象通讯录查询。
|
// ContactsService 抽象通讯录查询。
|
||||||
type ContactsService interface {
|
type ContactsService interface {
|
||||||
GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error)
|
GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error)
|
||||||
|
|
@ -669,6 +697,7 @@ type ChannelsService interface {
|
||||||
VoteMessagePoll(ctx context.Context, userID int64, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error)
|
VoteMessagePoll(ctx context.Context, userID int64, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error)
|
||||||
CloseMessagePoll(ctx context.Context, userID int64, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error)
|
CloseMessagePoll(ctx context.Context, userID int64, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error)
|
||||||
ListMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error)
|
ListMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error)
|
||||||
|
FindMessageReaction(ctx context.Context, userID int64, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error)
|
||||||
TopReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
|
TopReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
|
||||||
RecentReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
|
RecentReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
|
||||||
ClearRecentReactions(ctx context.Context, userID int64) error
|
ClearRecentReactions(ctx context.Context, userID int64) error
|
||||||
|
|
@ -875,9 +904,28 @@ type EphemeralService interface {
|
||||||
ReportTarget(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error)
|
ReportTarget(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ModerationService accepts only final report choices. Implementations must
|
||||||
|
// validate and snapshot referenced evidence, then durably commit the immutable
|
||||||
|
// submission before returning success.
|
||||||
|
type ModerationService interface {
|
||||||
|
ReportPeer(ctx context.Context, reporterUserID int64, source domain.ModerationReportSource, target domain.Peer, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error)
|
||||||
|
ReportMessages(ctx context.Context, req domain.ModerationMessageReportRequest) (domain.ModerationReport, bool, error)
|
||||||
|
ReportProfilePhoto(ctx context.Context, req domain.ModerationProfilePhotoReportRequest) (domain.ModerationReport, bool, error)
|
||||||
|
ReportChannelSpam(ctx context.Context, req domain.ModerationChannelSpamReportRequest) (domain.ModerationReport, bool, error)
|
||||||
|
ReportReaction(ctx context.Context, req domain.ModerationReactionReportRequest) (domain.ModerationReport, bool, error)
|
||||||
|
ReportEncryptedSpam(ctx context.Context, reporterUserID int64, chat domain.SecretChat, createdAt time.Time) (domain.ModerationReport, bool, error)
|
||||||
|
ReportStories(ctx context.Context, req domain.ModerationStoryReportRequest) (domain.ModerationReport, bool, error)
|
||||||
|
ReportEphemeral(ctx context.Context, reporterUserID int64, target domain.EphemeralMessage, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error)
|
||||||
|
SponsoredImpression(ctx context.Context, userID int64, randomID []byte, now time.Time) (domain.SponsoredMessageImpression, error)
|
||||||
|
ReportSponsored(ctx context.Context, userID int64, randomID []byte, reason domain.ModerationReason, option string, now time.Time) (domain.ModerationReport, bool, error)
|
||||||
|
ReportAntiSpamFalsePositive(ctx context.Context, reporterUserID, channelID int64, messageID int, now time.Time) (domain.ModerationReport, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Deps 按业务域注入服务接口。各域的 handler 注册见对应文件(auth.go / users.go / updates.go)。
|
// Deps 按业务域注入服务接口。各域的 handler 注册见对应文件(auth.go / users.go / updates.go)。
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
Auth AuthService
|
Auth AuthService
|
||||||
|
AuthDeliveryReports AuthDeliveryReportService
|
||||||
|
ClientTelemetry ClientTelemetryService
|
||||||
// AuthKeySessionLayers is the protocol-only durable ordering boundary for
|
// AuthKeySessionLayers is the protocol-only durable ordering boundary for
|
||||||
// explicit invokeWithLayer evidence. Production must wire the same auth-key
|
// explicit invokeWithLayer evidence. Production must wire the same auth-key
|
||||||
// store used by the MTProto edge; nil is reserved for isolated router tests.
|
// store used by the MTProto edge; nil is reserved for isolated router tests.
|
||||||
|
|
@ -889,7 +937,7 @@ type Deps struct {
|
||||||
AICompose AIComposeService
|
AICompose AIComposeService
|
||||||
Ephemeral EphemeralService
|
Ephemeral EphemeralService
|
||||||
EphemeralPush store.EphemeralPushBroker
|
EphemeralPush store.EphemeralPushBroker
|
||||||
EphemeralReports store.EphemeralReportStore
|
Moderation ModerationService
|
||||||
Users UsersService
|
Users UsersService
|
||||||
TelegramLogin TelegramLoginService
|
TelegramLogin TelegramLoginService
|
||||||
Updates UpdatesService
|
Updates UpdatesService
|
||||||
|
|
|
||||||
|
|
@ -93,12 +93,24 @@ func (r *Router) onMessagesReceivedQueue(ctx context.Context, maxQts int) ([]int
|
||||||
return []int64{}, nil
|
return []int64{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// onMessagesReportEncryptedSpam 纯记录:服务端不自动 discard、不拉黑、不产生 update
|
// onMessagesReportEncryptedSpam persists an immutable chat-metadata snapshot;
|
||||||
// (discard/block 由客户端独立 RPC 完成)。P1 接受并回 true。
|
// the server remains unable to inspect encrypted message plaintext. Reporting
|
||||||
func (r *Router) onMessagesReportEncryptedSpam(ctx context.Context, _ tg.InputEncryptedChat) (bool, error) {
|
// does not discard or block the chat and emits no update.
|
||||||
if _, err := r.secretChatRequireUser(ctx); err != nil {
|
func (r *Router) onMessagesReportEncryptedSpam(ctx context.Context, peer tg.InputEncryptedChat) (bool, error) {
|
||||||
|
if r.deps.SecretChats == nil || r.deps.Moderation == nil {
|
||||||
|
return false, notImplementedErr()
|
||||||
|
}
|
||||||
|
userID, err := r.secretChatRequireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
chat, _, _, err := r.resolveSecretChatPeer(ctx, userID, peer)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if _, _, err := r.deps.Moderation.ReportEncryptedSpam(ctx, userID, chat, r.clock.Now()); err != nil {
|
||||||
|
return false, moderationReportError(err)
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ import (
|
||||||
|
|
||||||
"github.com/iamxvbaba/td/tg"
|
"github.com/iamxvbaba/td/tg"
|
||||||
|
|
||||||
|
appmoderation "telesrv/internal/app/moderation"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
"telesrv/internal/postresponse"
|
"telesrv/internal/postresponse"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
)
|
)
|
||||||
|
|
||||||
// acceptChat 跑完 request→accept,返回 normal 态密聊 id 与 participant 视角 access_hash。
|
// acceptChat 跑完 request→accept,返回 normal 态密聊 id 与 participant 视角 access_hash。
|
||||||
|
|
@ -46,6 +48,33 @@ func encNewMessagePayload(t *testing.T, rec phonePushRecord) *tg.UpdateNewEncryp
|
||||||
return upd
|
return upd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReportEncryptedSpamPersistsMetadataOnly(t *testing.T) {
|
||||||
|
f := newEncryptedFixture(t)
|
||||||
|
chatID, participantAccessHash := f.acceptChat(t)
|
||||||
|
reports := memory.NewModerationReportStore()
|
||||||
|
f.router.deps.Moderation = appmoderation.NewService(reports)
|
||||||
|
ok, err := f.router.onMessagesReportEncryptedSpam(
|
||||||
|
f.participantCtx(),
|
||||||
|
tg.InputEncryptedChat{ChatID: chatID, AccessHash: participantAccessHash},
|
||||||
|
)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("report encrypted spam ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
stored := reports.Reports()
|
||||||
|
if len(stored) != 1 ||
|
||||||
|
stored[0].Source != domain.ModerationSourceEncryptedSpam ||
|
||||||
|
stored[0].ReporterUserID != f.participant.ID ||
|
||||||
|
stored[0].Target != (domain.Peer{Type: domain.PeerTypeUser, ID: f.admin.ID}) ||
|
||||||
|
len(stored[0].Items) != 1 ||
|
||||||
|
stored[0].Items[0].Kind != domain.ModerationItemEncryptedChat {
|
||||||
|
t.Fatalf("stored encrypted report=%+v", stored)
|
||||||
|
}
|
||||||
|
if string(stored[0].Items[0].Evidence) == "" ||
|
||||||
|
string(stored[0].Items[0].Evidence) == "plaintext" {
|
||||||
|
t.Fatalf("encrypted metadata evidence=%s", stored[0].Items[0].Evidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendEncryptedRPCFlow(t *testing.T) {
|
func TestSendEncryptedRPCFlow(t *testing.T) {
|
||||||
f := newEncryptedFixture(t)
|
f := newEncryptedFixture(t)
|
||||||
chatID, _ := f.acceptChat(t)
|
chatID, _ := f.acceptChat(t)
|
||||||
|
|
|
||||||
|
|
@ -245,13 +245,18 @@ func (r *Router) onEphemeralReportMessage(ctx context.Context, request *tg.Ephem
|
||||||
if _, final := result.(*tg.ReportResultReported); !final {
|
if _, final := result.(*tg.ReportResultReported); !final {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
if r.deps.EphemeralReports == nil {
|
reason, ok := moderationReasonForReportOption(string(request.Option))
|
||||||
|
if !ok {
|
||||||
|
return nil, tgerr.New(400, "OPTION_INVALID")
|
||||||
|
}
|
||||||
|
if r.deps.Moderation == nil {
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
}
|
}
|
||||||
report := domain.NewEphemeralAbuseReport(userID, string(request.Option), request.Message, target, r.clock.Now())
|
if _, _, err := r.deps.Moderation.ReportEphemeral(
|
||||||
if _, err := r.deps.EphemeralReports.CreateEphemeralReport(ctx, report); err != nil {
|
ctx, userID, target, reason, string(request.Option), request.Message, r.clock.Now(),
|
||||||
|
); err != nil {
|
||||||
r.log.Warn("persist ephemeral abuse report", zap.Int64("reporter_user_id", userID), zap.Int64("channel_id", peer.ID), zap.Int("ephemeral_message_id", request.ID), zap.Error(err))
|
r.log.Warn("persist ephemeral abuse report", zap.Int64("reporter_user_id", userID), zap.Int64("channel_id", peer.ID), zap.Int("ephemeral_message_id", request.ID), zap.Error(err))
|
||||||
return nil, internalErr()
|
return nil, moderationReportError(err)
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package rpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -9,6 +10,7 @@ import (
|
||||||
"github.com/iamxvbaba/td/tg"
|
"github.com/iamxvbaba/td/tg"
|
||||||
"go.uber.org/zap/zaptest"
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
|
appmoderation "telesrv/internal/app/moderation"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
"telesrv/internal/store/memory"
|
"telesrv/internal/store/memory"
|
||||||
)
|
)
|
||||||
|
|
@ -49,13 +51,14 @@ func TestEphemeralReportPersistsOnlyFinalIdempotentEvidence(t *testing.T) {
|
||||||
OriginDevice: domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKey, SessionID: 99},
|
OriginDevice: domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKey, SessionID: 99},
|
||||||
PayloadHash: [32]byte{9}, Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
|
PayloadHash: [32]byte{9}, Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
|
||||||
}
|
}
|
||||||
reports := memory.NewEphemeralReportStore()
|
reports := memory.NewModerationReportStore()
|
||||||
|
moderation := appmoderation.NewService(reports)
|
||||||
ephemeral := &ephemeralReportService{target: target}
|
ephemeral := &ephemeralReportService{target: target}
|
||||||
channels := &ephemeralReportChannels{view: domain.ChannelView{
|
channels := &ephemeralReportChannels{view: domain.ChannelView{
|
||||||
Channel: domain.Channel{ID: channelID, AccessHash: 42, Megagroup: true},
|
Channel: domain.Channel{ID: channelID, AccessHash: 42, Megagroup: true},
|
||||||
Self: domain.ChannelMember{ChannelID: channelID, UserID: userID, Status: domain.ChannelMemberActive},
|
Self: domain.ChannelMember{ChannelID: channelID, UserID: userID, Status: domain.ChannelMemberActive},
|
||||||
}}
|
}}
|
||||||
router := New(Config{}, Deps{Ephemeral: ephemeral, EphemeralReports: reports, Channels: channels}, zaptest.NewLogger(t), clock.System)
|
router := New(Config{}, Deps{Ephemeral: ephemeral, Moderation: moderation, Channels: channels}, zaptest.NewLogger(t), clock.System)
|
||||||
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), authKey), 99)
|
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), authKey), 99)
|
||||||
request := &tg.EphemeralReportMessageRequest{
|
request := &tg.EphemeralReportMessageRequest{
|
||||||
Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: 42}, ID: target.ID,
|
Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: 42}, ID: target.ID,
|
||||||
|
|
@ -87,7 +90,9 @@ func TestEphemeralReportPersistsOnlyFinalIdempotentEvidence(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stored := reports.Reports()
|
stored := reports.Reports()
|
||||||
if len(stored) != 1 || stored[0].Evidence.Content.Message != "abuse" || stored[0].Comment != "evidence comment" {
|
if len(stored) != 1 || stored[0].Source != domain.ModerationSourceEphemeral ||
|
||||||
|
stored[0].Comment != "evidence comment" || len(stored[0].Items) != 1 ||
|
||||||
|
!strings.Contains(string(stored[0].Items[0].Evidence), `"Message":"abuse"`) {
|
||||||
t.Fatalf("reports=%+v", stored)
|
t.Fatalf("reports=%+v", stored)
|
||||||
}
|
}
|
||||||
if ephemeral.calls != 4 {
|
if ephemeral.calls != 4 {
|
||||||
|
|
|
||||||
|
|
@ -382,6 +382,9 @@ func callProtocolFlagsInvalidErr() error {
|
||||||
|
|
||||||
func userIsBlockedErr() error { return tgerr.New(400, "USER_IS_BLOCKED") }
|
func userIsBlockedErr() error { return tgerr.New(400, "USER_IS_BLOCKED") }
|
||||||
func userPrivacyRestrictedErr() error { return tgerr.New(403, "USER_PRIVACY_RESTRICTED") }
|
func userPrivacyRestrictedErr() error { return tgerr.New(403, "USER_PRIVACY_RESTRICTED") }
|
||||||
|
func chatSendVoicesForbiddenErr() error {
|
||||||
|
return tgerr.New(403, "CHAT_SEND_VOICES_FORBIDDEN")
|
||||||
|
}
|
||||||
|
|
||||||
// signalingDataInvalidErr 表示 phone.sendSignalingData 载荷超限或非法。
|
// signalingDataInvalidErr 表示 phone.sendSignalingData 载荷超限或非法。
|
||||||
func signalingDataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") }
|
func signalingDataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") }
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const (
|
||||||
maxReactionVector = 16
|
maxReactionVector = 16
|
||||||
maxReactionListOffset = 128
|
maxReactionListOffset = 128
|
||||||
maxReportOptionLength = 32
|
maxReportOptionLength = 32
|
||||||
maxReportCommentLength = 1024
|
maxReportCommentLength = domain.MaxModerationCommentRunes
|
||||||
maxReportRandomIDLength = 128
|
maxReportRandomIDLength = 128
|
||||||
maxReadMetrics = 100
|
maxReadMetrics = 100
|
||||||
maxBusinessConnIDLength = 128
|
maxBusinessConnIDLength = 128
|
||||||
|
|
|
||||||
|
|
@ -14,24 +14,52 @@ func reportResultForOption(option string) (tg.ReportResultClass, error) {
|
||||||
return &tg.ReportResultChooseOption{
|
return &tg.ReportResultChooseOption{
|
||||||
Title: "Report",
|
Title: "Report",
|
||||||
Options: []tg.MessageReportOption{
|
Options: []tg.MessageReportOption{
|
||||||
{Text: "Spam", Option: []byte("spam")},
|
{Text: "Scam or spam", Option: []byte("spam")},
|
||||||
{Text: "Violence", Option: []byte("violence")},
|
{Text: "Violence", Option: []byte("violence")},
|
||||||
{Text: "Illegal goods", Option: []byte("illegal_goods")},
|
{Text: "Pornography", Option: []byte("pornography")},
|
||||||
{Text: "Child abuse", Option: []byte("child_abuse")},
|
{Text: "Child abuse", Option: []byte("child_abuse")},
|
||||||
{Text: "Personal data", Option: []byte("personal_data")},
|
{Text: "Illegal drugs", Option: []byte("illegal_drugs")},
|
||||||
|
{Text: "Personal details", Option: []byte("personal_details")},
|
||||||
{Text: "Copyright", Option: []byte("copyright")},
|
{Text: "Copyright", Option: []byte("copyright")},
|
||||||
|
{Text: "Fake or impersonation", Option: []byte("fake")},
|
||||||
{Text: "Other", Option: []byte("other")},
|
{Text: "Other", Option: []byte("other")},
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
case "other":
|
case "other":
|
||||||
return &tg.ReportResultAddComment{Optional: false, Option: []byte("other:comment")}, nil
|
return &tg.ReportResultAddComment{Optional: false, Option: []byte("other:comment")}, nil
|
||||||
case "spam", "violence", "illegal_goods", "child_abuse", "personal_data", "copyright", "other:comment":
|
case "spam", "violence", "pornography", "child_abuse", "illegal_drugs",
|
||||||
|
"personal_details", "copyright", "fake", "other:comment":
|
||||||
return &tg.ReportResultReported{}, nil
|
return &tg.ReportResultReported{}, nil
|
||||||
default:
|
default:
|
||||||
return nil, tgerr.New(400, "OPTION_INVALID")
|
return nil, tgerr.New(400, "OPTION_INVALID")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func moderationReasonForReportOption(option string) (domain.ModerationReason, bool) {
|
||||||
|
switch option {
|
||||||
|
case "spam":
|
||||||
|
return domain.ModerationReasonSpam, true
|
||||||
|
case "violence":
|
||||||
|
return domain.ModerationReasonViolence, true
|
||||||
|
case "pornography":
|
||||||
|
return domain.ModerationReasonPornography, true
|
||||||
|
case "child_abuse":
|
||||||
|
return domain.ModerationReasonChildAbuse, true
|
||||||
|
case "illegal_drugs":
|
||||||
|
return domain.ModerationReasonIllegalDrugs, true
|
||||||
|
case "personal_details":
|
||||||
|
return domain.ModerationReasonPersonalDetails, true
|
||||||
|
case "copyright":
|
||||||
|
return domain.ModerationReasonCopyright, true
|
||||||
|
case "fake":
|
||||||
|
return domain.ModerationReasonFake, true
|
||||||
|
case "other:comment":
|
||||||
|
return domain.ModerationReasonOther, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Router) inputPeerForDomainPeer(ctx context.Context, currentUserID int64, peer domain.Peer) tg.InputPeerClass {
|
func (r *Router) inputPeerForDomainPeer(ctx context.Context, currentUserID int64, peer domain.Peer) tg.InputPeerClass {
|
||||||
switch peer.Type {
|
switch peer.Type {
|
||||||
case domain.PeerTypeUser:
|
case domain.PeerTypeUser:
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,43 @@ package rpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/iamxvbaba/td/tg"
|
"github.com/iamxvbaba/td/tg"
|
||||||
"github.com/iamxvbaba/td/tgerr"
|
"github.com/iamxvbaba/td/tgerr"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
"unicode/utf8"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type readMetricTelemetry struct {
|
||||||
|
MessageID int `json:"message_id"`
|
||||||
|
ViewID int64 `json:"view_id"`
|
||||||
|
TimeInViewMS int `json:"time_in_view_ms"`
|
||||||
|
ActiveTimeInViewMS int `json:"active_time_in_view_ms"`
|
||||||
|
HeightToViewportRatioPermille int `json:"height_to_viewport_ratio_permille"`
|
||||||
|
SeenRangeRatioPermille int `json:"seen_range_ratio_permille"`
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Router) onMessagesReportSpam(ctx context.Context, peer tg.InputPeerClass) (bool, error) {
|
func (r *Router) onMessagesReportSpam(ctx context.Context, peer tg.InputPeerClass) (bool, error) {
|
||||||
userID, _, err := r.currentUserID(ctx)
|
userID, _, err := r.currentUserID(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, internalErr()
|
return false, internalErr()
|
||||||
}
|
}
|
||||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil {
|
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer)
|
||||||
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if r.deps.Moderation == nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
if _, _, err := r.deps.Moderation.ReportPeer(
|
||||||
|
ctx, userID, domain.ModerationSourceMessagesSpam, target,
|
||||||
|
domain.ModerationReasonSpam, "spam", "", r.clock.Now(),
|
||||||
|
); err != nil {
|
||||||
|
return false, moderationReportError(err)
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,11 +47,12 @@ func (r *Router) onMessagesReport(ctx context.Context, req *tg.MessagesReportReq
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
}
|
}
|
||||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(req.ID) == 0 {
|
if len(req.ID) == 0 {
|
||||||
return nil, tgerr.New(400, "MESSAGE_REQUIRED")
|
return nil, tgerr.New(400, "MESSAGE_ID_REQUIRED")
|
||||||
}
|
}
|
||||||
if len(req.ID) > maxGetMessagesIDs || len(req.Option) > maxReportOptionLength || utf8.RuneCountInString(req.Message) > maxReportCommentLength {
|
if len(req.ID) > maxGetMessagesIDs || len(req.Option) > maxReportOptionLength || utf8.RuneCountInString(req.Message) > maxReportCommentLength {
|
||||||
return nil, limitInvalidErr()
|
return nil, limitInvalidErr()
|
||||||
|
|
@ -38,7 +62,28 @@ func (r *Router) onMessagesReport(ctx context.Context, req *tg.MessagesReportReq
|
||||||
return nil, messageIDInvalidErr()
|
return nil, messageIDInvalidErr()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return reportResultForOption(string(req.Option))
|
result, err := reportResultForOption(string(req.Option))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, final := result.(*tg.ReportResultReported); !final {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
reason, ok := moderationReasonForReportOption(string(req.Option))
|
||||||
|
if !ok {
|
||||||
|
return nil, tgerr.New(400, "OPTION_INVALID")
|
||||||
|
}
|
||||||
|
if r.deps.Moderation == nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
if _, _, err := r.deps.Moderation.ReportMessages(ctx, domain.ModerationMessageReportRequest{
|
||||||
|
ReporterUserID: userID, Target: target, MessageIDs: req.ID,
|
||||||
|
Reason: reason, Option: string(req.Option), Comment: req.Message,
|
||||||
|
CreatedAt: r.clock.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
return nil, moderationReportError(err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onMessagesReportReaction(ctx context.Context, req *tg.MessagesReportReactionRequest) (bool, error) {
|
func (r *Router) onMessagesReportReaction(ctx context.Context, req *tg.MessagesReportReactionRequest) (bool, error) {
|
||||||
|
|
@ -49,21 +94,50 @@ func (r *Router) onMessagesReportReaction(ctx context.Context, req *tg.MessagesR
|
||||||
if req.ID <= 0 || req.ID > domain.MaxMessageBoxID {
|
if req.ID <= 0 || req.ID > domain.MaxMessageBoxID {
|
||||||
return false, messageIDInvalidErr()
|
return false, messageIDInvalidErr()
|
||||||
}
|
}
|
||||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||||
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ReactionPeer); err != nil {
|
reactor, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ReactionPeer)
|
||||||
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if reactor.Type != domain.PeerTypeUser || reactor.ID <= 0 {
|
||||||
|
return false, peerIDInvalidErr()
|
||||||
|
}
|
||||||
|
if r.deps.Moderation == nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
if _, _, err := r.deps.Moderation.ReportReaction(ctx, domain.ModerationReactionReportRequest{
|
||||||
|
ReporterUserID: userID, Target: target, MessageID: req.ID,
|
||||||
|
ReactorUserID: reactor.ID, CreatedAt: r.clock.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
return false, moderationReportError(err)
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func moderationReportError(err error) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrModerationEvidenceNotFound):
|
||||||
|
return messageIDInvalidErr()
|
||||||
|
case errors.Is(err, domain.ErrModerationPermissionDenied):
|
||||||
|
return tgerr.New(403, "CHAT_ADMIN_REQUIRED")
|
||||||
|
case errors.Is(err, domain.ErrModerationRateLimited):
|
||||||
|
return floodWaitErr(60)
|
||||||
|
case errors.Is(err, domain.ErrModerationReportInvalid):
|
||||||
|
return inputRequestInvalidErr()
|
||||||
|
default:
|
||||||
|
return internalErr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Router) onMessagesReportMessagesDelivery(ctx context.Context, req *tg.MessagesReportMessagesDeliveryRequest) (bool, error) {
|
func (r *Router) onMessagesReportMessagesDelivery(ctx context.Context, req *tg.MessagesReportMessagesDeliveryRequest) (bool, error) {
|
||||||
userID, _, err := r.currentUserID(ctx)
|
userID, _, err := r.currentUserID(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, internalErr()
|
return false, internalErr()
|
||||||
}
|
}
|
||||||
if len(req.ID) > maxGetMessagesIDs {
|
if len(req.ID) == 0 || len(req.ID) > maxGetMessagesIDs {
|
||||||
return false, limitInvalidErr()
|
return false, limitInvalidErr()
|
||||||
}
|
}
|
||||||
for _, msgID := range req.ID {
|
for _, msgID := range req.ID {
|
||||||
|
|
@ -71,9 +145,26 @@ func (r *Router) onMessagesReportMessagesDelivery(ctx context.Context, req *tg.M
|
||||||
return false, messageIDInvalidErr()
|
return false, messageIDInvalidErr()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||||
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if err := r.validateTelemetryMessageIDs(ctx, userID, peer, req.ID); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
ids := messageIDs64(req.ID)
|
||||||
|
if r.deps.ClientTelemetry == nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
if _, _, err := r.deps.ClientTelemetry.Record(
|
||||||
|
ctx, userID, domain.ClientTelemetryMessageDelivery, peer, ids,
|
||||||
|
struct {
|
||||||
|
Push bool `json:"push"`
|
||||||
|
}{Push: req.Push},
|
||||||
|
r.clock.Now(),
|
||||||
|
); err != nil {
|
||||||
|
return false, clientTelemetryError(err)
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,46 +173,210 @@ func (r *Router) onMessagesReportReadMetrics(ctx context.Context, req *tg.Messag
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, internalErr()
|
return false, internalErr()
|
||||||
}
|
}
|
||||||
if len(req.Metrics) > maxReadMetrics {
|
if len(req.Metrics) == 0 || len(req.Metrics) > maxReadMetrics {
|
||||||
return false, limitInvalidErr()
|
return false, limitInvalidErr()
|
||||||
}
|
}
|
||||||
|
ids := make([]int, 0, len(req.Metrics))
|
||||||
|
payload := make([]readMetricTelemetry, 0, len(req.Metrics))
|
||||||
for _, metric := range req.Metrics {
|
for _, metric := range req.Metrics {
|
||||||
if metric.MsgID <= 0 || metric.MsgID > domain.MaxMessageBoxID {
|
if metric.MsgID <= 0 || metric.MsgID > domain.MaxMessageBoxID {
|
||||||
return false, messageIDInvalidErr()
|
return false, messageIDInvalidErr()
|
||||||
}
|
}
|
||||||
if metric.TimeInViewMs < 0 || metric.ActiveTimeInViewMs < 0 || metric.HeightToViewportRatioPermille < 0 || metric.SeenRangeRatioPermille < 0 {
|
if metric.ViewID == 0 || metric.TimeInViewMs < 0 ||
|
||||||
|
metric.TimeInViewMs > 24*60*60*1000 ||
|
||||||
|
metric.ActiveTimeInViewMs < 0 ||
|
||||||
|
metric.ActiveTimeInViewMs > metric.TimeInViewMs ||
|
||||||
|
metric.HeightToViewportRatioPermille < 0 ||
|
||||||
|
metric.HeightToViewportRatioPermille > 1_000_000 ||
|
||||||
|
metric.SeenRangeRatioPermille < 0 ||
|
||||||
|
metric.SeenRangeRatioPermille > 1000 {
|
||||||
return false, limitInvalidErr()
|
return false, limitInvalidErr()
|
||||||
}
|
}
|
||||||
|
ids = append(ids, metric.MsgID)
|
||||||
|
payload = append(payload, readMetricTelemetry{
|
||||||
|
MessageID: metric.MsgID, ViewID: metric.ViewID,
|
||||||
|
TimeInViewMS: metric.TimeInViewMs,
|
||||||
|
ActiveTimeInViewMS: metric.ActiveTimeInViewMs,
|
||||||
|
HeightToViewportRatioPermille: metric.HeightToViewportRatioPermille,
|
||||||
|
SeenRangeRatioPermille: metric.SeenRangeRatioPermille,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
sort.Slice(payload, func(i, j int) bool {
|
||||||
|
return payload[i].MessageID < payload[j].MessageID
|
||||||
|
})
|
||||||
|
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||||
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if err := r.validateTelemetryMessageIDs(ctx, userID, peer, ids); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if r.deps.ClientTelemetry == nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
if _, _, err := r.deps.ClientTelemetry.Record(
|
||||||
|
ctx, userID, domain.ClientTelemetryReadMetrics, peer,
|
||||||
|
messageIDs64(ids),
|
||||||
|
struct {
|
||||||
|
Metrics []readMetricTelemetry `json:"metrics"`
|
||||||
|
}{Metrics: payload},
|
||||||
|
r.clock.Now(),
|
||||||
|
); err != nil {
|
||||||
|
return false, clientTelemetryError(err)
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onMessagesReportMusicListen(ctx context.Context, req *tg.MessagesReportMusicListenRequest) (bool, error) {
|
func (r *Router) onMessagesReportMusicListen(ctx context.Context, req *tg.MessagesReportMusicListenRequest) (bool, error) {
|
||||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
return false, internalErr()
|
return false, internalErr()
|
||||||
}
|
}
|
||||||
if req.ID == nil {
|
if req.ListenedDuration < 0 || req.ListenedDuration > 24*60*60 {
|
||||||
return false, tgerr.New(400, "DOCUMENT_INVALID")
|
|
||||||
}
|
|
||||||
if req.ListenedDuration < 0 {
|
|
||||||
return false, limitInvalidErr()
|
return false, limitInvalidErr()
|
||||||
}
|
}
|
||||||
|
document, err := r.musicDocumentFromInput(ctx, req.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if r.deps.ClientTelemetry == nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
if _, _, err := r.deps.ClientTelemetry.Record(
|
||||||
|
ctx, userID, domain.ClientTelemetryMusicListen, domain.Peer{},
|
||||||
|
[]int64{document.ID},
|
||||||
|
struct {
|
||||||
|
ListenedDuration int `json:"listened_duration"`
|
||||||
|
}{ListenedDuration: req.ListenedDuration},
|
||||||
|
r.clock.Now(),
|
||||||
|
); err != nil {
|
||||||
|
return false, clientTelemetryError(err)
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onMessagesReportSponsoredMessage(ctx context.Context, req *tg.MessagesReportSponsoredMessageRequest) (tg.ChannelsSponsoredMessageReportResultClass, error) {
|
func (r *Router) onMessagesReportSponsoredMessage(ctx context.Context, req *tg.MessagesReportSponsoredMessageRequest) (tg.ChannelsSponsoredMessageReportResultClass, error) {
|
||||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
}
|
}
|
||||||
if len(req.RandomID) == 0 || len(req.RandomID) > maxReportRandomIDLength || len(req.Option) > maxReportOptionLength {
|
if len(req.RandomID) == 0 || len(req.RandomID) > maxReportRandomIDLength || len(req.Option) > maxReportOptionLength {
|
||||||
return nil, limitInvalidErr()
|
return nil, limitInvalidErr()
|
||||||
}
|
}
|
||||||
|
if r.deps.Moderation == nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
if _, err := r.deps.Moderation.SponsoredImpression(
|
||||||
|
ctx, userID, req.RandomID, r.clock.Now(),
|
||||||
|
); err != nil {
|
||||||
|
if errors.Is(err, domain.ErrModerationImpressionExpired) ||
|
||||||
|
errors.Is(err, domain.ErrModerationEvidenceNotFound) {
|
||||||
|
return nil, tgerr.New(400, "RANDOM_ID_INVALID")
|
||||||
|
}
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
option := string(req.Option)
|
||||||
|
if option == "" {
|
||||||
|
return &tg.ChannelsSponsoredMessageReportResultChooseOption{
|
||||||
|
Title: "Report sponsored message",
|
||||||
|
Options: []tg.SponsoredMessageReportOption{
|
||||||
|
{Text: "Scam or spam", Option: []byte("spam")},
|
||||||
|
{Text: "Violence", Option: []byte("violence")},
|
||||||
|
{Text: "Pornography", Option: []byte("pornography")},
|
||||||
|
{Text: "Child abuse", Option: []byte("child_abuse")},
|
||||||
|
{Text: "Illegal drugs", Option: []byte("illegal_drugs")},
|
||||||
|
{Text: "Personal details", Option: []byte("personal_details")},
|
||||||
|
{Text: "Fake or impersonation", Option: []byte("fake")},
|
||||||
|
{Text: "Other", Option: []byte("other")},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
reason, ok := moderationReasonForReportOption(option)
|
||||||
|
if option == "other" {
|
||||||
|
reason, ok = domain.ModerationReasonOther, true
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, tgerr.New(400, "OPTION_INVALID")
|
||||||
|
}
|
||||||
|
if _, _, err := r.deps.Moderation.ReportSponsored(
|
||||||
|
ctx, userID, req.RandomID, reason, option, r.clock.Now(),
|
||||||
|
); err != nil {
|
||||||
|
if errors.Is(err, domain.ErrModerationImpressionExpired) ||
|
||||||
|
errors.Is(err, domain.ErrModerationEvidenceNotFound) {
|
||||||
|
return nil, tgerr.New(400, "RANDOM_ID_INVALID")
|
||||||
|
}
|
||||||
|
return nil, moderationReportError(err)
|
||||||
|
}
|
||||||
return &tg.ChannelsSponsoredMessageReportResultReported{}, nil
|
return &tg.ChannelsSponsoredMessageReportResultReported{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Router) validateTelemetryMessageIDs(ctx context.Context, userID int64, peer domain.Peer, ids []int) error {
|
||||||
|
if len(ids) == 0 || len(ids) > domain.MaxGetMessageIDs {
|
||||||
|
return limitInvalidErr()
|
||||||
|
}
|
||||||
|
needed := make(map[int]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||||
|
return messageIDInvalidErr()
|
||||||
|
}
|
||||||
|
if _, duplicate := needed[id]; duplicate {
|
||||||
|
return messageIDInvalidErr()
|
||||||
|
}
|
||||||
|
needed[id] = struct{}{}
|
||||||
|
}
|
||||||
|
switch peer.Type {
|
||||||
|
case domain.PeerTypeUser:
|
||||||
|
if r.deps.Messages == nil {
|
||||||
|
return internalErr()
|
||||||
|
}
|
||||||
|
list, err := r.deps.Messages.GetMessages(ctx, userID, ids)
|
||||||
|
if err != nil {
|
||||||
|
return internalErr()
|
||||||
|
}
|
||||||
|
for _, message := range list.Messages {
|
||||||
|
if message.Peer == peer {
|
||||||
|
delete(needed, message.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case domain.PeerTypeChannel:
|
||||||
|
if r.deps.Channels == nil {
|
||||||
|
return internalErr()
|
||||||
|
}
|
||||||
|
history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, ids)
|
||||||
|
if err != nil {
|
||||||
|
return internalErr()
|
||||||
|
}
|
||||||
|
for _, message := range history.Messages {
|
||||||
|
delete(needed, message.ID)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return peerIDInvalidErr()
|
||||||
|
}
|
||||||
|
if len(needed) != 0 {
|
||||||
|
return messageIDInvalidErr()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageIDs64(ids []int) []int64 {
|
||||||
|
out := make([]int64, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
out[i] = int64(id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientTelemetryError(err error) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrClientTelemetryRateLimited):
|
||||||
|
return floodWaitErr(60)
|
||||||
|
case errors.Is(err, domain.ErrClientTelemetryInvalid):
|
||||||
|
return inputRequestInvalidErr()
|
||||||
|
default:
|
||||||
|
return internalErr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Router) onMessagesGetSponsoredMessages(ctx context.Context, req *tg.MessagesGetSponsoredMessagesRequest) (tg.MessagesSponsoredMessagesClass, error) {
|
func (r *Router) onMessagesGetSponsoredMessages(ctx context.Context, req *tg.MessagesGetSponsoredMessagesRequest) (tg.MessagesSponsoredMessagesClass, error) {
|
||||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
|
|
|
||||||
71
internal/rpc/moderation_flags_projection_test.go
Normal file
71
internal/rpc/moderation_flags_projection_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/iamxvbaba/td/bin"
|
||||||
|
"github.com/iamxvbaba/td/tg"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestModerationFlagsProjectToSelfUserAndWire(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
user domain.User
|
||||||
|
scam bool
|
||||||
|
fake bool
|
||||||
|
}{
|
||||||
|
{name: "scam", user: domain.User{ID: 1, Scam: true}, scam: true},
|
||||||
|
{name: "fake", user: domain.User{ID: 2, Fake: true}, fake: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
projected := tgSelfUser(test.user)
|
||||||
|
if projected.Scam != test.scam || projected.Fake != test.fake ||
|
||||||
|
!projected.Self {
|
||||||
|
t.Fatalf("projected self=%+v", projected)
|
||||||
|
}
|
||||||
|
var wire bin.Buffer
|
||||||
|
if err := projected.Encode(&wire); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var decoded tg.User
|
||||||
|
input := bin.Buffer{Buf: append([]byte(nil), wire.Buf...)}
|
||||||
|
if err := decoded.Decode(&input); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decoded.Scam != test.scam || decoded.Fake != test.fake ||
|
||||||
|
!decoded.Self {
|
||||||
|
t.Fatalf("decoded self=%+v", decoded)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModerationFlagsProjectToChannelWithoutMutatingAbout(t *testing.T) {
|
||||||
|
channel := domain.Channel{
|
||||||
|
ID: 10, AccessHash: 20, CreatorUserID: 1,
|
||||||
|
Title: "Reported channel", About: "Owner description",
|
||||||
|
Broadcast: true, Scam: true,
|
||||||
|
}
|
||||||
|
projected := tgChannel(2, channel, nil)
|
||||||
|
if !projected.Scam || projected.Fake {
|
||||||
|
t.Fatalf("projected channel=%+v", projected)
|
||||||
|
}
|
||||||
|
var wire bin.Buffer
|
||||||
|
if err := projected.Encode(&wire); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var decoded tg.Channel
|
||||||
|
input := bin.Buffer{Buf: append([]byte(nil), wire.Buf...)}
|
||||||
|
if err := decoded.Decode(&input); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !decoded.Scam || decoded.Fake {
|
||||||
|
t.Fatalf("decoded channel=%+v", decoded)
|
||||||
|
}
|
||||||
|
full := tgChannelFull(domain.ChannelView{Channel: channel})
|
||||||
|
if full.About != channel.About {
|
||||||
|
t.Fatalf("about=%q, want unmodified %q", full.About, channel.About)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,10 +38,15 @@ func (r *Router) sendStarGiftTransferForm(ctx context.Context, userID, formID in
|
||||||
return nil, starsErr(err)
|
return nil, starsErr(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
recipientUnsaved, err := r.starGiftRecipientUnsaved(ctx, userID, to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
result, err := r.deps.Gifts.Transfer(ctx, domain.StarGiftTransferRequest{ActorUserID: userID,
|
result, err := r.deps.Gifts.Transfer(ctx, domain.StarGiftTransferRequest{ActorUserID: userID,
|
||||||
Ref: domain.SavedStarGiftRef{Owner: target.Owner, MsgID: target.MsgID, SavedID: target.SavedID}, To: to,
|
Ref: domain.SavedStarGiftRef{Owner: target.Owner, MsgID: target.MsgID, SavedID: target.SavedID}, To: to,
|
||||||
ChargeStars: target.TransferStars, FormID: formID, CommandKey: fmt.Sprintf("paid-transfer:%d:%d", target.ID, formID),
|
ChargeStars: target.TransferStars, FormID: formID, CommandKey: fmt.Sprintf("paid-transfer:%d:%d", target.ID, formID),
|
||||||
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
|
Date: int(r.clock.Now().Unix()), RecipientUnsaved: recipientUnsaved,
|
||||||
|
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, starGiftLifecycleErr(err)
|
return nil, starGiftLifecycleErr(err)
|
||||||
}
|
}
|
||||||
|
|
@ -105,9 +110,14 @@ func (r *Router) sendStarGiftResaleForm(ctx context.Context, userID, formID int6
|
||||||
return nil, starsErr(err)
|
return nil, starsErr(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
recipientUnsaved, err := r.starGiftRecipientUnsaved(ctx, userID, to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
result, err := r.deps.Gifts.PurchaseResale(ctx, domain.StarGiftResalePurchaseRequest{BuyerUserID: userID,
|
result, err := r.deps.Gifts.PurchaseResale(ctx, domain.StarGiftResalePurchaseRequest{BuyerUserID: userID,
|
||||||
Slug: gift.Slug, To: to, Amount: amount, FormID: formID, CommandKey: fmt.Sprintf("resale:%d:%d", gift.ID, formID),
|
Slug: gift.Slug, To: to, Amount: amount, FormID: formID, CommandKey: fmt.Sprintf("resale:%d:%d", gift.ID, formID),
|
||||||
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
|
Date: int(r.clock.Now().Unix()), RecipientUnsaved: recipientUnsaved,
|
||||||
|
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, starGiftLifecycleErr(err)
|
return nil, starGiftLifecycleErr(err)
|
||||||
}
|
}
|
||||||
|
|
@ -566,10 +576,15 @@ func (r *Router) onPaymentsTransferStarGift(ctx context.Context, req *tg.Payment
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
recipientUnsaved, err := r.starGiftRecipientUnsaved(ctx, userID, to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
now := int(r.clock.Now().Unix())
|
now := int(r.clock.Now().Unix())
|
||||||
result, err := r.deps.Gifts.Transfer(ctx, domain.StarGiftTransferRequest{ActorUserID: userID, Ref: ref, To: to,
|
result, err := r.deps.Gifts.Transfer(ctx, domain.StarGiftTransferRequest{ActorUserID: userID, Ref: ref, To: to,
|
||||||
CommandKey: fmt.Sprintf("free:%s:%d:%s:%s:%d", ref.Owner.Type, ref.Owner.ID, starGiftRefValue(ref), to.Type, to.ID),
|
CommandKey: fmt.Sprintf("free:%s:%d:%s:%s:%d", ref.Owner.Type, ref.Owner.ID, starGiftRefValue(ref), to.Type, to.ID),
|
||||||
Date: now, OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
|
Date: now, RecipientUnsaved: recipientUnsaved,
|
||||||
|
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, starGiftLifecycleErr(err)
|
return nil, starGiftLifecycleErr(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -230,11 +230,16 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
|
||||||
ChargeStars: gift.Stars + upgradeStars, FormID: req.FormID, CommandKey: fmt.Sprintf("purchase:%d", req.FormID), Date: now,
|
ChargeStars: gift.Stars + upgradeStars, FormID: req.FormID, CommandKey: fmt.Sprintf("purchase:%d", req.FormID), Date: now,
|
||||||
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}
|
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}
|
||||||
recipientBlocked := false
|
recipientBlocked := false
|
||||||
|
recipientUnsaved := false
|
||||||
if peer.Type == domain.PeerTypeUser {
|
if peer.Type == domain.PeerTypeUser {
|
||||||
recipientBlocked, err = r.peerBlocksUser(ctx, userID, peer.ID)
|
recipientBlocked, err = r.peerBlocksUser(ctx, userID, peer.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
}
|
}
|
||||||
|
recipientUnsaved, err = r.starGiftRecipientUnsaved(ctx, userID, peer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if capability, ok := r.deps.Gifts.(interface{ AtomicPurchaseConfigured() bool }); ok && !capability.AtomicPurchaseConfigured() {
|
if capability, ok := r.deps.Gifts.(interface{ AtomicPurchaseConfigured() bool }); ok && !capability.AtomicPurchaseConfigured() {
|
||||||
if err := r.deps.Gifts.ValidatePurchaseForm(ctx, purchaseReq); err != nil {
|
if err := r.deps.Gifts.ValidatePurchaseForm(ctx, purchaseReq); err != nil {
|
||||||
|
|
@ -249,6 +254,7 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
|
||||||
return nil, starsErr(err)
|
return nil, starsErr(err)
|
||||||
}
|
}
|
||||||
purchaseReq.RecipientBlocked = recipientBlocked
|
purchaseReq.RecipientBlocked = recipientBlocked
|
||||||
|
purchaseReq.RecipientUnsaved = recipientUnsaved
|
||||||
result, err := r.deps.Gifts.Purchase(ctx, purchaseReq)
|
result, err := r.deps.Gifts.Purchase(ctx, purchaseReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, starGiftLifecycleErr(err)
|
return nil, starGiftLifecycleErr(err)
|
||||||
|
|
@ -364,6 +370,10 @@ func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, i
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SavedStarGiftRef, *tg.Updates, error) {
|
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SavedStarGiftRef, *tg.Updates, error) {
|
||||||
|
unsaved, err := r.starGiftRecipientUnsaved(ctx, senderID, domain.Peer{Type: domain.PeerTypeUser, ID: recipientID})
|
||||||
|
if err != nil {
|
||||||
|
return domain.SavedStarGiftRef{}, nil, err
|
||||||
|
}
|
||||||
prepaidUpgradeHash := ""
|
prepaidUpgradeHash := ""
|
||||||
if prepaidUpgradeStars == 0 && gift.UpgradeStars > 0 && gift.UpgradeIssued < gift.UpgradeTotal {
|
if prepaidUpgradeStars == 0 && gift.UpgradeStars > 0 && gift.UpgradeIssued < gift.UpgradeTotal {
|
||||||
var token [32]byte
|
var token [32]byte
|
||||||
|
|
@ -386,7 +396,7 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i
|
||||||
MsgID: send.RecipientMessage.ID,
|
MsgID: send.RecipientMessage.ID,
|
||||||
Date: send.RecipientMessage.Date,
|
Date: send.RecipientMessage.Date,
|
||||||
NameHidden: hideName,
|
NameHidden: hideName,
|
||||||
Unsaved: false,
|
Unsaved: unsaved,
|
||||||
ConvertStars: gift.ConvertStars,
|
ConvertStars: gift.ConvertStars,
|
||||||
PrepaidUpgradeStars: prepaidUpgradeStars,
|
PrepaidUpgradeStars: prepaidUpgradeStars,
|
||||||
PrepaidUpgradeHash: prepaidUpgradeHash,
|
PrepaidUpgradeHash: prepaidUpgradeHash,
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,10 @@ func (r *Router) adminGrantUpgradedStarGift(ctx context.Context, senderID int64,
|
||||||
grant.SenderID = senderID
|
grant.SenderID = senderID
|
||||||
grant.Date = int(r.clock.Now().Unix())
|
grant.Date = int(r.clock.Now().Unix())
|
||||||
grant.RecipientBlocked = recipientBlocked
|
grant.RecipientBlocked = recipientBlocked
|
||||||
|
grant.RecipientUnsaved, err = r.starGiftRecipientUnsaved(ctx, senderID, grant.Recipient)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err := granter.GrantUnique(ctx, grant); err != nil {
|
if _, err := granter.GrantUnique(ctx, grant); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,17 @@ func (r *Router) userPresenceStatusForUser(u domain.User) domain.UserStatus {
|
||||||
if userID == 0 {
|
if userID == 0 {
|
||||||
return domain.UserStatus{Kind: domain.UserStatusRecently}
|
return domain.UserStatus{Kind: domain.UserStatusRecently}
|
||||||
}
|
}
|
||||||
|
// The app projector marks a privacy-hidden exact timestamp with a coarse
|
||||||
|
// status and clears LastSeenAt. Never overlay the process presence tracker
|
||||||
|
// after that boundary, or every users/dialogs/history response would undo
|
||||||
|
// StatusTimestamp privacy.
|
||||||
|
switch u.Status.Kind {
|
||||||
|
case domain.UserStatusRecently,
|
||||||
|
domain.UserStatusLastWeek,
|
||||||
|
domain.UserStatusLastMonth,
|
||||||
|
domain.UserStatusEmpty:
|
||||||
|
return u.Status
|
||||||
|
}
|
||||||
now := int(r.clock.Now().Unix())
|
now := int(r.clock.Now().Unix())
|
||||||
if status, ok := r.presence.statusFor(userID, now); ok {
|
if status, ok := r.presence.statusFor(userID, now); ok {
|
||||||
return status
|
return status
|
||||||
|
|
@ -676,10 +687,15 @@ func (r *Router) pushUserStatus(ctx context.Context, userID int64, status domain
|
||||||
// contacts + dialog 对端 ∩ 在线」一次性算出(onlineRelevantPeerIDs,2 次查询 + 内存过滤),
|
// contacts + dialog 对端 ∩ 在线」一次性算出(onlineRelevantPeerIDs,2 次查询 + 内存过滤),
|
||||||
// 而非遍历全部在线候选逐个 GetPeerDialogs(旧 onlinePrivateDialogPeerIDs 的 O(在线数) N+1,
|
// 而非遍历全部在线候选逐个 GetPeerDialogs(旧 onlinePrivateDialogPeerIDs 的 O(在线数) N+1,
|
||||||
// 断连/sweeper 风暴下 O(M×512) 串行 PG)。私聊 dialog 双向建行,两种算法得到同一集合。
|
// 断连/sweeper 风暴下 O(M×512) 串行 PG)。私聊 dialog 双向建行,两种算法得到同一集合。
|
||||||
for _, recipientID := range r.onlineRelevantPeerIDs(ctx, userID) {
|
recipients := r.onlineRelevantPeerIDs(ctx, userID)
|
||||||
|
visible := r.statusTimestampVisibleToViewers(ctx, userID, recipients)
|
||||||
|
for _, recipientID := range recipients {
|
||||||
if recipientID == userID {
|
if recipientID == userID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !visible[recipientID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
r.pushUserMessageTransient(ctx, recipientID, "push user status", update)
|
r.pushUserMessageTransient(ctx, recipientID, "push user status", update)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -690,7 +706,11 @@ func (r *Router) pushOnlinePeerStatusesToCurrentSession(ctx context.Context, use
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updates := make([]tg.UpdateClass, 0, len(peerIDs))
|
updates := make([]tg.UpdateClass, 0, len(peerIDs))
|
||||||
|
visible := r.statusTimestampVisibleToViewer(ctx, peerIDs, userID)
|
||||||
for _, peerID := range peerIDs {
|
for _, peerID := range peerIDs {
|
||||||
|
if !visible[peerID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
status := r.userPresenceStatus(peerID)
|
status := r.userPresenceStatus(peerID)
|
||||||
if status.Kind != domain.UserStatusOnline {
|
if status.Kind != domain.UserStatusOnline {
|
||||||
continue
|
continue
|
||||||
|
|
@ -710,6 +730,99 @@ func (r *Router) pushOnlinePeerStatusesToCurrentSession(ctx context.Context, use
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type batchPrivacyEvaluator interface {
|
||||||
|
CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64, keys []domain.PrivacyKey) (map[int64]map[domain.PrivacyKey]bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type matrixPrivacyEvaluator interface {
|
||||||
|
CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs []int64, keys []domain.PrivacyKey) (map[int64]map[int64]map[domain.PrivacyKey]bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) statusTimestampVisibleToViewer(ctx context.Context, ownerUserIDs []int64, viewerUserID int64) map[int64]bool {
|
||||||
|
out := make(map[int64]bool, len(ownerUserIDs))
|
||||||
|
if r.deps.Privacy == nil {
|
||||||
|
for _, ownerID := range ownerUserIDs {
|
||||||
|
out[ownerID] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if batch, ok := r.deps.Privacy.(batchPrivacyEvaluator); ok {
|
||||||
|
visibility, err := batch.CanSeeBatch(ctx, ownerUserIDs, viewerUserID, []domain.PrivacyKey{domain.PrivacyKeyStatusTimestamp})
|
||||||
|
if err != nil {
|
||||||
|
r.log.Warn("evaluate status privacy batch", zap.Int64("viewer_user_id", viewerUserID), zap.Error(err))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, ownerID := range ownerUserIDs {
|
||||||
|
out[ownerID] = visibility[ownerID][domain.PrivacyKeyStatusTimestamp]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, ownerID := range ownerUserIDs {
|
||||||
|
allowed, err := r.deps.Privacy.CanSee(ctx, ownerID, viewerUserID, domain.PrivacyKeyStatusTimestamp)
|
||||||
|
if err == nil {
|
||||||
|
out[ownerID] = allowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) statusTimestampVisibleToViewers(ctx context.Context, ownerUserID int64, viewerUserIDs []int64) map[int64]bool {
|
||||||
|
out := make(map[int64]bool, len(viewerUserIDs))
|
||||||
|
if r.deps.Privacy == nil {
|
||||||
|
for _, viewerID := range viewerUserIDs {
|
||||||
|
out[viewerID] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if matrix, ok := r.deps.Privacy.(matrixPrivacyEvaluator); ok {
|
||||||
|
visibility, err := matrix.CanSeeMatrix(ctx, []int64{ownerUserID}, viewerUserIDs, []domain.PrivacyKey{domain.PrivacyKeyStatusTimestamp})
|
||||||
|
if err != nil {
|
||||||
|
r.log.Warn("evaluate status privacy matrix", zap.Int64("owner_user_id", ownerUserID), zap.Error(err))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, viewerID := range viewerUserIDs {
|
||||||
|
out[viewerID] = visibility[ownerUserID][viewerID][domain.PrivacyKeyStatusTimestamp]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, viewerID := range viewerUserIDs {
|
||||||
|
allowed, err := r.deps.Privacy.CanSee(ctx, ownerUserID, viewerID, domain.PrivacyKeyStatusTimestamp)
|
||||||
|
if err == nil {
|
||||||
|
out[viewerID] = allowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushStatusPrivacyRefresh immediately replaces stale exact statuses on other
|
||||||
|
// online accounts after StatusTimestamp changes. Allowed viewers receive the
|
||||||
|
// live value; denied viewers receive only a coarse bucket.
|
||||||
|
func (r *Router) pushStatusPrivacyRefresh(ctx context.Context, ownerUserID int64) {
|
||||||
|
recipients := r.onlineRelevantPeerIDs(ctx, ownerUserID)
|
||||||
|
visible := r.statusTimestampVisibleToViewers(ctx, ownerUserID, recipients)
|
||||||
|
exact := r.userPresenceStatus(ownerUserID)
|
||||||
|
if r.deps.Users != nil {
|
||||||
|
if owner, found, err := r.deps.Users.ByID(ctx, ownerUserID, ownerUserID); err == nil && found {
|
||||||
|
exact = r.userPresenceStatusForUser(owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coarse := domain.ApproximateUserStatus(exact.WasOnline, int(r.clock.Now().Unix()))
|
||||||
|
for _, recipientID := range recipients {
|
||||||
|
if recipientID == 0 || recipientID == ownerUserID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status := coarse
|
||||||
|
if visible[recipientID] {
|
||||||
|
status = exact
|
||||||
|
}
|
||||||
|
r.pushUserMessageTransient(ctx, recipientID, "push status privacy refresh", &tg.Updates{
|
||||||
|
Updates: []tg.UpdateClass{&tg.UpdateUserStatus{UserID: ownerUserID, Status: tgUserStatus(status)}},
|
||||||
|
Date: int(r.clock.Now().Unix()),
|
||||||
|
Seq: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// presenceCandidateCacheTTL 是 presence fan-out 候选集(联系人 ∪ 私聊对端)的缓存有效期;
|
// presenceCandidateCacheTTL 是 presence fan-out 候选集(联系人 ∪ 私聊对端)的缓存有效期;
|
||||||
// lastSeenPersistDebounce 是在线续期写 last_seen 的去抖窗口。
|
// lastSeenPersistDebounce 是在线续期写 last_seen 的去抖窗口。
|
||||||
const (
|
const (
|
||||||
|
|
|
||||||
28
internal/rpc/privacy_gifts.go
Normal file
28
internal/rpc/privacy_gifts.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// starGiftRecipientUnsaved evaluates privacyKeyStarGiftsAutoSave at the
|
||||||
|
// ownership-write boundary. The rule does not reject the gift: it decides
|
||||||
|
// whether an incoming user gift is displayed immediately (unsaved=false) or
|
||||||
|
// waits for the recipient's approval (unsaved=true).
|
||||||
|
func (r *Router) starGiftRecipientUnsaved(ctx context.Context, senderUserID int64, recipient domain.Peer) (bool, error) {
|
||||||
|
if recipient.Type != domain.PeerTypeUser || recipient.ID == 0 ||
|
||||||
|
senderUserID == 0 || senderUserID == recipient.ID || r.deps.Privacy == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
allowed, err := r.deps.Privacy.CanSee(
|
||||||
|
ctx,
|
||||||
|
recipient.ID,
|
||||||
|
senderUserID,
|
||||||
|
domain.PrivacyKeyStarGiftsAutoSave,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
return !allowed, nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue