changed botfather avatar and fixed import
This commit is contained in:
parent
c436c7c773
commit
0a6c05404a
18 changed files with 318 additions and 108 deletions
|
|
@ -837,6 +837,7 @@ type importDefaultStarGiftAPIRequest struct {
|
|||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
ID int `json:"id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportDefaultStarGiftAPI(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -851,6 +852,7 @@ func (s *server) handleImportDefaultStarGiftAPI(w http.ResponseWriter, r *http.R
|
|||
req := admin.ImportDefaultStarGiftRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-default-gift"),
|
||||
ID: body.ID,
|
||||
Enabled: body.Enabled,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/default-gifts/import", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
|
|
@ -860,6 +862,7 @@ type importAllDefaultStarGiftsAPIRequest struct {
|
|||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportAllDefaultStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -869,6 +872,7 @@ func (s *server) handleImportAllDefaultStarGiftsAPI(w http.ResponseWriter, r *ht
|
|||
}
|
||||
req := admin.ImportAllDefaultStarGiftsRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-all-default-gifts"),
|
||||
Enabled: body.Enabled,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/default-gifts/import-all", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
|
|
@ -915,6 +919,7 @@ type importAllOfficialStarGiftsAPIRequest struct {
|
|||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *server) handleImportAllOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -924,6 +929,7 @@ func (s *server) handleImportAllOfficialStarGiftsAPI(w http.ResponseWriter, r *h
|
|||
}
|
||||
req := admin.ImportAllOfficialStarGiftsRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-all-official-gifts"),
|
||||
Enabled: body.Enabled,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import-all", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
|
|
|
|||
|
|
@ -110,13 +110,13 @@ func TestStarGiftRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
|
|||
|
||||
func TestDefaultGiftImportActionDecodes(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/import-default-gift", strings.NewReader(`{
|
||||
"command_id":"c1","reason":"demo","confirm":true,"id":3
|
||||
"command_id":"c1","reason":"demo","confirm":true,"id":3,"enabled":true
|
||||
}`))
|
||||
var got importDefaultStarGiftAPIRequest
|
||||
if err := decodeJSON(req, &got); err != nil {
|
||||
t.Fatalf("decode default gift action: %v", err)
|
||||
}
|
||||
if got.ID != 3 || got.CommandID != "c1" || !got.Confirm {
|
||||
if got.ID != 3 || got.CommandID != "c1" || !got.Confirm || !got.Enabled {
|
||||
t.Fatalf("decoded default gift action = %+v", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
10
cmd/telesrv-admin/web/dist/assets/index-CBHSiG-t.js
vendored
Normal file
10
cmd/telesrv-admin/web/dist/assets/index-CBHSiG-t.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
cmd/telesrv-admin/web/dist/assets/index-cWo3_wIf.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-cWo3_wIf.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -5,8 +5,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<title>OwpenGram Admin</title>
|
||||
<script type="module" crossorigin src="/assets/index-6aaLOsAb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D2rrhA4q.css">
|
||||
<script type="module" crossorigin src="/assets/index-CBHSiG-t.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-cWo3_wIf.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -273,6 +273,11 @@ const translations: Record<string, string> = {
|
|||
"gifts.officialUnnamed": "Unnamed official gift #{id}",
|
||||
"gifts.officialEmpty": "No official gifts match this category and search.",
|
||||
"gifts.includeCollectible": "Import the complete collectible pool, including crafted models",
|
||||
"gifts.importingProgress": "Importing {done} of {total}",
|
||||
"gifts.bulkImportCount": "{count} gifts available to import",
|
||||
"gifts.bulkImportDone": "Import complete",
|
||||
"gifts.bulkImportSummary": "Imported {imported}, skipped {skipped}, failed {failed}",
|
||||
"gifts.startBulkImport": "Start import",
|
||||
"gifts.limited": "Limited · {total}",
|
||||
"gifts.premium": "Premium only",
|
||||
"gifts.searchPlaceholder": "Search gift ID, title or format",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { CheckCircle2, ChevronLeft, ChevronRight, FileJson2, Gem, Loader2, Pause
|
|||
import lottie from "lottie-web/build/player/lottie_light_canvas";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { api, APIError, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
|
|
@ -13,6 +13,11 @@ import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
|
|||
type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
|
||||
type GiftPageSize = 10 | 20 | 50 | 100 | "all";
|
||||
|
||||
// The demo pool only has 3 placeholder gifts left after pruning to one per
|
||||
// capability tier (Spark/Star/Coin); hide the tab until real custom designs
|
||||
// replace them. Flip back to true to re-enable.
|
||||
const SHOW_DEFAULT_GIFTS_TAB = false;
|
||||
|
||||
function defaultGiftAttributeCount(gift: DefaultGiftRow) {
|
||||
return gift.model_count + gift.pattern_count + gift.backdrop_count;
|
||||
}
|
||||
|
|
@ -106,7 +111,7 @@ export function GiftsPage() {
|
|||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [collectibleGift, setCollectibleGift] = useState<StarGiftRow | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [importSource, setImportSource] = useState<"default" | "official" | "file">("default");
|
||||
const [importSource, setImportSource] = useState<"default" | "official" | "file">(SHOW_DEFAULT_GIFTS_TAB ? "default" : "official");
|
||||
const [defaultGifts, setDefaultGifts] = useState<DefaultGiftRow[]>([]);
|
||||
const [selectedDefaultID, setSelectedDefaultID] = useState(0);
|
||||
const [officialGifts, setOfficialGifts] = useState<OfficialStarGiftRow[]>([]);
|
||||
|
|
@ -128,6 +133,14 @@ export function GiftsPage() {
|
|||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [importError, setImportError] = useState("");
|
||||
const [bulkImportOpen, setBulkImportOpen] = useState<"default" | "official" | null>(null);
|
||||
const [bulkImportItems, setBulkImportItems] = useState<Array<DefaultGiftRow | OfficialStarGiftRow>>([]);
|
||||
const [bulkImportEnabled, setBulkImportEnabled] = useState(true);
|
||||
const [bulkImportReason, setBulkImportReason] = useState("");
|
||||
const [bulkImportBusy, setBulkImportBusy] = useState(false);
|
||||
const [bulkImportProgress, setBulkImportProgress] = useState({ done: 0, total: 0 });
|
||||
const [bulkImportError, setBulkImportError] = useState("");
|
||||
const [bulkImportResult, setBulkImportResult] = useState<{ imported: number; skipped: number; failed: number; errors: string[] } | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkReason, setBulkReason] = useState("");
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
|
|
@ -301,6 +314,84 @@ export function GiftsPage() {
|
|||
setPreview(null);
|
||||
}
|
||||
|
||||
async function openBulkImport(source: "default" | "official") {
|
||||
setBulkImportOpen(source);
|
||||
setBulkImportItems([]);
|
||||
setBulkImportEnabled(true);
|
||||
setBulkImportReason("");
|
||||
setBulkImportBusy(false);
|
||||
setBulkImportProgress({ done: 0, total: 0 });
|
||||
setBulkImportError("");
|
||||
setBulkImportResult(null);
|
||||
try {
|
||||
if (source === "default") {
|
||||
const list = defaultGifts.length > 0 ? defaultGifts : (await api.defaultGifts()).gifts ?? [];
|
||||
setBulkImportItems(list);
|
||||
} else {
|
||||
const list = officialGifts.length > 0 ? officialGifts : (await api.officialGifts()).gifts ?? [];
|
||||
setBulkImportItems(list);
|
||||
}
|
||||
} catch (err) {
|
||||
setBulkImportError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
function closeBulkImport() {
|
||||
if (bulkImportBusy) return;
|
||||
setBulkImportOpen(null);
|
||||
}
|
||||
|
||||
async function runBulkImport() {
|
||||
if (!bulkImportOpen) return;
|
||||
if (!bulkImportReason.trim()) { setBulkImportError(t("action.reasonRequired")); return; }
|
||||
const source = bulkImportOpen;
|
||||
setBulkImportBusy(true); setBulkImportError(""); setBulkImportResult(null);
|
||||
setBulkImportProgress({ done: 0, total: bulkImportItems.length });
|
||||
let imported = 0, skipped = 0, failed = 0;
|
||||
const errors: string[] = [];
|
||||
for (const item of bulkImportItems) {
|
||||
const label = source === "default" ? (item as DefaultGiftRow).title : ((item as OfficialStarGiftRow).title || `#${(item as OfficialStarGiftRow).source_gift_id}`);
|
||||
try {
|
||||
// Stable per-gift command_id (mirrors the old server-side bulk
|
||||
// endpoint) so a gift already imported by a prior run is recognized
|
||||
// as a replay instead of creating a duplicate catalog entry -
|
||||
// CreateCatalogBundle has no unique constraint on title/source id to
|
||||
// fall back on. If this run's Enabled value differs from the run
|
||||
// that first created it, the server reports COMMAND_ID_CONFLICT
|
||||
// instead of silently re-importing; treat that as "skipped" too.
|
||||
const result = source === "default"
|
||||
? await api.importDefaultGift({
|
||||
command_id: `bulk-default-gift-${(item as DefaultGiftRow).id}`,
|
||||
reason: bulkImportReason.trim(),
|
||||
confirm: true,
|
||||
id: (item as DefaultGiftRow).id,
|
||||
enabled: bulkImportEnabled
|
||||
})
|
||||
: await api.importOfficialGift({
|
||||
command_id: `bulk-official-gift-${(item as OfficialStarGiftRow).source_gift_id}`,
|
||||
reason: bulkImportReason.trim(),
|
||||
confirm: true,
|
||||
source_gift_id: (item as OfficialStarGiftRow).source_gift_id,
|
||||
include_collectible: (item as OfficialStarGiftRow).can_upgrade,
|
||||
enabled: bulkImportEnabled
|
||||
});
|
||||
if (result.already_executed || result.details?.skipped) skipped++;
|
||||
else imported++;
|
||||
} catch (err) {
|
||||
if (err instanceof APIError && err.message === "COMMAND_ID_CONFLICT") {
|
||||
skipped++;
|
||||
} else {
|
||||
failed++;
|
||||
errors.push(`${label}: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
setBulkImportProgress((prev) => ({ ...prev, done: prev.done + 1 }));
|
||||
}
|
||||
setBulkImportBusy(false);
|
||||
setBulkImportResult({ imported, skipped, failed, errors });
|
||||
await load();
|
||||
}
|
||||
|
||||
async function validateImport() {
|
||||
setBusy(true); setImportError(""); setPreview(null);
|
||||
try {
|
||||
|
|
@ -331,8 +422,10 @@ export function GiftsPage() {
|
|||
function startImport() {
|
||||
setGiftID("0"); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
|
||||
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError("");
|
||||
setImportSource("default"); setSelectedDefaultID(0);
|
||||
setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); setImportOpen(true);
|
||||
setImportSource(SHOW_DEFAULT_GIFTS_TAB ? "default" : "official"); setSelectedDefaultID(0);
|
||||
setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all");
|
||||
setBulkImportBusy(false); setBulkImportProgress({ done: 0, total: 0 }); setBulkImportError("");
|
||||
setImportOpen(true);
|
||||
}
|
||||
|
||||
function startRevision(gift: StarGiftRow) {
|
||||
|
|
@ -434,22 +527,18 @@ export function GiftsPage() {
|
|||
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{t("gifts.stepImport")}</strong></div>
|
||||
</div>
|
||||
{giftID === "0" && <div className="gift-source-tabs">
|
||||
<button className={`btn ${importSource === "default" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("default"); setPreview(null); }}>{t("gifts.defaultSource")}</button>
|
||||
{SHOW_DEFAULT_GIFTS_TAB && <button className={`btn ${importSource === "default" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("default"); setPreview(null); }}>{t("gifts.defaultSource")}</button>}
|
||||
<button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{t("gifts.officialSource")}</button>
|
||||
<button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{t("gifts.fileSource")}</button>
|
||||
</div>}
|
||||
{importSource === "default" && giftID === "0" ? <section className="official-gift-picker">
|
||||
{importSource === "default" && giftID === "0" && SHOW_DEFAULT_GIFTS_TAB ? <section className="official-gift-picker">
|
||||
<div className="gift-import-note"><span>{t("gifts.defaultHint")}</span><div className="gift-format-chips"><span>{defaultGifts.length}</span><span>OwpenGram</span></div></div>
|
||||
<div className="official-gift-bulk-import">
|
||||
<ActionButton
|
||||
label={t("gifts.importAllDefault")}
|
||||
path="/api/actions/import-all-default-gifts"
|
||||
payload={() => ({})}
|
||||
tone="neutral"
|
||||
icon={<Upload size={14} />}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
<button className="btn" type="button" onClick={() => openBulkImport("default")}>
|
||||
<Upload size={14} /> {t("gifts.importAllDefault")}
|
||||
</button>
|
||||
</div>
|
||||
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
|
||||
<div className="official-gift-list" role="listbox" aria-label={t("gifts.defaultSelect")}>
|
||||
{defaultGifts.map((gift) => {
|
||||
const isSelected = gift.id === selectedDefaultID;
|
||||
|
|
@ -479,14 +568,9 @@ export function GiftsPage() {
|
|||
</section> : importSource === "official" && giftID === "0" ? <section className="official-gift-picker">
|
||||
<div className="gift-import-note"><span>{t("gifts.officialHint")}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div>
|
||||
<div className="official-gift-bulk-import">
|
||||
<ActionButton
|
||||
label={t("gifts.importAllOfficial")}
|
||||
path="/api/actions/import-all-official-gifts"
|
||||
payload={() => ({})}
|
||||
tone="neutral"
|
||||
icon={<Upload size={14} />}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
<button className="btn" type="button" onClick={() => openBulkImport("official")}>
|
||||
<Upload size={14} /> {t("gifts.importAllOfficial")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="official-gift-tools">
|
||||
<label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={t("gifts.officialSearch")} /></label>
|
||||
|
|
@ -569,6 +653,38 @@ export function GiftsPage() {
|
|||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{bulkImportOpen && createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal gift-bulk-import-modal" role="dialog" aria-modal="true"
|
||||
aria-label={bulkImportOpen === "default" ? t("gifts.importAllDefault") : t("gifts.importAllOfficial")}>
|
||||
<div className="modal-head">
|
||||
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{bulkImportOpen === "default" ? t("gifts.importAllDefault") : t("gifts.importAllOfficial")}</h2></div>
|
||||
<button className="icon-btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy} aria-label={t("action.close")}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<div className="gift-import-note"><span>{t("gifts.bulkImportCount", { count: bulkImportItems.length })}</span></div>
|
||||
<label className="gift-switch"><input type="checkbox" checked={bulkImportEnabled} disabled={bulkImportBusy} onChange={(e) => setBulkImportEnabled(e.target.checked)} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
|
||||
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={bulkImportReason} placeholder={t("gifts.reasonPlaceholder")} disabled={bulkImportBusy} onChange={(e) => setBulkImportReason(e.target.value)} /></label>
|
||||
{bulkImportBusy && <div className="gift-bulk-import-progress">
|
||||
<div className="gift-bulk-import-progress-bar"><div style={{ width: `${bulkImportProgress.total ? Math.round((bulkImportProgress.done / bulkImportProgress.total) * 100) : 0}%` }} /></div>
|
||||
<span>{t("gifts.importingProgress", { done: bulkImportProgress.done, total: bulkImportProgress.total })}</span>
|
||||
</div>}
|
||||
{bulkImportError && <Alert>{bulkImportError}</Alert>}
|
||||
{bulkImportResult && <div className="gift-validation">
|
||||
<div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("gifts.bulkImportDone")}</strong><span>{t("gifts.bulkImportSummary", { imported: bulkImportResult.imported, skipped: bulkImportResult.skipped, failed: bulkImportResult.failed })}</span></div></div>
|
||||
{bulkImportResult.errors.length > 0 && <pre>{bulkImportResult.errors.join("\n")}</pre>}
|
||||
</div>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy}>{t("common.close")}</button>
|
||||
<button className="btn primary" type="button" onClick={runBulkImport} disabled={bulkImportBusy || bulkImportItems.length === 0}>
|
||||
{bulkImportBusy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />} {t("gifts.startBulkImport")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{collectibleGift && <GiftCollectiblesModal gift={collectibleGift} onClose={() => setCollectibleGift(null)} onPublished={() => void load()} />}
|
||||
</PageFrame>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -275,9 +275,18 @@
|
|||
.gift-list-summary { margin-left: auto; color: var(--muted); font-size: 11px; font-weight: 700; }
|
||||
|
||||
.gift-import-modal { width: min(860px, 100%); }
|
||||
.gift-bulk-import-modal { width: min(480px, 100%); }
|
||||
.gift-bulk-import-modal .command-body { display: grid; gap: 14px; padding: 16px 18px; }
|
||||
.gift-import-modal-body { gap: 14px; }
|
||||
.gift-source-tabs { display: flex; gap: 8px; }
|
||||
.official-gift-picker { display: grid; min-width: 0; gap: 12px; }
|
||||
.official-gift-bulk-import { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.gift-bulk-import-progress { display: flex; align-items: center; gap: 8px; min-width: 180px; }
|
||||
.gift-bulk-import-progress-bar {
|
||||
flex: 1 1 auto; width: 120px; height: 6px; overflow: hidden; background: #e3e8ef; border-radius: 999px;
|
||||
}
|
||||
.gift-bulk-import-progress-bar > div { height: 100%; background: var(--brand); border-radius: 999px; transition: width .2s ease; }
|
||||
.gift-bulk-import-progress span { color: var(--muted); font-size: 11px; font-weight: 700; white-space: nowrap; }
|
||||
.official-gift-tools { display: flex; align-items: center; gap: 12px; }
|
||||
.official-gift-tools .searchbox { width: 100%; }
|
||||
.official-gift-tools > span { flex: 0 0 auto; color: var(--muted); font-size: 11px; font-weight: 750; }
|
||||
|
|
|
|||
|
|
@ -453,6 +453,11 @@ func run(logger *zap.Logger) error {
|
|||
} else if seeded {
|
||||
logger.Info("官方系统账号头像种子导入完成", zap.Int64("photo_id", domain.OfficialSystemUserPhotoID))
|
||||
}
|
||||
if seeded, err := filesService.SeedBotFatherAvatar(ctx); err != nil {
|
||||
return fmt.Errorf("seed botfather avatar: %w", err)
|
||||
} else if seeded {
|
||||
logger.Info("BotFather 头像种子导入完成", zap.Int64("photo_id", domain.BotFatherUserPhotoID))
|
||||
}
|
||||
if stats, err := filesService.WarmCaches(ctx); err != nil {
|
||||
logger.Warn("媒体资源缓存预热失败", zap.Error(err))
|
||||
} else if stats.StickerSets > 0 || stats.Documents > 0 || stats.Blobs > 0 {
|
||||
|
|
|
|||
|
|
@ -292,10 +292,12 @@ type ImportStarGiftRequest struct {
|
|||
type ImportDefaultStarGiftRequest struct {
|
||||
CommandMeta
|
||||
ID int `json:"id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type ImportAllDefaultStarGiftsRequest struct {
|
||||
CommandMeta
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type ImportOfficialStarGiftRequest struct {
|
||||
|
|
@ -317,6 +319,7 @@ type ImportOfficialStarGiftRequest struct {
|
|||
|
||||
type ImportAllOfficialStarGiftsRequest struct {
|
||||
CommandMeta
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type SetStarGiftEnabledRequest struct {
|
||||
|
|
@ -1127,6 +1130,7 @@ func (s *Service) ImportDefaultStarGift(ctx context.Context, req ImportDefaultSt
|
|||
if err != nil {
|
||||
return CommandResult{}, domain.ErrStarGiftInvalid
|
||||
}
|
||||
write.Catalog.Enabled = req.Enabled
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionImportDefaultStarGift, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{
|
||||
"id": req.ID, "title": title,
|
||||
|
|
@ -1182,6 +1186,7 @@ func (s *Service) ImportAllDefaultStarGifts(ctx context.Context, req ImportAllDe
|
|||
Reason: req.Reason,
|
||||
},
|
||||
ID: item.ID,
|
||||
Enabled: req.Enabled,
|
||||
}
|
||||
result, opErr := s.ImportDefaultStarGift(ctx, perReq)
|
||||
entry := map[string]any{"id": item.ID, "title": item.Title, "status": result.Status}
|
||||
|
|
@ -1428,6 +1433,7 @@ func (s *Service) ImportAllOfficialStarGifts(ctx context.Context, req ImportAllO
|
|||
// worth importing; CanUpgrade() is exactly the precondition
|
||||
// ImportOfficialStarGift enforces for IncludeCollectible.
|
||||
IncludeCollectible: item.CanUpgrade(),
|
||||
Enabled: req.Enabled,
|
||||
}
|
||||
result, opErr := s.ImportOfficialStarGift(ctx, perReq)
|
||||
entry := map[string]any{"source_gift_id": perReq.SourceGiftID, "status": result.Status}
|
||||
|
|
|
|||
|
|
@ -760,8 +760,8 @@ func TestImportDefaultStarGiftPublishesThroughRealService(t *testing.T) {
|
|||
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: giftService, Now: fixedNow})
|
||||
|
||||
if list := svc.DefaultStarGifts(); len(list) != 5 {
|
||||
t.Fatalf("default gifts = %d, want 5", len(list))
|
||||
if list := svc.DefaultStarGifts(); len(list) != 3 {
|
||||
t.Fatalf("default gifts = %d, want 3", len(list))
|
||||
}
|
||||
|
||||
// Dry-run must not write to the catalog.
|
||||
|
|
@ -773,15 +773,15 @@ func TestImportDefaultStarGiftPublishesThroughRealService(t *testing.T) {
|
|||
t.Fatalf("dry run wrote %d gifts", len(cat))
|
||||
}
|
||||
|
||||
// Confirmed import-all creates the whole demo set.
|
||||
all := ImportAllDefaultStarGiftsRequest{CommandMeta: CommandMeta{CommandID: "exec-default-all", Actor: "ops", Reason: "demo"}}
|
||||
// Confirmed import-all creates the whole demo set, enabled per the request flag.
|
||||
all := ImportAllDefaultStarGiftsRequest{CommandMeta: CommandMeta{CommandID: "exec-default-all", Actor: "ops", Reason: "demo"}, Enabled: true}
|
||||
result, err := svc.ImportAllDefaultStarGifts(ctx, all)
|
||||
if err != nil || result.Details["imported"] != 5 {
|
||||
if err != nil || result.Details["imported"] != 3 {
|
||||
t.Fatalf("import all: result=%+v err=%v", result, err)
|
||||
}
|
||||
catalog, err := giftService.Catalog(ctx)
|
||||
if err != nil || len(catalog) != 5 {
|
||||
t.Fatalf("catalog=%d err=%v, want 5", len(catalog), err)
|
||||
if err != nil || len(catalog) != 3 {
|
||||
t.Fatalf("catalog=%d err=%v, want 3", len(catalog), err)
|
||||
}
|
||||
limited, premium, upgradeable := 0, 0, 0
|
||||
var craftGiftID int64
|
||||
|
|
@ -799,7 +799,7 @@ func TestImportDefaultStarGiftPublishesThroughRealService(t *testing.T) {
|
|||
craftGiftID = g.ID
|
||||
}
|
||||
}
|
||||
if limited != 2 || premium != 1 || upgradeable != 4 {
|
||||
if limited != 0 || premium != 0 || upgradeable != 2 {
|
||||
t.Fatalf("stored flags limited=%d premium=%d upgradeable=%d", limited, premium, upgradeable)
|
||||
}
|
||||
// The craftable gift must carry a craft-only (named-rarity) model.
|
||||
|
|
@ -818,12 +818,43 @@ func TestImportDefaultStarGiftPublishesThroughRealService(t *testing.T) {
|
|||
}
|
||||
|
||||
// Re-running import-all is idempotent: everything already present -> skipped.
|
||||
result, err = svc.ImportAllDefaultStarGifts(ctx, ImportAllDefaultStarGiftsRequest{CommandMeta: CommandMeta{CommandID: "exec-default-all-2", Actor: "ops", Reason: "demo"}})
|
||||
if err != nil || result.Details["imported"] != 0 || result.Details["skipped"] != 5 {
|
||||
// Enabled must match the first run's value (true) or the per-gift replay
|
||||
// hits COMMAND_ID_CONFLICT since the payload would differ from the cached one.
|
||||
result, err = svc.ImportAllDefaultStarGifts(ctx, ImportAllDefaultStarGiftsRequest{CommandMeta: CommandMeta{CommandID: "exec-default-all-2", Actor: "ops", Reason: "demo"}, Enabled: true})
|
||||
if err != nil || result.Details["imported"] != 0 || result.Details["skipped"] != 3 {
|
||||
t.Fatalf("re-import: result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportDefaultStarGiftRespectsEnabledFlag is a regression test: a single
|
||||
// default gift import must honor the request's Enabled flag rather than
|
||||
// always landing enabled (or, before this fix, always disabled regardless of
|
||||
// the admin console checkbox).
|
||||
func TestImportDefaultStarGiftRespectsEnabledFlag(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
giftService := stargiftapp.NewService(memory.NewStarGiftStore(), &adminGiftBlob{data: map[string][]byte{}}, 2)
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Gifts: giftService, Now: fixedNow})
|
||||
|
||||
off := ImportDefaultStarGiftRequest{ID: 1, CommandMeta: CommandMeta{CommandID: "default-1-off", Actor: "ops", Reason: "demo"}, Enabled: false}
|
||||
if _, err := svc.ImportDefaultStarGift(ctx, off); err != nil {
|
||||
t.Fatalf("import disabled: %v", err)
|
||||
}
|
||||
on := ImportDefaultStarGiftRequest{ID: 2, CommandMeta: CommandMeta{CommandID: "default-2-on", Actor: "ops", Reason: "demo"}, Enabled: true}
|
||||
if _, err := svc.ImportDefaultStarGift(ctx, on); err != nil {
|
||||
t.Fatalf("import enabled: %v", err)
|
||||
}
|
||||
|
||||
// Catalog() only ever returns enabled gifts, so presence/absence here
|
||||
// directly proves whether the Enabled flag was honored.
|
||||
catalog, err := giftService.Catalog(ctx)
|
||||
if err != nil || len(catalog) != 1 {
|
||||
t.Fatalf("catalog=%d err=%v, want 1 (only the enabled gift)", len(catalog), err)
|
||||
}
|
||||
if catalog[0].Title != "OwpenGram Star" {
|
||||
t.Fatalf("unexpected gift in catalog: %q, want %q", catalog[0].Title, "OwpenGram Star")
|
||||
}
|
||||
}
|
||||
|
||||
type adminGiftBlob struct{ data map[string][]byte }
|
||||
|
||||
func (b *adminGiftBlob) Name() string { return "localfs" }
|
||||
|
|
|
|||
54
internal/app/files/botfather_avatar_seed.go
Normal file
54
internal/app/files/botfather_avatar_seed.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
//go:embed seedassets/botfather_avatar.jpg
|
||||
var botFatherAvatarJPG []byte
|
||||
|
||||
// SeedBotFatherAvatar idempotently seeds the built-in BotFather account's
|
||||
// profile photo from the bundled avatar, mirroring SeedOfficialSystemAvatar:
|
||||
// writes it under the fixed domain.BotFatherUserPhotoID so the photo/blob
|
||||
// layer and the pure domain.BotFatherUser() struct literal stay in sync
|
||||
// across restarts, and registers it as the account's *current* profile photo
|
||||
// so users.getFullUser resolves it too. Returns true if it actually wrote a
|
||||
// new photo.
|
||||
func (s *Service) SeedBotFatherAvatar(ctx context.Context) (bool, error) {
|
||||
photoID := domain.BotFatherUserPhotoID
|
||||
wrote := false
|
||||
if _, found, err := s.media.GetPhoto(ctx, photoID); err != nil {
|
||||
return false, err
|
||||
} else if !found {
|
||||
sizes, err := s.putPhotoStaticSizes(ctx, photoID, botFatherAvatarJPG, photoSizeSpecsForAvatar(botFatherAvatarJPG))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
photo := domain.Photo{
|
||||
ID: photoID,
|
||||
AccessHash: domain.BotFatherUserPhotoAccessHash,
|
||||
FileReference: randomFileReference(),
|
||||
Date: int(time.Now().Unix()),
|
||||
DCID: s.dc,
|
||||
Sizes: sizes,
|
||||
}
|
||||
if err := s.media.PutPhoto(ctx, photo); err != nil {
|
||||
return false, err
|
||||
}
|
||||
wrote = true
|
||||
}
|
||||
photo, ok, err := s.SetCurrentProfilePhoto(ctx, domain.PeerTypeUser, domain.BotFatherUserID, photoID, int(time.Now().Unix()))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !ok {
|
||||
return false, fmt.Errorf("botfather avatar photo %d not found after seeding", photoID)
|
||||
}
|
||||
domain.SetBotFatherAvatar(photo.DCID, domain.StrippedFromSizes(photo.Sizes))
|
||||
return wrote, nil
|
||||
}
|
||||
BIN
internal/app/files/seedassets/botfather_avatar.jpg
Normal file
BIN
internal/app/files/seedassets/botfather_avatar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
|
|
@ -15,6 +15,10 @@ const (
|
|||
BotFatherUserID int64 = 93372553
|
||||
// BotFatherAccessHash 固定不变;与迁移 0090 的种子行双写,必须保持一致。
|
||||
BotFatherAccessHash int64 = 7421896403922962293
|
||||
// BotFatherUserPhotoID/AccessHash 是 BotFather 头像 photo 的固定 id,
|
||||
// 与 files.Service.SeedBotFatherAvatar 种子写入的行保持一致。
|
||||
BotFatherUserPhotoID int64 = 933725530001
|
||||
BotFatherUserPhotoAccessHash int64 = 3198475620194837201
|
||||
|
||||
// StickersBotUserID 是内置 @Stickers 账号。它是 server 内置 service bot,
|
||||
// 不走外部 Bot API 进程。
|
||||
|
|
@ -43,6 +47,20 @@ func SetOfficialSystemUserAvatar(dcID int, stripped []byte) {
|
|||
officialSystemUserPhotoStripped = stripped
|
||||
}
|
||||
|
||||
// botFatherPhotoDCID/Stripped 由 files.Service.SeedBotFatherAvatar 在启动时
|
||||
// 通过 SetBotFatherAvatar 写入一次;写入前 BotFatherUser() 不带头像(PhotoID==0)。
|
||||
var (
|
||||
botFatherPhotoDCID int
|
||||
botFatherPhotoStripped []byte
|
||||
)
|
||||
|
||||
// SetBotFatherAvatar 记录 BotFather 头像所在的 DC 与内联缩略图字节。
|
||||
// 只应在启动阶段、头像 seed 完成后调用一次。
|
||||
func SetBotFatherAvatar(dcID int, stripped []byte) {
|
||||
botFatherPhotoDCID = dcID
|
||||
botFatherPhotoStripped = stripped
|
||||
}
|
||||
|
||||
// OfficialSystemUser 返回第一阶段内置的官方系统账号。
|
||||
func OfficialSystemUser() User {
|
||||
u := User{
|
||||
|
|
@ -64,7 +82,7 @@ func OfficialSystemUser() User {
|
|||
|
||||
// BotFatherUser 返回内置 BotFather 账号。username 不以 bot 结尾属种子例外(与官方一致)。
|
||||
func BotFatherUser() User {
|
||||
return User{
|
||||
u := User{
|
||||
ID: BotFatherUserID,
|
||||
AccessHash: BotFatherAccessHash,
|
||||
FirstName: "BotFather",
|
||||
|
|
@ -73,6 +91,12 @@ func BotFatherUser() User {
|
|||
Bot: true,
|
||||
BotInfoVersion: 1,
|
||||
}
|
||||
if botFatherPhotoDCID != 0 {
|
||||
u.PhotoID = BotFatherUserPhotoID
|
||||
u.PhotoDCID = botFatherPhotoDCID
|
||||
u.PhotoStripped = botFatherPhotoStripped
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// StickersBotUser 返回内置 @Stickers 账号。username 不以 bot 结尾属种子例外(与官方一致)。
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ func newService() *stargifts.Service {
|
|||
|
||||
func TestListDescribesFullGiftSurface(t *testing.T) {
|
||||
list := List()
|
||||
if len(list) != 5 {
|
||||
t.Fatalf("List has %d gifts, want 5", len(list))
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("List has %d gifts, want 3", len(list))
|
||||
}
|
||||
upgradeable, craftable, limited, premium := 0, 0, 0, 0
|
||||
for _, g := range list {
|
||||
|
|
@ -50,7 +50,8 @@ func TestListDescribesFullGiftSurface(t *testing.T) {
|
|||
premium++
|
||||
}
|
||||
}
|
||||
if upgradeable != 4 || craftable != 3 || limited != 2 || premium != 1 {
|
||||
// One plain (Spark), one upgradeable-only (Star), one craftable (Coin).
|
||||
if upgradeable != 2 || craftable != 1 || limited != 0 || premium != 0 {
|
||||
t.Fatalf("surface counts upgradeable=%d craftable=%d limited=%d premium=%d", upgradeable, craftable, limited, premium)
|
||||
}
|
||||
}
|
||||
|
|
@ -79,8 +80,8 @@ func TestBuildBundleImportsEndToEnd(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(catalog) != 5 {
|
||||
t.Fatalf("catalog has %d, want 5", len(catalog))
|
||||
if len(catalog) != 3 {
|
||||
t.Fatalf("catalog has %d, want 3", len(catalog))
|
||||
}
|
||||
limited, premium, upgradeable := 0, 0, 0
|
||||
for _, g := range catalog {
|
||||
|
|
@ -94,7 +95,7 @@ func TestBuildBundleImportsEndToEnd(t *testing.T) {
|
|||
upgradeable++
|
||||
}
|
||||
}
|
||||
if limited != 2 || premium != 1 || upgradeable != 4 {
|
||||
if limited != 0 || premium != 0 || upgradeable != 2 {
|
||||
t.Fatalf("stored flags limited=%d premium=%d upgradeable=%d", limited, premium, upgradeable)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,18 +44,19 @@ func demoBackdrops() []backdropSpec {
|
|||
}
|
||||
}
|
||||
|
||||
// demoGifts returns the five demo gifts in display order.
|
||||
// demoGifts returns the three demo gifts in display order, one per capability
|
||||
// tier: plain (not upgradeable), upgradeable (no crafting), and craftable.
|
||||
func demoGifts() []giftSpec {
|
||||
return []giftSpec{
|
||||
{
|
||||
// #1 — cheapest, plain, not upgradeable.
|
||||
// #1 — Звичайний: cheapest, plain, not upgradeable.
|
||||
title: "OwpenGram Spark",
|
||||
stars: 15,
|
||||
convert: 15,
|
||||
base: burst(8, colGold, motionPulse),
|
||||
},
|
||||
{
|
||||
// #2 — standard upgradeable, no crafting.
|
||||
// #2 — Апгрейдебл: standard upgradeable, no crafting.
|
||||
title: "OwpenGram Star",
|
||||
stars: 50,
|
||||
convert: 50,
|
||||
|
|
@ -76,7 +77,7 @@ func demoGifts() []giftSpec {
|
|||
},
|
||||
},
|
||||
{
|
||||
// #3 — upgradeable + craftable.
|
||||
// #3 — Крафтабл: upgradeable + craftable.
|
||||
title: "OwpenGram Coin",
|
||||
stars: 100,
|
||||
convert: 75,
|
||||
|
|
@ -97,54 +98,5 @@ func demoGifts() []giftSpec {
|
|||
backdrops: demoBackdrops(),
|
||||
},
|
||||
},
|
||||
{
|
||||
// #4 — limited edition, upgradeable + craftable.
|
||||
title: "OwpenGram Gem",
|
||||
stars: 250,
|
||||
convert: 200,
|
||||
base: polygon(6, colViolet, colWhite, 10, motionSpinPulse),
|
||||
limited: true,
|
||||
availability: 5000,
|
||||
upgrade: &upgradeSpec{
|
||||
upgradeStars: 800,
|
||||
supplyTotal: 3000,
|
||||
slug: "owg-gem",
|
||||
models: []attrSpec{
|
||||
{name: "Amethyst", spec: polygon(6, colViolet, colWhite, 12, motionSpin), permille: 600},
|
||||
{name: "Verdant", spec: polygon(6, colEmerald, colWhite, 12, motionSpin), permille: 400},
|
||||
{name: "Prism", spec: polygon(8, colCyan, colWhite, 10, motionSpinPulse), crafted: true, rarity: domain.StarGiftRarityEpic},
|
||||
},
|
||||
patterns: []attrSpec{
|
||||
{name: "Facet", spec: burst(12, colViolet, motionPulse), permille: 700},
|
||||
{name: "Shine", spec: burst(8, colWhite, motionPulse), permille: 300},
|
||||
},
|
||||
backdrops: demoBackdrops(),
|
||||
},
|
||||
},
|
||||
{
|
||||
// #5 — premium-gated, limited, the full stack.
|
||||
title: "OwpenGram Crown",
|
||||
stars: 1000,
|
||||
convert: 800,
|
||||
base: star(3, colGold, colAmber, 12, motionSpinPulse),
|
||||
limited: true,
|
||||
availability: 500,
|
||||
requirePremium: true,
|
||||
upgrade: &upgradeSpec{
|
||||
upgradeStars: 2000,
|
||||
supplyTotal: 500,
|
||||
slug: "owg-crown",
|
||||
models: []attrSpec{
|
||||
{name: "Regal", spec: star(3, colGold, colAmber, 14, motionSpinPulse), permille: 600},
|
||||
{name: "Noble", spec: star(5, colAmber, colGold, 12, motionSpin), permille: 400},
|
||||
{name: "Eternal", spec: star(6, colGold, colWhite, 12, motionSpinPulse), crafted: true, rarity: domain.StarGiftRarityLegendary},
|
||||
},
|
||||
patterns: []attrSpec{
|
||||
{name: "Aura", spec: burst(12, colGold, motionPulse), permille: 700},
|
||||
{name: "Crest", spec: burst(8, colWhite, motionPulse), permille: 300},
|
||||
},
|
||||
backdrops: demoBackdrops(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue