feat: sync durable moderation and appeals

This commit is contained in:
iamxvbaba 2026-07-24 11:56:59 +08:00
parent e1a95c7318
commit 9f467f4be7
140 changed files with 13730 additions and 316 deletions

View file

@ -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}/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/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/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
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)
}
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) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")

View file

@ -21,7 +21,7 @@
}
})();
</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">
</head>
<body>

View file

@ -11,6 +11,9 @@ import type {
GroupMessageListResponse,
MessageDetail,
MessageListResponse,
ModerationCaseDetail,
ModerationCaseRow,
ModerationReport,
OfficialStarGiftListResponse,
StarGiftCollectiblePreview,
StarGiftListResponse
@ -73,6 +76,27 @@ export const api = {
const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) });
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"),
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),

View file

@ -7,6 +7,7 @@ import {
MessageSquareText,
Server,
Shield,
ShieldAlert,
ShieldCheck,
Smile,
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={<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={<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={<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>

View file

@ -57,6 +57,8 @@ const translations: Record<Language, Record<string, string>> = {
"route.accountsSubtitle": "Console / Accounts",
"route.channels": "Supergroups and Channels",
"route.channelsSubtitle": "Console / Channels",
"route.moderation": "Reports and Moderation",
"route.moderationSubtitle": "Console / Moderation",
"route.dashboard": "Operations Console",
"route.dashboardSubtitle": "Console / Overview",
"route.messages": "Message Audit",
@ -70,6 +72,7 @@ const translations: Record<Language, Record<string, string>> = {
"layout.dashboard": "Overview",
"layout.accounts": "Accounts",
"layout.channels": "Supergroups / Channels",
"layout.moderation": "Reports / Moderation",
"layout.messages": "Messages",
"layout.gifts": "Star Gifts",
"layout.giveGifts": "Give Gifts",
@ -539,6 +542,8 @@ const translations: Record<Language, Record<string, string>> = {
"route.accountsSubtitle": "控制台 / 账号",
"route.channels": "超级群与频道",
"route.channelsSubtitle": "控制台 / 频道",
"route.moderation": "举报与审核",
"route.moderationSubtitle": "控制台 / 内容安全",
"route.dashboard": "运维控制台",
"route.dashboardSubtitle": "控制台 / 总览",
"route.messages": "消息审计",
@ -552,6 +557,7 @@ const translations: Record<Language, Record<string, string>> = {
"layout.dashboard": "总览",
"layout.accounts": "账号",
"layout.channels": "超级群/频道",
"layout.moderation": "举报/审核",
"layout.messages": "消息",
"layout.gifts": "礼物目录",
"layout.giveGifts": "赠送礼物",
@ -1021,6 +1027,8 @@ const translations: Record<Language, Record<string, string>> = {
"route.accountsSubtitle": "Консоль / Аккаунты",
"route.channels": "Супергруппы и каналы",
"route.channelsSubtitle": "Консоль / Каналы",
"route.moderation": "Жалобы и модерация",
"route.moderationSubtitle": "Консоль / Модерация",
"route.dashboard": "Панель управления",
"route.dashboardSubtitle": "Консоль / Обзор",
"route.messages": "Аудит сообщений",
@ -1034,6 +1042,7 @@ const translations: Record<Language, Record<string, string>> = {
"layout.dashboard": "Обзор",
"layout.accounts": "Аккаунты",
"layout.channels": "Супергруппы / Каналы",
"layout.moderation": "Жалобы / Модерация",
"layout.messages": "Сообщения",
"layout.gifts": "Звёздные подарки",
"layout.giveGifts": "Выдача подарков",

View 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 };
}

View 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>;
}

View file

@ -13,11 +13,14 @@ import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
import { GiftsPage } from "./GiftsPage";
import { GiveGiftsPage } from "./GiveGiftsPage";
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
import { ModerationCasesPage } from "./ModerationCasesPage";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
const botID = route.path.match(/^\/bots\/(\d+)$/)?.[1];
const moderationCaseID = route.path.match(/^\/moderation\/(\d+)$/)?.[1];
if (accountID) {
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
}
@ -27,6 +30,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (botID) {
return <BotDetailPage id={Number(botID)} navigate={navigate} />;
}
if (moderationCaseID) {
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
}
if (route.path === "/accounts") {
return <AccountsPage navigate={navigate} />;
}
@ -36,6 +42,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/bots") {
return <BotsPage navigate={navigate} />;
}
if (route.path === "/moderation") {
return <ModerationCasesPage navigate={navigate} />;
}
if (route.path === "/emoji") {
return <EmojiPage />;
}

View file

@ -20,6 +20,7 @@ export function routeTitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/accounts")) return t("route.accounts");
if (pathname.startsWith("/channels")) return t("route.channels");
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("/messages")) return t("route.messages");
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("/channels")) return t("route.channelsSubtitle");
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("/messages")) return t("route.messagesSubtitle");
if (pathname.startsWith("/give-gifts")) return t("route.giveGiftsSubtitle");

View file

@ -239,6 +239,85 @@ export type 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 = {
id: string;
kind: "model" | "pattern" | "backdrop";

View file

@ -27,9 +27,11 @@ import (
"telesrv/internal/app/account"
aiapp "telesrv/internal/app/ai"
"telesrv/internal/app/auth"
authdiagnosticsapp "telesrv/internal/app/authdiagnostics"
botsapp "telesrv/internal/app/bots"
channelapp "telesrv/internal/app/channels"
chatlistsapp "telesrv/internal/app/chatlists"
clienttelemetryapp "telesrv/internal/app/clienttelemetry"
communitiesapp "telesrv/internal/app/communities"
"telesrv/internal/app/contacts"
"telesrv/internal/app/dialogs"
@ -41,6 +43,7 @@ import (
"telesrv/internal/app/livestream"
"telesrv/internal/app/maintenance"
messageapp "telesrv/internal/app/messages"
moderationapp "telesrv/internal/app/moderation"
passkeyapp "telesrv/internal/app/passkey"
phoneapp "telesrv/internal/app/phone"
pollsapp "telesrv/internal/app/polls"
@ -417,6 +420,9 @@ func run(logger *zap.Logger) error {
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
ephemeralReportStore := postgres.NewEphemeralReportStore(pool)
moderationReportStore := postgres.NewModerationReportStore(pool)
authDeliveryReportStore := postgres.NewAuthDeliveryReportStore(pool)
clientTelemetryStore := postgres.NewClientTelemetryStore(pool)
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
@ -521,6 +527,8 @@ func run(logger *zap.Logger) error {
tempAuthKeyStore := postgres.NewTempAuthKeyBindingStore(pool)
inlineRegistryStore := redisstore.NewInlineRegistryStore(rdb)
codeStore := redisstore.NewCodeStore(rdb)
authDeliveryReportService := authdiagnosticsapp.NewService(codeStore, authDeliveryReportStore)
clientTelemetryService := clienttelemetryapp.NewService(clientTelemetryStore)
rateLimiter := redisstore.NewRateLimiter(rdb)
activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions"))
adminService := adminapp.NewService(adminapp.Dependencies{
@ -536,6 +544,9 @@ func run(logger *zap.Logger) error {
WithBotAPIUpdateRetention(botAPIUpdateStore, cfg.BotAPIUpdateRetention).
WithAuthKeySessionLayerRetention(authKeyStore).
WithLoginCodeDeliveryRetention(messageStore).
WithClientTelemetryRetention(clientTelemetryStore, 30*24*time.Hour).
WithAuthDeliveryReportRetention(authDeliveryReportStore, 30*24*time.Hour).
WithModerationRetention(moderationReportStore).
WithUserUpdateRetention(updateEventStore).
WithChannelUpdateRetention(channelStore).
WithOrphanAuthKeyRetention(authKeyStore, activeSessions, cfg.OrphanAuthKeyRetention).
@ -573,6 +584,7 @@ func run(logger *zap.Logger) error {
// userCache 与 users 服务共享同一实例bot 元数据写入version bump后必须
// 失效缓存,否则 TTL 内 getUsers 回旧 first_name/旧 bot_info_version。
userCache := redisstore.NewUserCache(rdb, redisstore.DefaultUserCacheTTL)
accountLifecycleStore := postgres.NewAccountLifecycleStore(pool)
accountOptions := []account.ServiceOption{
account.WithReactionSettings(passwordStore),
account.WithAccountSettings(passwordStore),
@ -583,7 +595,7 @@ func run(logger *zap.Logger) error {
account.WithBusinessAutomation(passwordStore),
account.WithUsers(userStore),
account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts),
account.WithAccountLifecycle(postgres.NewAccountLifecycleStore(pool)),
account.WithAccountLifecycle(accountLifecycleStore),
account.WithPublicBaseURL(cfg.PublicBaseURL),
}
var webhookSender otpdelivery.Sender
@ -753,6 +765,7 @@ func run(logger *zap.Logger) error {
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
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))
privacyService.ConfigureReadModels(usersService, channelStore)
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
botsService.SetAIChatGenerator(aiComposeService)
dialogsService := dialogs.NewService(dialogStore, channelStore).Configure(
@ -773,6 +786,7 @@ func run(logger *zap.Logger) error {
)
communitiesService := communitiesapp.NewService(communityStore)
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
storiesService := storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService))
chatlistsService := chatlistsapp.NewService(
chatlistStore,
dialogStore,
@ -790,6 +804,21 @@ func run(logger *zap.Logger) error {
messageapp.WithSendPermissionChecker(adminService),
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(
messagesService,
channelsService,
@ -820,7 +849,6 @@ func run(logger *zap.Logger) error {
Sender: loginEmailSender,
}))
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
rpc.SetModerationWarnings(cfg.ScamWarning, cfg.FakeWarning)
router := rpc.New(rpc.Config{
DC: cfg.DC,
IP: cfg.AdvertiseIP,
@ -847,6 +875,8 @@ func run(logger *zap.Logger) error {
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
}, rpc.Deps{
Auth: authService,
AuthDeliveryReports: authDeliveryReportService,
ClientTelemetry: clientTelemetryService,
AuthKeySessionLayers: authKeyStore,
Account: accountService,
Privacy: privacyService,
@ -855,7 +885,7 @@ func run(logger *zap.Logger) error {
AICompose: aiComposeService,
Ephemeral: ephemeralService,
EphemeralPush: ephemeralStore,
EphemeralReports: ephemeralReportStore,
Moderation: moderationService,
Users: usersService,
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
Updates: updatesService,
@ -872,7 +902,7 @@ func run(logger *zap.Logger) error {
Files: filesService,
Bots: botsService,
Polls: pollsapp.NewService(pollStore),
Stories: storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService)),
Stories: storiesService,
Phone: phoneService,
SecretChats: secretChatService,
Stars: starsService,
@ -896,7 +926,7 @@ func run(logger *zap.Logger) error {
ChannelBoosts: channelBoostCache,
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
Dialogs: dialogsService,
Privacy: privacyStore,
Privacy: privacyService,
ProfilePhotos: cachedPhotos,
Stories: router,
ChannelFullBots: router,
@ -925,7 +955,24 @@ func run(logger *zap.Logger) error {
GiftGranter: router,
Bots: botsService,
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.* 边界),
// router 创建后注入。
botsService.SetRouterHooks(router)
@ -989,20 +1036,21 @@ func run(logger *zap.Logger) error {
return fmt.Errorf("start admin api: %w", err)
}
if _, err := web.Start(ctx, web.Config{
Addr: cfg.PublicLinkWebAddr,
PublicBaseURL: cfg.PublicBaseURL,
AppScheme: cfg.PublicAppScheme,
AppLinkBase: cfg.PublicAppLinkBase,
WebBaseURL: cfg.PublicWebBaseURL,
AppName: cfg.PublicAppName,
StickerSets: filesService,
Users: userStore,
Channels: channelStore,
Privacy: privacyService,
Photos: filesService,
UniqueGifts: giftsService,
GiftWithdrawals: giftsService,
TelegramLogin: telegramLoginHTTPHandler,
Addr: cfg.PublicLinkWebAddr,
PublicBaseURL: cfg.PublicBaseURL,
AppScheme: cfg.PublicAppScheme,
AppLinkBase: cfg.PublicAppLinkBase,
WebBaseURL: cfg.PublicWebBaseURL,
AppName: cfg.PublicAppName,
StickerSets: filesService,
Users: userStore,
Channels: channelStore,
Privacy: privacyService,
Photos: filesService,
UniqueGifts: giftsService,
GiftWithdrawals: giftsService,
ModerationAppeals: moderationService,
TelegramLogin: telegramLoginHTTPHandler,
}, logger.Named("public-web")); err != nil {
return fmt.Errorf("start public Web: %w", err)
}

View 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;

View 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
);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.auth_delivery_reports;

View 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);

View 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;

View 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';

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.client_telemetry_events;

View 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);

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS public.channel_antispam_decisions;
DROP TABLE IF EXISTS public.sponsored_message_impressions;

View 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;

View 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;

View 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[])
);

View file

@ -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_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_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_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_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_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_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_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
| Setting | Type / code default | Description and constraints |

View file

@ -165,6 +165,16 @@ type EmojiService interface {
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
// without charging Stars. Implemented by the RPC router, it reuses the standard
// gift-delivery path (service message for users, saved-gift + admin log for
@ -191,6 +201,7 @@ type Dependencies struct {
OfficialGifts OfficialGiftsSource
Bots BotService
Emoji EmojiService
Moderation ModerationService
Now func() time.Time
}
@ -212,6 +223,7 @@ type Service struct {
officialGifts OfficialGiftsSource
bots BotService
emoji EmojiService
moderation ModerationService
now func() time.Time
}
@ -272,6 +284,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.Emoji != nil {
s.emoji = deps.Emoji
}
if deps.Moderation != nil {
s.moderation = deps.Moderation
}
if deps.Now != nil {
s.now = deps.Now
}
@ -281,6 +296,61 @@ func (s *Service) Configure(deps Dependencies) *Service {
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 {
CommandID string `json:"command_id"`
Actor string `json:"actor"`

View file

@ -57,6 +57,13 @@ type Service interface {
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, 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)
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) {
@ -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/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/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
}
@ -634,6 +648,267 @@ func (s *Server) handleStarGiftCollectibleAnimation(w http.ResponseWriter, r *ht
_, _ = 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 {
defer r.Body.Close()
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))

View file

@ -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) {
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}`))
@ -357,3 +436,31 @@ func (fakeService) StarGiftCollectibles(context.Context, int64) (domain.StarGift
func (fakeService) StarGiftCollectibleAnimation(context.Context, int64, domain.StarGiftCollectibleAttributeKind, int64) ([]byte, bool, error) {
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
}

View file

@ -1016,6 +1016,41 @@ func (s *Service) GetAccountSettings(ctx context.Context, userID int64) (domain.
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 持久化账号全局隐私开关,返回合并后的完整设置。
func (s *Service) SetGlobalPrivacy(ctx context.Context, userID int64, privacy domain.GlobalPrivacy) (domain.AccountSettings, error) {
settings, err := s.GetAccountSettings(ctx, userID)

View 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)
}

View 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)
}
}

View file

@ -1031,6 +1031,29 @@ func (s *Service) ListMessageReactions(ctx context.Context, userID int64, req do
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 {
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)
}
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.
func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) {
if s == nil || s.channels == nil || userID == 0 {

View 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)
}

View file

@ -149,7 +149,9 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
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) {
if s == nil || s.contacts == nil || s.users == nil || userID == 0 || contactUserID == 0 || contactUserID == userID {
return domain.Contact{}, ErrContactIDInvalid
@ -188,11 +190,6 @@ func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64
return domain.Contact{}, err
}
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)
if err != nil {
return domain.Contact{}, err

View file

@ -67,6 +67,19 @@ type LoginCodeDeliveryRetentionStore interface {
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 行的删除宽限:确认水位之下的行不会再被
// getUpdates 读取fromID 恒 > confirmed宽限仅防御 offset 回拨调试;回收目标是清堆积。
const botAPIConfirmedGrace = 15 * time.Minute
@ -92,24 +105,29 @@ const (
// 前缀;落后或缺 state 的任一设备都会把 floor 压回 0。客户端偶然带回已确认前的旧 pts 时,
// updates 服务通过普通 differenceSlice checkpoint 推进,不发送 differenceTooLong。
type RetentionWorker struct {
outbox DispatchOutboxRetentionStore
tempKeys TempAuthKeyRetentionStore // 可为 nil不回收 temp key 绑定)
authKeySessionLayers AuthKeySessionLayerRetentionStore
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil不回收 Bot API 队列)
userUpdates UserUpdateEventRetentionStore
channelUpdates ChannelUpdateEventRetentionStore
loginCodeDeliveries LoginCodeDeliveryRetentionStore
orphanAuthKeys OrphanAuthKeyRetentionStore
activeAuthKeys ActiveRawAuthKeyProvider
activeAuthKeyHeartbeat ActiveAuthKeyHeartbeatStore
logger *zap.Logger
retention time.Duration
botAPIRetention time.Duration
orphanRetention time.Duration
outboxPoisonRetention time.Duration
outboxPoisonInterval time.Duration
interval time.Duration
batch int
outbox DispatchOutboxRetentionStore
tempKeys TempAuthKeyRetentionStore // 可为 nil不回收 temp key 绑定)
authKeySessionLayers AuthKeySessionLayerRetentionStore
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil不回收 Bot API 队列)
userUpdates UserUpdateEventRetentionStore
channelUpdates ChannelUpdateEventRetentionStore
loginCodeDeliveries LoginCodeDeliveryRetentionStore
clientTelemetry ClientTelemetryRetentionStore
authDeliveryReports AuthDeliveryReportRetentionStore
moderation ModerationRetentionStore
orphanAuthKeys OrphanAuthKeyRetentionStore
activeAuthKeys ActiveRawAuthKeyProvider
activeAuthKeyHeartbeat ActiveAuthKeyHeartbeatStore
logger *zap.Logger
retention time.Duration
botAPIRetention time.Duration
orphanRetention time.Duration
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 {
@ -191,6 +209,29 @@ func (w *RetentionWorker) WithAuthKeySessionLayerRetention(store AuthKeySessionL
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
// 不能提供 temp→perm business key否则未登录或 PFS 连接会被误判为 orphan。
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))
}
}
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 {
expiredBefore := time.Now().Add(-tempAuthKeyExpiryGrace).Unix()
tempDeleted, err := w.tempKeys.DeleteExpired(ctx, expiredBefore, w.batch)

View file

@ -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) {
f.calls++
f.confirmedGrace = confirmedGrace

View 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
}

View 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)
}
}

View 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
}

View 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)
}
}

View 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
}

View 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)
}
}

View 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
}

View 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
}
}

View 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)
}
}

View 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)
}

View 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)
}
}

View 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)
}

View 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)
}
}

View file

@ -87,6 +87,34 @@ func (c *CachedPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.P
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 的完整规则集。调用方必须先
// InvalidateOwnersepoch 保证预热期间若又发生失效,不会把旧快照写回。
func (c *CachedPrivacyStore) WarmOwners(ctx context.Context, ownerUserIDs ...int64) error {
@ -185,6 +213,40 @@ func (c *CachedPrivacyStore) FlushReadModelCache() {
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),
// 这样「查过且无规则」的 owner 也被负缓存,不会反复打后端。
func buildPrivacyRulesByOwner(list []domain.PrivacyRules, owners []int64) map[int64]privacyRulesMap {

View 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()
}
}

View file

@ -3,21 +3,49 @@ package privacy
import (
"context"
"slices"
"time"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
const maxPrivacyRules = 100
const (
maxPrivacyRules = 100
maxPrivacyRuleIDs = 5000
)
// Service owns account privacy rules and viewer-specific evaluation.
type Service struct {
rules store.PrivacyStore
contacts store.ContactStore
rules store.PrivacyStore
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 {
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) {
@ -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) {
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) {
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 {
return domain.PrivacyRules{}, err
}
out := domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}
if s != nil && s.rules != nil {
if err := s.rules.SetPrivacyRules(ctx, out); err != nil {
return domain.PrivacyRules{}, err
}
}
return out, nil
return domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}, nil
}
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 {
return false, err
}
needs := needsForRules(rules)
evalCtx := domain.PrivacyContext{
OwnerUserID: ownerUserID,
ViewerUserID: viewerUserID,
}
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
} else if found {
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
}
@ -183,6 +267,16 @@ func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerU
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 的
// contacts.Get(owner, viewer))。
var reverse map[int64]domain.Contact
@ -193,25 +287,82 @@ func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerU
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 {
_, isContact := reverse[owner]
contact, isContact := reverse[owner]
m := make(map[domain.PrivacyKey]bool, len(keys))
for _, k := range keys {
rules, ok := rulesByOwner[owner][k]
if !ok {
rules = defaultRules(owner, k)
}
m[k] = Evaluate(rules, domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewerUserID,
ViewerIsContact: isContact,
})
evalCtx := domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewerUserID,
ViewerIsContact: isContact,
ViewerCloseFriend: isContact && contact.CloseFriend,
}
applyViewerFacts(&evalCtx, baseFacts[viewerUserID], now)
applyMembershipFacts(&evalCtx, needs.chatIDs, membershipFacts)
m[k] = Evaluate(rules, evalCtx)
}
out[owner] = m
}
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)
// 调 CanSee但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate
// (把 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)
}
}
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 {
// owner 的联系人中哪些是本批 viewer= privacy 的 ViewerIsContact对应 contacts.Get(owner,viewer))。
var ownerContacts map[int64]domain.Contact
@ -269,17 +447,21 @@ func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs
perViewer[viewer] = m
continue
}
_, isContact := ownerContacts[viewer]
contact, isContact := ownerContacts[viewer]
for _, k := range keys {
rules, ok := rulesByOwner[owner][k]
if !ok {
rules = defaultRules(owner, k)
}
m[k] = Evaluate(rules, domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewer,
ViewerIsContact: isContact,
})
evalCtx := domain.PrivacyContext{
OwnerUserID: owner,
ViewerUserID: viewer,
ViewerIsContact: isContact,
ViewerCloseFriend: isContact && contact.CloseFriend,
}
applyViewerFacts(&evalCtx, baseFacts[viewer], now)
applyMembershipFacts(&evalCtx, needs.chatIDs, membershipFacts)
m[k] = Evaluate(rules, evalCtx)
}
perViewer[viewer] = m
}
@ -370,6 +552,7 @@ func validateRules(rules []domain.PrivacyRule) error {
if len(rules) > maxPrivacyRules {
return domain.ErrPrivacyRuleInvalid
}
totalIDs := 0
for _, rule := range rules {
switch rule.Kind {
case domain.PrivacyRuleAllowContacts,
@ -387,6 +570,20 @@ func validateRules(rules []domain.PrivacyRule) error {
default:
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
}

View file

@ -607,6 +607,19 @@ func (s *Service) RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]b
}, 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 记录某会话云草稿变化(保存/清空都是同一事件——草稿是绝对
// 状态,重放时按 peer 重载当前值。updateDraftMessage 无 pts 字段,走 LacksWirePts
// aux 簿记topMsgID 是 forum 话题草稿键(复用 MaxID 列持久化)。

View file

@ -2,6 +2,7 @@ package userprojection
import (
"context"
"time"
"golang.org/x/sync/errgroup"
@ -660,10 +661,8 @@ func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID in
return domain.User{}, err
}
if !statusAllowed {
user.Status = domain.ApproximateUserStatus(user.LastSeenAt, int(time.Now().Unix()))
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 {
ref.Personal = true

View file

@ -138,6 +138,14 @@ func (s *Service) AdminUser(ctx context.Context, userID int64) (domain.User, boo
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 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。
func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
if currentUserID == 0 {

View file

@ -90,14 +90,9 @@ type Config struct {
PublicWebBaseURL string
// PublicAppName 是公开落地页展示的产品名,不参与协议路由。
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 是公开链接落地页监听地址;为空关闭。
// 生产应只监听 loopback并由 nginx 将 /<username>、/addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。
// 生产应只监听 loopback并由 nginx 将 /<username>、/addstickers/、/addemoji/、
// /addlist/ 与 hash-only /appeal/ 路由反代到该地址。
PublicLinkWebAddr string
// TelegramLoginEnabled mounts the self-hosted Telegram Login/OIDC provider
// on PublicLinkWebAddr. Secrets are file-backed so they are not exposed in
@ -504,8 +499,6 @@ func Load() (Config, error) {
PublicAppLinkBase: publicAppLinkBase,
PublicWebBaseURL: publicWebBaseURL,
PublicAppName: publicAppName,
ScamWarning: envAllowEmptyOr("TELESRV_SCAM_WARNING", ""),
FakeWarning: envAllowEmptyOr("TELESRV_FAKE_WARNING", ""),
PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""),
TelegramLoginEnabled: envBoolOr("TELESRV_TELEGRAM_LOGIN_ENABLE", false),
TelegramLoginIssuer: strings.TrimSuffix(envOr("TELESRV_TELEGRAM_LOGIN_ISSUER", publicBaseURL), "/"),

View 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
}

View file

@ -508,6 +508,21 @@ type ChannelMember struct {
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
// see and address every subscriber topic in the linked direct-messages
// monoforum. Telegram deliberately does not grant this capability to an
@ -869,6 +884,22 @@ type ChannelMessageReactionsList struct {
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.
type RecentMessageReaction struct {
UserID int64

View 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
}

View 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)
}
}

View 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
}

View 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
}

View 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
}

View 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)
}
}

View file

@ -75,6 +75,10 @@ func DefaultPrivacyRules(key PrivacyKey) []PrivacyRule {
switch key {
case PrivacyKeyPhoneNumber:
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:
return []PrivacyRule{{Kind: PrivacyRuleAllowContacts}}
default:

View file

@ -353,6 +353,7 @@ type AdminStarGiftGrant struct {
CommandKey string
Date int
RecipientBlocked bool
RecipientUnsaved bool
ModelAttributeID int64
PatternAttributeID int64
BackdropAttributeID int64
@ -382,6 +383,7 @@ type StarGiftPurchaseRequest struct {
CommandKey string
Date int
RecipientBlocked bool
RecipientUnsaved bool
OriginAuthKeyID [8]byte
OriginSessionID int64
}
@ -555,15 +557,16 @@ type StarGiftValueInfo struct {
}
type StarGiftTransferRequest struct {
ActorUserID int64
Ref SavedStarGiftRef
To Peer
ChargeStars int64
FormID int64
CommandKey string
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
ActorUserID int64
Ref SavedStarGiftRef
To Peer
ChargeStars int64
FormID int64
CommandKey string
Date int
RecipientUnsaved bool
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftTransferResult struct {
@ -582,15 +585,16 @@ type StarGiftListingRequest struct {
}
type StarGiftResalePurchaseRequest struct {
BuyerUserID int64
Slug string
To Peer
Amount StarGiftAmount
FormID int64
CommandKey string
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
BuyerUserID int64
Slug string
To Peer
Amount StarGiftAmount
FormID int64
CommandKey string
Date int
RecipientUnsaved bool
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftOfferRequest struct {

View file

@ -36,7 +36,10 @@ const (
// UpdateEventUserEmojiStatus carries the exact immutable status snapshot.
// It consumes account pts even though updateUserEmojiStatus has no pts.
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私聊置顶/取消
// 置顶MessageIDs 是该 owner 自己视角的 box idBool 为 pinned
// TL 构造器自带账号 pts/pts_count不属于 LacksWirePts。
@ -88,6 +91,7 @@ type UpdateEvent struct {
Bool bool
Phone string
EmojiStatus UserEmojiStatus
Privacy PrivacyRules
Settings PeerSettings
MessageIDs []int
MaxID int
@ -140,6 +144,7 @@ func (e UpdateEvent) LacksWirePts() bool {
UpdateEventPeerStoryBlocked,
UpdateEventUserPhone,
UpdateEventUserEmojiStatus,
UpdateEventPrivacy,
UpdateEventDialogFilter,
UpdateEventDialogFilterOrder,
UpdateEventDialogFilters,

View file

@ -243,6 +243,26 @@ type UserStatus struct {
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 表示只填了月日不含年份。
type Birthday struct {
Day int

View file

@ -16,6 +16,12 @@ import (
// registerAccount 注册 account.* RPC handler。
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) {
return r.onAccountDeleteAccount(ctx, req)
})
@ -867,7 +873,27 @@ func (r *Router) onAccountSetPrivacy(ctx context.Context, req *tg.AccountSetPriv
if r.deps.Privacy == 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 {
return nil, privacyErr(err)
}
@ -876,14 +902,36 @@ func (r *Router) onAccountSetPrivacy(ctx context.Context, req *tg.AccountSetPriv
return nil, err
}
r.invalidateRPCProjectionForUser(userID)
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{},
})
if durableWrite {
if sessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event))
} else if updates, ok := r.deps.Updates.(PrivacyUpdatesService); ok {
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
}
@ -908,10 +956,11 @@ func (r *Router) onAccountSetAccountTTL(ctx context.Context, ttl tg.AccountDaysT
return false, tgerr400("TTL_DAYS_INVALID")
}
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()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
}
return true, nil
}
@ -938,7 +987,7 @@ func (r *Router) onAccountSetGlobalPrivacySettings(ctx context.Context, settings
if err != nil {
return nil, internalErr()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
return tgGlobalPrivacySettings(saved.GlobalPrivacy), nil
}
return &settings, nil
@ -965,10 +1014,11 @@ func (r *Router) onAccountSetContentSettings(ctx context.Context, req *tg.Accoun
return false, inputRequestInvalidErr()
}
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()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
}
return true, nil
}
@ -991,10 +1041,11 @@ func (r *Router) onAccountSetContactSignUpNotification(ctx context.Context, sile
return false, internalErr()
}
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()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
}
return true, nil
}

View 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
}
}

View 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)
}
}

View file

@ -45,6 +45,46 @@ func (c *accountSettingsCache) Delete(userID int64) {
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 取(缓存的)账号单例设置;服务未接通返回默认。
func (r *Router) cachedAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, error) {
svc, ok := r.accountSettingsSvc()

View file

@ -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) {
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) {
return r.onAuthResendCode(ctx, layerRequest)
})
@ -376,6 +379,36 @@ func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest
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 {
return tgSentCodeWithLength(hash, devCodeLength)
}

View file

@ -29,6 +29,10 @@ func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreat
return nil, err
}
memberIDs = createChatInviteMemberIDs(memberIDs, userID)
memberIDs, missingInvitees, err := r.filterChatInvitePrivacy(ctx, userID, memberIDs)
if err != nil {
return nil, internalErr()
}
date := int(r.clock.Now().Unix())
r.log.Debug("messages.createChat resolved 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 &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) {

View file

@ -311,7 +311,27 @@ func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.Channels
if err != nil {
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 {
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 {
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) {

View file

@ -350,6 +350,24 @@ func (r *Router) onChannelsDeleteMessages(ctx context.Context, req *tg.ChannelsD
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) {
if r.deps.Channels == nil {
return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil

View file

@ -3,7 +3,10 @@ package rpc
import (
"github.com/iamxvbaba/td/tg"
"strings"
apptelemetry "telesrv/internal/app/clienttelemetry"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
"time"
)
@ -11,6 +14,14 @@ import (
func TestTDesktopPassiveChannelStubs(t *testing.T) {
f := newRPCChannelFixture(t)
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")
friend := f.user(42, "15550002102", "Friend")
invited := f.user(43, "15550002103", "Invited")
@ -470,6 +481,9 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
if _, ok := reported.(*tg.ReportResultReported); !ok {
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{
Peer: inputPeerChannel(channel),
ID: []int{1},
@ -477,13 +491,6 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
}); err == nil || !strings.Contains(err.Error(), "OPTION_INVALID") {
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{
Peer: inputPeerChannel(channel),
ID: []int{1},
@ -503,11 +510,45 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
}); err != nil || !ok {
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{
ID: &tg.InputDocument{ID: 1, AccessHash: 2},
ListenedDuration: 1,
}); err != nil || !ok {
t.Fatalf("messages.reportMusicListen = ok %v err %v, want true nil", ok, err)
}); err == nil || ok || !strings.Contains(err.Error(), "DOCUMENT_INVALID") {
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{
RandomID: []byte("ad"),
@ -577,6 +618,13 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
if _, err := r.onMessagesSendReaction(friendCtx, friendReactionReq); err != nil {
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{
Peer: inputPeerChannel(channel),
Limit: 10,

View file

@ -206,9 +206,21 @@ func (r *Router) onChannelsReportAntiSpamFalsePositive(ctx context.Context, req
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
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
}
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
}

View file

@ -5,6 +5,7 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
@ -12,6 +13,7 @@ import (
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/app/readmodel"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
@ -598,9 +600,17 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002301", FirstName: "Owner"})
requester, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002302", FirstName: "Requester"})
channelStore := memory.NewChannelStore()
moderationStore := memory.NewModerationReportStore()
userService := appusers.NewService(userStore)
channelService := appchannels.NewService(channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Users: userService,
Channels: channelService,
Moderation: appmoderation.NewService(
moderationStore,
appmoderation.WithMessageReaders(nil, channelService),
appmoderation.WithPeerReaders(userService, channelService),
),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "Public Team",
@ -621,6 +631,23 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
t.Fatalf("send seed message: %v", err)
}
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{
Channel: input,

View file

@ -18,6 +18,12 @@ func validateEmptyChannelStickerSet(stickerset tg.InputStickerSetClass) 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 {
return false, limitInvalidErr()
}
@ -26,12 +32,30 @@ func (r *Router) onChannelsReportSpam(ctx context.Context, req *tg.ChannelsRepor
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
}
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()
}
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
}

View file

@ -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))
for _, contact := range list.Contacts {
id := contact.User.ID
@ -555,9 +556,19 @@ func (r *Router) onContactsGetStatuses(ctx context.Context) ([]tg.ContactStatus,
u.LastSeenAt = current.LastSeenAt
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{
UserID: id,
Status: tgUserStatus(r.userPresenceStatusForUser(u)),
Status: tgUserStatus(status),
})
}
return out, nil

View file

@ -543,16 +543,6 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
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 {
ch := view.Channel
full := &tg.ChannelFull{
@ -563,13 +553,15 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
CanSetUsername: view.Self.Role == domain.ChannelRoleCreator,
CanDeleteChannel: view.Self.Role == domain.ChannelRoleCreator,
ID: ch.ID,
About: channelAboutWithModerationWarning(ch),
ReadInboxMaxID: view.Dialog.ReadInboxMaxID,
ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID,
UnreadCount: view.Dialog.UnreadCount,
ChatPhoto: tgChannelChatPhotoFull(ch),
NotifySettings: *tdesktop.NotifySettings(),
Pts: ch.Pts,
// Official clients render localized warnings from scam/fake flags.
// About remains the owner's unmodified description.
About: ch.About,
ReadInboxMaxID: view.Dialog.ReadInboxMaxID,
ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID,
UnreadCount: view.Dialog.UnreadCount,
ChatPhoto: tgChannelChatPhotoFull(ch),
NotifySettings: *tdesktop.NotifySettings(),
Pts: ch.Pts,
}
if ch.ParticipantsCount > 0 {
full.SetParticipantsCount(ch.ParticipantsCount)

View file

@ -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
}

View file

@ -242,6 +242,14 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
return nil
}
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:
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 {
return nil

View file

@ -21,6 +21,8 @@ func tgSelfUser(u domain.User) *tg.User {
Phone: u.Phone,
Self: true,
Verified: u.Verified,
Scam: u.Scam,
Fake: u.Fake,
Support: u.Support,
Contact: u.Contact,
MutualContact: u.Mutual,

View file

@ -47,6 +47,14 @@ type AuthService interface {
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 的在线绑定。
//
// 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)
}
// 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 抽象通讯录查询。
type ContactsService interface {
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)
CloseMessagePoll(ctx context.Context, userID int64, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, 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)
RecentReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, 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)
}
// 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
type Deps struct {
Auth AuthService
Auth AuthService
AuthDeliveryReports AuthDeliveryReportService
ClientTelemetry ClientTelemetryService
// AuthKeySessionLayers is the protocol-only durable ordering boundary for
// explicit invokeWithLayer evidence. Production must wire the same auth-key
// store used by the MTProto edge; nil is reserved for isolated router tests.
@ -889,7 +937,7 @@ type Deps struct {
AICompose AIComposeService
Ephemeral EphemeralService
EphemeralPush store.EphemeralPushBroker
EphemeralReports store.EphemeralReportStore
Moderation ModerationService
Users UsersService
TelegramLogin TelegramLoginService
Updates UpdatesService

View file

@ -93,12 +93,24 @@ func (r *Router) onMessagesReceivedQueue(ctx context.Context, maxQts int) ([]int
return []int64{}, nil
}
// onMessagesReportEncryptedSpam 纯记录:服务端不自动 discard、不拉黑、不产生 update
// discard/block 由客户端独立 RPC 完成。P1 接受并回 true。
func (r *Router) onMessagesReportEncryptedSpam(ctx context.Context, _ tg.InputEncryptedChat) (bool, error) {
if _, err := r.secretChatRequireUser(ctx); err != nil {
// onMessagesReportEncryptedSpam persists an immutable chat-metadata snapshot;
// the server remains unable to inspect encrypted message plaintext. Reporting
// does not discard or block the chat and emits no update.
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
}
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
}

View file

@ -6,8 +6,10 @@ import (
"github.com/iamxvbaba/td/tg"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/domain"
"telesrv/internal/postresponse"
"telesrv/internal/store/memory"
)
// acceptChat 跑完 request→accept返回 normal 态密聊 id 与 participant 视角 access_hash。
@ -46,6 +48,33 @@ func encNewMessagePayload(t *testing.T, rec phonePushRecord) *tg.UpdateNewEncryp
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) {
f := newEncryptedFixture(t)
chatID, _ := f.acceptChat(t)

View file

@ -245,13 +245,18 @@ func (r *Router) onEphemeralReportMessage(ctx context.Context, request *tg.Ephem
if _, final := result.(*tg.ReportResultReported); !final {
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()
}
report := domain.NewEphemeralAbuseReport(userID, string(request.Option), request.Message, target, r.clock.Now())
if _, err := r.deps.EphemeralReports.CreateEphemeralReport(ctx, report); err != nil {
if _, _, err := r.deps.Moderation.ReportEphemeral(
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))
return nil, internalErr()
return nil, moderationReportError(err)
}
return result, nil
}

View file

@ -2,6 +2,7 @@ package rpc
import (
"context"
"strings"
"testing"
"time"
@ -9,6 +10,7 @@ import (
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
@ -49,13 +51,14 @@ func TestEphemeralReportPersistsOnlyFinalIdempotentEvidence(t *testing.T) {
OriginDevice: domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKey, SessionID: 99},
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}
channels := &ephemeralReportChannels{view: domain.ChannelView{
Channel: domain.Channel{ID: channelID, AccessHash: 42, Megagroup: true},
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)
request := &tg.EphemeralReportMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: 42}, ID: target.ID,
@ -87,7 +90,9 @@ func TestEphemeralReportPersistsOnlyFinalIdempotentEvidence(t *testing.T) {
}
}
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)
}
if ephemeral.calls != 4 {

View file

@ -382,6 +382,9 @@ func callProtocolFlagsInvalidErr() error {
func userIsBlockedErr() error { return tgerr.New(400, "USER_IS_BLOCKED") }
func userPrivacyRestrictedErr() error { return tgerr.New(403, "USER_PRIVACY_RESTRICTED") }
func chatSendVoicesForbiddenErr() error {
return tgerr.New(403, "CHAT_SEND_VOICES_FORBIDDEN")
}
// signalingDataInvalidErr 表示 phone.sendSignalingData 载荷超限或非法。
func signalingDataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") }

View file

@ -18,7 +18,7 @@ const (
maxReactionVector = 16
maxReactionListOffset = 128
maxReportOptionLength = 32
maxReportCommentLength = 1024
maxReportCommentLength = domain.MaxModerationCommentRunes
maxReportRandomIDLength = 128
maxReadMetrics = 100
maxBusinessConnIDLength = 128

View file

@ -14,24 +14,52 @@ func reportResultForOption(option string) (tg.ReportResultClass, error) {
return &tg.ReportResultChooseOption{
Title: "Report",
Options: []tg.MessageReportOption{
{Text: "Spam", Option: []byte("spam")},
{Text: "Scam or spam", Option: []byte("spam")},
{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: "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: "Fake or impersonation", Option: []byte("fake")},
{Text: "Other", Option: []byte("other")},
},
}, nil
case "other":
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
default:
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 {
switch peer.Type {
case domain.PeerTypeUser:

View file

@ -2,20 +2,43 @@ package rpc
import (
"context"
"errors"
"sort"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"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) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
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
}
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
}
@ -24,11 +47,12 @@ func (r *Router) onMessagesReport(ctx context.Context, req *tg.MessagesReportReq
if err != nil {
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
}
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 {
return nil, limitInvalidErr()
@ -38,7 +62,28 @@ func (r *Router) onMessagesReport(ctx context.Context, req *tg.MessagesReportReq
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) {
@ -49,21 +94,50 @@ func (r *Router) onMessagesReportReaction(ctx context.Context, req *tg.MessagesR
if req.ID <= 0 || req.ID > domain.MaxMessageBoxID {
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
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ReactionPeer); err != nil {
reactor, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ReactionPeer)
if err != nil {
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
}
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) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if len(req.ID) > maxGetMessagesIDs {
if len(req.ID) == 0 || len(req.ID) > maxGetMessagesIDs {
return false, limitInvalidErr()
}
for _, msgID := range req.ID {
@ -71,9 +145,26 @@ func (r *Router) onMessagesReportMessagesDelivery(ctx context.Context, req *tg.M
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
}
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
}
@ -82,46 +173,210 @@ func (r *Router) onMessagesReportReadMetrics(ctx context.Context, req *tg.Messag
if err != nil {
return false, internalErr()
}
if len(req.Metrics) > maxReadMetrics {
if len(req.Metrics) == 0 || len(req.Metrics) > maxReadMetrics {
return false, limitInvalidErr()
}
ids := make([]int, 0, len(req.Metrics))
payload := make([]readMetricTelemetry, 0, len(req.Metrics))
for _, metric := range req.Metrics {
if metric.MsgID <= 0 || metric.MsgID > domain.MaxMessageBoxID {
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()
}
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
}
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
}
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()
}
if req.ID == nil {
return false, tgerr.New(400, "DOCUMENT_INVALID")
}
if req.ListenedDuration < 0 {
if req.ListenedDuration < 0 || req.ListenedDuration > 24*60*60 {
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
}
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()
}
if len(req.RandomID) == 0 || len(req.RandomID) > maxReportRandomIDLength || len(req.Option) > maxReportOptionLength {
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
}
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) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()

View 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)
}
}

View file

@ -38,10 +38,15 @@ func (r *Router) sendStarGiftTransferForm(ctx context.Context, userID, formID in
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,
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),
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 {
return nil, starGiftLifecycleErr(err)
}
@ -105,9 +110,14 @@ func (r *Router) sendStarGiftResaleForm(ctx context.Context, userID, formID int6
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,
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 {
return nil, starGiftLifecycleErr(err)
}
@ -566,10 +576,15 @@ func (r *Router) onPaymentsTransferStarGift(ctx context.Context, req *tg.Payment
if err != nil {
return nil, err
}
recipientUnsaved, err := r.starGiftRecipientUnsaved(ctx, userID, to)
if err != nil {
return nil, err
}
now := int(r.clock.Now().Unix())
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),
Date: now, OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
Date: now, RecipientUnsaved: recipientUnsaved,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
if err != nil {
return nil, starGiftLifecycleErr(err)
}

View file

@ -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,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}
recipientBlocked := false
recipientUnsaved := false
if peer.Type == domain.PeerTypeUser {
recipientBlocked, err = r.peerBlocksUser(ctx, userID, peer.ID)
if err != nil {
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 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)
}
purchaseReq.RecipientBlocked = recipientBlocked
purchaseReq.RecipientUnsaved = recipientUnsaved
result, err := r.deps.Gifts.Purchase(ctx, purchaseReq)
if err != nil {
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) {
unsaved, err := r.starGiftRecipientUnsaved(ctx, senderID, domain.Peer{Type: domain.PeerTypeUser, ID: recipientID})
if err != nil {
return domain.SavedStarGiftRef{}, nil, err
}
prepaidUpgradeHash := ""
if prepaidUpgradeStars == 0 && gift.UpgradeStars > 0 && gift.UpgradeIssued < gift.UpgradeTotal {
var token [32]byte
@ -386,7 +396,7 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i
MsgID: send.RecipientMessage.ID,
Date: send.RecipientMessage.Date,
NameHidden: hideName,
Unsaved: false,
Unsaved: unsaved,
ConvertStars: gift.ConvertStars,
PrepaidUpgradeStars: prepaidUpgradeStars,
PrepaidUpgradeHash: prepaidUpgradeHash,

View file

@ -90,6 +90,10 @@ func (r *Router) adminGrantUpgradedStarGift(ctx context.Context, senderID int64,
grant.SenderID = senderID
grant.Date = int(r.clock.Now().Unix())
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 {
return err
}

View file

@ -306,6 +306,17 @@ func (r *Router) userPresenceStatusForUser(u domain.User) domain.UserStatus {
if userID == 0 {
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())
if status, ok := r.presence.statusFor(userID, now); ok {
return status
@ -676,10 +687,15 @@ func (r *Router) pushUserStatus(ctx context.Context, userID int64, status domain
// contacts + dialog 对端 ∩ 在线」一次性算出onlineRelevantPeerIDs2 次查询 + 内存过滤),
// 而非遍历全部在线候选逐个 GetPeerDialogs旧 onlinePrivateDialogPeerIDs 的 O(在线数) N+1
// 断连/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 {
continue
}
if !visible[recipientID] {
continue
}
r.pushUserMessageTransient(ctx, recipientID, "push user status", update)
}
}
@ -690,7 +706,11 @@ func (r *Router) pushOnlinePeerStatusesToCurrentSession(ctx context.Context, use
return
}
updates := make([]tg.UpdateClass, 0, len(peerIDs))
visible := r.statusTimestampVisibleToViewer(ctx, peerIDs, userID)
for _, peerID := range peerIDs {
if !visible[peerID] {
continue
}
status := r.userPresenceStatus(peerID)
if status.Kind != domain.UserStatusOnline {
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 候选集(联系人 私聊对端)的缓存有效期;
// lastSeenPersistDebounce 是在线续期写 last_seen 的去抖窗口。
const (

View 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