changed botfather avatar and fixed import

This commit is contained in:
onysd 2026-07-24 00:54:19 +03:00
parent c436c7c773
commit 0a6c05404a
18 changed files with 318 additions and 108 deletions

View file

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

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

@ -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",

View file

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

View file

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

View file

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

View file

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

View file

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

View file

@ -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 结尾属种子例外(与官方一致)。

View file

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

View file

@ -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(),
},
},
}
}