fix(admin): resolve moderation, gift, and admin-panel bugs from QA

- scam/fake: enforce mutual exclusivity (scam precedence) at TL conversion, admin command, and UI toggles; both flags could render as neither
- scam/fake flicker: persist scam/fake in the Redis user base cache so cache hits no longer drop the flags
- getSavedStarGifts: hidden (unsaved) gifts are now force-excluded for non-owner viewers; same guard for get-by-ref
- convertStarGift: map already-upgraded/owner-invalid/unavailable to a clean client error instead of 500
- channel force-settings: send only changed fields (partial patch) and re-sync toggles after reload, so one setting no longer resets another
- give gifts: removed custom collectible numbers (auto sequential only); locked sender to the system account 777000
- give gifts UI: fixed picker card spacing/right gap; static (hover-play) Lottie previews on emoji + picker to stop render lag; fixed emoji ID field overflow
This commit is contained in:
epilepticseizureee 2026-07-23 06:24:53 +03:00
parent 313624eab2
commit 90792cdfab
22 changed files with 214 additions and 135 deletions

View file

@ -1371,7 +1371,6 @@ type giveGiftAPIRequest struct {
ModelAttributeID int64 `json:"model_attribute_id,string"`
PatternAttributeID int64 `json:"pattern_attribute_id,string"`
BackdropAttributeID int64 `json:"backdrop_attribute_id,string"`
Num int `json:"num"`
}
func (s *server) handleGiveGiftAPI(w http.ResponseWriter, r *http.Request) {
@ -1391,7 +1390,6 @@ func (s *server) handleGiveGiftAPI(w http.ResponseWriter, r *http.Request) {
ModelAttributeID: body.ModelAttributeID,
PatternAttributeID: body.PatternAttributeID,
BackdropAttributeID: body.BackdropAttributeID,
Num: body.Num,
}
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/give", req)
writeCommandResultAPI(w, result, err)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -21,8 +21,8 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-Bx7A77x9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-XV_IEG5m.css">
<script type="module" crossorigin src="/assets/index-C0WDPjsF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwxAoLbQ.css">
</head>
<body>
<div id="root"></div>

View file

@ -0,0 +1,57 @@
import lottie from "lottie-web/build/player/lottie_light_canvas";
import { useEffect, useRef } from "react";
// StaticLottie renders a single (first) frame of a Lottie/TGS animation instead
// of looping it, so a grid of many stickers/emoji does not keep the canvas
// rendering and pinning the CPU. It plays only while hovered, then resets to the
// static frame. Use it for list/grid previews; keep the looping player for
// single, focused previews.
export function StaticLottie({
loader,
cacheKey,
className,
playOnHover = true,
onError
}: {
loader: () => Promise<Record<string, unknown>>;
cacheKey: string;
className?: string;
playOnHover?: boolean;
onError?: () => void;
}) {
const host = useRef<HTMLDivElement>(null);
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
useEffect(() => {
let cancelled = false;
loader()
.then((data) => {
if (cancelled || !host.current) return;
animation.current?.destroy();
animation.current = lottie.loadAnimation({
container: host.current,
renderer: "canvas",
loop: true,
autoplay: false,
animationData: structuredClone(data)
});
animation.current.goToAndStop(0, true);
})
.catch(() => onError?.());
return () => {
cancelled = true;
animation.current?.destroy();
animation.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cacheKey]);
function play() {
if (playOnHover) animation.current?.play();
}
function reset() {
if (playOnHover) animation.current?.goToAndStop(0, true);
}
return <div className={className} ref={host} onMouseEnter={play} onMouseLeave={reset} />;
}

View file

@ -1,5 +1,5 @@
import { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { ActionButton } from "./ActionButton";
import { useI18n } from "../i18n";
import { toInt } from "../lib/format";
@ -134,6 +134,33 @@ export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow
const [joinToSend, setJoinToSend] = useState(channel.JoinToSend);
const [joinRequest, setJoinRequest] = useState(channel.JoinRequest);
const [slowmode, setSlowmode] = useState(String(channel.SlowmodeSeconds));
// Re-sync the toggles with the persisted state whenever the channel reloads
// (e.g. after applying a change), so previously-applied settings stay checked.
useEffect(() => {
setGigagroup(channel.Gigagroup);
setAntispam(channel.AntiSpam);
setHidden(channel.ParticipantsHidden);
setNoforwards(channel.NoForwards);
setJoinToSend(channel.JoinToSend);
setJoinRequest(channel.JoinRequest);
setSlowmode(String(channel.SlowmodeSeconds));
}, [channel]);
// Send only the fields the admin actually changed. The backend applies a
// partial patch (nil = leave unchanged), so an unrelated setting is never
// reset when another one is applied.
function buildPatch() {
const patch: Record<string, unknown> = { channel_id: channel.ID };
if (gigagroup !== channel.Gigagroup) patch.gigagroup = gigagroup;
if (antispam !== channel.AntiSpam) patch.antispam = antispam;
if (hidden !== channel.ParticipantsHidden) patch.participants_hidden = hidden;
if (noforwards !== channel.NoForwards) patch.noforwards = noforwards;
if (joinToSend !== channel.JoinToSend) patch.join_to_send = joinToSend;
if (joinRequest !== channel.JoinRequest) patch.join_request = joinRequest;
if (toInt(slowmode) !== channel.SlowmodeSeconds) patch.slowmode_seconds = toInt(slowmode);
return patch;
}
return (
<div className="attr-block">
<label className="checkline"><input type="checkbox" checked={gigagroup} onChange={(e) => setGigagroup(e.target.checked)} /> {t("attr.gigagroup")}</label>
@ -151,16 +178,7 @@ export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow
icon={<Settings2 size={15} />}
tone="warn"
path="/api/actions/set-channel-settings"
payload={() => ({
channel_id: channel.ID,
gigagroup,
antispam,
participants_hidden: hidden,
noforwards,
join_to_send: joinToSend,
join_request: joinRequest,
slowmode_seconds: toInt(slowmode)
})}
payload={buildPatch}
onDone={onDone}
/>
</div>

View file

@ -17,9 +17,9 @@ export function ScamFakeBadges({ scam, fake }: { scam: boolean; fake: boolean })
);
}
// ScamFakeActions renders the two independent toggles. The combined setter
// receives the full desired state, so each button flips one flag and keeps the
// other unchanged.
// ScamFakeActions renders the two toggles. scam and fake are mutually exclusive
// (a peer is never both in Telegram), so enabling one clears the other; the
// combined setter always receives the full desired state.
export function ScamFakeActions({
idKey,
id,
@ -43,7 +43,7 @@ export function ScamFakeActions({
icon={<ShieldAlert size={15} />}
tone="danger"
path={path}
payload={() => ({ [idKey]: id, scam: !scam, fake })}
payload={() => ({ [idKey]: id, scam: !scam, fake: !scam ? false : fake })}
onDone={onDone}
/>
<ActionButton
@ -51,7 +51,7 @@ export function ScamFakeActions({
icon={<ShieldX size={15} />}
tone="danger"
path={path}
payload={() => ({ [idKey]: id, scam, fake: !fake })}
payload={() => ({ [idKey]: id, fake: !fake, scam: !fake ? false : scam })}
onDone={onDone}
/>
</div>

View file

@ -337,17 +337,15 @@ const translations: Record<Language, Record<string, string>> = {
"giveGift.pickChannel": "Recipient channel",
"giveGift.recipientRequired": "Select a recipient first",
"giveGift.sender": "Sender account ID",
"giveGift.senderHint": "Defaults to the system account 777000 (Telesrv).",
"giveGift.senderHint": "Gifts are always sent from the system account 777000 (Telesrv).",
"giveGift.message": "Attached message (optional)",
"giveGift.messagePlaceholder": "Shown with the gift",
"giveGift.hideName": "Hide sender name from recipient",
"giveGift.upgrade": "Deliver as upgraded collectible",
"giveGift.upgradeNote": "The gift is minted as a unique collectible. Pick specific attributes and a number below, or leave them on Random to draw from the published pool. Requires a published collectible upgrade with remaining supply.",
"giveGift.upgradeNote": "The gift is minted as a unique collectible. Pick specific attributes below, or leave them on Random to draw from the published pool. The collectible number is assigned automatically. Requires a published collectible upgrade with remaining supply.",
"giveGift.model": "Model",
"giveGift.pattern": "Pattern",
"giveGift.backdrop": "Backdrop",
"giveGift.number": "Number",
"giveGift.numberAuto": "Auto",
"giveGift.random": "Random",
"giveGift.confirm": "Give gift",
"giveGifts.pageTitle": "Give Gifts",
@ -821,17 +819,15 @@ const translations: Record<Language, Record<string, string>> = {
"giveGift.pickChannel": "接收频道",
"giveGift.recipientRequired": "请先选择接收方",
"giveGift.sender": "发送方账号 ID",
"giveGift.senderHint": "默认使用系统账号 777000Telesrv。",
"giveGift.senderHint": "礼物始终由系统账号 777000Telesrv发送。",
"giveGift.message": "附加留言(可选)",
"giveGift.messagePlaceholder": "随礼物一起显示",
"giveGift.hideName": "对接收方隐藏发送方名称",
"giveGift.upgrade": "作为升级收藏品发放",
"giveGift.upgradeNote": "礼物将铸造为唯一收藏品。可在下方指定具体属性和编号,或保持“随机”从已发布的属性池中抽取。需要存在有剩余供应量的已发布收藏品升级。",
"giveGift.upgradeNote": "礼物将铸造为唯一收藏品。可在下方指定具体属性,或保持“随机”从已发布的属性池中抽取。编号自动分配。需要存在有剩余供应量的已发布收藏品升级。",
"giveGift.model": "模型",
"giveGift.pattern": "图案",
"giveGift.backdrop": "背景",
"giveGift.number": "编号",
"giveGift.numberAuto": "自动",
"giveGift.random": "随机",
"giveGift.confirm": "赠送礼物",
"giveGifts.pageTitle": "赠送礼物",
@ -1305,17 +1301,15 @@ const translations: Record<Language, Record<string, string>> = {
"giveGift.pickChannel": "Получатель (канал)",
"giveGift.recipientRequired": "Сначала выберите получателя",
"giveGift.sender": "ID аккаунта-отправителя",
"giveGift.senderHint": "По умолчанию системный аккаунт 777000 (Telesrv).",
"giveGift.senderHint": "Подарки всегда отправляются от системного аккаунта 777000 (Telesrv).",
"giveGift.message": "Сообщение к подарку (необязательно)",
"giveGift.messagePlaceholder": "Показывается вместе с подарком",
"giveGift.hideName": "Скрыть имя отправителя от получателя",
"giveGift.upgrade": "Выдать как улучшенный коллекционный",
"giveGift.upgradeNote": "Подарок будет отчеканен как уникальный коллекционный. Ниже можно выбрать конкретные атрибуты и номер или оставить «Случайно» для выбора из опубликованного пула. Требуется опубликованное коллекционное улучшение с остатком тиража.",
"giveGift.upgradeNote": "Подарок будет отчеканен как уникальный коллекционный. Ниже можно выбрать конкретные атрибуты или оставить «Случайно» для выбора из опубликованного пула. Номер присваивается автоматически. Требуется опубликованное коллекционное улучшение с остатком тиража.",
"giveGift.model": "Модель",
"giveGift.pattern": "Узор",
"giveGift.backdrop": "Фон",
"giveGift.number": "Номер",
"giveGift.numberAuto": "Авто",
"giveGift.random": "Случайно",
"giveGift.confirm": "Выдать подарок",
"giveGifts.pageTitle": "Выдача подарков",

View file

@ -1,7 +1,7 @@
import lottie from "lottie-web/build/player/lottie_light_canvas";
import { Check, ChevronRight, Copy, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { StaticLottie } from "../components/StaticLottie";
import { Alert, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import type { EmojiListResponse, EmojiRow } from "../types";
@ -18,38 +18,25 @@ function isAnimated(mime: string): boolean {
}
function EmojiPreview({ row }: { row: EmojiRow }) {
const host = useRef<HTMLDivElement>(null);
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
const [failed, setFailed] = useState(!isAnimated(row.MimeType));
useEffect(() => {
if (!isAnimated(row.MimeType)) {
setFailed(true);
return;
}
let cancelled = false;
api.emojiAnimation(row.DocumentID).then((data) => {
if (cancelled || !host.current) return;
animation.current?.destroy();
animation.current = lottie.loadAnimation({
container: host.current,
renderer: "canvas",
loop: true,
autoplay: true,
animationData: structuredClone(data)
});
}).catch(() => setFailed(true));
return () => {
cancelled = true;
animation.current?.destroy();
animation.current = null;
};
setFailed(!isAnimated(row.MimeType));
}, [row.DocumentID, row.MimeType]);
if (failed) {
return <div className="emoji-glyph">{row.Alt || "🙂"}</div>;
}
return <div className="emoji-anim" ref={host} />;
// Render a static first frame (plays only on hover) so a full grid of emoji
// does not keep every Lottie canvas animating and lag the page.
return (
<StaticLottie
className="emoji-anim"
cacheKey={row.DocumentID}
loader={() => api.emojiAnimation(row.DocumentID)}
onError={() => setFailed(true)}
/>
);
}
function EmojiCard({ row }: { row: EmojiRow }) {

View file

@ -20,7 +20,6 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
const [kind, setKind] = useState<RecipientKind>("user");
const [user, setUser] = useState<AccountRow | null>(null);
const [channel, setChannel] = useState<ChannelRow | null>(null);
const [sender, setSender] = useState(SYSTEM_SENDER);
const [message, setMessage] = useState("");
const [hideName, setHideName] = useState(false);
const [upgrade, setUpgrade] = useState(false);
@ -29,7 +28,6 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
const [modelID, setModelID] = useState("0");
const [patternID, setPatternID] = useState("0");
const [backdropID, setBackdropID] = useState("0");
const [num, setNum] = useState("");
const [reason, setReason] = useState("");
const [result, setResult] = useState<CommandResult | null>(null);
const [error, setError] = useState("");
@ -47,7 +45,6 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
setModelID("0");
setPatternID("0");
setBackdropID("0");
setNum("");
setResult(null);
setError("");
}, [gift.GiftID]);
@ -63,11 +60,10 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
}, [upgradable, preview, gift.GiftID]);
function buildPayload(confirm: boolean): Record<string, unknown> {
const senderID = Number.parseInt(sender.trim() || SYSTEM_SENDER, 10);
const parsedNum = Number.parseInt(num.trim(), 10);
return {
gift_id: gift.GiftID,
sender_user_id: Number.isFinite(senderID) ? senderID : 0,
// Gifts are always sent from the official system account (777000).
sender_user_id: Number(SYSTEM_SENDER),
user_id: kind === "user" ? recipientID : 0,
channel_id: kind === "channel" ? recipientID : 0,
hide_name: hideName,
@ -76,13 +72,12 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
model_attribute_id: upgradable ? modelID : "0",
pattern_attribute_id: upgradable ? patternID : "0",
backdrop_attribute_id: upgradable ? backdropID : "0",
num: upgradable && Number.isFinite(parsedNum) && parsedNum > 0 ? parsedNum : 0,
reason: reason.trim(),
confirm
};
}
const previewPayload = useMemo(() => buildPayload(false), [gift.GiftID, kind, recipientID, sender, message, hideName, upgrade, modelID, patternID, backdropID, num, reason]);
const previewPayload = useMemo(() => buildPayload(false), [gift.GiftID, kind, recipientID, message, hideName, upgrade, modelID, patternID, backdropID, reason]);
const canConfirm = result?.dry_run && !result.error;
async function run(confirm: boolean) {
@ -134,7 +129,7 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
<label className="form-field">
<span>{t("giveGift.sender")}</span>
<input value={sender} inputMode="numeric" onChange={(event) => { setSender(event.target.value.replace(/[^0-9]/g, "")); setResult(null); }} placeholder={SYSTEM_SENDER} />
<input value={SYSTEM_SENDER} disabled readOnly />
<small className="field-hint">{t("giveGift.senderHint")}</small>
</label>
@ -152,7 +147,7 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
{kind === "user" && (
<>
<label className="gift-switch">
<input type="checkbox" checked={upgrade} onChange={(event) => { setUpgrade(event.target.checked); if (!event.target.checked) { setModelID("0"); setPatternID("0"); setBackdropID("0"); setNum(""); } setResult(null); }} />
<input type="checkbox" checked={upgrade} onChange={(event) => { setUpgrade(event.target.checked); if (!event.target.checked) { setModelID("0"); setPatternID("0"); setBackdropID("0"); } setResult(null); }} />
<span className="gift-switch-track" aria-hidden="true"><span /></span>
<span>{t("giveGift.upgrade")}</span>
</label>
@ -181,10 +176,6 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
{(preview.backdrops ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select>
</label>
<label>
<span>{t("giveGift.number")}</span>
<input type="number" min="1" max={preview.supply_total ?? undefined} value={num} placeholder={t("giveGift.numberAuto")} onChange={(event) => { setNum(event.target.value.replace(/[^0-9]/g, "")); setResult(null); }} />
</label>
</div>
)}
</>

View file

@ -1,10 +1,10 @@
import { Gift, RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api, errorMessage } from "../api";
import { StaticLottie } from "../components/StaticLottie";
import { Alert, Badge, PageFrame } from "../components/ui";
import { useI18n } from "../i18n";
import type { StarGiftRow } from "../types";
import { LottiePreview } from "./GiftsPage";
import { GiveGiftForm } from "./GiveGiftForm";
export function GiveGiftsPage() {
@ -58,13 +58,13 @@ export function GiveGiftsPage() {
<button key={gift.GiftID} type="button" role="option" aria-selected={active}
className={`give-gift-option ${active ? "selected" : ""} ${gift.Enabled ? "" : "gift-row-disabled"}`}
onClick={() => setSelected(gift)}>
<LottiePreview giftID={gift.GiftID} revision={gift.Revision} compact />
<StaticLottie className="give-gift-thumb" cacheKey={`${gift.GiftID}:${gift.Revision}`} loader={() => api.giftAnimation(gift.GiftID)} />
<span className="give-gift-option-info">
<strong>{gift.Title || `Gift #${gift.GiftID}`}</strong>
<span className="mono">#{gift.GiftID}</span>
<span className="give-gift-option-meta">
{gift.Enabled ? <Badge> {gift.Stars}</Badge> : <Badge tone="neutral">{t("common.disabled")}</Badge>}
</span>
</span>
<span className="give-gift-option-price">
{gift.Enabled ? <Badge> {gift.Stars}</Badge> : <Badge tone="neutral">{t("common.disabled")}</Badge>}
</span>
</button>
);

View file

@ -297,7 +297,7 @@
/* Collectible attribute pickers reuse .gift-fields-grid but need equal columns
and site-styled selects rather than the import modal's fixed template. */
.give-gift-attrs { grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: end; }
.give-gift-attrs { grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; }
.give-gift-attrs select,
.give-gift-attrs input {
width: 100%; min-width: 0; height: 38px; padding: 0 32px 0 10px;
@ -314,27 +314,28 @@
.give-gift-attrs input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--focus); outline: none; }
/* Give Gifts page: two-panel picker + form */
.give-gift-layout { display: grid; grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); gap: 16px; align-items: start; }
.give-gift-layout { display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); gap: 16px; align-items: start; }
.give-gift-picker { display: grid; gap: 10px; align-content: start; }
.give-gift-picker-head { display: flex; align-items: center; gap: 12px; }
.give-gift-picker-head .searchbox { flex: 1 1 auto; }
.give-gift-picker-list {
display: grid; gap: 8px; max-height: 640px; padding: 8px; overflow: auto;
background: var(--panel-subtle); border: 1px solid var(--line); border-radius: var(--radius-lg); scrollbar-gutter: stable;
display: grid; gap: 8px; max-height: 640px; padding: 8px; overflow-y: auto;
background: var(--panel-subtle); border: 1px solid var(--line); border-radius: var(--radius-lg);
}
.give-gift-option {
display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 11px; align-items: center; min-width: 0;
padding: 9px; text-align: left; color: var(--text); background: var(--panel);
display: grid; grid-template-columns: 46px minmax(0, 1fr) auto; gap: 11px; align-items: center; min-width: 0;
padding: 9px 11px; text-align: left; color: var(--text); background: var(--panel);
border: 1px solid var(--line); border-radius: var(--radius); cursor: pointer; box-shadow: var(--shadow-sm);
transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
}
.give-gift-option:hover { border-color: var(--brand-tint-border); box-shadow: var(--shadow); transform: translateY(-1px); }
.give-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px var(--focus), var(--shadow); }
.give-gift-option .gift-animation-shell.compact { pointer-events: none; }
.give-gift-thumb { display: grid; place-items: center; width: 46px; height: 46px; }
.give-gift-thumb canvas { width: 100% !important; height: 100% !important; }
.give-gift-option-info { display: grid; gap: 3px; min-width: 0; }
.give-gift-option-info strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.give-gift-option-info .mono { color: var(--muted); font-size: 10px; }
.give-gift-option-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; }
.give-gift-option-price { justify-self: end; white-space: nowrap; }
.give-gift-panel {
display: grid; gap: 12px; padding: 16px; min-width: 0;
background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--radius-lg);
@ -645,7 +646,9 @@
}
.emoji-id {
display: inline-flex;
display: flex;
width: 100%;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 6px;
@ -659,11 +662,17 @@
}
.emoji-id .mono {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emoji-id svg {
flex: 0 0 auto;
}
.emoji-id:hover {
border-color: var(--brand-tint-border);
color: var(--brand);

View file

@ -117,6 +117,12 @@
font-weight: 800;
}
.form-field input:disabled,
.form-field textarea:disabled {
opacity: .6;
cursor: not-allowed;
}
.command-preview {
display: grid;
gap: 8px;

View file

@ -358,7 +358,6 @@ type GiveGiftRequest struct {
ModelAttributeID int64 `json:"model_attribute_id"`
PatternAttributeID int64 `json:"pattern_attribute_id"`
BackdropAttributeID int64 `json:"backdrop_attribute_id"`
Num int `json:"num"`
}
type StarGiftCollectibleAnimationUpload struct {
@ -871,6 +870,11 @@ func (s *Service) SetUserFlags(ctx context.Context, req SetUserFlagsRequest) (Co
if s == nil || s.users == nil {
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
}
// scam and fake are mutually exclusive (a peer is never both in Telegram).
// scam takes precedence so the two never persist together.
if req.Scam {
req.Fake = false
}
return s.runCommand(ctx, req.CommandMeta, ActionSetUserFlags, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
@ -967,8 +971,8 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
if req.Upgrade && recipient.Type != domain.PeerTypeUser {
return CommandResult{}, fmt.Errorf("upgraded gift delivery is supported for user recipients only")
}
if !req.Upgrade && (req.ModelAttributeID > 0 || req.PatternAttributeID > 0 || req.BackdropAttributeID > 0 || req.Num > 0) {
return CommandResult{}, fmt.Errorf("collectible attributes and number require upgrade")
if !req.Upgrade && (req.ModelAttributeID > 0 || req.PatternAttributeID > 0 || req.BackdropAttributeID > 0) {
return CommandResult{}, fmt.Errorf("collectible attributes require upgrade")
}
return s.runCommand(ctx, req.CommandMeta, ActionGiveGift, req.UserID, recipient, req, func() (CommandResult, error) {
details := map[string]any{
@ -1012,9 +1016,6 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
if req.BackdropAttributeID > 0 && !collectibleAttrPresent(preview.Backdrops, req.BackdropAttributeID) {
return CommandResult{}, fmt.Errorf("backdrop attribute %d is not part of gift %d", req.BackdropAttributeID, req.GiftID)
}
if req.Num > 0 && req.Num > preview.SupplyTotal {
return CommandResult{}, fmt.Errorf("number %d exceeds collectible supply %d", req.Num, preview.SupplyTotal)
}
details["collectible_supply_total"] = preview.SupplyTotal
details["collectible_issued"] = preview.Issued
if req.ModelAttributeID > 0 {
@ -1026,9 +1027,6 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
if req.BackdropAttributeID > 0 {
details["backdrop_attribute_id"] = req.BackdropAttributeID
}
if req.Num > 0 {
details["num"] = req.Num
}
}
}
if req.DryRun {
@ -1044,7 +1042,6 @@ func (s *Service) GiveGift(ctx context.Context, req GiveGiftRequest) (CommandRes
ModelAttributeID: req.ModelAttributeID,
PatternAttributeID: req.PatternAttributeID,
BackdropAttributeID: req.BackdropAttributeID,
Num: req.Num,
}); err != nil {
return CommandResult{}, err
}

View file

@ -328,21 +328,21 @@ type StarGiftUpgradeRequest struct {
OriginAuthKeyID [8]byte
OriginSessionID int64
// Admin-controlled minting overrides. When non-zero these pin the specific
// collectible attributes / number instead of the random pool draw and the
// sequential issued+1 number. They are only honoured on the admin grant path;
// the DB FK (attribute must belong to the revision) and UNIQUE(gift_id,num)
// constraints remain the source of truth.
// Admin-controlled attribute overrides. When non-zero these pin the specific
// collectible model/pattern/backdrop instead of the random pool draw. They
// are only honoured on the admin grant path; the DB FK (attribute must belong
// to the revision) remains the source of truth. The collectible number is
// always assigned automatically (sequential).
ModelAttributeID int64
PatternAttributeID int64
BackdropAttributeID int64
Num int
}
// AdminStarGiftGrant is one admin "give gift" command: deliver GiftID to
// Recipient from Sender (0 => official system account 777000) at no charge.
// When Upgrade is set the gift is minted as a collectible; the optional
// attribute IDs / Num pin specific collectible facts (0 => random/auto).
// attribute IDs pin specific model/pattern/backdrop (0 => random). The
// collectible number is always assigned automatically.
type AdminStarGiftGrant struct {
SenderID int64
Recipient Peer
@ -353,7 +353,6 @@ type AdminStarGiftGrant struct {
ModelAttributeID int64
PatternAttributeID int64
BackdropAttributeID int64
Num int
}
type StarGiftPurchaseRequest struct {
@ -927,7 +926,6 @@ var (
ErrStarGiftAlreadyUpgraded = errors.New("stargift: already upgraded")
ErrStarGiftCollectibleSoldOut = errors.New("stargift: collectible supply exhausted")
ErrStarGiftCollectibleInvalid = errors.New("stargift: invalid collectible definition")
ErrStarGiftCollectibleNumberTaken = errors.New("stargift: collectible number already taken")
ErrStarGiftCollectionNotFound = errors.New("stargift: collection not found")
ErrStarGiftCollectionsFull = errors.New("stargift: collections full")
ErrStarGiftUnavailable = errors.New("stargift: unavailable")

View file

@ -451,7 +451,9 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0,
Verified: ch.Verified,
Scam: ch.Scam,
Fake: ch.Fake,
// scam and fake are mutually exclusive in Telegram; a peer flagged as
// both would render neither badge on clients. scam takes precedence.
Fake: ch.Fake && !ch.Scam,
Gigagroup: ch.Gigagroup,
Broadcast: ch.Broadcast,
Megagroup: ch.Megagroup,

View file

@ -53,7 +53,9 @@ func tgUser(u domain.User) *tg.User {
Phone: u.Phone,
Verified: u.Verified,
Scam: u.Scam,
Fake: u.Fake,
// scam and fake are mutually exclusive in Telegram; a peer flagged as
// both would render neither badge on clients. scam takes precedence.
Fake: u.Fake && !u.Scam,
Support: u.Support,
Contact: u.Contact,
MutualContact: u.Mutual,

View file

@ -523,10 +523,17 @@ func (r *Router) onPaymentsGetSavedStarGifts(ctx context.Context, req *tg.Paymen
if r.deps.Gifts == nil {
return emptySavedStarGifts(), nil
}
// Gifts hidden from the profile (unsaved) are visible only to the owner (or a
// channel admin). Never trust the client's exclude_unsaved flag for other
// viewers: force-exclude hidden gifts unless the requester manages the owner.
excludeUnsaved := req.ExcludeUnsaved
if r.ensureCanManageStarGiftOwner(ctx, userID, owner) != nil {
excludeUnsaved = true
}
collectionID, _ := req.GetCollectionID()
page, err := r.deps.Gifts.ListSavedFiltered(ctx, domain.SavedStarGiftFilter{
Owner: owner,
ExcludeUnsaved: req.ExcludeUnsaved,
ExcludeUnsaved: excludeUnsaved,
ExcludeSaved: req.ExcludeSaved,
ExcludeUnlimited: req.ExcludeUnlimited,
ExcludeUnique: req.ExcludeUnique,
@ -556,6 +563,17 @@ func (r *Router) onPaymentsGetSavedStarGift(ctx context.Context, refs []tg.Input
return emptySavedStarGifts(), nil
}
gifts := make([]domain.SavedStarGift, 0, len(refs))
// A gift hidden from the profile (unsaved) is visible only to the owner or a
// channel admin. Memoize the manage check per owner to avoid repeat lookups.
manageCache := make(map[domain.Peer]bool)
canManageOwner := func(owner domain.Peer) bool {
if v, ok := manageCache[owner]; ok {
return v
}
v := r.ensureCanManageStarGiftOwner(ctx, userID, owner) == nil
manageCache[owner] = v
return v
}
for _, ref := range refs {
dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref)
if err != nil {
@ -569,6 +587,9 @@ func (r *Router) onPaymentsGetSavedStarGift(ctx context.Context, refs []tg.Input
return nil, internalErr()
}
if found && !g.Converted {
if g.Unsaved && !canManageOwner(g.Owner) {
continue
}
gifts = append(gifts, g)
}
}
@ -669,9 +690,14 @@ func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSave
})
if err != nil {
switch {
case errors.Is(err, domain.ErrStarGiftNotFound):
return false, starGiftInvalidErr()
case errors.Is(err, domain.ErrStarGiftAlreadyConverted):
case errors.Is(err, domain.ErrStarGiftNotFound),
errors.Is(err, domain.ErrStarGiftAlreadyConverted),
errors.Is(err, domain.ErrStarGiftAlreadyUpgraded),
errors.Is(err, domain.ErrStarGiftOwnerInvalid),
errors.Is(err, domain.ErrStarGiftUnavailable):
// These are known business conditions (e.g. converting an already
// upgraded/unique gift). Surface a clean client error instead of a
// 500 INTERNAL_SERVER_ERROR.
return false, starGiftInvalidErr()
default:
return false, internalErr()

View file

@ -90,7 +90,6 @@ func (r *Router) adminGrantUpgradedStarGift(ctx context.Context, senderID int64,
ModelAttributeID: grant.ModelAttributeID,
PatternAttributeID: grant.PatternAttributeID,
BackdropAttributeID: grant.BackdropAttributeID,
Num: grant.Num,
}); err != nil {
return err
}

View file

@ -164,19 +164,6 @@ WHERE collectible_revision_id=$1 AND crafted
}
num := revision.Issued + 1
if req.Num > 0 {
if req.Num > revision.SupplyTotal {
return domain.ErrStarGiftCollectibleInvalid
}
var numTaken bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM unique_star_gifts WHERE gift_id=$1 AND num=$2)`, locked.GiftID, req.Num).Scan(&numTaken); err != nil {
return fmt.Errorf("check collectible number availability: %w", err)
}
if numTaken {
return domain.ErrStarGiftCollectibleNumberTaken
}
num = req.Num
}
var uniqueID int64
if err := tx.QueryRow(ctx, `SELECT nextval('unique_star_gift_id_seq')`).Scan(&uniqueID); err != nil {
return fmt.Errorf("allocate unique star gift id: %w", err)

View file

@ -41,6 +41,10 @@ type userBaseValue struct {
CountryCode string `json:"country_code"`
Verified bool `json:"verified"`
Support bool `json:"support"`
// scam / fake 同理必须随缓存往返:丢失会让缓存命中路径把带标记的账号输出成
// 普通账号,导致资料页 SCAM/FAKE 标记随缓存命中/未命中间歇性消失(与 bot 列同坑)。
Scam bool `json:"scam,omitempty"`
Fake bool `json:"fake,omitempty"`
// bot 字段必须随缓存往返:丢失会让缓存命中路径把 bot 输出成普通用户,
// 污染客户端本地缓存TDesktop 的 bot 标记不可逆)。
Bot bool `json:"bot,omitempty"`
@ -78,6 +82,8 @@ func baseValueFromUser(u domain.User) userBaseValue {
CountryCode: u.CountryCode,
Verified: u.Verified,
Support: u.Support,
Scam: u.Scam,
Fake: u.Fake,
Bot: u.Bot,
BotInfoVersion: u.BotInfoVersion,
PremiumUntil: u.PremiumUntil,
@ -110,6 +116,8 @@ func (v userBaseValue) user() domain.User {
CountryCode: v.CountryCode,
Verified: v.Verified,
Support: v.Support,
Scam: v.Scam,
Fake: v.Fake,
Bot: v.Bot,
BotInfoVersion: v.BotInfoVersion,
PremiumUntil: v.PremiumUntil,