[
  {
    "path": ".dockerignore",
    "content": "node_modules\nnpm-debug.log\n"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\nindent_style = space\nindent_size = 2\n"
  },
  {
    "path": ".eslintrc.json",
    "content": "{\n  \"env\": {\n      \"es6\": true,\n      \"node\": true\n  },\n  \"extends\": \"standard\",\n  \"globals\": {\n      \"Atomics\": \"readonly\",\n      \"SharedArrayBuffer\": \"readonly\"\n  },\n  \"parserOptions\": {\n      \"ecmaVersion\": 2022,\n      \"sourceType\": \"module\"\n  },\n  \"rules\": {\n  }\n}\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]\npatreon: # Replace with a single Patreon username\nopen_collective: # Replace with a single Open Collective username\nko_fi: # Replace with a single Ko-fi username\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\notechie: # Replace with a single Otechie username\ncustom: ['https://donate.lyo.su/']\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# TypeScript v1 declaration files\ntypings/\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n.env\n\n# next.js build output\n.next\n\ntmp\ntgsnake\nsession\n.mtproto-session\n.locale-sync-mtime\n\n.DS_Store\nconfig.json\n.superpowers/\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  // Use IntelliSense to learn about possible attributes.\n  // Hover to view descriptions of existing attributes.\n  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"Launch Program\",\n      \"program\": \"${workspaceFolder}/index.js\",\n      \"request\": \"launch\",\n      \"skipFiles\": [\n        \"<node_internals>/**\"\n      ],\n      \"type\": \"pwa-node\"\n    }\n  ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n    \"i18n-ally.localesPaths\": [\n        \"locales\"\n    ]\n}\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM node:lts-alpine as base\nFROM base as builder\nRUN mkdir /install\nWORKDIR /install\nCOPY package.json .\nRUN npm i --production\nFROM base\nRUN addgroup -S bot && adduser -S bot -G bot\nCOPY --from=builder /install/node_modules /app/node_modules\nCOPY ./ /app\nENV NODE_WORKDIR /app\nWORKDIR $NODE_WORKDIR\nUSER bot\nCMD [\"node\", \"index.js\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "# PolyForm Noncommercial License 1.0.0\n\n<https://polyformproject.org/licenses/noncommercial/1.0.0>\n\nRequired Notice: Copyright (c) 2019-2026 LyoSU (https://github.com/LyoSU)\n\n## Acceptance\n\nIn order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.\n\n## Copyright License\n\nThe licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).\n\n## Distribution License\n\nThe licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with any changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).\n\n## Notices\n\nYou must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:\n\n> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)\n\n## Changes and New Works License\n\nThe licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.\n\n## Patent License\n\nThe licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.\n\n## Noncommercial Purposes\n\nAny noncommercial purpose is a permitted purpose.\n\n## Personal Uses\n\nPersonal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.\n\n## Noncommercial Organizations\n\nUse by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.\n\n## Fair Use\n\nYou may have \"fair use\" rights for the software under the law. These terms do not limit them.\n\n## No Other Rights\n\nThese terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.\n\n## Patent Defense\n\nIf you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.\n\n## Violations\n\nThe first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.\n\n## No Liability\n\n***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***\n\n## Definitions\n\nThe **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.\n\n**You** refers to the individual or entity agreeing to these terms.\n\n**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.\n\n**Your licenses** are all the licenses granted to you for the software under these terms.\n\n**Use** means anything you do with the software requiring one of your licenses.\n"
  },
  {
    "path": "README.md",
    "content": "# fStikBot\n\nTelegram sticker bot. Make packs, copy packs, edit stickers, search a public catalog. Runs [@fStikBot](https://t.me/fStikBot).\n\n## What it does\n\n- Create and edit sticker, emoji and video packs\n- Copy any existing pack into your own\n- Inline search across a public catalog (plus Tenor GIFs)\n- Frame, mosaic, round-video and background-removal tools\n- Co-edit packs with other users\n- Group-mode packs and boosts\n- Admin panel, broadcasts, moderation (OpenAI)\n\n## Stack\n\nNode.js, [telegraf](https://github.com/telegraf/telegraf) for Bot API, [gram.js](https://github.com/gram-js/gramjs) MTProto for large files, MongoDB, Redis + Bull for queues, Sharp for image work.\n\n## Run it\n\n```bash\ngit clone https://github.com/LyoSU/fStikBot.git\ncd fStikBot\ncp .env.example .env\ncp config.example.json config.json\n# fill in BOT_TOKEN and friends\ndocker compose up -d\n```\n\nWithout Docker: install Node LTS, MongoDB and Redis, then `npm i && npm start`.\n\n## Configuration\n\nTwo files:\n\n- `.env` — runtime secrets (bot token, MTProto keys, MongoDB URI, Redis host, OpenAI key, Tenor key)\n- `config.json` — non-secret app config (admin id, log chat, sticker link prefix, messaging limits)\n\nMinimum to boot: `BOT_TOKEN`, `MONGODB_URI`, `REDIS_HOST`. Everything else is optional and disables the matching feature when missing (OpenAI moderation, Tenor, GramAds, large-file downloads).\n\n## Scripts\n\n```bash\nnpm start               # run the bot\nnpm run lint            # eslint\nnpm run lint:fix        # eslint --fix\nnpm run banners:build   # rebuild banner assets\n```\n\nWebhook mode turns on when `BOT_DOMAIN` is set. Otherwise the bot uses long polling.\n\n## License\n\n[PolyForm Noncommercial 1.0.0](LICENSE). Free for personal, research, educational and nonprofit use. For anything commercial ping [@LyoSU](https://t.me/LyoSU) for a separate license.\n"
  },
  {
    "path": "banners/DESIGN.md",
    "content": "# Banner Design System\n\nVisual language for the hero banners that sit above `/start` and section\nentry messages. Reference this doc when adding new banners or adjusting\nexisting ones — palette choices, type decisions, and pattern density are\nall encoded here.\n\n---\n\n## 1. Concept\n\n**fStikBot promo slides.** Each banner is one issue in a consistent series,\nthe same way Telegram's own promo banners (Premium, Stars, Business) share\none layout language and change only the colour/icon/title per product.\n\nThree ingredients define every banner:\n\n1. **Coloured gradient page** with a soft bottom-vignette for depth\n2. **Doodle-pattern wallpaper** (Tabler Icons stroked white) tiled over the\n   gradient via `mix-blend-mode: soft-light`\n3. **Left wordmark + right tile** composition — bold italic condensed\n   typography on the left, a rounded-square app-icon-style tile on the right\n\nWhat makes the series recognisable is the **combination**: cohesive bold\nitalic type + doodle texture + tilted app-icon tile. Change any one and it\nstops feeling like fStikBot.\n\n---\n\n## 2. Canvas\n\n| Spec | Value | Why |\n|---|---|---|\n| Output size | 960 × 360 (2.67:1) | Wide short header that doesn't get cropped by Telegram's mobile client. Original 1200 × 630 OG format was too tall and 1200 × 400 was too wide on phones. |\n| Retina scale | 2× | Final PNG ships at 1920 × 720. Sharp on high-DPI devices, ~500–700 KB per file. |\n| Safe margins | 48–56 px | Left brand padding 56 px, right tile 48 px. Keeps content off edges so Telegram client padding doesn't clip type. |\n\n---\n\n## 3. Colour\n\n### 3.1 Palette structure\n\nEvery banner defines **3 gradient stops** (lightest → mid → deepest) plus\n**2 ink shadow values** (mid + deep) that are derived from the palette's\ndeepest colour at low opacity. Centralising this in three vars means each\nbanner file is ~4 lines of palette override.\n\n```css\n.page {\n  --sky-0: #5BAEEF;        /* lightest — top-left of gradient */\n  --sky-1: #4693DA;        /* mid — middle of gradient */\n  --sky-2: #2E7BC8;        /* deepest — bottom-right, used as icon stroke */\n  --ink-shadow: rgba(10, 45, 95, 0.22);       /* soft press shadow */\n  --ink-shadow-deep: rgba(10, 45, 95, 0.32);  /* deep drop shadow */\n}\n```\n\n### 3.2 Per-issue palettes\n\nColour is the primary signal for which section you're in. Palettes are\npicked so adjacent sections in the flow contrast (welcome blue → catalog\nteal doesn't feel samey) and so warm/cool alternate nicely across the set.\n\n| Issue | Spot hue | Intent |\n|---|---|---|\n| `welcome` | sky blue `#2E7BC8` | Brand primary, matches marketplace promos |\n| `packs` | indigo/violet `#3A3BAF` | Personal collection, inward/private energy |\n| `catalog` | teal/mint `#0C8A78` | Discovery, freshness |\n| `new-pack` | amber/orange `#D86820` | Creation, action, warm |\n| `boost` | magenta/pink `#A62A6E` | High energy, promotion |\n| `help` | green/sage `#2C8F46` | Calm, supportive |\n| `donate` | gold/yellow `#C88617` | Appreciation, Stars-adjacent |\n\n**Adjacent-section rule**: if you add a new banner, pick a hue ≥ 60° away\non the wheel from any banner it's reachable from. Keeps the transition\nvisible even in quick navigation.\n\n### 3.3 Text / foreground\n\nAll wordmarks are white `#FFFFFF`. Taglines are `rgba(255,255,255,0.88)`.\nThis is intentional — coloured bg + white text reads as app promo; white\nbg + coloured text would read as SaaS landing. Do not break this.\n\n---\n\n## 4. Typography\n\n### 4.1 Font\n\n**Barlow Condensed** (Google Fonts, OFL) — one family, two voices:\n\n- **Wordmark**: 900 italic at 140 px, line-height 1, tracking −0.01em\n- **Tagline**: 700 italic at 26 px UPPERCASE, tracking 0.04em\n\nWhy Barlow Condensed:\n- Has proper italic designs (not slanted upright) — critical for the\n  App Store promo look\n- Condensed fits bold big wordmarks without crowding\n- Full Cyrillic coverage (needed when we localise later)\n- Not overused by AI landing pages the way Inter / Unbounded are\n\n**Don't use**: Inter, Unbounded, Space Grotesk, Poppins — they read as\ngeneric AI output. Don't mix a second typeface in — one family, two\nweights, two styles is the whole system.\n\n### 4.2 Shadows (critical for legibility)\n\nWhite text on coloured bg with a busy pattern underneath needs layered\nshadow to lift off the surface:\n\n```css\ntext-shadow:\n  0 3px 0 var(--ink-shadow),              /* crisp press shadow — old-poster feel */\n  0 12px 24px var(--ink-shadow),          /* soft drop — implies elevation */\n  0 0 36px rgba(255, 255, 255, 0.18);     /* halo — ties text to the glossy tile */\n```\n\nThe third layer (white halo) is what separates the S-tier polish from\nflat-print. It visually links the wordmark to the glass tile on the right.\n\n### 4.3 Descender clearance\n\nWordmark line-height is `1` and tag margin-top is `18 px`. This prevents\n\"g\" / \"p\" / \"y\" descenders from touching the tagline. If you add a\nwordmark with a descender on its last character and tag underneath, check\nvisually — nudge margin-top if needed.\n\n---\n\n## 5. Layout\n\n```\n┌───────────────────────────────────────────────────────┐\n│                                                       │\n│  ┌─── .brand (left 56px, vcentered)                   │\n│  │                              ┌─── .tile (right 48px,\n│  │  Wordmark                    │      vcentered,\n│  │  TAGLINE · DOTS              │      230×230,\n│  │                              │      rotate 8deg)\n│  │                              │\n│  └────────────                  └────\n│                                                       │\n└───────────────────────────────────────────────────────┘\n                    960 × 360\n```\n\n- **Absolutely positioned**: both `.brand` and `.tile` use absolute\n  positioning with `top: 50%; transform: translateY(-50%)`. Keeps vertical\n  centre math trivial regardless of content length.\n- **Max-width 620 px on .brand**: prevents long titles from overlapping\n  the tile area.\n- **Tile rotates 8°** — signature \"sticker peeled onto page\" feel.\n  Always the same angle across all banners for consistency.\n\n---\n\n## 6. Pattern wallpaper\n\nOne SVG (`assets/pattern.svg`) is tiled across every banner. It's a\n480×480 composition of 18 Tabler Icons (star, heart, sparkles, cloud,\nleaf, bolt, music, gift, feather, ghost, mushroom) positioned at varied\nangles, strokes white, licensed MIT.\n\n**`soft-light` blend mode** lets the underlying palette tint through the\nstrokes — same asset reads differently against every hue without needing\nre-export per colour.\n\n### 6.1 Per-issue density tuning\n\nEach banner overrides two vars:\n\n```css\n.page {\n  --pattern-size: 340px;        /* smaller = denser */\n  --pattern-opacity: 0.48;      /* higher = more visible */\n}\n```\n\n| Issue | Size | Opacity | Intent |\n|---|---|---|---|\n| `welcome` | 340 px | 0.48 | Vibrant, \"lots going on\" |\n| `packs` | 420 px | 0.36 | Subtle — focus is the collection |\n| `catalog` | 320 px | 0.46 | Busy, \"many discoveries\" |\n| `new-pack` | 460 px | 0.55 | Sparse + bold — creation space |\n| `boost` | 300 px | 0.55 | Dense + loud — high energy |\n| `help` | 520 px | 0.32 | Sparsest — calm, quiet |\n| `donate` | 380 px | 0.42 | Balanced baseline |\n\nRule of thumb: denser pattern = higher energy. Quieter sections (help)\nuse larger tile + lower opacity; energetic sections (boost, new-pack) go\ndenser + more visible.\n\n---\n\n## 7. Tile (right-side hero)\n\nTwo variants, same position/size/tilt so the family feels cohesive.\n\n### 7.1 `.tile--mascot` — the fStikBot app icon\n\nUsed on `welcome` only. The actual bot avatar (yellow star on blue-yellow\ngradient) inside a rounded-square frame at 8° tilt. Treats the real brand\nasset as the hero — no synthetic illustration.\n\n### 7.2 `.tile--icon` — section glyph on a glass tile\n\nUsed on every other banner. A white rounded-square with:\n\n- Subtle tonal gradient (`#FFFFFF` → `#EFF3F9` at 100%) — adds depth so\n  it doesn't read as flat paper\n- Glossy top-half highlight (`.tile--icon::before`) — curved gradient\n  fading to transparent, masks as an enamel/glass app icon would catch\n  overhead light\n- Tabler icon inside at 134 px, stroke `var(--sky-2)` — icon takes the\n  banner's deepest palette colour so it connects to the bg\n\n### 7.3 Picking an icon\n\nEvery section icon comes from Tabler Icons (outline set, MIT licensed).\nWhen adding a new banner:\n\n1. Pick an icon that directly represents the section's *verb* (what the\n   user does there), not a decoration of its *noun*. `search` for catalog\n   (user searches), not `book` (which would decorate \"catalog\" as a\n   concept).\n2. Paste the path data from `https://tabler.io/icons/icon/<name>` into\n   the banner's `.tile--icon svg` slot.\n3. Keep stroke-width at 2.2 — matches the wordmark's visual weight.\n\n**Current icon choices:**\n\n| Issue | Icon | Why |\n|---|---|---|\n| `welcome` | mascot PNG | Real brand |\n| `packs` | `stack-2` | Three horizontal layers = stacked sticker packs |\n| `catalog` | `search` | Direct verb — \"find packs\" |\n| `new-pack` | `sparkles` | Creation/magic hint, more interesting than a plus |\n| `boost` | `bolt` | Energy/reach — common promotion metaphor |\n| `help` | `help-circle` | Universal — question in circle |\n| `donate` | `heart` | Universal — appreciation |\n\n---\n\n## 8. What NOT to put on banners\n\nThings we explicitly rejected during design, listed here so we don't drift\nback to them:\n\n- **Personalised text** (\"Hi, Yuri!\" \"You have 12 packs\") — kills the\n  file_id cache, forces per-user render. Put dynamic state in the message\n  caption below the banner.\n- **Slogans** (\"Create magic from every moment!\") — read as AI-slop.\n  Subtitles stay functional: section title + at most one short tagline.\n- **Multiple decorative SVGs** (ribbons, registration marks, washi tape,\n  starbursts, etc.) — tried during early iteration, felt fussy. One\n  strong visual element (tile) beats ten small ones.\n- **Pastel gradient with centered title + 3 overlapping rounded cards** —\n  the textbook AI-generated hero. Banned.\n- **Kicker labels** with a coloured dot (\"● TELEGRAM · STICKER BOT\") —\n  Vercel / Linear cliché.\n- **Gradient words** (`f` in blue, rest in white) — early attempt, reads\n  as tired SaaS.\n- **Emoji characters** in SVG/type — font rendering is unreliable across\n  librsvg / Puppeteer / Chromium versions. Use Tabler paths instead.\n\n---\n\n## 9. Adding a new banner\n\n1. `cp src/help.html src/<name>.html` — pick the existing banner closest\n   in tone to what you're making.\n2. Update the `.page` block:\n   - 3 palette stops (use tools like [huemint](https://huemint.com/) to\n     pick a palette ≥60° from adjacent banners)\n   - 2 `--ink-shadow*` values derived from the deepest palette colour at\n     0.22 / 0.32 opacity\n   - `--pattern-size` and `--pattern-opacity` matched to the section's\n     energy (see 6.1)\n3. Change `.brand__name` to the section title (one or two words max) and\n   `.brand__tag` to a factual sub-line (no slogans — see §8).\n4. Swap the `<svg viewBox=\"0 0 24 24\">…</svg>` inside `.tile--icon` with\n   a new Tabler icon's paths.\n5. Add `{ name: '<name>', file: '<name>.html' }` to the `BANNERS` array in\n   `build.js`.\n6. `npm run banners:build` → verify `dist/<name>.png` visually.\n7. Commit both the `src/<name>.html` and `dist/<name>.png`.\n8. Wire it up in the relevant handler using `sendBanner` / `editBanner` /\n   `replyOrEditBanner` from `banners/index.js`.\n\n---\n\n## 10. Future extensions\n\nDesign-space decisions deferred for later:\n\n- **Per-locale titles** — 7 banners × 3 locales = 21 PNGs (~10 MB). Would\n  require a per-locale output dir and a lookup in `sendBanner`. Skipped\n  at v1, worth doing when a non-English market actually matters.\n- **Seasonal variants** — swap `welcome.png` → `welcome-holiday.png` on a\n  date range. `sendBanner` wouldn't need to change; the build script\n  would pick a variant.\n- **Stats footer on welcome** (e.g. \"14M+ packs created\") — doesn't break\n  file_id caching because the banner stays static between rebuilds. Would\n  require a build-time DB query to avoid fabricated numbers.\n- **Transparent mascot** — current mascot carries its own blue-yellow\n  square bg. On welcome it reads fine as an \"app icon\". If we ever want\n  the mascot \"peeking\" from behind a tile edge, we'd need a transparent\n  PNG.\n"
  },
  {
    "path": "banners/README.md",
    "content": "# Banners\n\nBuild-time generated hero banners that sit above `/start` and section entry\nmessages. Built from HTML+CSS with Puppeteer, shipped as PNGs in `dist/`,\nuploaded to Telegram on first send, then reused by `file_id`.\n\n## Layout\n\n```\nbanners/\n├── src/                    # templates (dev)\n│   ├── _system.css         # shared design system — palette, pattern, wordmark\n│   ├── welcome.html        # /start\n│   ├── catalog.html        # search_catalog\n│   ├── new-pack.html       # (available, not yet wired)\n│   └── assets/\n│       ├── mascot.jpg      # fStikBot app icon\n│       └── pattern.svg     # doodle wallpaper (Tabler Icons, MIT)\n├── dist/                   # committed PNG output\n│   └── *.png               # 2400×800 (retina), ship these\n├── build.js                # Puppeteer → PNG export\n└── index.js                # bot runtime: sendBanner / editBanner / editMenu\n```\n\n## Adding a new banner\n\n1. `cp src/welcome.html src/<name>.html`\n2. Change the `.page` palette vars (3 gradient stops + 2 shadow colors) and\n   the `.brand__name` / `.brand__tag` copy.\n3. Add `{ name: '<name>', file: '<name>.html' }` to `BANNERS` in `build.js`.\n4. `npm run banners:build` → check `dist/<name>.png`.\n5. Commit both `src/<name>.html` and `dist/<name>.png`.\n\nTo iterate on design: open the HTML file directly in a browser. Tweak CSS,\nrefresh. Run `npm run banners:build` only when ready to export.\n\n## Using in handlers\n\n```js\nconst { sendBanner, editBanner, editMenu } = require('../banners')\n\n// Fresh send (from /command or plain message)\nawait sendBanner(ctx, 'welcome', captionHTML, {\n  reply_markup: Markup.inlineKeyboard(keyboard)\n})\n\n// Navigate between different banners (swap media + caption)\nawait editBanner(ctx, 'catalog', captionHTML, {\n  reply_markup: Markup.inlineKeyboard(keyboard)\n})\n\n// Stay on same banner, just update text/keyboard\nawait editMenu(ctx, captionHTML, {\n  reply_markup: Markup.inlineKeyboard(keyboard)\n})\n```\n\n## Caching\n\nFirst `sendBanner` reads the PNG from disk → Telegram returns a `file_id` →\nwe cache it in RAM keyed by `{name}:{mtimeMs}`. Subsequent sends reuse the\n`file_id` string — no file transfer, served from Telegram's CDN.\n\nCache wipes on process restart; one re-upload per banner per deploy is\nacceptable overhead. Rebuilding a PNG changes its mtime → new cache key →\nautomatic invalidation, no manual bust.\n\n## Telegram edit-API note\n\nTelegram allows **text → text+media** (`editMessageMedia`), but NOT the\nreverse. Once a message is a photo, it stays a photo; you can only swap the\nphoto, caption, or buttons. Helpers respect this:\n\n- `editBanner` — uses `editMessageMedia`, works from either text or photo\n  source.\n- `editMenu` — auto-picks `editMessageCaption` (photo source) or\n  `editMessageText` (text source) to update text without touching the banner.\n"
  },
  {
    "path": "banners/build.js",
    "content": "#!/usr/bin/env node\n/**\n * Banner build script.\n * Reads HTML templates from banners/src/ and exports high-DPI PNG renders\n * into banners/dist/. Run: `npm run banners:build` (or `node banners/build.js`).\n *\n * Puppeteer is a devDependency — the resulting PNGs are what the bot ships,\n * so production Docker never touches a browser.\n */\n\nconst fs = require('fs')\nconst path = require('path')\n\nlet puppeteer\ntry {\n  puppeteer = require('puppeteer')\n} catch (err) {\n  console.error('\\n[banners] puppeteer is not installed.')\n  console.error('Run once:  npm install --save-dev puppeteer\\n')\n  process.exit(1)\n}\n\nconst WIDTH = 960\nconst HEIGHT = 360\nconst SCALE = 2 // retina output → 1920×720, looks crisp on high-DPI devices\n\nconst SRC = path.join(__dirname, 'src')\nconst DIST = path.join(__dirname, 'dist')\n\n// Banners to build. Add a new entry when you create a new template.\n// `name` becomes the output filename (dist/<name>.png).\n// `width`/`height` override the defaults for banners that ship at different\n// aspect ratios (e.g. Telegram's description picture is 640×360, not 960×360).\nconst BANNERS = [\n  { name: 'welcome',     file: 'welcome.html' },\n  { name: 'packs',       file: 'packs.html' },\n  { name: 'catalog',     file: 'catalog.html' },\n  { name: 'new-pack',    file: 'new-pack.html' },\n  { name: 'boost',       file: 'boost.html' },\n  { name: 'help',        file: 'help.html' },\n  { name: 'donate',      file: 'donate.html' },\n  { name: 'origin',      file: 'origin.html' },\n  { name: 'publish',     file: 'publish.html' },\n  { name: 'language',    file: 'language.html' },\n  { name: 'emoji',       file: 'emoji.html' },\n  { name: 'group',       file: 'group.html' },\n  { name: 'mosaic',      file: 'mosaic.html' },\n  { name: 'description', file: 'description.html', width: 640, height: 360 }\n]\n\nasync function main () {\n  fs.mkdirSync(DIST, { recursive: true })\n\n  const browser = await puppeteer.launch({\n    defaultViewport: { width: WIDTH, height: HEIGHT, deviceScaleFactor: SCALE }\n  })\n\n  try {\n    for (const { name, file, width, height } of BANNERS) {\n      const src = path.join(SRC, file)\n      if (!fs.existsSync(src)) {\n        console.warn(`[banners] skip ${name}: ${file} not found`)\n        continue\n      }\n      const w = width || WIDTH\n      const h = height || HEIGHT\n      const page = await browser.newPage()\n      await page.setViewport({ width: w, height: h, deviceScaleFactor: SCALE })\n\n      const url = 'file://' + src\n      const missing = []\n      page.on('requestfailed', req => {\n        const u = req.url()\n        if (u.startsWith('file://') && !u.endsWith('.html')) missing.push(u)\n      })\n\n      await page.goto(url, { waitUntil: 'networkidle0' })\n\n      // Wait for web fonts (Google Fonts via <link>) to actually load before shot.\n      await page.evaluate(() => document.fonts.ready)\n\n      if (missing.length) {\n        console.warn(`[banners]   ⚠ ${name} is missing local assets:`)\n        missing.forEach(u => console.warn('     ' + u.replace('file://', '')))\n      }\n\n      const out = path.join(DIST, `${name}.png`)\n      await page.screenshot({\n        path: out,\n        clip: { x: 0, y: 0, width: w, height: h },\n        omitBackground: false\n      })\n      await page.close()\n\n      const bytes = fs.statSync(out).size\n      console.log(`[banners] ✓ ${name.padEnd(10)} → ${path.relative(process.cwd(), out)}  (${(bytes / 1024).toFixed(1)} KB)`)\n    }\n  } finally {\n    await browser.close()\n  }\n}\n\nmain().catch(err => { console.error(err); process.exit(1) })\n"
  },
  {
    "path": "banners/index.js",
    "content": "// Banner runtime helpers.\n//\n// Flow: first call uploads the PNG from disk, Telegram returns a file_id,\n// we cache it in RAM keyed by {name}:{mtimeMs}. Every subsequent call uses\n// the cached file_id — Telegram serves it from its own CDN, no file transfer.\n// Cache wipes on restart (one re-upload per banner per deploy). Cache key\n// includes mtime, so rebuilding banners/dist/*.png auto-invalidates without\n// any manual bust.\n//\n// Why RAM not Redis: banners are a tiny number (~3–10), file_ids are short\n// strings, losing cache on restart costs one re-upload per banner — Redis\n// complexity isn't worth it here.\n//\n// Navigation note: Telegram allows editing a text message INTO a media\n// message via editMessageMedia, but NOT the reverse. So once /start sends\n// a banner (photo + caption + keyboard), subsequent navigation within that\n// message stays media-based forever — we use editMessageCaption to change\n// only the text/keyboard (banner unchanged), or editMessageMedia to swap\n// to a different banner.\n\nconst fs = require('fs')\nconst path = require('path')\n\nconst DIST = path.join(__dirname, 'dist')\n\nconst cache = new Map()\n\nfunction resolveBanner (name) {\n  const file = path.join(DIST, `${name}.png`)\n  if (!fs.existsSync(file)) return null\n  const { mtimeMs } = fs.statSync(file)\n  return { file, cacheKey: `${name}:${Math.floor(mtimeMs)}` }\n}\n\nfunction photoInput (banner) {\n  return cache.get(banner.cacheKey) || { source: fs.createReadStream(banner.file) }\n}\n\nfunction rememberFileId (banner, message) {\n  const photos = message?.photo\n  if (!photos?.length) return\n  // Largest size — Telegram reuses this file_id across all size requests\n  cache.set(banner.cacheKey, photos[photos.length - 1].file_id)\n}\n\nfunction assertBanner (name) {\n  const b = resolveBanner(name)\n  if (!b) throw new Error(`[banners] missing dist/${name}.png — run: node banners/build.js`)\n  return b\n}\n\n// First-time send (from a /command or plain message trigger).\nasync function sendBanner (ctx, name, caption = '', extra = {}) {\n  const banner = assertBanner(name)\n  const msg = await ctx.replyWithPhoto(photoInput(banner), {\n    caption,\n    parse_mode: 'HTML',\n    ...extra\n  })\n  rememberFileId(banner, msg)\n  return msg\n}\n\n// Swap the current message's banner (use when navigating between *different*\n// banners: e.g. /start welcome → catalog). Works whether the prior message\n// was text (upgrades it) or already a photo (replaces the media).\n//\n// Single-edit guarantee: we send photo + caption + keyboard in ONE API call.\n// Telegraf 3.40 serializes `ctx.editMessageMedia(media, extra)` correctly —\n// `caption`/`parse_mode` ride inside the InputMedia JSON, `reply_markup` is\n// top-level (see node_modules/telegraf/telegram.js:316). So no keyboard-less\n// flash between calls, which used to cause visible flicker on navigation.\n//\n// Same-banner fast path: if the message already shows this exact banner\n// (cached file_id matches the largest PhotoSize), skip the media swap and\n// do a caption-only edit. That's the pagination case (e.g. packs:N → N+1)\n// where Telegram would otherwise re-render the identical photo and briefly\n// drop the keyboard.\nasync function editBanner (ctx, name, caption = '', extra = {}) {\n  const banner = assertBanner(name)\n  const msg = ctx.callbackQuery?.message\n  const currentFileId = msg?.photo?.[msg.photo.length - 1]?.file_id\n  const cachedFileId = cache.get(banner.cacheKey)\n\n  if (currentFileId && cachedFileId && currentFileId === cachedFileId) {\n    try {\n      return await ctx.editMessageCaption(caption, {\n        parse_mode: 'HTML',\n        reply_markup: extra.reply_markup\n      })\n    } catch (err) {\n      // MESSAGE_NOT_MODIFIED is benign; anything else falls through to a\n      // full media edit (and ultimately sendBanner) below.\n      if (err?.description?.includes('message is not modified')) return\n    }\n  }\n\n  const source = photoInput(banner)\n  const media = {\n    type: 'photo',\n    media: typeof source === 'string' ? source : { source: fs.createReadStream(banner.file) },\n    caption,\n    parse_mode: 'HTML'\n  }\n  try {\n    const edited = await ctx.editMessageMedia(media, { reply_markup: extra.reply_markup })\n    if (edited && typeof edited === 'object') rememberFileId(banner, edited)\n    return edited\n  } catch (err) {\n    // Message too old / not editable — fall back to a fresh send so the user\n    // still sees something rather than a silent no-op.\n    return sendBanner(ctx, name, caption, extra)\n  }\n}\n\n// In-place text/keyboard edit without touching the banner. Use when the user\n// navigates WITHIN the same banner section (e.g. paging through packs).\n// Auto-picks editMessageCaption (if current message is a photo) or\n// editMessageText (if it's still plain text) — this keeps legacy text-only\n// flows working while photo-based flows just work too.\nasync function editMenu (ctx, text, extra = {}) {\n  const msg = ctx.callbackQuery?.message\n  const isPhoto = !!(msg && msg.photo)\n  const opts = { parse_mode: 'HTML', ...extra }\n  try {\n    if (isPhoto) {\n      return await ctx.editMessageCaption(text, opts)\n    }\n    return await ctx.editMessageText(text, opts)\n  } catch (err) {\n    // benign: message-not-modified / message-to-edit-not-found\n  }\n}\n\n// Convenience: pick sendBanner vs editBanner by trigger type. Use in handlers\n// that can be reached both as a command and as a callback from another menu.\nasync function replyOrEditBanner (ctx, name, caption = '', extra = {}) {\n  if (ctx.callbackQuery) return editBanner(ctx, name, caption, extra)\n  return sendBanner(ctx, name, caption, extra)\n}\n\nmodule.exports = { sendBanner, editBanner, editMenu, replyOrEditBanner }\n"
  },
  {
    "path": "banners/src/_system.css",
    "content": "/* ==========================================================================\n   fStikBot · Banner design system\n   Shared base across every banner. Each banner overrides only the palette\n   (--sky-0/1/2), pattern density (--pattern-size / --pattern-opacity), and\n   its title text. Composition, typography, tile treatment stay consistent\n   so the series reads as one family.\n   ========================================================================== */\n\n:root {\n  --w: 960px;\n  --h: 360px;\n\n  /* Default palette — overridden per banner */\n  --sky-0: #5BAEEF;\n  --sky-1: #4693DA;\n  --sky-2: #2E7BC8;\n\n  --fg: #FFFFFF;\n  --fg-dim: rgba(255, 255, 255, 0.88);\n\n  /* Ink shadows — overridden per palette */\n  --ink-shadow: rgba(10, 45, 95, 0.22);\n  --ink-shadow-deep: rgba(10, 45, 95, 0.32);\n\n  /* Pattern tuning — per banner, gives each section its own texture energy */\n  --pattern-size: 380px;\n  --pattern-opacity: 0.42;\n}\n\n* { box-sizing: border-box; margin: 0; padding: 0; }\n\nhtml, body {\n  width: var(--w);\n  height: var(--h);\n  font-family: 'Barlow Condensed', -apple-system, system-ui, sans-serif;\n  color: var(--fg);\n  -webkit-font-smoothing: antialiased;\n  text-rendering: geometricPrecision;\n}\n\n/* ---- Background ----------------------------------------------------\n   Layered: base gradient + soft highlights at top-right / bottom-left +\n   a subtle bottom-corner vignette that darkens toward the edges. Gives\n   the banner a \"lit from above-center\" feel instead of flat print. */\n\n.page {\n  position: relative;\n  width: var(--w);\n  height: var(--h);\n  overflow: hidden;\n  background:\n    radial-gradient(900px 500px at 20% 120%, rgba(255, 255, 255, 0.20) 0%, transparent 55%),\n    radial-gradient(700px 400px at 90% 0%, rgba(255, 255, 255, 0.14) 0%, transparent 55%),\n    radial-gradient(600px 260px at 0% 100%, rgba(0, 0, 0, 0.16) 0%, transparent 60%),\n    radial-gradient(600px 260px at 100% 100%, rgba(0, 0, 0, 0.14) 0%, transparent 60%),\n    linear-gradient(165deg, var(--sky-0) 0%, var(--sky-1) 50%, var(--sky-2) 100%);\n}\n\n/* Doodle pattern overlay — 480×480 SVG tile, Tabler Icons stroked white.\n   Soft-light blend lets the underlying gradient tint through. Size and\n   opacity are tunable per banner so each section has its own density. */\n.pattern {\n  position: absolute;\n  inset: 0;\n  background-image: url(\"./assets/pattern.svg\");\n  background-size: var(--pattern-size) var(--pattern-size);\n  background-repeat: repeat;\n  opacity: var(--pattern-opacity);\n  mix-blend-mode: soft-light;\n  pointer-events: none;\n}\n\n/* ---- Wordmark block ------------------------------------------------- */\n\n.brand {\n  position: absolute;\n  left: 56px;\n  top: 50%;\n  transform: translateY(-50%);\n  max-width: 620px;\n}\n.brand__name {\n  font-family: 'Barlow Condensed', sans-serif;\n  font-weight: 900;\n  font-style: italic;\n  font-size: 140px;\n  line-height: 1;\n  letter-spacing: -0.01em;\n  color: var(--fg);\n  text-shadow:\n    0 3px 0 var(--ink-shadow),\n    0 12px 24px var(--ink-shadow),\n    0 0 36px rgba(255, 255, 255, 0.18);        /* soft halo — lifts text off the pattern */\n  margin-left: -4px;\n}\n.brand__tag {\n  margin-top: 18px;\n  font-family: 'Barlow Condensed', sans-serif;\n  font-weight: 700;\n  font-style: italic;\n  font-size: 26px;\n  line-height: 1;\n  letter-spacing: 0.04em;\n  text-transform: uppercase;\n  color: var(--fg-dim);\n  text-shadow: 0 2px 0 var(--ink-shadow);\n}\n\n/* ---- Tile (right-side hero element) --------------------------------\n     .tile--mascot   → the fStikBot app icon (welcome only)\n     .tile--icon     → white app-icon-style tile with a section glyph\n   -------------------------------------------------------------------- */\n\n.tile {\n  position: absolute;\n  right: 48px;\n  top: 50%;\n  width: 230px;\n  height: 230px;\n  transform: translateY(-50%) rotate(8deg);\n  border-radius: 50px;\n  overflow: hidden;\n  box-shadow:\n    0 26px 36px var(--ink-shadow-deep),\n    0 10px 18px var(--ink-shadow),\n    inset 0 0 0 1px rgba(255, 255, 255, 0.28),\n    inset 0 -3px 0 rgba(0, 0, 0, 0.07);\n}\n.tile img {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  display: block;\n}\n\n/* Icon tile — off-white with a subtle tonal gradient, plus a glossy\n   highlight across the top half (like a glass/enamel app icon).\n   Mascot tile skips both so the star isn't washed out. */\n\n.tile--icon {\n  background: linear-gradient(180deg, #FFFFFF 0%, #FFFFFF 55%, #EFF3F9 100%);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n.tile--icon::before {\n  content: \"\";\n  position: absolute;\n  top: 4px; left: 4px; right: 4px;\n  height: 48%;\n  border-radius: 46px 46px 50% 50% / 46px 46px 100% 100%;\n  background: linear-gradient(180deg, rgba(255, 255, 255, 0.85) 0%, rgba(255, 255, 255, 0) 100%);\n  opacity: 0.55;\n  pointer-events: none;\n}\n.tile--icon svg {\n  width: 134px;\n  height: 134px;\n  stroke: var(--sky-2);\n  fill: none;\n  stroke-width: 2.2;\n  stroke-linecap: round;\n  stroke-linejoin: round;\n  filter: drop-shadow(0 3px 0 rgba(10, 45, 95, 0.08));\n  position: relative;\n  z-index: 1;\n}\n\n/* ---- Stats footer (optional, used on welcome) ---------------------\n   Factual numbers baked into the PNG. Update copy + rebuild on\n   milestones (every few months). Subtle tracking + small size so it\n   doesn't compete with the wordmark. */\n\n.stats {\n  position: absolute;\n  left: 56px;\n  right: 48px;\n  bottom: 22px;\n  display: flex;\n  gap: 10px;\n  align-items: center;\n  font-family: 'Barlow Condensed', sans-serif;\n  font-weight: 700;\n  font-style: italic;\n  font-size: 19px;\n  letter-spacing: 0.10em;\n  text-transform: uppercase;\n  color: var(--fg-dim);\n  text-shadow: 0 1px 0 var(--ink-shadow);\n}\n.stats b {\n  font-weight: 900;\n  color: var(--fg);\n}\n.stats__dot {\n  width: 5px; height: 5px;\n  border-radius: 50%;\n  background: var(--fg);\n  opacity: 0.5;\n}\n"
  },
  {
    "path": "banners/src/assets/README.md",
    "content": "# Banner assets\n\nDrop brand imagery here. Referenced from templates as `./assets/<file>`.\n\n## Required\n\n- **`mascot.png`** — the fStikBot yellow-star icon, ≥512×512, ideally\n  transparent background (square with built-in bg also works — the template\n  masks it to a rounded shape).\n\n  Shortcut to save from macOS clipboard:\n  ```\n  pngpaste banners/src/assets/mascot.png    # brew install pngpaste\n  ```\n  Or simply drag-drop the PNG into this folder in Finder / your IDE.\n"
  },
  {
    "path": "banners/src/boost.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Boost</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,700;0,800;0,900;1,700;1,800;1,900&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"./_system.css\">\n<style>\n  /* Boost issue — magenta/pink. Energetic, signals promotion. */\n  .page {\n    --sky-0: #F56BB0;\n    --sky-1: #D4458F;\n    --sky-2: #A62A6E;\n    --ink-shadow: rgba(80, 20, 55, 0.22);\n    --ink-shadow-deep: rgba(80, 20, 55, 0.32);\n    --pattern-size: 300px;\n    --pattern-opacity: 0.55;\n  }\n</style>\n</head>\n<body>\n  <div class=\"page\">\n    <div class=\"pattern\"></div>\n    <div class=\"brand\">\n      <h1 class=\"brand__name\">Boost</h1>\n      <p class=\"brand__tag\">promote your pack</p>\n    </div>\n    <div class=\"tile tile--icon\">\n      <svg viewBox=\"0 0 24 24\">\n        <path d=\"M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11\"/>\n      </svg>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "banners/src/catalog.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Catalog</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,700;0,800;0,900;1,700;1,800;1,900&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"./_system.css\">\n<style>\n  /* Catalog issue — teal/mint palette. Distinguishes \"discovery\" from\n     \"welcome\" while staying within the same brand temperature range. */\n  .page {\n    --sky-0: #3FC5B0;\n    --sky-1: #1FA793;\n    --sky-2: #0C8A78;\n    --ink-shadow: rgba(8, 60, 50, 0.22);\n    --ink-shadow-deep: rgba(8, 60, 50, 0.32);\n    --pattern-size: 320px;\n    --pattern-opacity: 0.46;\n  }\n</style>\n</head>\n<body>\n  <div class=\"page\">\n    <div class=\"pattern\"></div>\n    <div class=\"brand\">\n      <h1 class=\"brand__name\">Catalog</h1>\n      <p class=\"brand__tag\">discover sticker packs</p>\n    </div>\n    <div class=\"tile tile--icon\">\n      <svg viewBox=\"0 0 24 24\">\n        <path d=\"M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0\"/>\n        <path d=\"M21 21l-6 -6\"/>\n      </svg>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "banners/src/description.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Description</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,700;0,800;0,900;1,700;1,800;1,900&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"./_system.css\">\n<style>\n  /* Description picture — 640×360, shown in Telegram's \"What can this bot do?\"\n     block when a user opens the chat with the bot for the first time.\n     Same palette + mascot as welcome, re-flowed for the narrower canvas. */\n  :root { --w: 640px; }\n\n  .page {\n    --sky-0: #5BAEEF; --sky-1: #4693DA; --sky-2: #2E7BC8;\n    --ink-shadow: rgba(10, 45, 95, 0.22);\n    --ink-shadow-deep: rgba(10, 45, 95, 0.32);\n    --pattern-size: 260px;\n    --pattern-opacity: 0.46;\n  }\n\n  /* 640 wide gives ~380px of runway for the wordmark once the mascot tile\n     claims the right side — shrink text + tile proportionally. */\n  .brand          { left: 44px; max-width: 380px; }\n  .brand__name    { font-size: 104px; }\n  .brand__tag     { margin-top: 14px; font-size: 22px; }\n\n  .tile--mascot   { right: 40px; width: 200px; height: 200px; border-radius: 44px; }\n\n  .stats          { left: 44px; right: 40px; bottom: 20px; font-size: 16px; gap: 8px; }\n</style>\n</head>\n<body>\n  <div class=\"page\">\n    <div class=\"pattern\"></div>\n    <div class=\"brand\">\n      <h1 class=\"brand__name\">fStikBot</h1>\n      <p class=\"brand__tag\">stickers · emoji · packs</p>\n    </div>\n    <div class=\"tile tile--mascot\"><img src=\"./assets/mascot.jpg\" alt=\"\"></div>\n    <div class=\"stats\">\n      <span><b>400M+</b> stickers</span>\n      <span class=\"stats__dot\"></span>\n      <span><b>30M+</b> packs</span>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "banners/src/donate.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Support</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,700;0,800;0,900;1,700;1,800;1,900&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"./_system.css\">\n<style>\n  /* Donate issue — warm gold. Appreciation, Telegram Stars adjacent. */\n  .page {\n    --sky-0: #FFD85C;\n    --sky-1: #F2AE2C;\n    --sky-2: #C88617;\n    --ink-shadow: rgba(85, 55, 5, 0.22);\n    --ink-shadow-deep: rgba(85, 55, 5, 0.32);\n    --pattern-size: 380px;\n    --pattern-opacity: 0.42;\n  }\n</style>\n</head>\n<body>\n  <div class=\"page\">\n    <div class=\"pattern\"></div>\n    <div class=\"brand\">\n      <h1 class=\"brand__name\">Support</h1>\n      <p class=\"brand__tag\">keep fStikBot alive</p>\n    </div>\n    <div class=\"tile tile--icon\">\n      <svg viewBox=\"0 0 24 24\">\n        <path d=\"M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572\"/>\n      </svg>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "banners/src/emoji.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Emoji</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,700;0,800;0,900;1,700;1,800;1,900&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"./_system.css\">\n<style>\n  /* Emoji issue — lime. Playful, matches the joyful nature of custom emoji. */\n  .page {\n    --sky-0: #BEF264;\n    --sky-1: #84CC16;\n    --sky-2: #4D7C0F;\n    --ink-shadow: rgba(25, 55, 5, 0.22);\n    --ink-shadow-deep: rgba(25, 55, 5, 0.32);\n    --pattern-size: 360px;\n    --pattern-opacity: 0.44;\n  }\n</style>\n</head>\n<body>\n  <div class=\"page\">\n    <div class=\"pattern\"></div>\n    <div class=\"brand\">\n      <h1 class=\"brand__name\">Emoji</h1>\n      <p class=\"brand__tag\">your own custom emoji</p>\n    </div>\n    <div class=\"tile tile--icon\">\n      <svg viewBox=\"0 0 24 24\">\n        <path d=\"M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0\"/>\n        <path d=\"M9 10l.01 0\"/>\n        <path d=\"M15 10l.01 0\"/>\n        <path d=\"M9.5 15a3.5 3.5 0 0 0 5 0\"/>\n      </svg>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "banners/src/group.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Group</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,700;0,800;0,900;1,700;1,800;1,900&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"./_system.css\">\n<style>\n  /* Group issue — slate blue-gray. Neutral, admin/settings tone. */\n  .page {\n    --sky-0: #94A3B8;\n    --sky-1: #475569;\n    --sky-2: #1E293B;\n    --ink-shadow: rgba(10, 15, 25, 0.28);\n    --ink-shadow-deep: rgba(10, 15, 25, 0.38);\n    --pattern-size: 440px;\n    --pattern-opacity: 0.30;\n  }\n</style>\n</head>\n<body>\n  <div class=\"page\">\n    <div class=\"pattern\"></div>\n    <div class=\"brand\">\n      <h1 class=\"brand__name\">Group</h1>\n      <p class=\"brand__tag\">shared pack for the chat</p>\n    </div>\n    <div class=\"tile tile--icon\">\n      <svg viewBox=\"0 0 24 24\">\n        <path d=\"M10 13a2 2 0 1 0 4 0a2 2 0 0 0 -4 0\"/>\n        <path d=\"M8 21v-1a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v1\"/>\n        <path d=\"M15 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0\"/>\n        <path d=\"M17 10h2a2 2 0 0 1 2 2v1\"/>\n        <path d=\"M5 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0\"/>\n        <path d=\"M3 13v-1a2 2 0 0 1 2 -2h2\"/>\n      </svg>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "banners/src/help.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Help</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,700;0,800;0,900;1,700;1,800;1,900&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"./_system.css\">\n<style>\n  /* Help issue — mint/sage. Calm, supportive tone. */\n  .page {\n    --sky-0: #8CD48B;\n    --sky-1: #4FB464;\n    --sky-2: #2C8F46;\n    --ink-shadow: rgba(10, 60, 30, 0.22);\n    --ink-shadow-deep: rgba(10, 60, 30, 0.32);\n    --pattern-size: 520px;\n    --pattern-opacity: 0.32;\n  }\n</style>\n</head>\n<body>\n  <div class=\"page\">\n    <div class=\"pattern\"></div>\n    <div class=\"brand\">\n      <h1 class=\"brand__name\">Help</h1>\n      <p class=\"brand__tag\">guides &amp; commands</p>\n    </div>\n    <div class=\"tile tile--icon\">\n      <svg viewBox=\"0 0 24 24\">\n        <path d=\"M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0\"/>\n        <path d=\"M12 16v.01\"/>\n        <path d=\"M12 13a2 2 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483\"/>\n      </svg>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "banners/src/language.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Language</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,700;0,800;0,900;1,700;1,800;1,900&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"./_system.css\">\n<style>\n  /* Language issue — cyan. Fresh, communicative, distinct from welcome blue. */\n  .page {\n    --sky-0: #67E8F9;\n    --sky-1: #0891B2;\n    --sky-2: #164E63;\n    --ink-shadow: rgba(5, 45, 65, 0.22);\n    --ink-shadow-deep: rgba(5, 45, 65, 0.32);\n    --pattern-size: 420px;\n    --pattern-opacity: 0.36;\n  }\n</style>\n</head>\n<body>\n  <div class=\"page\">\n    <div class=\"pattern\"></div>\n    <div class=\"brand\">\n      <h1 class=\"brand__name\">Language</h1>\n      <p class=\"brand__tag\">pick your language</p>\n    </div>\n    <div class=\"tile tile--icon\">\n      <svg viewBox=\"0 0 24 24\">\n        <path d=\"M9 6.371c0 4.418 -2.239 6.629 -5 6.629\"/>\n        <path d=\"M4 6.371h7\"/>\n        <path d=\"M5 9c0 2.144 2.252 3.908 6 4\"/>\n        <path d=\"M12 20l4 -9l4 9\"/>\n        <path d=\"M19.1 18h-6.2\"/>\n        <path d=\"M6.694 3l.793 .582\"/>\n      </svg>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "banners/src/mosaic.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Mosaic</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,700;0,800;0,900;1,700;1,800;1,900&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"./_system.css\">\n<style>\n  /* Mosaic issue — fuchsia. Bold, creative, stands apart from boost magenta. */\n  .page {\n    --sky-0: #F0ABFC;\n    --sky-1: #C026D3;\n    --sky-2: #701A75;\n    --ink-shadow: rgba(65, 10, 75, 0.22);\n    --ink-shadow-deep: rgba(65, 10, 75, 0.32);\n    --pattern-size: 320px;\n    --pattern-opacity: 0.50;\n  }\n</style>\n</head>\n<body>\n  <div class=\"page\">\n    <div class=\"pattern\"></div>\n    <div class=\"brand\">\n      <h1 class=\"brand__name\">Mosaic</h1>\n      <p class=\"brand__tag\">split image into emoji tiles</p>\n    </div>\n    <div class=\"tile tile--icon\">\n      <svg viewBox=\"0 0 24 24\">\n        <path d=\"M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4\"/>\n        <path d=\"M14 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4\"/>\n        <path d=\"M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4\"/>\n        <path d=\"M14 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4\"/>\n      </svg>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "banners/src/new-pack.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · New pack</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,700;0,800;0,900;1,700;1,800;1,900&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"./_system.css\">\n<style>\n  /* New pack issue — warm amber, signals creation / action. */\n  .page {\n    --sky-0: #F7B94A;\n    --sky-1: #EE8F2A;\n    --sky-2: #D86820;\n    --ink-shadow: rgba(90, 45, 5, 0.22);\n    --ink-shadow-deep: rgba(90, 45, 5, 0.32);\n    --pattern-size: 460px;\n    --pattern-opacity: 0.55;\n  }\n</style>\n</head>\n<body>\n  <div class=\"page\">\n    <div class=\"pattern\"></div>\n    <div class=\"brand\">\n      <h1 class=\"brand__name\">New pack</h1>\n      <p class=\"brand__tag\">turn anything into stickers</p>\n    </div>\n    <div class=\"tile tile--icon\">\n      <svg viewBox=\"0 0 24 24\">\n        <path d=\"M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2m0 -12a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2m-7 12a6 6 0 0 1 6 -6a6 6 0 0 1 -6 -6a6 6 0 0 1 -6 6a6 6 0 0 1 6 6\"/>\n      </svg>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "banners/src/origin.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Origin</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,700;0,800;0,900;1,700;1,800;1,900&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"./_system.css\">\n<style>\n  /* Identify issue — deep violet. \"Find where this sticker came from.\" */\n  .page {\n    --sky-0: #A78BFA;\n    --sky-1: #7C4EE4;\n    --sky-2: #5B21B6;\n    --ink-shadow: rgba(40, 10, 90, 0.22);\n    --ink-shadow-deep: rgba(40, 10, 90, 0.32);\n    --pattern-size: 380px;\n    --pattern-opacity: 0.40;\n  }\n</style>\n</head>\n<body>\n  <div class=\"page\">\n    <div class=\"pattern\"></div>\n    <div class=\"brand\">\n      <h1 class=\"brand__name\">Origin</h1>\n      <p class=\"brand__tag\">original file &amp; author</p>\n    </div>\n    <div class=\"tile tile--icon\">\n      <svg viewBox=\"0 0 24 24\">\n        <path d=\"M14 3v4a1 1 0 0 0 1 1h4\"/>\n        <path d=\"M12 21h-5a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v4.5\"/>\n        <path d=\"M14 17.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0\"/>\n        <path d=\"M18.5 19.5l2.5 2.5\"/>\n      </svg>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "banners/src/packs.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · My Packs</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,700;0,800;0,900;1,700;1,800;1,900&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"./_system.css\">\n<style>\n  /* Packs issue — indigo/violet. Reads as \"personal collection\". */\n  .page {\n    --sky-0: #7A7BE8;\n    --sky-1: #5758D4;\n    --sky-2: #3A3BAF;\n    --ink-shadow: rgba(25, 20, 80, 0.22);\n    --ink-shadow-deep: rgba(25, 20, 80, 0.32);\n    --pattern-size: 420px;\n    --pattern-opacity: 0.36;\n  }\n</style>\n</head>\n<body>\n  <div class=\"page\">\n    <div class=\"pattern\"></div>\n    <div class=\"brand\">\n      <h1 class=\"brand__name\">My packs</h1>\n      <p class=\"brand__tag\">your sticker collection</p>\n    </div>\n    <div class=\"tile tile--icon\">\n      <svg viewBox=\"0 0 24 24\">\n        <path d=\"M12 4l-8 4l8 4l8 -4l-8 -4\"/>\n        <path d=\"M4 12l8 4l8 -4\"/>\n        <path d=\"M4 16l8 4l8 -4\"/>\n      </svg>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "banners/src/publish.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Publish</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,700;0,800;0,900;1,700;1,800;1,900&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"./_system.css\">\n<style>\n  /* Publish issue — coral/tomato. Bold, \"share it with the world.\" */\n  .page {\n    --sky-0: #FB7185;\n    --sky-1: #E11D48;\n    --sky-2: #9F1239;\n    --ink-shadow: rgba(80, 10, 30, 0.22);\n    --ink-shadow-deep: rgba(80, 10, 30, 0.32);\n    --pattern-size: 360px;\n    --pattern-opacity: 0.46;\n  }\n</style>\n</head>\n<body>\n  <div class=\"page\">\n    <div class=\"pattern\"></div>\n    <div class=\"brand\">\n      <h1 class=\"brand__name\">Publish</h1>\n      <p class=\"brand__tag\">share your pack in the catalog</p>\n    </div>\n    <div class=\"tile tile--icon\">\n      <svg viewBox=\"0 0 24 24\">\n        <path d=\"M10 14l11 -11\"/>\n        <path d=\"M21 3l-6.5 18a.55 .55 0 0 1 -1 0l-3.5 -7l-7 -3.5a.55 .55 0 0 1 0 -1l18 -6.5\"/>\n      </svg>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "banners/src/welcome.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Welcome</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,700;0,800;0,900;1,700;1,800;1,900&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"./_system.css\">\n<style>\n  /* Welcome issue — sky-blue palette, the same the marketplace promos use. */\n  .page {\n    --sky-0: #5BAEEF; --sky-1: #4693DA; --sky-2: #2E7BC8;\n    --ink-shadow: rgba(10, 45, 95, 0.22);\n    --ink-shadow-deep: rgba(10, 45, 95, 0.32);\n    --pattern-size: 340px;\n    --pattern-opacity: 0.48;\n  }\n</style>\n</head>\n<body>\n  <div class=\"page\">\n    <div class=\"pattern\"></div>\n    <div class=\"brand\">\n      <h1 class=\"brand__name\">fStikBot</h1>\n      <p class=\"brand__tag\">stickers · emoji · packs</p>\n    </div>\n    <div class=\"tile tile--mascot\"><img src=\"./assets/mascot.jpg\" alt=\"\"></div>\n    <div class=\"stats\">\n      <span><b>400M+</b> stickers</span>\n      <span class=\"stats__dot\"></span>\n      <span><b>30M+</b> packs</span>\n      <span class=\"stats__dot\"></span>\n      <span>since 2017</span>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "bot/commands.js",
    "content": "// All bot commands, actions, and hears. Preserves the exact registration\n// order from the original bot.js — order matters for the\n// addstickers/addemoji restore→copy chain and for /start payload routing.\nconst Composer = require('telegraf/composer')\nconst sendStickerAsDocument = require('../utils/send-sticker-as-document')\n\nmodule.exports = (bot, privateMessage, {\n  handlers,\n  limitPublicPack,\n  privacyHtml,\n  db,\n  scenes\n}) => {\n  const {\n    handleStats,\n    handlePing,\n    handleStart,\n    handleHelp,\n    handleDonate,\n    handleSticker,\n    handleDeleteSticker,\n    handleRestoreSticker,\n    handlePacks,\n    handleSelectPack,\n    handleSelectGroupPack,\n    handleHidePack,\n    handleRestorePack,\n    handleBoostPack,\n    handleCatalog,\n    handleSearchCatalog,\n    handleCopyPack,\n    handleCoedit,\n    handleLanguage,\n    handleEmoji,\n    handleStickerUpdate,\n    handleInlineQuery,\n    handleGroupSettings\n  } = handlers\n\n  // --- Admin-only /json dump ---\n  // Used to be public; now gated to the main admin to avoid leaking arbitrary\n  // message payloads (forwarded chats can carry sensitive content).\n  bot.command('json', Composer.privateChat((ctx) => {\n    if (ctx.config.mainAdminId !== ctx.from.id) return\n    return ctx.replyWithHTML('<code>' + JSON.stringify(ctx.message, null, 2) + '</code>')\n  }))\n\n  // Scenes (Stage) mount — must come before any composer that uses ctx.scene.enter\n  bot.use(scenes)\n\n  // Admin panel + news-channel onboarding\n  privateMessage.use(require('../handlers/admin'))\n  privateMessage.use(require('../handlers/news-channel'))\n\n  bot.use(handleStats)\n  bot.use(handlePing)\n\n  // --- /start with merged startPayload routing ---\n  // Originally there were three separate bot.start() calls branching on\n  // different payload values — merged here for legibility. Falls through\n  // via next() so handleDonate (mounted later) can still intercept the\n  // 'donate' payload, and the final bot.start(handleStart) catches the rest.\n  bot.start(async (ctx, next) => {\n    const payload = ctx.startPayload\n\n    if (payload === 'inline_pack') {\n      ctx.state.type = 'inline'\n      return handlePacks(ctx)\n    }\n    if (payload === 'pack' || payload === 'packs') return handlePacks(ctx)\n    if (payload && /^s_(.*)/.test(payload)) return handleSelectPack(ctx)\n\n    return next()\n  })\n\n  // Bot added to a new group → run start flow\n  bot.on('new_chat_members', (ctx, next) => {\n    if (ctx.message.new_chat_members.find((m) => m.id === ctx.botInfo.id)) {\n      return handleStart(ctx, next)\n    }\n    return next()\n  })\n\n  // Pack navigation\n  privateMessage.command('help', handleHelp)\n  bot.command('packs', handlePacks)\n  bot.command('pack', handleSelectGroupPack)\n  bot.use(handleGroupSettings)\n\n  privateMessage.action(/packs:(type):(.*)/, handlePacks)\n  privateMessage.action(/packs:(.*)/, handlePacks)\n\n  // Support / legal\n  privateMessage.command('paysupport', (ctx) => ctx.replyWithHTML(ctx.i18n.t('cmd.paysupport')))\n  privateMessage.command('privacy', (ctx) => ctx.replyWithHTML(privacyHtml))\n\n  // Pack link handler chain: restore (if owner) → copy (if not owner).\n  // Both hears use the same regex; handleRestorePack calls next() when the\n  // pack isn't owned, which lets handleCopyPack fire.\n  privateMessage.hears(/(addstickers|addemoji)\\/(.*)/, handleRestorePack)\n\n  privateMessage.command('report', (ctx) => ctx.replyWithHTML(ctx.i18n.t('cmd.report')))\n  privateMessage.hears(/\\/new/, (ctx) => ctx.scene.enter('newPack'))\n  privateMessage.action(/new_pack:(.*)/, async (ctx) => {\n    const packType = ctx.match[1]\n    if (packType === 'inline') {\n      ctx.session.scene = ctx.session.scene || {}\n      ctx.session.scene.newPack = {\n        inline: true,\n        packType: 'regular'\n      }\n    }\n    // Scene sends its own new-pack banner below (reply keyboards can't\n    // attach via editMessageMedia, so we don't swap the /start message —\n    // user keeps their welcome banner in history + sees the scene flow\n    // as the next message).\n    return ctx.scene.enter('newPack')\n  })\n  privateMessage.hears(/(addstickers|addemoji)\\/(.*)/, handleCopyPack)\n\n  privateMessage.command('publish', (ctx) => ctx.scene.enter('catalogPublishNew'))\n  privateMessage.action(/publish/, (ctx) => ctx.scene.enter('catalogPublishNew'))\n  privateMessage.command('frame', (ctx) => ctx.scene.enter('packFrame'))\n  privateMessage.action(/frame/, (ctx) => ctx.scene.enter('packFrame'))\n  privateMessage.command('delete', (ctx) => ctx.scene.enter('deleteSticker'))\n  privateMessage.action(/^delete_sticker$/, (ctx) => ctx.scene.enter('deleteSticker'))\n  privateMessage.command('catalog', handleCatalog)\n  privateMessage.action(/search_catalog/, handleSearchCatalog)\n  privateMessage.action(/^catalog$/, handleCatalog)\n  privateMessage.command('public', handleSelectPack)\n  privateMessage.command('emoji', handleEmoji)\n  privateMessage.command('copy', (ctx) => ctx.replyWithHTML(ctx.i18n.t('cmd.copy')))\n  privateMessage.command('restore', (ctx) => ctx.replyWithHTML(ctx.i18n.t('cmd.restore')))\n  privateMessage.command('original', (ctx) => ctx.scene.enter('originalSticker'))\n  privateMessage.action(/^original$/, (ctx) => ctx.scene.enter('originalSticker'))\n  privateMessage.command('about', (ctx) => ctx.scene.enter('packAbout'))\n  privateMessage.action(/about/, (ctx) => ctx.scene.enter('packAbout'))\n\n  // Download-original — used by the /about scene's \"Download original\" button.\n  // Tries to resend the stored original sticker directly; on any failure\n  // (expired file_id, emoji/regular mismatch, etc.) re-uploads via URL as a\n  // document. Never falls back to sendPhoto/sendVideo — Telegram rejects\n  // sticker file_ids there with \"can't use file of type Sticker as Photo\".\n  privateMessage.action(/^download_original$/, async (ctx) => {\n    await ctx.answerCbQuery()\n\n    const sticker = ctx.session?.lastStickerForDownload\n    if (!sticker) {\n      return ctx.replyWithHTML(ctx.i18n.t('scenes.original.error.not_found'))\n    }\n\n    // Query supports both new (original) and legacy (file) schema\n    const stickerInfo = await db.Sticker.findOne({\n      fileUniqueId: sticker.file_unique_id,\n      $or: [\n        { 'original.fileId': { $ne: null } },\n        { 'file.file_id': { $ne: null } }\n      ]\n    })\n\n    if (stickerInfo && stickerInfo.hasOriginal()) {\n      const originalFileId = stickerInfo.getOriginalFileId()\n      const originalFileUniqueId = stickerInfo.getOriginalFileUniqueId()\n\n      try {\n        await ctx.replyWithSticker(originalFileId, { caption: stickerInfo.emojis })\n        return\n      } catch (_) { /* fall through to document fallback */ }\n\n      await sendStickerAsDocument(ctx, originalFileId, originalFileUniqueId)\n      return\n    }\n\n    await sendStickerAsDocument(ctx, sticker.file_id, sticker.file_unique_id)\n  })\n\n  // Show-all-packs — companion to /about scene. Chunks responses to 70 packs\n  // per message to stay under Telegram's message-length limit.\n  privateMessage.action(/^show_all_packs$/, async (ctx) => {\n    await ctx.answerCbQuery()\n\n    const data = ctx.session?.showAllPacksData\n    if (!data) return\n\n    const packs = await db.StickerSet.find({\n      ownerTelegramId: data.ownerId,\n      _id: { $ne: data.excludeSetId }\n    }).limit(500)\n\n    if (packs.length === 0) return\n\n    const chunkSize = 70\n    const formattedPacks = packs.map((pack) => {\n      if (pack.name.toLowerCase().endsWith('fstikbot') && pack.public !== true) {\n        if (\n          ctx.from.id === data.ownerId ||\n          ctx.from.id === ctx.config.mainAdminId ||\n          ctx?.session?.userInfo?.adminRights?.includes('pack')\n        ) {\n          return `<a href=\"https://t.me/addstickers/${pack.name}\"><s>${pack.name}</s></a>`\n        } else {\n          return ctx.i18n.t('scenes.packAbout.hidden')\n        }\n      }\n      return `<a href=\"https://t.me/addstickers/${pack.name}\">${pack.name}</a>`\n    })\n\n    // Skip first 70 (already shown) and send the rest in chunks\n    const remainingPacks = formattedPacks.slice(chunkSize)\n    const chunks = []\n    for (let i = 0; i < remainingPacks.length; i += chunkSize) {\n      chunks.push(remainingPacks.slice(i, i + chunkSize))\n    }\n\n    for (const chunk of chunks) {\n      await ctx.replyWithHTML(chunk.join(', '), { disable_web_page_preview: true })\n    }\n  })\n\n  // Media-edit scenes\n  privateMessage.command('clear', (ctx) => ctx.scene.enter('photoClearSelect'))\n  privateMessage.command('round', (ctx) => ctx.scene.enter('videoRound'))\n  privateMessage.command('mosaic', (ctx) => ctx.scene.enter('mosaic'))\n  privateMessage.action(/clear/, (ctx) => ctx.scene.enter('photoClearSelect'))\n  privateMessage.action(/catalog:publish:(.*)/, (ctx) => ctx.scene.enter('catalogPublish'))\n  privateMessage.action(/catalog:unpublish:(.*)/, (ctx) => ctx.scene.enter('catalogUnpublish'))\n\n  // Language picker\n  bot.command('lang', handleLanguage)\n  bot.action(/set_language:(.*)/, handleLanguage)\n\n  privateMessage.action(/delete_pack:(.*)/, (ctx) => ctx.scene.enter('packDelete'))\n\n  privateMessage.action('mosaic:enter', (ctx) => {\n    ctx.answerCbQuery()\n    return ctx.scene.enter('mosaic')\n  })\n\n  // Donate (Stars) + boost + coedit\n  bot.use(handleDonate)\n  privateMessage.use(handleBoostPack)\n  privateMessage.use(handleCoedit)\n\n  // Inline queries (packs or GIFs)\n  bot.use(handleInlineQuery)\n\n  // Final /start catch-all — if none of the startPayload branches matched\n  // AND handleDonate's composer didn't handle it, run the menu.\n  bot.start(handleStart)\n\n  // Pack management callbacks\n  privateMessage.action(/(set_pack):(.*)/, handlePacks)\n  privateMessage.action(/(hide_pack):(.*)/, handleHidePack)\n  privateMessage.action(/(rename_pack):(.*)/, (ctx) => ctx.scene.enter('packRename'))\n  privateMessage.action(/(delete_sticker):(.*)/, limitPublicPack, handleDeleteSticker)\n  privateMessage.action(/(restore_sticker):(.*)/, limitPublicPack, handleRestoreSticker)\n\n  // /ss — quote-reply style sticker creation (works in groups too)\n  bot.command('ss', handleSticker)\n\n  // Sticker detection in private chats (images, videos, video notes, etc.)\n  privateMessage.on(['sticker', 'document', 'photo', 'video', 'video_note'], limitPublicPack, handleSticker)\n  privateMessage.on('message', (ctx, next) => {\n    if (ctx.message && ctx.message.entities && ctx.message.entities[0] && ctx.message.entities[0].type === 'custom_emoji') {\n      return handleSticker(ctx)\n    }\n    return next()\n  })\n  privateMessage.action(/add_sticker/, handleSticker)\n\n  // Sticker metadata updates (emoji suffix edit). These listen for free-form\n  // text and must be registered BEFORE bot.use(privateMessage) so the\n  // composer is fully populated at mount time.\n  privateMessage.on('text', handleStickerUpdate)\n  privateMessage.on('message', handleStart)\n\n  // Mount privateMessage only after every handler is attached\n  bot.use(privateMessage)\n}\n"
  },
  {
    "path": "bot/launch.js",
    "content": "// Bot launch + graceful shutdown.\n// Webhook mode when BOT_DOMAIN is set, polling otherwise.\n//\n// allowedUpdates cuts channel_post, edited_channel_post, and poll updates\n// at the Telegram side — the bot doesn't handle them, and previously there\n// was a no-op bot.on([...]) catcher that still consumed network + CPU.\nconst ALLOWED_UPDATES = [\n  'message',\n  'edited_message',\n  'callback_query',\n  'inline_query',\n  'pre_checkout_query',\n  'my_chat_member'\n]\n\nmodule.exports = async function launch (bot) {\n  if (process.env.BOT_DOMAIN) {\n    // Keep the original raw-token path — server nginx is configured to\n    // proxy exactly this route to the bot port. Changing to sha256(token)\n    // requires a coordinated nginx update; revisit as a separate change.\n    const hookPath = `/fStikBot:${process.env.BOT_TOKEN}`\n    await bot.launch({\n      webhook: {\n        domain: process.env.BOT_DOMAIN,\n        hookPath,\n        port: process.env.WEBHOOK_PORT || 2500\n      },\n      allowedUpdates: ALLOWED_UPDATES\n    })\n    console.log('bot start webhook')\n  } else {\n    await bot.launch({ allowedUpdates: ALLOWED_UPDATES })\n    console.log('bot start polling')\n  }\n}\n\nmodule.exports.ALLOWED_UPDATES = ALLOWED_UPDATES\n"
  },
  {
    "path": "bot/locale-sync.js",
    "content": "// Locale sync — pushes bot name/description/commands to Telegram for every\n// locale in locales/. This is idempotent but expensive: up to ~8 API calls\n// per locale × 18 locales = ~144 calls on every process start.\n//\n// PM2 restarts the process every 6h, which used to trigger the whole sync.\n// We now cache a hash of the locales/ directory's max mtime in a dotfile;\n// if nothing changed since last run, sync is skipped entirely.\nconst fs = require('fs')\nconst path = require('path')\n\nconst LOCALES_DIR = path.resolve(__dirname, '..', 'locales')\nconst CACHE_FILE = path.resolve(__dirname, '..', '.locale-sync-mtime')\n\nfunction computeMaxMtime () {\n  let max = 0\n  for (const name of fs.readdirSync(LOCALES_DIR)) {\n    const stat = fs.statSync(path.join(LOCALES_DIR, name))\n    if (stat.mtimeMs > max) max = stat.mtimeMs\n  }\n  return Math.floor(max)\n}\n\nfunction readCachedMtime () {\n  try {\n    const raw = fs.readFileSync(CACHE_FILE, 'utf8').trim()\n    return parseInt(raw, 10) || 0\n  } catch {\n    return 0\n  }\n}\n\nfunction writeCachedMtime (mtime) {\n  try {\n    fs.writeFileSync(CACHE_FILE, String(mtime))\n  } catch (err) {\n    console.warn('[locale-sync] failed to persist mtime cache:', err.message)\n  }\n}\n\nasync function syncOneLocale (bot, i18n, localeName, enDescriptionLong, enDescriptionShort) {\n  // NAME\n  const name = i18n.t(localeName, 'name')\n  const myName = await bot.telegram.callApi('getMyName', { language_code: localeName })\n  if (myName.name !== name) {\n    try {\n      await bot.telegram.callApi('setMyName', { name, language_code: localeName })\n      console.log('setMyName', localeName)\n    } catch (error) {\n      console.error('setMyName', localeName, error.description)\n    }\n  }\n\n  // LONG DESCRIPTION\n  const myDescription = await bot.telegram.callApi('getMyDescription', { language_code: localeName })\n  const descriptionLong = i18n.t(localeName, 'description.long')\n  const newDescriptionLong = localeName === 'en' || descriptionLong !== enDescriptionLong\n    ? descriptionLong.replace(/[\\r\\n]/gm, '')\n    : ''\n\n  if (newDescriptionLong !== myDescription.description.replace(/[\\r\\n]/gm, '')) {\n    try {\n      const description = newDescriptionLong ? i18n.t(localeName, 'description.long') : ''\n      await bot.telegram.callApi('setMyDescription', { description, language_code: localeName })\n      console.log('setMyDescription', localeName)\n    } catch (error) {\n      console.error('setMyDescription', localeName, error.description)\n    }\n  }\n\n  // SHORT DESCRIPTION\n  const myShortDescription = await bot.telegram.callApi('getMyShortDescription', { language_code: localeName })\n  const descriptionShort = i18n.t(localeName, 'description.short')\n  const newDescriptionShort = localeName === 'en' || descriptionShort !== enDescriptionShort\n    ? descriptionShort.replace(/[\\r\\n]/gm, '')\n    : ''\n\n  if (newDescriptionShort !== myShortDescription.short_description.replace(/[\\r\\n]/gm, '')) {\n    try {\n      const shortDescription = newDescriptionShort ? i18n.t(localeName, 'description.short') : ''\n      await bot.telegram.callApi('setMyShortDescription', { short_description: shortDescription, language_code: localeName })\n      console.log('setMyShortDescription', localeName)\n    } catch (error) {\n      console.error('setMyShortDescription', localeName, error.description)\n    }\n  }\n\n  // PRIVATE COMMANDS\n  // Slim menu — contextual commands (delete/copy/publish/about/privacy) are\n  // available through pack buttons or direct typing, not surfaced in\n  // Telegram's command picker.\n  const privateCommands = [\n    { command: 'start', description: i18n.t(localeName, 'cmd.start.commands.start') },\n    { command: 'packs', description: i18n.t(localeName, 'cmd.start.commands.packs') },\n    { command: 'new', description: i18n.t(localeName, 'cmd.start.commands.new') },\n    { command: 'catalog', description: i18n.t(localeName, 'cmd.start.commands.catalog') },\n    { command: 'clear', description: i18n.t(localeName, 'cmd.start.commands.clear') },\n    { command: 'round', description: i18n.t(localeName, 'cmd.start.commands.round') },\n    { command: 'original', description: i18n.t(localeName, 'cmd.start.commands.original') },\n    { command: 'donate', description: i18n.t(localeName, 'cmd.start.commands.donate') },\n    { command: 'lang', description: i18n.t(localeName, 'cmd.start.commands.lang') }\n  ]\n\n  const myCommandsInPrivate = await bot.telegram.callApi('getMyCommands', {\n    language_code: localeName,\n    scope: JSON.stringify({ type: 'all_private_chats' })\n  })\n\n  let needUpdatePrivate = myCommandsInPrivate.length !== privateCommands.length\n  if (!needUpdatePrivate) {\n    for (const cmd of privateCommands) {\n      const existing = myCommandsInPrivate.find(c => c.command === cmd.command)\n      if (!existing || existing.description !== cmd.description) {\n        needUpdatePrivate = true\n        break\n      }\n    }\n  }\n\n  if (needUpdatePrivate) {\n    await bot.telegram.callApi('setMyCommands', {\n      commands: privateCommands,\n      language_code: localeName,\n      scope: JSON.stringify({ type: 'all_private_chats' })\n    })\n  }\n\n  // GROUP COMMANDS\n  const groupCommands = [\n    { command: 'ss', description: i18n.t(localeName, 'cmd.start.commands.ss') },\n    { command: 'packs', description: i18n.t(localeName, 'cmd.start.commands.packs') }\n  ]\n\n  const myCommandsInGroup = await bot.telegram.callApi('getMyCommands', {\n    language_code: localeName,\n    scope: JSON.stringify({ type: 'all_group_chats' })\n  })\n\n  let needUpdateGroup = myCommandsInGroup.length !== groupCommands.length\n  if (!needUpdateGroup) {\n    for (const cmd of groupCommands) {\n      const existing = myCommandsInGroup.find(c => c.command === cmd.command)\n      if (!existing || existing.description !== cmd.description) {\n        needUpdateGroup = true\n        break\n      }\n    }\n  }\n\n  if (needUpdateGroup) {\n    await bot.telegram.callApi('setMyCommands', {\n      commands: groupCommands,\n      language_code: localeName,\n      scope: JSON.stringify({ type: 'all_group_chats' })\n    })\n  }\n}\n\nmodule.exports = async function syncLocales (bot, i18n) {\n  const currentMtime = computeMaxMtime()\n  const cachedMtime = readCachedMtime()\n\n  if (currentMtime === cachedMtime) {\n    console.log('[locale-sync] locales unchanged since last run — skipping')\n    return\n  }\n\n  console.log('[locale-sync] locales changed, running full sync')\n\n  const locales = fs.readdirSync(LOCALES_DIR)\n  const enDescriptionLong = i18n.t('en', 'description.long')\n  const enDescriptionShort = i18n.t('en', 'description.short')\n\n  const results = await Promise.allSettled(locales.map((locale) => {\n    const localeName = locale.split('.')[0]\n    return syncOneLocale(bot, i18n, localeName, enDescriptionLong, enDescriptionShort)\n  }))\n\n  const failed = results.filter(r => r.status === 'rejected').length\n  if (failed > 0) {\n    console.warn(`[locale-sync] ${failed}/${results.length} locale(s) failed — not caching mtime, will retry next boot`)\n    return\n  }\n\n  writeCachedMtime(currentMtime)\n  console.log('[locale-sync] completed successfully')\n}\n"
  },
  {
    "path": "bot/middleware.js",
    "content": "// All `bot.use(...)` middleware + the privateMessage composer construction.\n// Order matters — preserves the exact chain from the original bot.js.\nconst Composer = require('telegraf/composer')\nconst rateLimit = require('telegraf-ratelimit')\n\nconst { perfStage, perfRecord, perfTick, ENABLED: PERF_TIMING_ENABLED } = require('../utils/perf-timing')\nconst { touchLastSeen } = require('../utils/last-seen')\nconst handleError = require('../handlers/catch')\nconst log = require('../utils/logger').scope('middleware')\n\nconst MAX_CHAIN_ACTIONS = 15\n\n// Polling detach: enabled by default (set POLLING_DETACH=0 to disable).\n// Default ON because we've verified the tradeoffs are covered:\n//   - Errors routed through handleError (same pipeline as bot.catch)\n//   - Heavy work already fire-and-forget at handler level (addSticker)\n//   - Session save-wrap awaits user persist inline\nconst POLLING_DETACH = process.env.POLLING_DETACH !== '0'\n\nmodule.exports = (bot, {\n  i18n,\n  sessionMiddleware,\n  updateUser,\n  updateGroup,\n  stats,\n  retryMiddleware\n}) => {\n  // Detach from Telegraf's batch-await loop.\n  //\n  // Telegraf 3.40's fetchUpdates does:\n  //   handleUpdates(batch).then(() => fetchUpdates())   // next poll\n  // which waits for Promise.all of all handleUpdate(u) in the batch to\n  // resolve before issuing the next getUpdates. Returning a resolved\n  // Promise from the FIRST middleware short-circuits that wait: the\n  // batch Promise.all completes immediately, fetchUpdates re-polls, and\n  // the downstream middleware chain still executes in the background.\n  //\n  // This preserves throughput under rare bursts where any middleware\n  // gets slow. Trade-offs we consciously accept:\n  //   - Telegraf's handlerTimeout (60s) cannot interrupt detached work.\n  //     We don't rely on it — all slow paths are already fire-and-forget\n  //     via Bull queues (convert/removebg) or the sticker-handler IIFE.\n  //   - Two rapid updates from the same user run concurrently, so a\n  //     session SET race is theoretically possible. Session is Redis-\n  //     backed and small; dirty-check cuts writes; last writer wins for\n  //     the rare race. Scene state advances one step at a time via user\n  //     actions spaced >>100ms apart — not observed in practice.\n  //   - Errors don't reach bot.catch. We route them through handleError\n  //     manually so the log channel still gets git blame + stack +\n  //     chainActions.\n  if (POLLING_DETACH) {\n    bot.use((ctx, next) => {\n      next().catch((err) => handleError(err, ctx).catch((e) => {\n        console.error('[polling-detach] handleError itself failed:', e)\n      }))\n      return Promise.resolve()\n    })\n  }\n\n  // i18n\n  bot.use(i18n)\n\n  // Retry 429s at the ctx level (prototype-level patch already handles the\n  // underlying Telegram.callApi; this just exposes ctx.withRetry helper)\n  // AND clears the blocked-chat cache for the current chat_id so a user\n  // who unblocked us can receive replies immediately.\n  bot.use(retryMiddleware())\n\n  // Rate-limit writes to public packs (1 sticker per minute) to prevent\n  // vandalism on shared \"public\" sets.\n  const limitPublicPack = Composer.optional(\n    (ctx) => ctx?.session?.userInfo?.stickerSet?.passcode === 'public',\n    rateLimit({\n      window: 1000 * 60,\n      limit: 1,\n      onLimitExceeded: (ctx) => ctx.reply(ctx.i18n.t('ratelimit'))\n    })\n  )\n\n  // Response-time stats\n  bot.use(stats)\n\n  // Session (in-memory telegraf/session — see bot/session-store.js)\n  bot.use(perfStage('session', sessionMiddleware))\n\n  // Chain-actions logger: records the last N actions per session to help\n  // reproduce error traces. Also prepares answerCbQuery/answerInlineQuery\n  // state arrays so handlers can mutate them and the middleware finalizes.\n  bot.use(async (ctx, next) => {\n    if (ctx.session && !ctx.session.chainActions) ctx.session.chainActions = []\n    let action\n\n    if (ctx.message && ctx.message.text) action = ctx.message.text\n    else if (ctx.callbackQuery) action = ctx.callbackQuery.data\n    else if (ctx.updateType) action = `{${ctx.updateType}} `\n\n    if (ctx.updateSubTypes) action += ` [${ctx.updateSubTypes.join(', ')}]`\n\n    if (!action) action = 'undefined'\n\n    if (ctx.session) {\n      if (ctx.session.chainActions.length > MAX_CHAIN_ACTIONS) ctx.session.chainActions.shift()\n      ctx.session.chainActions.push(action)\n    }\n\n    if (ctx.inlineQuery) ctx.state.answerIQ = []\n    if (ctx.callbackQuery) ctx.state.answerCbQuery = []\n\n    return next(ctx).then(() => {\n      // Auto-answer the callback. Silently swallow failures: with\n      // handlerTimeout=60s, a long-running handler can outlive Telegram's\n      // ~5-10 min callback_query_id TTL. Propagating that would spam the\n      // global error handler with \"query is too old\" noise.\n      if (ctx.callbackQuery) {\n        return ctx.answerCbQuery(...ctx.state.answerCbQuery).catch(() => {})\n      }\n    })\n  })\n\n  // Group chat commands upsert the group record\n  bot.use(Composer.groupChat(Composer.command(updateGroup)))\n\n  // User upsert — hydrates ctx.session.userInfo with a fresh Mongoose doc\n  // from the DB. Runs BEFORE locale auto-switch and banned guard because\n  // those read userInfo; without this ordering they'd see stale\n  // Redis-hydrated plain objects (no save() method, stale flags).\n  bot.use(perfStage('updateUser', async (ctx, next) => {\n    await updateUser(ctx)\n    return next()\n  }))\n\n  // Лагідна українізація — auto-switch ru → uk when Telegram reports uk.\n  // Now runs after updateUser so userInfo is a live Mongoose doc and\n  // its .save() actually fires.\n  bot.use((ctx, next) => {\n    if (\n      ctx?.session?.userInfo?.locale === 'ru' &&\n      ctx.from && ctx.from.language_code === 'uk'\n    ) {\n      ctx.session.userInfo.locale = 'uk'\n      if (typeof ctx.session.userInfo.save === 'function') {\n        ctx.session.userInfo.save().catch(err => log.error('Failed to save user locale:', err.message))\n      }\n      ctx.i18n.locale('uk')\n    }\n    return next()\n  })\n\n  // Banned user guard — runs after updateUser so the flag is fresh.\n  bot.use((ctx, next) => {\n    if (ctx?.session?.userInfo?.banned) {\n      return ctx.replyWithHTML(ctx.i18n.t('error.banned'))\n    }\n    return next()\n  })\n\n  // Persist userInfo after the handler runs. Split from the updateUser\n  // middleware above so locale/banned middlewares can sit between\n  // hydration and handler execution.\n  //\n  // Perf instrumentation is inlined (not via perfStage) because we want\n  // to split the measurement: 'handler' captures the full downstream\n  // next() — i.e. the rest of the middleware chain + handler body —\n  // and 'userSave' captures just the post-next save() duration.\n  // Persist the user doc only if a handler actually modified it. Unmodified\n  // requests just throttle-bump updatedAt via a fire-and-forget updateOne\n  // (see utils/last-seen.js). This turns ~every-update saves into ~once-\n  // per-hour-per-user cheap updates + real saves only on real changes.\n  const persistUserIfDirty = (ctx) => {\n    const user = ctx.session?.userInfo\n    if (!user || typeof user.save !== 'function') return null\n    if (user.isModified && user.isModified()) {\n      return user.save().catch(err => log.error('Failed to save user:', err.message))\n    }\n    // Not dirty — no save, just bump last-seen (throttled, async).\n    touchLastSeen(ctx.db.User, user._id)\n    return null\n  }\n\n  bot.use(async (ctx, next) => {\n    if (!PERF_TIMING_ENABLED) {\n      await next(ctx)\n      const maybeSave = persistUserIfDirty(ctx)\n      if (maybeSave) await maybeSave\n      return\n    }\n    const handlerStart = Date.now()\n    try {\n      try {\n        await next(ctx)\n      } finally {\n        // Wall-clock handler duration — recorded on success and on error\n        // so perf samples reflect real load even when handlers throw.\n        perfRecord('handler', Date.now() - handlerStart)\n      }\n      // Persist only on normal completion (preserves original behavior:\n      // don't write userInfo after a handler error).\n      const saveStart = Date.now()\n      const maybeSave = persistUserIfDirty(ctx)\n      try {\n        if (maybeSave) await maybeSave\n      } finally {\n        perfRecord('userSave', Date.now() - saveStart)\n      }\n    } finally {\n      // perfTick fires regardless of handler outcome so log cadence stays\n      // stable under error load.\n      perfTick()\n    }\n  })\n\n  // my_chat_member updates are noisy — ignore them after user-update above\n  // (which handles the blocked-flag flip).\n  bot.use((ctx, next) => {\n    if (ctx.update.my_chat_member) return false\n    return next()\n  })\n\n  // privateMessage composer — only runs for 1:1 chats\n  const privateMessage = new Composer()\n  privateMessage.use((ctx, next) => {\n    if (ctx.chat && ctx.chat.type === 'private') return next()\n    return false\n  })\n\n  return { privateMessage, limitPublicPack }\n}\n"
  },
  {
    "path": "bot/preflight.js",
    "content": "// Preflight checks — verify env + connectivity before the bot starts\n// accepting updates. Fast-fail with a clear message instead of starting\n// a half-broken bot that shows up as PM2-alive but mysteriously silent.\n//\n// Each check returns { ok, name, detail } so the caller can decide\n// whether to abort or proceed (some checks are advisory).\n\nconst defaultLog = require('../utils/logger').scope('preflight')\n\nconst requireBotToken = () => {\n  const token = process.env.BOT_TOKEN\n  if (!token) {\n    return { ok: false, name: 'BOT_TOKEN', detail: 'env var is empty or unset' }\n  }\n  // Format: <bot_id>:<35-char alphanumeric+_-> — bot id is numeric.\n  if (!/^\\d+:[A-Za-z0-9_-]{30,}$/.test(token)) {\n    return { ok: false, name: 'BOT_TOKEN', detail: 'malformed (expected `<digits>:<35+ chars>`)' }\n  }\n  return { ok: true, name: 'BOT_TOKEN' }\n}\n\nconst requireMongoUri = () => {\n  const uri = process.env.MONGODB_URI\n  if (!uri) {\n    return { ok: false, name: 'MONGODB_URI', detail: 'env var is empty or unset' }\n  }\n  if (!/^mongodb(\\+srv)?:\\/\\//.test(uri)) {\n    return { ok: false, name: 'MONGODB_URI', detail: 'must start with mongodb:// or mongodb+srv://' }\n  }\n  return { ok: true, name: 'MONGODB_URI' }\n}\n\n// Wait for a Mongoose connection's first `open` event with a timeout.\n// Without this, a misconfigured MONGODB_URI leaves the bot hanging\n// indefinitely with no progress past \"Connecting…\".\nconst waitForMongo = (connection, timeoutMs = 30_000) => new Promise((resolve) => {\n  if (connection.readyState === 1) {\n    return resolve({ ok: true, name: 'mongo' })\n  }\n  const timer = setTimeout(() => {\n    connection.removeListener('open', onOpen)\n    resolve({ ok: false, name: 'mongo', detail: `did not open within ${timeoutMs}ms — check MONGODB_URI and that the cluster is reachable` })\n  }, timeoutMs)\n  const onOpen = () => {\n    clearTimeout(timer)\n    resolve({ ok: true, name: 'mongo' })\n  }\n  connection.once('open', onOpen)\n})\n\n// Verify the bot token actually works by hitting Telegram getMe.\n// 401 → bad token, network errors → infrastructure issue. Both should\n// surface immediately, not 30 seconds into a polling loop.\nconst pingTelegram = async (bot) => {\n  try {\n    const me = await bot.telegram.getMe()\n    return { ok: true, name: 'telegram', detail: `@${me.username} (id=${me.id})` }\n  } catch (err) {\n    return {\n      ok: false,\n      name: 'telegram',\n      detail: err?.description || err?.message || String(err)\n    }\n  }\n}\n\n// Run all checks; abort process if any required check fails.\nconst runPreflight = async ({ bot, dbConnection, log = defaultLog }) => {\n  const checks = []\n\n  // Required env validations — synchronous, run first so we don't spend\n  // 30s waiting for Mongo only to fail on a missing token afterwards.\n  checks.push(requireBotToken())\n  checks.push(requireMongoUri())\n\n  // Async connectivity probes only run if env passes.\n  if (checks.every((c) => c.ok)) {\n    const [mongo, telegram] = await Promise.all([\n      waitForMongo(dbConnection),\n      pingTelegram(bot)\n    ])\n    checks.push(mongo, telegram)\n  }\n\n  for (const check of checks) {\n    if (check.ok) {\n      log.info(`✓ ${check.name}${check.detail ? ` — ${check.detail}` : ''}`)\n    } else {\n      log.error(`✗ ${check.name} — ${check.detail}`)\n    }\n  }\n\n  const failed = checks.filter((c) => !c.ok)\n  if (failed.length > 0) {\n    log.error(`${failed.length} check(s) failed — aborting startup`)\n    process.exit(1)\n  }\n}\n\nmodule.exports = {\n  runPreflight,\n  requireBotToken,\n  requireMongoUri,\n  waitForMongo,\n  pingTelegram\n}\n"
  },
  {
    "path": "bot/session-store.js",
    "content": "// In-memory Telegraf session.\n//\n// For a single-process bot (PM2, 6h restarts) Redis sessions were net\n// negative: free-tier latency spikes, +1 network write per update, extra\n// failure surface. Scenes are short-lived — losing state across a restart\n// is the same UX as the bot briefly going offline.\n//\n// Redis is still used for multi-process state that genuinely needs\n// persistence (broadcast campaigns — see utils/messaging.js).\nconst session = require('telegraf/session')\n\nconst SESSION_TTL_SECONDS = 60 * 60 // 1 hour — telegraf checks expires on read\n\nfunction getSessionKey (ctx) {\n  if ((ctx.from && ctx.chat && ctx.chat.id === ctx.from.id) || (!ctx.chat && ctx.from)) {\n    return `user:${ctx.from.id}`\n  }\n  if (ctx.from && ctx.chat) {\n    return `${ctx.from.id}:${ctx.chat.id}`\n  }\n  return undefined\n}\n\n// telegraf/session stores `{ session, expires }`; the default `new Map()`\n// never evicts. Wrap it so idle keys get collected and the Map doesn't\n// grow unbounded over a long-running process.\nconst MEM_MAX = 50000\nconst MEM_SWEEP_MS = 5 * 60 * 1000\n\nfunction createMemoryStore () {\n  const data = new Map()\n  const interval = setInterval(() => {\n    const now = Date.now()\n    for (const [key, entry] of data) {\n      if (entry && entry.expires && entry.expires < now) data.delete(key)\n    }\n    if (data.size > MEM_MAX) {\n      // Drop oldest entries by insertion order until under limit.\n      const excess = data.size - MEM_MAX\n      let i = 0\n      for (const key of data.keys()) {\n        if (i++ >= excess) break\n        data.delete(key)\n      }\n    }\n  }, MEM_SWEEP_MS)\n  if (interval.unref) interval.unref()\n  return data\n}\n\nconst store = createMemoryStore()\n\nfunction sessionMiddleware () {\n  return session({ store, getSessionKey, ttl: SESSION_TTL_SECONDS })\n}\n\nmodule.exports = {\n  sessionMiddleware,\n  getSessionKey\n}\n"
  },
  {
    "path": "bot.js",
    "content": "// Entrypoint — thin orchestrator. The old 681-line monolith was split into\n// focused modules under bot/:\n//   - bot/session-store.js  in-memory telegraf/session with bounded Map\n//   - bot/middleware.js     all bot.use(...) middleware\n//   - bot/commands.js       all commands / actions / hears registrations\n//   - bot/locale-sync.js    mtime-cached locale push to Telegram\n//   - bot/launch.js         webhook vs polling, allowedUpdates\nconst fs = require('fs')\nconst path = require('path')\nconst Telegraf = require('telegraf')\nconst I18n = require('telegraf-i18n')\n\nconst { db } = require('./database')\nconst handlers = require('./handlers')\nconst scenes = require('./scenes')\nconst {\n  updateUser,\n  updateGroup,\n  stats,\n  updateMonitor,\n  retryMiddleware\n} = require('./utils')\n\nconst { sessionMiddleware } = require('./bot/session-store')\nconst registerMiddleware = require('./bot/middleware')\nconst registerCommands = require('./bot/commands')\nconst launch = require('./bot/launch')\nconst syncLocales = require('./bot/locale-sync')\nconst { runPreflight } = require('./bot/preflight')\nconst log = require('./utils/logger').scope('bot')\n\nglobal.startDate = new Date()\n\n// Was 1000ms — aborted any handler that touched Bull or a slow Telegram call.\n// 60s is generous but bounded; PM2 will kill the process on true hangs.\nconst HANDLER_TIMEOUT_MS = 60_000\nconst MONITOR_INTERVAL_MS = 25 * 1000\n\nconst bot = new Telegraf(process.env.BOT_TOKEN, {\n  telegram: { webhookReply: false },\n  handlerTimeout: HANDLER_TIMEOUT_MS\n})\n\nbot.catch(handlers.handleError)\n\nbot.context.config = require('./config.json')\nbot.context.db = db\n\nconst i18n = new I18n({\n  directory: path.resolve(__dirname, 'locales'),\n  defaultLanguage: 'en',\n  defaultLanguageOnMissing: true\n})\n\n// Cached at startup — privacy policy is static HTML.\nconst privacyHtml = fs.readFileSync(path.resolve(__dirname, 'privacy.html'), 'utf-8')\n\nconst { privateMessage, limitPublicPack } = registerMiddleware(bot, {\n  i18n,\n  sessionMiddleware: sessionMiddleware(),\n  updateUser,\n  updateGroup,\n  stats,\n  retryMiddleware\n})\n\nregisterCommands(bot, privateMessage, {\n  handlers,\n  limitPublicPack,\n  privacyHtml,\n  db,\n  scenes\n})\n\n// Preflight runs the gauntlet before we accept any updates: validates\n// env vars, waits for Mongo with a hard timeout, and pings Telegram\n// getMe to verify the token. Any failure aborts with exit(1) so PM2\n// surfaces the problem immediately instead of restarting a silent bot.\n;(async () => {\n  await runPreflight({ bot, dbConnection: db.connection })\n\n  await launch(bot)\n\n  // Don't block startup on the locale sync — it's eventually consistent.\n  syncLocales(bot, i18n).catch((err) => log.error('[locale-sync] failed:', err.message))\n\n  // Side-effect import: starts messaging queue polling\n  require('./utils/messaging')\n\n  const monitorInterval = setInterval(() => updateMonitor(), MONITOR_INTERVAL_MS)\n  if (monitorInterval.unref) monitorInterval.unref()\n})().catch((err) => {\n  log.error('Startup failed:', err?.stack || err)\n  process.exit(1)\n})\n\n// Graceful shutdown — PM2 sends SIGTERM before killing\nconst gracefulShutdown = (signal) => {\n  log.info(`${signal} received, shutting down gracefully…`)\n  bot.stop(signal)\n  process.exit(0)\n}\nprocess.on('SIGTERM', gracefulShutdown)\nprocess.on('SIGINT', gracefulShutdown)\n\n// Postmortem logging for crashes. We don't suppress the default Node\n// behavior (it exits the process), we just make sure the cause is in\n// the log channel before PM2 restarts us. Without these, all we'd see\n// in PM2 logs is \"process exited\" with no stack trace.\nprocess.on('unhandledRejection', (reason) => {\n  log.error('Unhandled rejection:', reason instanceof Error ? reason.stack : reason)\n  // Re-throw so Node's default termination kicks in — promise state may\n  // be inconsistent, restart is safer than continuing on corrupted state.\n  // Use setImmediate so the error bubbles to uncaughtException with full\n  // context, not swallowed by the rejection handler chain.\n  setImmediate(() => { throw reason })\n})\n\nprocess.on('uncaughtException', (err, origin) => {\n  log.error(`Uncaught exception (origin=${origin}):`, err?.stack || err)\n  // Don't try to clean up — state is unknown. PM2 will restart us.\n  process.exit(1)\n})\n"
  },
  {
    "path": "config.example.json",
    "content": "{\n  \"mainAdminId\": 66478514,\n  \"logChatId\": -1001665705393,\n  \"stickerLinkPrefix\": \"t.me/addstickers/\",\n  \"emojiLinkPrefix\": \"t.me/addemoji/\",\n  \"charTitleMax\": 35,\n  \"premiumCharTitleMax\": 64,\n  \"messaging\": {\n    \"limit\": {\n      \"max\": 20,\n      \"duration\": 1500\n    }\n  }\n}\n"
  },
  {
    "path": "crowdin.yml",
    "content": "files:\n  - source: /locales/en.yaml\n    translation: /locales/%two_letters_code%.yaml\n"
  },
  {
    "path": "database/connection.js",
    "content": "const mongoose = require('mongoose')\n\n// Визначаємо чи це SRV URI (mongodb+srv://)\nconst isSrvUri = (uri) => uri && uri.startsWith('mongodb+srv://')\n\n// Основне з'єднання.\n// Pool sized for burst recovery: after a PM2 restart with ~300 pending\n// updates, the bot processes them concurrently. Each update does ~4 Mongo\n// ops (updateUser: findOne + 2 populates + user.save). With pool=10 that\n// queued 120+ deep per connection, forcing each query to wait ~600-1300ms.\n// Pool=50 keeps the burst queue ≤20 deep so each query waits <100ms.\n// Memory cost is trivial (~1MB per connection client-side).\nconst mainUri = process.env.MONGODB_URI\nconst connection = mongoose.createConnection(mainUri, {\n  ...(isSrvUri(mainUri) ? {} : { directConnection: true }),\n  autoIndex: false,\n  maxPoolSize: parseInt(process.env.MONGO_POOL_SIZE, 10) || 50,\n  minPoolSize: parseInt(process.env.MONGO_POOL_MIN, 10) || 10,\n  serverSelectionTimeoutMS: 5000,\n  socketTimeoutMS: 30000,\n  retryWrites: true,\n  retryReads: true\n})\n\nconnection.on('error', error => {\n  console.error('MongoDB connection error:', error)\n})\n\nconnection.on('disconnected', () => {\n  console.warn('MongoDB disconnected')\n})\n\nconnection.on('reconnected', () => {\n  console.log('MongoDB reconnected')\n})\n\n// Atlas з'єднання (для аналітики/top-sets)\nconst atlasUri = process.env.ATLAS_MONGODB_URI || process.env.MONGODB_URI\nconst atlasConnection = mongoose.createConnection(atlasUri, {\n  ...(isSrvUri(atlasUri) ? {} : { directConnection: true }),\n  maxPoolSize: 5,\n  minPoolSize: 1,\n  serverSelectionTimeoutMS: 5000,\n  socketTimeoutMS: 30000,\n  retryWrites: true,\n  retryReads: true\n})\n\natlasConnection.on('error', error => {\n  console.error('Atlas MongoDB error:', error)\n})\n\nmodule.exports = {\n  connection,\n  atlasConnection\n}\n"
  },
  {
    "path": "database/index.js",
    "content": "const collections = require('./models')\nconst {\n  connection,\n  atlasConnection\n} = require('./connection')\n\nconst db = {\n  connection\n}\n\nObject.keys(collections).forEach((collectionName) => {\n  db[collectionName] = connection.model(collectionName, collections[collectionName])\n})\n\nconst atlasDb = {\n  connection: atlasConnection\n}\n\nObject.keys(collections).forEach((collectionName) => {\n  atlasDb[collectionName] = atlasConnection.model(collectionName, collections[collectionName])\n})\n\n// Truncate string to max length\nconst truncate = (str, maxLength) => {\n  if (!str) return null\n  return str.length > maxLength ? str.slice(0, maxLength) : str\n}\n\ndb.User.getData = async (tgUser) => {\n  let telegramId\n\n  if (tgUser.telegram_id) telegramId = tgUser.telegram_id\n  else telegramId = tgUser.id\n\n  // Optimized: single populate call with select for only needed fields\n  let user = await db.User.findOne({ telegram_id: telegramId })\n    .populate({\n      path: 'stickerSet',\n      select: '_id name title packType inline create emojiSuffix frameType boost hide owner passcode'\n    })\n    .populate({\n      path: 'inlineStickerSet',\n      select: '_id name title inline'\n    })\n\n  if (!user) {\n    user = new db.User()\n    user.telegram_id = tgUser.id\n  }\n\n  return user\n}\n\ndb.User.updateData = async (tgUser) => {\n  const user = await db.User.getData(tgUser)\n\n  // Coerce missing/empty Telegram fields to '' once — same reason as\n  // utils/user-update.js: deleted/deactivated accounts can omit\n  // first_name, and we don't want undefined sneaking into the DB.\n  user.first_name = tgUser.first_name || ''\n  user.last_name = tgUser.last_name || ''\n  user.username = tgUser.username\n  user.updatedAt = new Date()\n  await user.save()\n\n  return user\n}\n\ndb.StickerSet.newSet = async (stickerSetInfo) => {\n  const oldStickerSet = await db.StickerSet.findOneAndDelete({ name: stickerSetInfo.name })\n\n  if (oldStickerSet) {\n    await db.Sticker.updateMany(\n      { stickerSet: oldStickerSet._id },\n      { $set: { deleted: true, deletedAt: new Date() } }\n    )\n  }\n\n  const stickerSet = new db.StickerSet()\n\n  stickerSet.owner = stickerSetInfo.owner\n  stickerSet.ownerTelegramId = stickerSetInfo.ownerTelegramId\n  stickerSet.name = stickerSetInfo.name\n  stickerSet.title = stickerSetInfo.title\n  stickerSet.inline = stickerSetInfo.inline || false\n  stickerSet.packType = stickerSetInfo.packType || 'regular'\n  stickerSet.emojiSuffix = stickerSetInfo.emojiSuffix\n  stickerSet.create = stickerSetInfo.create || false\n  stickerSet.boost = stickerSetInfo.boost || false\n  await stickerSet.save()\n\n  // Increment user's pack count\n  if (stickerSetInfo.owner && stickerSetInfo.create) {\n    const countField = stickerSetInfo.inline\n      ? 'packsCount.inline'\n      : `packsCount.${stickerSetInfo.packType || 'regular'}`\n    await db.User.updateOne(\n      { _id: stickerSetInfo.owner },\n      { $inc: { [countField]: 1 } }\n    )\n  }\n\n  return stickerSet\n}\n\ndb.StickerSet.getSet = async (stickerSetInfo) => {\n  let stickerSet = await db.StickerSet.findOne({ name: stickerSetInfo.name })\n\n  if (!stickerSet) {\n    stickerSet = await db.StickerSet.newSet(stickerSetInfo)\n  }\n\n  return stickerSet\n}\n\n/**\n * Add a new sticker to the database\n * Uses optimized flat structure for new documents (backwards-compatible)\n *\n * @param {ObjectId|string} stickerSet - The sticker set ID\n * @param {string|string[]} emojisText - Emoji(s) associated with the sticker\n * @param {Object} info - Current sticker info from Telegram API\n * @param {Object} [originalFile] - Original file data (if different from current)\n * @returns {Promise<Document>} The created sticker document\n */\ndb.Sticker.addSticker = async (stickerSet, emojisText = '', info, originalFile = null) => {\n  if (!info || !info.file_unique_id) {\n    throw new Error('Sticker info with file_unique_id is required')\n  }\n\n  const emojis = Array.isArray(emojisText)\n    ? emojisText.join(' ')\n    : truncate(emojisText, 150)\n\n  const stickerData = {\n    stickerSet,\n    fileUniqueId: info.file_unique_id,\n    emojis,\n\n    // New flat fields (optimized storage)\n    fileId: info.file_id,\n    stickerType: info.stickerType || null,\n    caption: truncate(info.caption, 150)\n  }\n\n  // Store original only if provided AND different from current\n  if (originalFile && originalFile.file_id && originalFile.file_id !== info.file_id) {\n    stickerData.original = {\n      fileId: originalFile.file_id,\n      fileUniqueId: originalFile.file_unique_id,\n      stickerType: originalFile.stickerType || null\n    }\n  }\n\n  const sticker = new db.Sticker(stickerData)\n  await sticker.save()\n\n  return sticker\n}\n\nmodule.exports = {\n  db,\n  atlasDb\n}\n"
  },
  {
    "path": "database/models/deeplink.js",
    "content": "const mongoose = require('mongoose')\n\nconst deeplinkSchema = mongoose.Schema({\n  user: {\n    type: mongoose.Schema.Types.ObjectId,\n    ref: 'User'\n  },\n  deepLink: {\n    type: String\n  }\n}, {\n  timestamps: true\n})\n\n// Compound index for findOne({ deepLink, user }) queries\n// deepLink first as it's more selective\ndeeplinkSchema.index({ deepLink: 1, user: 1 })\n\nmodule.exports = deeplinkSchema\n"
  },
  {
    "path": "database/models/group.js",
    "content": "const mongoose = require('mongoose')\n\nconst groupSchema = mongoose.Schema({\n  telegram_id: {\n    type: Number,\n    index: true,\n    unique: true,\n    required: true\n  },\n  title: String,\n  username: String,\n  memberCount: Number,\n  stickerSet: {\n    type: mongoose.Schema.Types.ObjectId,\n    ref: 'StickerSet',\n    index: true\n  },\n  settings: {\n    rights: {\n      add: {\n        type: String,\n        default: 'all'\n      },\n      delete: {\n        type: String,\n        default: 'all'\n      }\n    }\n  }\n}, {\n  timestamps: true\n})\n\nmodule.exports = groupSchema\n"
  },
  {
    "path": "database/models/index.js",
    "content": "const User = require('./user')\nconst Group = require('./group')\nconst Sticker = require('./sticker')\nconst StickerSet = require('./sticker-set')\nconst Messaging = require('./messaging')\nconst Payment = require('./payment')\nconst DeepLink = require('./deeplink')\n\nmodule.exports = {\n  User,\n  Group,\n  Sticker,\n  StickerSet,\n  Messaging,\n  Payment,\n  DeepLink\n}\n"
  },
  {
    "path": "database/models/messaging.js",
    "content": "const mongoose = require('mongoose')\n\nconst schema = mongoose.Schema({\n  creator: {\n    type: mongoose.Schema.Types.ObjectId,\n    ref: 'User'\n  },\n  name: String,\n  message: {\n    type: { type: String },\n    data: Object\n  },\n  sendErrors: Array,\n  status: {\n    type: Number,\n    default: 0\n  },\n  editStatus: {\n    type: Number,\n    default: 0\n  },\n  result: {\n    total: {\n      type: Number,\n      default: 0\n    },\n    state: {\n      type: Number,\n      default: 0\n    },\n    error: {\n      type: Number,\n      default: 0\n    }\n  },\n  date: Date\n}, {\n  timestamps: true\n})\n\n// Indexes for queue processing queries\nschema.index({ status: 1, date: 1 })\nschema.index({ editStatus: 1, date: 1 })\nschema.index({ creator: 1 })\nschema.index({ createdAt: -1 })\n\nmodule.exports = schema\n"
  },
  {
    "path": "database/models/payment.js",
    "content": "const mongoose = require('mongoose')\n\nconst paymentsSchema = mongoose.Schema({\n  user: {\n    type: mongoose.Schema.Types.ObjectId,\n    ref: 'User',\n    index: true\n  },\n  amount: {\n    type: Number\n    // Note: index removed - no queries filter by amount alone\n  },\n  price: {\n    type: Number\n    // Note: index removed - no queries filter by price alone\n  },\n  currency: {\n    type: String\n    // Note: index removed - no queries filter by currency alone\n  },\n  paymentSystem: {\n    type: String\n    // Note: index removed - no queries filter by paymentSystem alone\n  },\n  paymentId: {\n    type: String\n    // Note: index removed - queries use resultData.telegram_payment_charge_id instead\n  },\n  status: {\n    type: String,\n    index: true\n  },\n  resultData: {\n    type: Object\n  }\n}, {\n  timestamps: true\n})\n\n// Index for admin refund lookups by Telegram charge ID\npaymentsSchema.index({ 'resultData.telegram_payment_charge_id': 1 })\n\nmodule.exports = paymentsSchema\n"
  },
  {
    "path": "database/models/sticker-set.js",
    "content": "const mongoose = require('mongoose')\n\nconst stickerSetsSchema = mongoose.Schema({\n  owner: {\n    type: mongoose.Schema.Types.ObjectId,\n    ref: 'User'\n    // Note: No separate index needed - covered by compound indexes below\n  },\n  ownerTelegramId: {\n    type: Number,\n    index: true\n  },\n  passcode: {\n    type: String,\n    index: true\n  },\n  name: {\n    type: String,\n    unique: true,\n    required: true\n  },\n  title: {\n    type: String,\n    required: true\n  },\n  inline: {\n    type: Boolean,\n    default: false\n  },\n  packType: {\n    type: String,\n    default: 'regular'\n  },\n  boost: {\n    type: Boolean,\n    default: false\n  },\n  frameType: String,\n  emojiSuffix: String,\n  create: {\n    type: Boolean,\n    default: false\n  },\n  thirdParty: {\n    type: Boolean,\n    default: false\n  },\n  hide: {\n    type: Boolean,\n    default: false\n  },\n  deleted: {\n    type: Boolean,\n    default: false\n  },\n  public: {\n    type: Boolean,\n    default: false\n  },\n  publishDate: {\n    type: Date\n  },\n  about: {\n    description: String,\n    tags: [String],\n    languages: [String],\n    safe: {\n      type: Boolean,\n      default: false\n    },\n    verified: {\n      type: Boolean,\n      default: false\n    }\n  },\n  reaction: {\n    like: {\n      type: Number,\n      default: 0\n    },\n    dislike: {\n      type: Number,\n      default: 0\n    },\n    total: {\n      type: Number,\n      default: 0\n    }\n  },\n  installations: {\n    day: {\n      type: Number,\n      default: 0\n    },\n    week: {\n      type: Number,\n      default: 0\n    },\n    month: {\n      type: Number,\n      default: 0\n    },\n    total: {\n      type: Number,\n      default: 0\n    }\n  },\n  moderated: {\n    type: Boolean,\n    default: false\n  },\n  aiModeration: {\n    checked: {\n      type: Boolean,\n      default: false\n    },\n    isFlagged: {\n      type: Boolean,\n      default: false\n    },\n    categoryScores: {\n      type: Object\n    }\n  },\n  stickerChannel: {\n    messageId: Number\n  }\n}, {\n  timestamps: true\n})\n\n// Compound indexes for /packs query performance\n// Covers: find({ owner, create, hide, inline/packType }).sort({ updatedAt: -1 })\nstickerSetsSchema.index({ owner: 1, create: 1, hide: 1, inline: 1, packType: 1, updatedAt: -1 })\n// For inline queries: find({ owner, inline }).sort({ updatedAt: -1 })\nstickerSetsSchema.index({ owner: 1, inline: 1, updatedAt: -1 })\n// Note: { owner: 1, hide: 1 } removed - covered by the main compound index above\n\nmodule.exports = stickerSetsSchema\n"
  },
  {
    "path": "database/models/sticker.js",
    "content": "const mongoose = require('mongoose')\n\n// NOTE on schema coexistence:\n// The collection holds ~488M docs, of which ~94% still use the nested\n// info.* / file.* shape from 2019-2022. A bulk rewrite is not viable\n// at that scale (~weeks of writes on a single-node setup), so the\n// legacy shape is treated as a FIRST-CLASS format, not tech debt.\n// Every read path uses $or against both shapes; getter methods below\n// normalize reads transparently. Writes go to the new shape only.\n// See scripts/README.md for the full rationale.\nconst stickersSchema = mongoose.Schema({\n  stickerSet: {\n    type: mongoose.Schema.Types.ObjectId,\n    ref: 'StickerSet'\n    // Note: No separate index - covered by compound { stickerSet: 1, deleted: 1 } below\n  },\n  fileUniqueId: {\n    type: String,\n    index: true,\n    required: true\n  },\n  emojis: String,\n\n  // NEW: Flat fields (used for new documents)\n  fileId: String,\n  stickerType: String,\n  caption: String,\n\n  // NEW: Original file data (only if different from current)\n  original: {\n    fileId: String,\n    fileUniqueId: String,\n    stickerType: String\n  },\n\n  // LEGACY: Keep for backwards compatibility (old documents)\n  info: {\n    stickerType: String,\n    file_id: String,\n    file_unique_id: String,\n    caption: String\n  },\n  file: {\n    stickerType: String,\n    file_id: String,\n    file_unique_id: String\n  },\n\n  deleted: {\n    type: Boolean,\n    default: false\n  },\n\n  // NEW: For TTL auto-cleanup\n  deletedAt: {\n    type: Date,\n    default: null\n  }\n}, {\n  timestamps: true\n})\n\n// ===================\n// GETTER METHODS\n// Read from new format OR fallback to legacy\n// ===================\n\nstickersSchema.methods.getFileId = function () {\n  return this.fileId || (this.info && this.info.file_id)\n}\n\nstickersSchema.methods.getStickerType = function () {\n  return this.stickerType || (this.info && this.info.stickerType) || 'sticker'\n}\n\nstickersSchema.methods.getCaption = function () {\n  return this.caption || (this.info && this.info.caption)\n}\n\nstickersSchema.methods.getOriginalFileId = function () {\n  return (this.original && this.original.fileId) || (this.file && this.file.file_id)\n}\n\nstickersSchema.methods.getOriginalFileUniqueId = function () {\n  return (this.original && this.original.fileUniqueId) || (this.file && this.file.file_unique_id)\n}\n\nstickersSchema.methods.hasOriginal = function () {\n  return !!((this.original && this.original.fileId) || (this.file && this.file.file_id))\n}\n\nstickersSchema.methods.getOriginalStickerType = function () {\n  return (this.original && this.original.stickerType) || (this.file && this.file.stickerType)\n}\n\n// ===================\n// INDEXES\n// ===================\n\n// Text index for search (supports both old and new caption fields)\nstickersSchema.index({ caption: 'text', 'info.caption': 'text' })\n\n// Compound index for inline queries (stickerSet + deleted)\nstickersSchema.index({ stickerSet: 1, deleted: 1 })\n\n// Single field index - highly selective, covers most lookups\nstickersSchema.index({ fileUniqueId: 1 })\n\n// Index for duplicate detection on original files\nstickersSchema.index({ 'original.fileUniqueId': 1 }, { sparse: true })\n\n// TTL index - auto-delete documents 30 days after deletedAt is set\n// Note: Created manually in MongoDB, not via Mongoose to avoid recreation issues\n// db.stickers.createIndex({ deletedAt: 1 }, { expireAfterSeconds: 2592000, partialFilterExpression: { deletedAt: { $type: \"date\" } } })\n// stickersSchema.index(\n//   { deletedAt: 1 },\n//   {\n//     expireAfterSeconds: 30 * 24 * 60 * 60, // 30 days\n//     partialFilterExpression: { deletedAt: { $type: 'date' } }\n//   }\n// )\n\nmodule.exports = stickersSchema\n"
  },
  {
    "path": "database/models/user.js",
    "content": "const mongoose = require('mongoose')\n\nconst userSchema = mongoose.Schema({\n  telegram_id: {\n    type: Number,\n    index: true,\n    unique: true,\n    required: true\n  },\n  // Display-only field, never load-bearing. Telegram User.first_name is\n  // formally required by Bot API, but in practice it can arrive empty or\n  // missing (deleted/deactivated accounts, rare anonymous-sender edges).\n  // Mongoose String `required: true` rejects empty strings too, which\n  // would crash persistUserIfDirty on those updates — so we keep the\n  // field optional and let renderers handle the empty case.\n  first_name: String,\n  last_name: String,\n  username: String,\n  stickerSet: {\n    type: mongoose.Schema.Types.ObjectId,\n    ref: 'StickerSet',\n    index: true\n  },\n  inlineStickerSet: {\n    type: mongoose.Schema.Types.ObjectId,\n    ref: 'StickerSet',\n    index: true\n  },\n  inlineType: {\n    type: String\n  },\n  newsSubscribedDate: {\n    type: Date\n  },\n  balance: {\n    type: Number,\n    default: 0\n  },\n  locale: {\n    type: String\n    // Note: No separate index - covered by compound { locale: 1, blocked: 1 } below\n  },\n  blocked: {\n    type: Boolean,\n    default: false,\n    index: true\n  },\n  adminRights: {\n    type: Array,\n    default: []\n  },\n  webapp: {\n    country: String,\n    platform: String,\n    browser: String,\n    version: String,\n    os: String\n  },\n  moderator: {\n    type: Boolean,\n    default: false\n  },\n  banned: {\n    type: Boolean,\n    default: false\n  },\n  publicBan: {\n    type: Boolean,\n    default: false\n  },\n  packsCount: {\n    regular: { type: Number, default: 0 },\n    custom_emoji: { type: Number, default: 0 },\n    inline: { type: Number, default: 0 }\n  }\n}, {\n  timestamps: true\n})\n\n// Compound index for messaging queries\nuserSchema.index({ locale: 1, blocked: 1 })\n\nmodule.exports = userSchema\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n\n  mongo:\n    image: mongo\n    restart: always\n    volumes:\n      - mongo-data:/data/db\n    healthcheck:\n      test: [\"CMD\", \"mongosh\", \"--eval\", \"db.adminCommand('ping')\"]\n      interval: 30s\n      timeout: 10s\n      retries: 3\n      start_period: 20s\n\n  redis:\n    image: redis:7-alpine\n    restart: always\n    ports:\n      - \"6379:6379\"\n    healthcheck:\n      test: [\"CMD\", \"redis-cli\", \"ping\"]\n      interval: 10s\n      timeout: 5s\n      retries: 3\n\n  fstikbot:\n    build: .\n    depends_on:\n      mongo:\n        condition: service_healthy\n      redis:\n        condition: service_healthy\n    restart: always\n    env_file:\n    - .env\n\nvolumes:\n  mongo-data:\n"
  },
  {
    "path": "docs/superpowers/plans/2026-04-05-emoji-mosaic.md",
    "content": "# Emoji Mosaic Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Add mosaic mode that splits photos into custom emoji grids, uploads them to the user's pack, and sends a copyable mosaic message in chat.\n\n**Architecture:** New Telegraf scene (`mosaic`) with a looping flow: wait for photo → show grid preview → split & upload → send mosaic → repeat. Three utility modules handle grid math, preview generation, and image splitting. Integrates with existing pack menu via a new button for custom_emoji packs.\n\n**Tech Stack:** Telegraf v3 scenes, Sharp (image processing), MongoDB/Mongoose (sticker count queries), Telegram Bot API (uploadStickerFile, addStickerToSet, sendMessage with custom_emoji entities)\n\n**Spec:** `docs/superpowers/specs/2026-04-05-emoji-mosaic-design.md`\n\n---\n\n## File Map\n\n| File | Action | Responsibility |\n|------|--------|----------------|\n| `utils/mosaic-grid.js` | Create | Grid recommendation algorithm (aspect ratio → rows/cols suggestions) |\n| `utils/mosaic-preview.js` | Create | Sharp: generate preview image with dashed grid overlay |\n| `utils/mosaic-split.js` | Create | Sharp: split image into NxM cells, each 100×100 WEBP |\n| `scenes/mosaic.js` | Create | Scene with looping flow: waitPhoto → waitGrid → upload → result → loop |\n| `scenes/index.js` | Modify | Register mosaic scene in Stage |\n| `handlers/packs.js` | Modify | Add \"Mosaic\" button for custom_emoji packs |\n| `locales/en.yaml` | Modify | English strings for mosaic feature |\n| `locales/uk.yaml` | Modify | Ukrainian strings for mosaic feature |\n\n---\n\n### Task 1: Grid Recommendation Algorithm (`utils/mosaic-grid.js`)\n\n**Files:**\n- Create: `utils/mosaic-grid.js`\n\nThis is a pure function with no external dependencies — good starting point.\n\n- [ ] **Step 1: Create `utils/mosaic-grid.js` with `getGridSuggestions`**\n\n```javascript\nconst getGridSuggestions = (width, height, freeSlots = 200) => {\n  const ratio = width / height\n\n  // Determine type\n  if (ratio >= 2.5) return getStripSuggestions(ratio, 'horizontal', freeSlots)\n  if (ratio <= 0.4) return getStripSuggestions(1 / ratio, 'vertical', freeSlots)\n  return getGridOptions(ratio, freeSlots)\n}\n\nconst getStripSuggestions = (ratio, direction, freeSlots) => {\n  const count = Math.max(3, Math.min(10, Math.round(ratio)))\n  const isHorizontal = direction === 'horizontal'\n\n  const options = []\n  for (let delta = -2; delta <= 2; delta++) {\n    const n = count + delta\n    if (n < 3 || n > 10 || n > freeSlots) continue\n    const rows = isHorizontal ? 1 : n\n    const cols = isHorizontal ? n : 1\n    options.push({ rows, cols, total: n })\n  }\n\n  if (options.length === 0) return { type: 'no_space', options: [] }\n\n  const recommended = options.find(o => o.total === count) || options[Math.floor(options.length / 2)]\n  const alternatives = options.filter(o => o !== recommended).slice(0, 3)\n\n  return { type: 'strip', recommended, alternatives }\n}\n\nconst getGridOptions = (ratio, freeSlots) => {\n  const candidates = []\n\n  for (let rows = 2; rows <= 10; rows++) {\n    for (let cols = 2; cols <= 10; cols++) {\n      const total = rows * cols\n      if (total > 50 || total > freeSlots) continue\n\n      const gridRatio = cols / rows\n      const ratioScore = Math.abs(gridRatio - ratio) / ratio\n      // How close each cell is to square (1:1)\n      const cellRatio = (ratio / gridRatio)\n      const squareScore = Math.abs(1 - cellRatio)\n      // Prefer medium-sized grids\n      const sizeScore = Math.abs(total - 12) / 50\n\n      const score = ratioScore * 2 + squareScore + sizeScore * 0.5\n      candidates.push({ rows, cols, total, score })\n    }\n  }\n\n  if (candidates.length === 0) return { type: 'no_space', options: [] }\n\n  candidates.sort((a, b) => a.score - b.score)\n\n  const recommended = candidates[0]\n  // Pick alternatives: one smaller, one medium, one larger than recommended\n  const smaller = candidates.find(c => c.total < recommended.total && c !== recommended)\n  const larger = candidates.find(c => c.total > recommended.total && c !== recommended)\n  const largest = candidates.find(c => c.total > (larger?.total || 0) && c !== recommended && c !== larger)\n\n  const alternatives = [smaller, larger, largest].filter(Boolean).slice(0, 3)\n\n  return { type: 'grid', recommended, alternatives }\n}\n\nmodule.exports = { getGridSuggestions }\n```\n\n- [ ] **Step 2: Manual test with node REPL**\n\nRun: `cd /Users/ly/dev/fStikBot && node -e \"const {getGridSuggestions} = require('./utils/mosaic-grid'); console.log(JSON.stringify(getGridSuggestions(1200, 800), null, 2)); console.log(JSON.stringify(getGridSuggestions(2000, 400), null, 2)); console.log(JSON.stringify(getGridSuggestions(800, 800), null, 2)); console.log(JSON.stringify(getGridSuggestions(600, 1200), null, 2))\"`\n\nExpected:\n- 1200×800 (3:2 landscape) → type: \"grid\", recommended ~3×4 or 2×3\n- 2000×400 (5:1 panorama) → type: \"strip\", recommended 1×5\n- 800×800 (square) → type: \"grid\", recommended ~3×3\n- 600×1200 (1:2 portrait) → type: \"grid\", recommended ~4×2 or similar\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add utils/mosaic-grid.js\ngit commit -m \"feat(mosaic): add grid recommendation algorithm\"\n```\n\n---\n\n### Task 2: Preview Generation (`utils/mosaic-preview.js`)\n\n**Files:**\n- Create: `utils/mosaic-preview.js`\n\n**Depends on:** Nothing (standalone Sharp utility)\n\n- [ ] **Step 1: Create `utils/mosaic-preview.js`**\n\n```javascript\nconst sharp = require('sharp')\n\nconst generatePreview = async (imageBuffer, rows, cols) => {\n  const image = sharp(imageBuffer, {\n    failOnError: false,\n    limitInputPixels: false\n  })\n\n  const metadata = await image.metadata()\n\n  // Resize to max 512px on longest side for preview\n  const scale = Math.min(512 / metadata.width, 512 / metadata.height, 1)\n  const previewWidth = Math.round(metadata.width * scale)\n  const previewHeight = Math.round(metadata.height * scale)\n\n  // Use floor-based coordinates to match actual split boundaries\n  // (same math as splitImage uses on the original)\n  const cellW = Math.floor(previewWidth / cols)\n  const cellH = Math.floor(previewHeight / rows)\n  // Crop preview to exact grid area (discard remainder pixels)\n  const cropWidth = cellW * cols\n  const cropHeight = cellH * rows\n\n  const strokeWidth = 2\n  const lines = []\n\n  // Vertical lines (at floor-based cell boundaries)\n  for (let c = 1; c < cols; c++) {\n    const x = c * cellW\n    lines.push(`<line x1=\"${x}\" y1=\"0\" x2=\"${x}\" y2=\"${cropHeight}\" stroke=\"white\" stroke-width=\"${strokeWidth}\" stroke-dasharray=\"8,6\" stroke-opacity=\"0.85\"/>`)\n    lines.push(`<line x1=\"${x}\" y1=\"0\" x2=\"${x}\" y2=\"${cropHeight}\" stroke=\"black\" stroke-width=\"${strokeWidth}\" stroke-dasharray=\"8,6\" stroke-dashoffset=\"8\" stroke-opacity=\"0.4\"/>`)\n  }\n\n  // Horizontal lines (at floor-based cell boundaries)\n  for (let r = 1; r < rows; r++) {\n    const y = r * cellH\n    lines.push(`<line x1=\"0\" y1=\"${y}\" x2=\"${cropWidth}\" y2=\"${y}\" stroke=\"white\" stroke-width=\"${strokeWidth}\" stroke-dasharray=\"8,6\" stroke-opacity=\"0.85\"/>`)\n    lines.push(`<line x1=\"0\" y1=\"${y}\" x2=\"${cropWidth}\" y2=\"${y}\" stroke=\"black\" stroke-width=\"${strokeWidth}\" stroke-dasharray=\"8,6\" stroke-dashoffset=\"8\" stroke-opacity=\"0.4\"/>`)\n  }\n\n  // Grid size label in center\n  const label = `${rows}×${cols}`\n  const fontSize = Math.max(24, Math.round(previewWidth / 10))\n  lines.push(`<rect x=\"${previewWidth / 2 - fontSize * 1.5}\" y=\"${previewHeight / 2 - fontSize * 0.7}\" width=\"${fontSize * 3}\" height=\"${fontSize * 1.4}\" rx=\"8\" fill=\"rgba(0,0,0,0.6)\"/>`)\n  lines.push(`<text x=\"${previewWidth / 2}\" y=\"${previewHeight / 2 + fontSize * 0.3}\" text-anchor=\"middle\" font-size=\"${fontSize}\" font-family=\"Arial,sans-serif\" font-weight=\"bold\" fill=\"white\">${label}</text>`)\n\n  const svgOverlay = Buffer.from(\n    `<svg width=\"${cropWidth}\" height=\"${cropHeight}\">${lines.join('')}</svg>`\n  )\n\n  const result = await image\n    .clone()\n    .resize(previewWidth, previewHeight, { fit: 'fill' })\n    .extract({ left: 0, top: 0, width: cropWidth, height: cropHeight })\n    .composite([{ input: svgOverlay, top: 0, left: 0 }])\n    .webp({ quality: 80 })\n    .toBuffer()\n\n  return result\n}\n\nmodule.exports = { generatePreview }\n```\n\n- [ ] **Step 2: Manual test — generate preview and save to disk**\n\nRun: `cd /Users/ly/dev/fStikBot && node -e \"\nconst sharp = require('sharp');\nconst { generatePreview } = require('./utils/mosaic-preview');\n// Create a test image 600x400\nsharp({ create: { width: 600, height: 400, channels: 3, background: { r: 100, g: 150, b: 200 } } })\n  .jpeg().toBuffer()\n  .then(buf => generatePreview(buf, 3, 4))\n  .then(result => { require('fs').writeFileSync('/tmp/mosaic-preview-test.webp', result); console.log('Preview saved to /tmp/mosaic-preview-test.webp, size:', result.length); })\n  .catch(err => console.error(err))\n\"`\n\nExpected: File created at `/tmp/mosaic-preview-test.webp`, viewable, shows a 3×4 grid overlay.\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add utils/mosaic-preview.js\ngit commit -m \"feat(mosaic): add preview generation with grid overlay\"\n```\n\n---\n\n### Task 3: Image Splitting (`utils/mosaic-split.js`)\n\n**Files:**\n- Create: `utils/mosaic-split.js`\n\n**Depends on:** Nothing (standalone Sharp utility)\n\n- [ ] **Step 1: Create `utils/mosaic-split.js`**\n\n```javascript\nconst sharp = require('sharp')\n\nconst splitImage = async (imageBuffer, rows, cols) => {\n  const image = sharp(imageBuffer, {\n    failOnError: false,\n    limitInputPixels: false\n  })\n\n  const metadata = await image.metadata()\n  const cellWidth = Math.floor(metadata.width / cols)\n  const cellHeight = Math.floor(metadata.height / rows)\n\n  const cells = []\n\n  for (let r = 0; r < rows; r++) {\n    for (let c = 0; c < cols; c++) {\n      const cell = await image\n        .clone()\n        .extract({\n          left: c * cellWidth,\n          top: r * cellHeight,\n          width: cellWidth,\n          height: cellHeight\n        })\n        .resize(100, 100, { fit: 'fill' })\n        .webp({ quality: 90 })\n        .toBuffer()\n\n      cells.push(cell)\n    }\n  }\n\n  return cells\n}\n\nconst checkMinCellSize = (width, height, rows, cols) => {\n  const cellWidth = Math.floor(width / cols)\n  const cellHeight = Math.floor(height / rows)\n  return cellWidth >= 80 && cellHeight >= 80\n}\n\nmodule.exports = { splitImage, checkMinCellSize }\n```\n\n- [ ] **Step 2: Manual test — split a test image**\n\nRun: `cd /Users/ly/dev/fStikBot && node -e \"\nconst sharp = require('sharp');\nconst { splitImage, checkMinCellSize } = require('./utils/mosaic-split');\nsharp({ create: { width: 600, height: 400, channels: 3, background: { r: 100, g: 150, b: 200 } } })\n  .jpeg().toBuffer()\n  .then(buf => splitImage(buf, 3, 4))\n  .then(cells => {\n    console.log('Cells count:', cells.length);\n    console.log('First cell size:', cells[0].length, 'bytes');\n    return sharp(cells[0]).metadata();\n  })\n  .then(meta => console.log('Cell dimensions:', meta.width, 'x', meta.height, meta.format))\n  .catch(err => console.error(err));\nconsole.log('Min cell check 600x400 3x4:', checkMinCellSize(600, 400, 3, 4));\nconsole.log('Min cell check 150x200 3x4:', checkMinCellSize(150, 200, 3, 4));\n\"`\n\nExpected:\n- 12 cells (3×4)\n- Each cell: 100×100 webp\n- `checkMinCellSize(600,400,3,4)` → true (150×133)\n- `checkMinCellSize(150,200,3,4)` → false (37×66)\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add utils/mosaic-split.js\ngit commit -m \"feat(mosaic): add image splitting utility\"\n```\n\n---\n\n### Task 4: Locale Strings\n\n**Files:**\n- Modify: `locales/en.yaml`\n- Modify: `locales/uk.yaml`\n\n**Depends on:** Nothing\n\n- [ ] **Step 1: Add English locale strings to `locales/en.yaml`**\n\nAdd at the end of the file:\n\n```yaml\n  mosaic:\n    enter: |\n      🔲 Mosaic mode for <b>${packTitle}</b>\n\n      Send a photo to split into custom emoji grid.\n    no_pack: |\n      You need a custom emoji pack first.\n      Use /new to create one and select \"Custom Emoji\" type.\n    choose_grid: |\n      📐 Choose grid size:\n    btn:\n      recommended: \"✅ ${rows}×${cols}\"\n      option: \"${rows}×${cols} · ${total}pcs\"\n      custom: \"✏️ Custom size\"\n      cancel: \"❌ Cancel\"\n      exit: \"🚪 Exit mosaic\"\n      undo: \"🗑 Remove this mosaic\"\n    custom_prompt: |\n      Enter grid size (e.g. 3x4):\n    custom_invalid: |\n      Invalid format. Use e.g. 3x4 (rows from 1 to 10, cols from 1 to 10, max 50 total).\n    no_space: |\n      Not enough space in pack. ${freeSlots} slots left, but ${total} needed.\n      Choose a smaller grid or create a new pack with /new.\n    blurry_warning: |\n      ⚠️ Source image is small — result may be blurry at this grid size.\n    uploading: \"⏳ Uploading ${current}/${total}...\"\n    done: |\n      ✅ Mosaic ${rows}×${cols} added to pack!\n    done_link: \"📦 Use pack\"\n    undo_done: |\n      🗑 Mosaic removed (${count} emoji deleted from pack).\n    undo_failed: |\n      ❌ Could not remove some emoji. Try deleting manually.\n    wait_photo: |\n      Send another photo or tap Exit.\n```\n\n- [ ] **Step 2: Add Ukrainian locale strings to `locales/uk.yaml`**\n\nAdd at the end of the file:\n\n```yaml\n  mosaic:\n    enter: |\n      🔲 Режим мозаїки для <b>${packTitle}</b>\n\n      Надішліть фото для розрізання на сітку емодзі.\n    no_pack: |\n      Спочатку потрібен пак кастомних емодзі.\n      Використайте /new і оберіть тип \"Custom Emoji\".\n    choose_grid: |\n      📐 Оберіть розмір сітки:\n    btn:\n      recommended: \"✅ ${rows}×${cols}\"\n      option: \"${rows}×${cols} · ${total}шт\"\n      custom: \"✏️ Свій розмір\"\n      cancel: \"❌ Скасувати\"\n      exit: \"🚪 Вийти з мозаїки\"\n      undo: \"🗑 Видалити цю мозаїку\"\n    custom_prompt: |\n      Введіть розмір сітки (напр. 3x4):\n    custom_invalid: |\n      Невірний формат. Наприклад 3x4 (рядки від 1 до 10, стовпці від 1 до 10, макс 50 всього).\n    no_space: |\n      Недостатньо місця в паку. Вільно ${freeSlots} слотів, потрібно ${total}.\n      Оберіть меншу сітку або створіть новий пак через /new.\n    blurry_warning: |\n      ⚠️ Зображення замале — результат може бути розмитим при цьому розмірі сітки.\n    uploading: \"⏳ Завантаження ${current}/${total}...\"\n    done: |\n      ✅ Мозаїка ${rows}×${cols} додана в пак!\n    done_link: \"📦 Використати пак\"\n    undo_done: |\n      🗑 Мозаїку видалено (${count} емодзі видалено з пака).\n    undo_failed: |\n      ❌ Не вдалося видалити деякі емодзі. Спробуйте вручну.\n    wait_photo: |\n      Надішліть інше фото або натисніть Вийти.\n```\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add locales/en.yaml locales/uk.yaml\ngit commit -m \"feat(mosaic): add en/uk locale strings\"\n```\n\n---\n\n### Task 5: Mosaic Scene (`scenes/mosaic.js`)\n\n**Files:**\n- Create: `scenes/mosaic.js`\n\n**Depends on:** Tasks 1-4\n\nThis is the core file. It wires together grid suggestions, preview, splitting, upload, and mosaic message.\n\n- [ ] **Step 1: Create `scenes/mosaic.js` — scene setup and enter handler**\n\n```javascript\nconst Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst { getGridSuggestions } = require('../utils/mosaic-grid')\nconst { generatePreview } = require('../utils/mosaic-preview')\nconst { splitImage, checkMinCellSize } = require('../utils/mosaic-split')\n\nconst https = require('https')\n\nconst mosaic = new Scene('mosaic')\n\n// Helper: download file buffer from Telegram\nconst downloadFile = (fileUrl, timeout = 30000) => new Promise((resolve, reject) => {\n  const data = []\n  let totalSize = 0\n  const MAX_SIZE = 20 * 1024 * 1024\n\n  const req = https.get(fileUrl, (response) => {\n    if (response.statusCode !== 200) {\n      req.destroy()\n      reject(new Error(`Download failed: ${response.statusCode}`))\n      return\n    }\n    response.on('data', (chunk) => {\n      totalSize += chunk.length\n      if (totalSize > MAX_SIZE) {\n        req.destroy()\n        reject(new Error('File too large'))\n        return\n      }\n      data.push(chunk)\n    })\n    response.on('end', () => resolve(Buffer.concat(data)))\n  })\n  req.on('error', reject)\n  req.setTimeout(timeout, () => { req.destroy(); reject(new Error('Timeout')) })\n})\n\n// Helper: build inline keyboard for grid selection\nconst buildGridKeyboard = (ctx, suggestions) => {\n  const { recommended, alternatives } = suggestions\n  const buttons = []\n\n  // Row 1: recommended\n  buttons.push([\n    Markup.callbackButton(\n      ctx.i18n.t('cmd.mosaic.btn.recommended', { rows: recommended.rows, cols: recommended.cols }),\n      `mosaic:grid:${recommended.rows}:${recommended.cols}`\n    )\n  ])\n\n  // Row 2: alternatives\n  if (alternatives.length > 0) {\n    buttons.push(alternatives.map(alt =>\n      Markup.callbackButton(\n        ctx.i18n.t('cmd.mosaic.btn.option', { rows: alt.rows, cols: alt.cols, total: alt.total }),\n        `mosaic:grid:${alt.rows}:${alt.cols}`\n      )\n    ))\n  }\n\n  // Row 3: custom + cancel\n  buttons.push([\n    Markup.callbackButton(ctx.i18n.t('cmd.mosaic.btn.custom'), 'mosaic:custom'),\n    Markup.callbackButton(ctx.i18n.t('cmd.mosaic.btn.cancel'), 'mosaic:cancel')\n  ])\n\n  // Row 4: exit\n  buttons.push([\n    Markup.callbackButton(ctx.i18n.t('cmd.mosaic.btn.exit'), 'mosaic:exit')\n  ])\n\n  return Markup.inlineKeyboard(buttons)\n}\n\nmosaic.enter(async (ctx) => {\n  if (!ctx.session.scene) ctx.session.scene = {}\n  ctx.session.scene.mosaic = {}\n\n  // Check if user has a custom_emoji pack selected\n  const userInfo = ctx.session.userInfo\n  if (!userInfo || !userInfo.stickerSet) {\n    await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_pack'))\n    return ctx.scene.leave()\n  }\n\n  const stickerSet = await ctx.db.StickerSet.findById(userInfo.stickerSet)\n  if (!stickerSet || stickerSet.packType !== 'custom_emoji') {\n    await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_pack'))\n    return ctx.scene.leave()\n  }\n\n  ctx.session.scene.mosaic.packId = stickerSet.id\n  ctx.session.scene.mosaic.packName = stickerSet.name\n\n  await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.enter', {\n    packTitle: stickerSet.title\n  }), {\n    reply_markup: Markup.keyboard([\n      [{ text: ctx.i18n.t('cmd.mosaic.btn.exit') }]\n    ]).resize()\n  })\n})\n\nmodule.exports = mosaic\n```\n\n- [ ] **Step 2: Add photo handler — generate preview and show grid options**\n\nAppend to `scenes/mosaic.js` before `module.exports`:\n\n```javascript\nmosaic.on('photo', async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n\n  // Block new photos while uploading\n  if (ctx.session.scene.mosaic.uploading) {\n    return ctx.replyWithHTML('⏳ Please wait, upload in progress...')\n  }\n\n  const photo = ctx.message.photo\n  const largest = photo[photo.length - 1]\n\n  // Download the photo\n  const fileUrl = await ctx.telegram.getFileLink(largest.file_id)\n  const imageBuffer = await downloadFile(fileUrl.href || fileUrl)\n\n  const width = largest.width\n  const height = largest.height\n\n  // Count existing stickers in pack\n  const stickerSet = await ctx.db.StickerSet.findById(ctx.session.scene.mosaic.packId)\n  const currentCount = await ctx.db.Sticker.countDocuments({\n    stickerSet: stickerSet.id,\n    deleted: false\n  })\n  const freeSlots = 200 - currentCount\n\n  const suggestions = getGridSuggestions(width, height, freeSlots)\n\n  if (suggestions.type === 'no_space') {\n    await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_space', { freeSlots, total: 4 }))\n    return\n  }\n\n  // Store in scene state\n  ctx.session.scene.mosaic.photoFileId = largest.file_id\n  ctx.session.scene.mosaic.photoWidth = width\n  ctx.session.scene.mosaic.photoHeight = height\n  ctx.session.scene.mosaic.imageBuffer = null // Don't store buffer in session\n  ctx.session.scene.mosaic.freeSlots = freeSlots\n\n  // Generate preview with recommended grid\n  const { recommended } = suggestions\n  const previewBuffer = await generatePreview(imageBuffer, recommended.rows, recommended.cols)\n\n  // Check for blurry warning\n  const isBlurry = !checkMinCellSize(width, height, recommended.rows, recommended.cols)\n  const blurryText = isBlurry ? '\\n' + ctx.i18n.t('cmd.mosaic.blurry_warning') : ''\n\n  const msg = await ctx.replyWithPhoto(\n    { source: previewBuffer },\n    {\n      caption: ctx.i18n.t('cmd.mosaic.choose_grid') + blurryText,\n      parse_mode: 'HTML',\n      reply_markup: buildGridKeyboard(ctx, suggestions)\n    }\n  )\n\n  ctx.session.scene.mosaic.previewMessageId = msg.message_id\n})\n```\n\n- [ ] **Step 3: Add grid selection callback — split, upload, send mosaic**\n\nAppend to `scenes/mosaic.js` before `module.exports`:\n\n```javascript\nmosaic.action(/^mosaic:grid:(\\d+):(\\d+)$/, async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n\n  const rows = parseInt(ctx.match[1])\n  const cols = parseInt(ctx.match[2])\n  const total = rows * cols\n  const state = ctx.session.scene.mosaic\n\n  await ctx.answerCbQuery()\n\n  // Validate space\n  if (total > state.freeSlots) {\n    return ctx.answerCbQuery(ctx.i18n.t('cmd.mosaic.no_space', {\n      freeSlots: state.freeSlots, total\n    }), true)\n  }\n\n  // Download photo again (not stored in session)\n  const fileUrl = await ctx.telegram.getFileLink(state.photoFileId)\n  const imageBuffer = await downloadFile(fileUrl.href || fileUrl)\n\n  // Check min cell size\n  const isBlurry = !checkMinCellSize(state.photoWidth, state.photoHeight, rows, cols)\n\n  // Send progress message\n  const progressMsg = await ctx.replyWithHTML(\n    ctx.i18n.t('cmd.mosaic.uploading', { current: 0, total })\n  )\n\n  // Split image\n  const cells = await splitImage(imageBuffer, rows, cols)\n\n  // Upload all cells to the pack\n  const stickerSet = await ctx.db.StickerSet.findById(state.packId)\n  const uploadedIds = []\n  const uploadedFileIds = []\n\n  for (let i = 0; i < cells.length; i++) {\n    const r = Math.floor(i / cols) + 1\n    const c = (i % cols) + 1\n\n    // Upload sticker file\n    const uploaded = await ctx.telegram.callApi('uploadStickerFile', {\n      user_id: ctx.from.id,\n      sticker_format: 'static',\n      sticker: { source: cells[i] }\n    })\n\n    // Add to set\n    await ctx.telegram.callApi('addStickerToSet', {\n      user_id: ctx.from.id,\n      name: stickerSet.name,\n      sticker: {\n        sticker: uploaded.file_id,\n        format: 'static',\n        emoji_list: ['🔲'],\n        keywords: ['mosaic', `r${r}c${c}`]\n      }\n    })\n\n    // Get the sticker info to find custom_emoji_id\n    const setInfo = await ctx.telegram.callApi('getStickerSet', { name: stickerSet.name })\n    const lastSticker = setInfo.stickers[setInfo.stickers.length - 1]\n    uploadedIds.push(lastSticker.custom_emoji_id)\n    uploadedFileIds.push(lastSticker.file_id)\n\n    // Save sticker to DB\n    await ctx.db.Sticker.addSticker(stickerSet.id, '🔲', {\n      file_id: lastSticker.file_id,\n      file_unique_id: lastSticker.file_unique_id,\n      stickerType: 'custom_emoji'\n    })\n\n    // Update progress every 3 uploads\n    if ((i + 1) % 3 === 0 || i === cells.length - 1) {\n      await ctx.telegram.editMessageText(\n        ctx.chat.id,\n        progressMsg.message_id,\n        null,\n        ctx.i18n.t('cmd.mosaic.uploading', { current: i + 1, total })\n      ).catch(() => {})\n      await ctx.telegram.callApi('sendChatAction', {\n        chat_id: ctx.chat.id,\n        action: 'upload_document'\n      }).catch(() => {})\n    }\n  }\n\n  // Delete progress message\n  await ctx.telegram.deleteMessage(ctx.chat.id, progressMsg.message_id).catch(() => {})\n\n  // Build mosaic message with custom_emoji entities\n  const placeholder = '\\u2B1C' // ⬜ white square as placeholder\n  let text = ''\n  const entities = []\n\n  for (let r = 0; r < rows; r++) {\n    for (let c = 0; c < cols; c++) {\n      const idx = r * cols + c\n      const offset = text.length\n      text += placeholder\n      entities.push({\n        type: 'custom_emoji',\n        offset,\n        length: placeholder.length,\n        custom_emoji_id: uploadedIds[idx]\n      })\n    }\n    if (r < rows - 1) text += '\\n'\n  }\n\n  // Add pack link\n  const packLink = `${ctx.config.emojiLinkPrefix}${stickerSet.name}`\n  text += '\\n\\n'\n  const linkOffset = text.length\n  text += ctx.i18n.t('cmd.mosaic.done', { rows, cols })\n\n  await ctx.telegram.callApi('sendMessage', {\n    chat_id: ctx.chat.id,\n    text,\n    entities,\n    reply_markup: Markup.inlineKeyboard([\n      [Markup.urlButton(ctx.i18n.t('cmd.mosaic.done_link'), packLink)],\n      [Markup.callbackButton(ctx.i18n.t('cmd.mosaic.btn.undo'), 'mosaic:undo')]\n    ])\n  })\n\n  // Store uploaded file IDs for undo\n  state.lastMosaicIds = uploadedFileIds\n  state.lastMosaicCount = total\n\n  // Ready for next photo\n  await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.wait_photo'))\n})\n```\n\n- [ ] **Step 4: Add custom size handler**\n\nAppend to `scenes/mosaic.js` before `module.exports`:\n\n```javascript\nmosaic.action('mosaic:custom', async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n  await ctx.answerCbQuery()\n  ctx.session.scene.mosaic.waitingCustom = true\n  await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.custom_prompt'))\n})\n\nmosaic.on('text', async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n\n  // Only handle text if waiting for custom input\n  if (!ctx.session.scene.mosaic.waitingCustom) return\n\n  const text = ctx.message.text.trim()\n\n  // Flexible parsing: 3x4, 3×4, 3*4, 3:4, 3 на 4\n  const match = text.match(/^(\\d+)\\s*[x×*:]\\s*(\\d+)$/i) ||\n                text.match(/^(\\d+)\\s+(?:на|by|on)\\s+(\\d+)$/i)\n\n  if (!match) {\n    return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.custom_invalid'))\n  }\n\n  const rows = parseInt(match[1])\n  const cols = parseInt(match[2])\n  const total = rows * cols\n\n  if (rows < 1 || rows > 10 || cols < 1 || cols > 10 || total > 50) {\n    return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.custom_invalid'))\n  }\n\n  const state = ctx.session.scene.mosaic\n  if (total > state.freeSlots) {\n    return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_space', {\n      freeSlots: state.freeSlots, total\n    }))\n  }\n\n  state.waitingCustom = false\n\n  // Trigger the same logic as grid callback\n  // Simulate the action by calling the handler logic directly\n  ctx.match = [null, String(rows), String(cols)]\n  return mosaic.middleware()[0]  // This won't work — we need a different approach\n})\n```\n\nActually, extract the split-upload-send logic into a shared function. **Revise Step 3 and Step 4:**\n\nReplace the grid action handler and custom text handler with a shared `processMosaic` function. The full revised code for steps 3+4:\n\n```javascript\n// Retry helper with exponential backoff\nconst retry = async (fn, maxRetries = 3) => {\n  for (let attempt = 0; attempt <= maxRetries; attempt++) {\n    try {\n      return await fn()\n    } catch (err) {\n      if (attempt === maxRetries) throw err\n      const delay = Math.pow(2, attempt) * 1000 // 1s, 2s, 4s\n      await new Promise(resolve => setTimeout(resolve, delay))\n    }\n  }\n}\n\n// Colored square fallbacks for variety in emoji search\nconst FALLBACK_EMOJI = ['🟥', '🟧', '🟨', '🟩', '🟦', '🟪', '🟫', '⬛', '⬜', '🔲']\n\n// Shared function: split, upload, send mosaic\nconst processMosaic = async (ctx, rows, cols) => {\n  const state = ctx.session.scene.mosaic\n  const total = rows * cols\n\n  // Lock: prevent concurrent processing\n  if (state.uploading) {\n    await ctx.replyWithHTML('⏳ Please wait, upload in progress...')\n    return\n  }\n  state.uploading = true\n\n  try {\n    // Download photo again\n    const fileUrl = await ctx.telegram.getFileLink(state.photoFileId)\n    const imageBuffer = await downloadFile(fileUrl.href || fileUrl)\n\n    // Send progress message\n    const progressMsg = await ctx.replyWithHTML(\n      ctx.i18n.t('cmd.mosaic.uploading', { current: 0, total })\n    )\n\n    // Split image\n    const cells = await splitImage(imageBuffer, rows, cols)\n\n    // Upload all cells to the pack\n    const stickerSet = await ctx.db.StickerSet.findById(state.packId)\n    const uploadedIds = []\n    const uploadedFileIds = []\n\n    for (let i = 0; i < cells.length; i++) {\n      const r = Math.floor(i / cols) + 1\n      const c = (i % cols) + 1\n      const fallbackEmoji = FALLBACK_EMOJI[i % FALLBACK_EMOJI.length]\n\n      try {\n        const uploaded = await retry(() =>\n          ctx.telegram.callApi('uploadStickerFile', {\n            user_id: ctx.from.id,\n            sticker_format: 'static',\n            sticker: { source: cells[i] }\n          })\n        )\n\n        await retry(() =>\n          ctx.telegram.callApi('addStickerToSet', {\n            user_id: ctx.from.id,\n            name: stickerSet.name,\n            sticker: {\n              sticker: uploaded.file_id,\n              format: 'static',\n              emoji_list: [fallbackEmoji],\n              keywords: ['mosaic', `r${r}c${c}`]\n            }\n          })\n        )\n\n        const setInfo = await ctx.telegram.callApi('getStickerSet', { name: stickerSet.name })\n        const lastSticker = setInfo.stickers[setInfo.stickers.length - 1]\n        uploadedIds.push(lastSticker.custom_emoji_id)\n        uploadedFileIds.push(lastSticker.file_id)\n\n        await ctx.db.Sticker.addSticker(stickerSet.id, fallbackEmoji, {\n          file_id: lastSticker.file_id,\n          file_unique_id: lastSticker.file_unique_id,\n          stickerType: 'custom_emoji'\n        })\n      } catch (err) {\n        // Upload failed after retries — rollback all uploaded stickers\n        for (const fileId of uploadedFileIds) {\n          await ctx.telegram.callApi('deleteStickerFromSet', { sticker: fileId }).catch(() => {})\n          await ctx.db.Sticker.updateOne(\n            { fileId, stickerSet: stickerSet.id },\n            { $set: { deleted: true, deletedAt: new Date() } }\n          ).catch(() => {})\n        }\n        await ctx.telegram.deleteMessage(ctx.chat.id, progressMsg.message_id).catch(() => {})\n        await ctx.replyWithHTML(`❌ Upload failed at piece ${i + 1}/${total}. All uploaded pieces rolled back. Try again.`)\n        return\n      }\n\n      if ((i + 1) % 3 === 0 || i === cells.length - 1) {\n        await ctx.telegram.editMessageText(\n          ctx.chat.id, progressMsg.message_id, null,\n          ctx.i18n.t('cmd.mosaic.uploading', { current: i + 1, total })\n        ).catch(() => {})\n        await ctx.telegram.callApi('sendChatAction', {\n          chat_id: ctx.chat.id, action: 'choose_sticker'\n        }).catch(() => {})\n      }\n    }\n\n    await ctx.telegram.deleteMessage(ctx.chat.id, progressMsg.message_id).catch(() => {})\n\n  // Build mosaic message\n  const placeholder = '\\u2B1C'\n  let text = ''\n  const entities = []\n\n  for (let r = 0; r < rows; r++) {\n    for (let c = 0; c < cols; c++) {\n      const idx = r * cols + c\n      const offset = text.length\n      text += placeholder\n      entities.push({\n        type: 'custom_emoji',\n        offset,\n        length: placeholder.length,\n        custom_emoji_id: uploadedIds[idx]\n      })\n    }\n    if (r < rows - 1) text += '\\n'\n  }\n\n  const packLink = `${ctx.config.emojiLinkPrefix}${stickerSet.name}`\n  text += '\\n\\n' + ctx.i18n.t('cmd.mosaic.done', { rows, cols })\n\n  await ctx.telegram.callApi('sendMessage', {\n    chat_id: ctx.chat.id,\n    text,\n    entities,\n    reply_markup: Markup.inlineKeyboard([\n      [Markup.urlButton(ctx.i18n.t('cmd.mosaic.done_link'), packLink)],\n      [Markup.callbackButton(ctx.i18n.t('cmd.mosaic.btn.undo'), 'mosaic:undo')]\n    ])\n  })\n\n  state.lastMosaicIds = uploadedFileIds\n  state.lastMosaicCount = total\n  state.waitingCustom = false\n\n  await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.wait_photo'))\n  } finally {\n    state.uploading = false\n  }\n}\n\n// Grid selection callback\nmosaic.action(/^mosaic:grid:(\\d+):(\\d+)$/, async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n  await ctx.answerCbQuery()\n\n  const rows = parseInt(ctx.match[1])\n  const cols = parseInt(ctx.match[2])\n  const total = rows * cols\n  const state = ctx.session.scene.mosaic\n\n  if (total > state.freeSlots) {\n    return ctx.answerCbQuery(ctx.i18n.t('cmd.mosaic.no_space', {\n      freeSlots: state.freeSlots, total\n    }), true)\n  }\n\n  return processMosaic(ctx, rows, cols)\n})\n\n// Custom size: prompt\nmosaic.action('mosaic:custom', async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n  await ctx.answerCbQuery()\n  ctx.session.scene.mosaic.waitingCustom = true\n  await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.custom_prompt'))\n})\n\n// Custom size: parse text input\nmosaic.on('text', async (ctx) => {\n  if (!ctx.session.scene?.mosaic?.waitingCustom) return\n\n  const text = ctx.message.text.trim()\n  const match = text.match(/^(\\d+)\\s*[x×*:]\\s*(\\d+)$/i) ||\n                text.match(/^(\\d+)\\s+(?:на|by|on)\\s+(\\d+)$/i)\n\n  if (!match) {\n    return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.custom_invalid'))\n  }\n\n  const rows = parseInt(match[1])\n  const cols = parseInt(match[2])\n  const total = rows * cols\n\n  if (rows < 1 || rows > 10 || cols < 1 || cols > 10 || total > 50) {\n    return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.custom_invalid'))\n  }\n\n  const state = ctx.session.scene.mosaic\n  if (total > state.freeSlots) {\n    return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_space', {\n      freeSlots: state.freeSlots, total\n    }))\n  }\n\n  return processMosaic(ctx, rows, cols)\n})\n```\n\n- [ ] **Step 5: Add cancel, undo, and exit handlers**\n\nAppend to `scenes/mosaic.js` before `module.exports`:\n\n```javascript\n// Cancel current photo\nmosaic.action('mosaic:cancel', async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n  await ctx.answerCbQuery()\n  ctx.session.scene.mosaic.photoFileId = null\n  ctx.session.scene.mosaic.waitingCustom = false\n  await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.wait_photo'))\n})\n\n// Undo: remove last mosaic from pack\nmosaic.action('mosaic:undo', async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n  await ctx.answerCbQuery()\n\n  const state = ctx.session.scene.mosaic\n  if (!state.lastMosaicIds || state.lastMosaicIds.length === 0) {\n    return ctx.answerCbQuery('Nothing to undo', true)\n  }\n\n  let deleted = 0\n  for (const fileId of state.lastMosaicIds) {\n    try {\n      await ctx.telegram.callApi('deleteStickerFromSet', { sticker: fileId })\n      await ctx.db.Sticker.updateOne(\n        { fileId, stickerSet: state.packId },\n        { $set: { deleted: true, deletedAt: new Date() } }\n      )\n      deleted++\n    } catch (e) {\n      // Sticker may already be deleted\n    }\n  }\n\n  state.lastMosaicIds = []\n\n  if (deleted > 0) {\n    await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.undo_done', { count: deleted }))\n  } else {\n    await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.undo_failed'))\n  }\n})\n\n// Exit scene\nmosaic.action('mosaic:exit', async (ctx) => {\n  await ctx.answerCbQuery()\n  delete ctx.session.scene.mosaic\n  await ctx.scene.leave()\n})\n\nmosaic.hears(/🚪/, async (ctx) => {\n  delete ctx.session.scene.mosaic\n  await ctx.scene.leave()\n})\n```\n\n- [ ] **Step 6: Test scene loads without errors**\n\nRun: `cd /Users/ly/dev/fStikBot && node -e \"const mosaic = require('./scenes/mosaic'); console.log('Scene name:', mosaic.id); console.log('Type:', typeof mosaic.middleware)\"`\n\nExpected: `Scene name: mosaic`, `Type: function`\n\n- [ ] **Step 7: Commit**\n\n```bash\ngit add scenes/mosaic.js\ngit commit -m \"feat(mosaic): add mosaic scene with full split/upload/preview flow\"\n```\n\n---\n\n### Task 6: Register Scene and Command\n\n**Files:**\n- Modify: `scenes/index.js:1-85`\n- Modify: `bot.js` (command registration section)\n\n**Depends on:** Task 5\n\n- [ ] **Step 1: Register mosaic scene in `scenes/index.js`**\n\nAdd import after line 23 (`const donate = require('./donate')`):\n\n```javascript\nconst mosaic = require('./mosaic')\n```\n\nAdd `mosaic` to the Stage array (after `donate` on line 39):\n\n```javascript\nconst stage = new Stage([].concat(\n  sceneNewPack,\n  originalSticker,\n  deleteSticker,\n  messaging,\n  packEdit,\n  adminPackBulkDelete,\n  searchStickerSet,\n  photoClear,\n  videoRound,\n  packCatalog,\n  packFrame,\n  packRename,\n  packDelete,\n  packAbout,\n  donate,\n  mosaic\n))\n```\n\nAdd `/mosaic` to the command passthrough list (line 66-82):\n\n```javascript\nstage.hears(([\n  '/start',\n  '/help',\n  '/packs',\n  '/emoji',\n  '/lang',\n  '/donate',\n  '/publish',\n  '/delete',\n  '/frame',\n  '/rename',\n  '/catalog',\n  '/mosaic'\n]), async (ctx, next) => {\n```\n\n- [ ] **Step 2: Add /mosaic command in `bot.js`**\n\nFind the section where scene entry commands are defined (near `privateMessage.hears(/\\/new/`). Add:\n\n```javascript\nprivateMessage.command('mosaic', (ctx) => ctx.scene.enter('mosaic'))\n```\n\n- [ ] **Step 3: Verify bot starts without errors**\n\nRun: `cd /Users/ly/dev/fStikBot && timeout 5 node -e \"require('./bot')\" 2>&1 || true`\n\nExpected: No immediate crash errors (may timeout waiting for DB, that's ok).\n\n- [ ] **Step 4: Commit**\n\n```bash\ngit add scenes/index.js bot.js\ngit commit -m \"feat(mosaic): register scene and /mosaic command\"\n```\n\n---\n\n### Task 7: Add Mosaic Button to Pack Menu\n\n**Files:**\n- Modify: `handlers/packs.js:176-196`\n\n**Depends on:** Task 6\n\n- [ ] **Step 1: Add mosaic button for custom_emoji packs in `handlers/packs.js`**\n\nFind the inline keyboard section (around line 176-195). Add a mosaic button row conditionally for custom_emoji packs. Insert after the frame button row (line 187-188):\n\n```javascript\n// Existing:\n[\n  Markup.callbackButton(ctx.i18n.t('callback.pack.btn.frame'), 'set_frame')\n],\n// Add this:\n...(stickerSet.packType === 'custom_emoji' ? [[\n  Markup.callbackButton('🔲 ' + ctx.i18n.t('callback.pack.btn.mosaic'), 'mosaic:enter')\n]] : []),\n```\n\n- [ ] **Step 2: Add callback handler for mosaic:enter in `bot.js` or `handlers/packs.js`**\n\nAdd callback action handler (wherever other pack-related actions are handled):\n\n```javascript\nbot.action('mosaic:enter', (ctx) => {\n  ctx.answerCbQuery()\n  return ctx.scene.enter('mosaic')\n})\n```\n\n- [ ] **Step 3: Add locale string for the button**\n\nIn `locales/en.yaml`, add under `callback.pack.btn`:\n\n```yaml\n      mosaic: Mosaic\n```\n\nIn `locales/uk.yaml`, add under `callback.pack.btn`:\n\n```yaml\n      mosaic: Мозаїка\n```\n\n- [ ] **Step 4: Commit**\n\n```bash\ngit add handlers/packs.js bot.js locales/en.yaml locales/uk.yaml\ngit commit -m \"feat(mosaic): add mosaic button to pack menu for custom_emoji packs\"\n```\n\n---\n\n### Task 8: End-to-End Testing\n\n**Files:** None (manual testing)\n\n**Depends on:** Tasks 1-7\n\n- [ ] **Step 1: Verify bot starts**\n\nRun: `cd /Users/ly/dev/fStikBot && node index.js`\n\nCheck: No startup errors.\n\n- [ ] **Step 2: Test /mosaic command**\n\nIn Telegram:\n1. Ensure you have a custom_emoji pack selected\n2. Send `/mosaic`\n3. Expected: Bot replies with \"Mosaic mode for {pack}. Send a photo.\"\n\n- [ ] **Step 3: Test photo → preview → grid selection**\n\n1. Send a landscape photo\n2. Expected: Bot replies with preview image (photo with grid overlay) + inline buttons\n3. Tap recommended grid button\n4. Expected: Progress messages → mosaic message with custom emoji → pack link + undo button\n\n- [ ] **Step 4: Test undo**\n\n1. Tap \"Remove this mosaic\" button\n2. Expected: Bot confirms deletion with count\n\n- [ ] **Step 5: Test custom size**\n\n1. Send another photo\n2. Tap \"Custom size\"\n3. Type \"2x3\"\n4. Expected: Mosaic created with 2×3 grid\n\n- [ ] **Step 6: Test edge cases**\n\n1. Send a very wide panorama image → should get strip suggestions (1×N)\n2. Send a small image (< 300px) → should see blurry warning\n3. Type invalid custom size (e.g. \"abc\") → should see error message\n4. Test exit button → should leave scene\n\n- [ ] **Step 7: Final commit if any fixes were needed**\n\n```bash\ngit add -A\ngit commit -m \"fix(mosaic): fixes from e2e testing\"\n```\n"
  },
  {
    "path": "docs/superpowers/plans/2026-04-15-mosaic-input-types.md",
    "content": "# Mosaic Input Types Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Extend the mosaic scene to accept image documents (JPEG/PNG/WebP) and static stickers in addition to photos, with clear rejection messages for animated/video inputs.\n\n**Architecture:** Add a pure `getMosaicSource(message)` helper to `scenes/mosaic.js` that normalizes any accepted message type into `{ fileId, width, height }` or returns an i18n error key. The existing `mosaic.on('photo', ...)` handler is widened to `['photo', 'document', 'sticker']` and delegates to the helper. A second lightweight handler for `['animation', 'video', 'video_note']` replies with a rejection. Processing pipeline (sharp, grid, upload) is untouched.\n\n**Tech Stack:** Telegraf v3 scenes, Sharp (image processing), existing `utils/mosaic-*` modules.\n\n**Spec:** `docs/superpowers/specs/2026-04-15-mosaic-input-types-design.md`\n\n**Testing approach:** Manual smoke test only — consistent with existing mosaic code (no unit/integration tests exist for the scene today). Adding a test harness for this small extension is out of scope.\n\n---\n\n## File Map\n\n| File | Action | Responsibility |\n|------|--------|----------------|\n| `locales/uk.yaml` | Modify (lines 318–353 region) | Add 3 keys under `cmd.mosaic`: `reject_animated`, `reject_document`, `reject_media` |\n| `locales/en.yaml` | Modify (lines 325–360 region) | Same 3 keys in English |\n| `scenes/mosaic.js` | Modify | Add `getMosaicSource` helper; replace `mosaic.on('photo', ...)` with multi-type handler; add animation/video reject handler |\n\n**Naming note:** The scene stores `photoFileId`, `photoWidth`, `photoHeight` in session state (`scenes/mosaic.js:144-146, 180, 189, 336, 362`). These names become slightly misleading (sources can now be documents or stickers too), but they functionally describe \"the file_id of the image we will mosaic\". Do **not** rename — it's 6 sites of churn for zero behavioural benefit.\n\n---\n\n### Task 1: Add i18n keys for rejection messages\n\n**Files:**\n- Modify: `locales/uk.yaml` (after line 353, before `donate:` on line 354)\n- Modify: `locales/en.yaml` (after line 360, before `donate:` on line 361)\n\nThis task is independent and safe to land alone. The keys have no runtime effect until Task 3 uses them.\n\n- [ ] **Step 1: Add 3 keys to `locales/uk.yaml`**\n\nFind the line `    wait_photo: |` (line 352) followed by its body `      Надішліть інше фото або натисніть Вийти.` (line 353). Insert immediately after, at the same indentation as other `cmd.mosaic.*` keys (4 spaces):\n\n```yaml\n    reject_animated: |\n      Анімовані/відео стікери поки не підтримую. Надішліть статичний стікер, фото або PNG/JPEG/WebP файлом.\n    reject_document: |\n      Підтримую тільки зображення (JPEG/PNG/WebP). Надішліть файл у цьому форматі.\n    reject_media: |\n      Анімації та відео поки не підтримую. Надішліть статичний стікер, фото або PNG/JPEG/WebP файлом.\n```\n\n- [ ] **Step 2: Add 3 keys to `locales/en.yaml`**\n\nFind the line `    wait_photo: |` (line 359) followed by its body `      Send another photo or tap Exit.` (line 360). Insert immediately after, at the same indentation:\n\n```yaml\n    reject_animated: |\n      Animated/video stickers aren't supported yet. Send a static sticker, a photo, or a PNG/JPEG/WebP file.\n    reject_document: |\n      Only images are supported (JPEG/PNG/WebP). Please send a file in one of these formats.\n    reject_media: |\n      Animations and videos aren't supported yet. Send a static sticker, a photo, or a PNG/JPEG/WebP file.\n```\n\n- [ ] **Step 3: Verify YAML parses**\n\nRun:\n```bash\nnode -e \"require('js-yaml').load(require('fs').readFileSync('locales/uk.yaml','utf8')); require('js-yaml').load(require('fs').readFileSync('locales/en.yaml','utf8')); console.log('ok')\"\n```\n\nExpected output: `ok`\n\nIf it errors, the most likely cause is indentation — all `cmd.mosaic.*` keys must be at 4 spaces of indent, with the body block at 6 spaces.\n\n- [ ] **Step 4: Commit**\n\n```bash\ngit add locales/uk.yaml locales/en.yaml\ngit commit -m \"i18n(mosaic): add rejection messages for unsupported input types\"\n```\n\n---\n\n### Task 2: Add `getMosaicSource` helper\n\n**Files:**\n- Modify: `scenes/mosaic.js` (add helper above line 108 where `// --- Photo handler ---` comment is)\n\nPure function. Does not download anything — only reads fields from `message` and returns a normalized shape or an i18n key. Kept inline (not extracted to `utils/`) per spec decision (YAGNI).\n\n- [ ] **Step 1: Insert the helper**\n\nFind line 107 in `scenes/mosaic.js` (blank line before `// --- Photo handler ---`). Insert the following **above** the `// --- Photo handler ---` comment:\n\n```javascript\n// Normalize any accepted message into { fileId, width, height } or { error: <i18n-key> }.\n// For documents, width/height come from the optional thumb — may be null, caller reads from buffer.\nconst IMAGE_DOCUMENT_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp'])\n\nconst getMosaicSource = (message) => {\n  if (message.photo && message.photo.length > 0) {\n    const largest = message.photo[message.photo.length - 1]\n    return { fileId: largest.file_id, width: largest.width, height: largest.height }\n  }\n\n  if (message.sticker) {\n    if (message.sticker.is_animated || message.sticker.is_video) {\n      return { error: 'cmd.mosaic.reject_animated' }\n    }\n    return {\n      fileId: message.sticker.file_id,\n      width: message.sticker.width,\n      height: message.sticker.height\n    }\n  }\n\n  if (message.document) {\n    const mime = message.document.mime_type\n    if (!mime || !IMAGE_DOCUMENT_MIMES.has(mime)) {\n      return { error: 'cmd.mosaic.reject_document' }\n    }\n    return {\n      fileId: message.document.file_id,\n      width: message.document.thumb ? message.document.thumb.width : null,\n      height: message.document.thumb ? message.document.thumb.height : null\n    }\n  }\n\n  // Should not be reachable — handler only binds to photo/document/sticker.\n  return { error: 'cmd.mosaic.reject_media' }\n}\n\n```\n\n- [ ] **Step 2: Syntax check**\n\nRun:\n```bash\nnode -c scenes/mosaic.js\n```\n\nExpected: no output (silent success). If there's a syntax error, fix indentation/brackets before proceeding.\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add scenes/mosaic.js\ngit commit -m \"feat(mosaic): add getMosaicSource helper to normalize input types\"\n```\n\n---\n\n### Task 3: Rewire handler to accept photo, document, and sticker\n\n**Files:**\n- Modify: `scenes/mosaic.js:108-167` (the `mosaic.on('photo', ...)` block)\n\nThe full existing handler is replaced. Behaviour changes: (a) binds to three message types, (b) pulls source via `getMosaicSource`, (c) short-circuits with a reply on error, (d) reads width/height from sharp metadata when the message doesn't provide them (image documents).\n\n- [ ] **Step 1: Add sharp require**\n\nSharp is not directly imported in `scenes/mosaic.js` today — it's used only indirectly via `utils/mosaic-*`. We need it here for the image-document dimension fallback.\n\nAdd this line directly after the existing `const https = require('https')` line (currently line 6):\n\n```javascript\nconst sharp = require('sharp')\n```\n\n- [ ] **Step 2: Replace the photo handler**\n\nLocate the block starting at `mosaic.on('photo', async (ctx) => {` (line 110) through its closing `})` (line 167 — the line that closes right after `ctx.session.scene.mosaic.previewMessageId = msg.message_id`).\n\nReplace the **entire block** (lines 110–167) with:\n\n```javascript\nmosaic.on(['photo', 'document', 'sticker'], async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n\n  // Block new input while uploading\n  if (ctx.session.scene.mosaic.uploading) {\n    return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.uploading', { current: '...', total: '...' }))\n  }\n\n  const source = getMosaicSource(ctx.message)\n  if (source.error) {\n    return ctx.replyWithHTML(ctx.i18n.t(source.error))\n  }\n\n  // Download the source\n  const fileUrl = await ctx.telegram.getFileLink(source.fileId)\n  const imageBuffer = await downloadFile(fileUrl.href || fileUrl)\n\n  // Documents don't carry width/height on the message itself — read from buffer.\n  let { width, height } = source\n  if (!width || !height) {\n    const meta = await sharp(imageBuffer).metadata()\n    width = meta.width\n    height = meta.height\n  }\n\n  // Count existing stickers in pack\n  const stickerSet = await ctx.db.StickerSet.findById(ctx.session.scene.mosaic.packId)\n  const currentCount = await ctx.db.Sticker.countDocuments({\n    stickerSet: stickerSet.id,\n    deleted: false\n  })\n  const freeSlots = 200 - currentCount\n\n  const suggestions = getGridSuggestions(width, height, freeSlots)\n\n  if (suggestions.type === 'no_space') {\n    await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_space', { freeSlots, total: 4 }))\n    return\n  }\n\n  // Store in scene state\n  ctx.session.scene.mosaic.photoFileId = source.fileId\n  ctx.session.scene.mosaic.photoWidth = width\n  ctx.session.scene.mosaic.photoHeight = height\n  ctx.session.scene.mosaic.freeSlots = freeSlots\n\n  // Generate preview with recommended grid\n  const { recommended } = suggestions\n  const previewBuffer = await generatePreview(imageBuffer, recommended.rows, recommended.cols)\n\n  // Check for blurry warning\n  const isBlurry = !checkMinCellSize(width, height, recommended.rows, recommended.cols)\n  const blurryText = isBlurry ? '\\n' + ctx.i18n.t('cmd.mosaic.blurry_warning') : ''\n\n  const msg = await ctx.replyWithPhoto(\n    { source: previewBuffer },\n    {\n      caption: ctx.i18n.t('cmd.mosaic.choose_grid') + blurryText,\n      parse_mode: 'HTML',\n      reply_markup: buildGridKeyboard(ctx, suggestions)\n    }\n  )\n\n  ctx.session.scene.mosaic.previewMessageId = msg.message_id\n})\n```\n\nKey diffs from the original (for reviewers):\n- `on('photo'` → `on(['photo', 'document', 'sticker']`\n- Removed `const photo = ctx.message.photo; const largest = photo[photo.length - 1]`\n- Added `getMosaicSource` call with error short-circuit\n- Width/height now taken from `source`, with sharp fallback for docs\n- `largest.file_id` → `source.fileId` at the state-storage site\n\n- [ ] **Step 3: Syntax check**\n\nRun:\n```bash\nnode -c scenes/mosaic.js\n```\n\nExpected: no output.\n\n- [ ] **Step 4: Commit**\n\n```bash\ngit add scenes/mosaic.js\ngit commit -m \"feat(mosaic): accept image documents and static stickers as input\"\n```\n\n---\n\n### Task 4: Reject handler for animations and videos\n\n**Files:**\n- Modify: `scenes/mosaic.js` (insert after the block just modified in Task 3)\n\nSeparate handler because animation/video/video_note never have a valid mosaic path — we just want a friendly reply, no downloads, no state changes.\n\n- [ ] **Step 1: Insert the reject handler**\n\nAfter the closing `})` of the multi-type handler (the new end of what used to be line 167), insert:\n\n```javascript\n\n// --- Reject animated/video inputs ---\n\nmosaic.on(['animation', 'video', 'video_note'], async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n  if (ctx.session.scene.mosaic.uploading) return\n  await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.reject_media'))\n})\n```\n\nPlacement check: this must come **before** the `// --- Shared processMosaic function ---` comment (previously line 169). The `processMosaic` function is not a handler registration, so order vs. it doesn't matter for Telegraf, but keep the code grouped.\n\n- [ ] **Step 2: Syntax check**\n\nRun:\n```bash\nnode -c scenes/mosaic.js\n```\n\nExpected: no output.\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add scenes/mosaic.js\ngit commit -m \"feat(mosaic): reject animations, videos, and video notes with a friendly message\"\n```\n\n---\n\n### Task 5: Manual smoke test\n\n**Files:** none (verification only)\n\nNo automated tests exist for the mosaic scene. This checklist must be executed by a human (or agent with Telegram access) against a running bot instance before the feature is considered done.\n\n- [ ] **Step 1: Start the bot in dev mode**\n\nRun:\n```bash\nnpm start\n```\n\n(Or whatever the project's dev-run command is — see `package.json` scripts and `README`.)\n\n- [ ] **Step 2: Execute the test matrix**\n\nIn Telegram, with a custom-emoji pack selected, enter `/mosaic` and send each of the following inputs one at a time. After each, verify the expected behaviour and then send the next input **without leaving the scene**.\n\n| # | Input | Expected |\n|---|---|---|\n| 1 | A regular photo (camera icon) | Preview with grid keyboard appears (existing behaviour) |\n| 2 | A JPEG file sent via paperclip → \"File\" | Preview appears, mosaic generates crisply |\n| 3 | A PNG file sent as document | Preview appears |\n| 4 | A WebP file sent as document | Preview appears |\n| 5 | A static sticker from any pack | Preview appears |\n| 6 | An animated (.tgs) sticker | Reply: \"Анімовані/відео стікери поки не підтримую…\". Scene stays open. |\n| 7 | A video (.webm) sticker | Same reject as #6. Scene stays open. |\n| 8 | A GIF (sent as animation) | Reply: \"Анімації та відео поки не підтримую…\". Scene stays open. |\n| 9 | An MP4 video | Same reject as #8. |\n| 10 | A PDF or any non-image document | Reply: \"Підтримую тільки зображення (JPEG/PNG/WebP)…\". Scene stays open. |\n| 11 | After any reject (say #6), send a regular photo | Scene proceeds normally — confirms rejects don't break state |\n| 12 | Complete a full mosaic from a PNG document (tap a grid button through to done) | Mosaic appears in chat correctly |\n\n- [ ] **Step 3: Check for visual regressions**\n\nFor tests #2, #3, #5, look at the final mosaic message in chat and compare to a photo-source mosaic of the same image:\n- Are the emoji aligned? (No orphan newlines.)\n- Does the transparency of a WebP sticker source produce acceptable visual output? (Transparent pixels visible through emoji.)\n\nIf a cell looks broken, screenshot it and stop. Otherwise proceed.\n\n- [ ] **Step 4: Announce done**\n\nIf all 12 rows pass, the feature is complete. Report:\n- Which inputs were tested\n- Any notable visual quirks observed\n- Whether any follow-up tasks emerged (e.g. \"WebP stickers with heavy alpha look weird — file for later\")\n\nNo further commit — the code commits in Tasks 1–4 are the deliverable.\n\n---\n\n## Self-review checklist (for plan author)\n\n- Spec requirements covered: photo ✅, image doc ✅, static sticker ✅, reject animated sticker ✅, reject video sticker ✅, reject doc with non-image MIME ✅, reject animation/video/video_note ✅, i18n keys in uk and en ✅, no pipeline changes ✅, scene stays open after reject ✅\n- No placeholders — every step shows the exact code or command\n- Type consistency — `getMosaicSource` returns `{ fileId, width, height }` or `{ error }` in every task reference\n- File paths and line numbers match what's in the repo at the time of writing\n"
  },
  {
    "path": "docs/superpowers/plans/2026-04-15-security-sweep-pr1.md",
    "content": "# Security Sweep PR-1 Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Close 22 Dependabot alerts by bumping `moment` and `@pm2/io` in `package.json` and running `npm audit fix` to resolve transitive vulnerabilities.\n\n**Architecture:** Single-commit dependency update. No application code changes — only `package.json` and `package-lock.json`. The two direct bumps are chosen because their public APIs are unchanged across the bumped ranges. `npm audit fix` without `--force` stays within declared semver ranges so it cannot introduce major-version surprises.\n\n**Tech Stack:** npm 10.9, Node 22.14. No application code modified.\n\n**Spec:** `docs/superpowers/specs/2026-04-15-security-sweep-pr1-design.md`\n\n**Testing approach:** No automated tests exist in this project. Verification is via `npm run lint`, `node -c` syntax checks on files that `require()` the bumped packages, and a one-time `require('./utils/stats.js')` runtime load. Full prod verification is manual by the user post-push.\n\n---\n\n## File Map\n\n| File | Action | Responsibility |\n|------|--------|----------------|\n| `package.json` | Modify | Bump `moment` from `^2.29.2` to `^2.30.1` and `@pm2/io` from `^5.0.0` to `^6.1.0` |\n| `package-lock.json` | Regenerated | Reflects direct bumps plus transitive fixes from `npm audit fix` |\n\n---\n\n### Task 1: Bump direct dependencies\n\n**Files:**\n- Modify: `package.json` (lines 22–42 area — the `dependencies` block)\n- Modify: `package-lock.json` (regenerated by npm)\n\n`npm install pkg@range` updates both `package.json` and the lock in one shot.\n\n- [ ] **Step 1: Bump moment and @pm2/io together**\n\nRun:\n```bash\nnpm install moment@^2.30.1 @pm2/io@^6.1.0\n```\n\nExpected: no errors. The command writes the new ranges to `package.json` and updates `package-lock.json` accordingly. If there's a peer-dep warning, note it but continue.\n\n- [ ] **Step 2: Confirm the ranges landed**\n\nRun:\n```bash\nnode -e \"const p=require('./package.json'); console.log(p.dependencies.moment, p.dependencies['@pm2/io'])\"\n```\n\nExpected output: `^2.30.1 ^6.1.0` (exact match).\n\nIf the output shows the old ranges, npm didn't write — rerun Step 1.\n\n---\n\n### Task 2: Run `npm audit fix` for transitive fixes\n\n**Files:**\n- Modify: `package-lock.json`\n\n`npm audit fix` (without `--force`) only updates packages within their existing declared ranges. It cannot touch `package.json`. This is the safe path.\n\n- [ ] **Step 1: Run audit fix**\n\nRun:\n```bash\nnpm audit fix\n```\n\nExpected: npm reports a count of vulnerabilities fixed. Some alerts may remain (the ones marked `no-fix` by Dependabot: `ip`, `tmp`, low-severity `elliptic`). This is expected.\n\n- [ ] **Step 2: Sanity-check the remaining audit**\n\nRun:\n```bash\nnpm audit --audit-level=high 2>&1 | tail -15\n```\n\nExpected: only `mongoose` appears as critical/high (mongoose is PR-2). If anything else still shows critical/high, stop and investigate — do not continue committing until understood.\n\n---\n\n### Task 3: Verify lint and syntax\n\n**Files:** none modified — verification only\n\n- [ ] **Step 1: Run project lint**\n\nRun:\n```bash\nnpm run lint\n```\n\nExpected: exits 0 with no output, or minor warnings only. If eslint reports **errors**, stop — investigate whether a transitive bump broke something eslint catches (unlikely, but possible).\n\n- [ ] **Step 2: Syntax-check files that require the bumped packages**\n\nRun (one command, all files):\n```bash\nnode -c utils/stats.js && node -c scenes/messaging.js && node -c handlers/admin/messaging.js && node -c index.js && echo \"all ok\"\n```\n\nExpected: `all ok`.\n\n- [ ] **Step 3: Runtime-load the `@pm2/io` consumer**\n\nRun:\n```bash\ntimeout 2 node -e \"require('./utils/stats.js'); console.log('loaded'); process.exit(0)\" 2>&1 || true\n```\n\nExpected: either `loaded` is printed, or the process is killed by timeout after printing `loaded` (the file has a `setInterval` that keeps it alive, so timeout is expected — we just need to confirm the `require` didn't throw on import).\n\nIf there's an error from `@pm2/io` import (e.g. `TypeError: io.metric is not a function`), STOP. The v5→v6 bump broke the public API — revert `package.json`/lock and hand back to the user.\n\n---\n\n### Task 4: Commit and push\n\n**Files:** `package.json` + `package-lock.json`\n\n- [ ] **Step 1: Inspect the diff**\n\nRun:\n```bash\ngit diff --stat package.json package-lock.json\n```\n\nExpected: both files modified. `package-lock.json` will show a large line-count diff — that's normal for transitive resolution updates.\n\n- [ ] **Step 2: Commit**\n\nRun:\n```bash\ngit add package.json package-lock.json\ngit commit -m \"$(cat <<'EOF'\nchore(deps): security sweep — npm audit fix + moment/@pm2/io bumps\n\nCloses 22 Dependabot alerts including 5 critical (cipher-base, elliptic,\nform-data, pbkdf2, sha.js) via transitive resolution, plus direct bumps\nof moment (2.29.2 -> 2.30.1) and @pm2/io (5.0.0 -> 6.1.0) which both\nkeep public APIs stable across the bumped ranges.\n\nRemaining open alerts (ip, tmp, low-severity elliptic, mongoose) are\neither marked no-fix upstream or addressed in PR-2 (mongoose 5 -> 6).\n\nSpec: docs/superpowers/specs/2026-04-15-security-sweep-pr1-design.md\nEOF\n)\"\n```\n\nExpected: commit succeeds. No pre-commit hooks configured in this repo, so nothing to bypass.\n\n- [ ] **Step 3: Push**\n\nRun:\n```bash\ngit push origin master\n```\n\nExpected: push succeeds, outputs `master -> master`.\n\n---\n\n### Task 5: Post-push verification\n\n**Files:** none — verification by user on live bot\n\n- [ ] **Step 1: User restarts the bot**\n\nThe user runs the bot's deploy process (whatever it is for this project — pm2, docker, bare `node index.js`, etc.) and confirms:\n- Bot connects to Mongo without error\n- Bot connects to Telegram without error\n- A sticker-related command (any one) works end-to-end\n\n- [ ] **Step 2: Confirm alert-count drop**\n\nAfter GitHub re-scans (usually within an hour of push), run:\n```bash\ngh api repos/LyoSU/fStikBot/dependabot/alerts --paginate | node -e \"\nconst arr = JSON.parse(require('fs').readFileSync(0,'utf8'));\nconst open = arr.filter(a => a.state === 'open');\nconsole.log('open alerts:', open.length);\nconst bySev = {};\nfor (const a of open) { const s = a.security_advisory.severity; bySev[s]=(bySev[s]||0)+1; }\nconsole.log('by severity:', JSON.stringify(bySev));\n\"\n```\n\nExpected: open count drops from 31 to somewhere in the 3–6 range.\n\n- [ ] **Step 3: Hand off to PR-2**\n\nOnce PR-1 is verified stable in prod for at least a few hours under real traffic, brainstorm PR-2 (mongoose 5 → 6.13.9 migration). That PR is much higher risk and should not land on top of an unverified PR-1.\n\n---\n\n## Rollback procedure\n\nIf anything breaks at Task 3 or after push:\n\n```bash\ngit checkout HEAD~1 -- package.json package-lock.json\nnpm install\n```\n\nIf push already happened and the bot is broken in prod:\n\n```bash\ngit revert HEAD\ngit push origin master\n```\n\nNo app code was changed, so there is nothing application-level to revert.\n\n---\n\n## Self-review (plan author)\n\n- Spec covered: direct bumps ✅ (Task 1), transitive fixes ✅ (Task 2), verification ✅ (Task 3), commit ✅ (Task 4), post-push ✅ (Task 5)\n- No placeholders — every step has exact command + expected output\n- No \"appropriate error handling\" hand-waving — failure paths are explicit (stop and investigate)\n- Naming consistency — `moment@^2.30.1` and `@pm2/io@^6.1.0` used the same way in Tasks 1 and 4\n"
  },
  {
    "path": "docs/superpowers/specs/2026-04-05-emoji-mosaic-design.md",
    "content": "# Emoji Mosaic Feature — Design Spec\n\n## Overview\n\nAdd a \"mosaic mode\" to fStikBot that splits a photo into a grid of custom emoji pieces. When placed together in a Telegram message, the emoji reassemble into the original image.\n\n## User Flow\n\n```\n/mosaic (or button in pack menu)\n    → Bot: \"Mosaic mode enabled for pack {packName}. Send a photo.\"\n    → (check: does user have a current custom_emoji pack? if not — prompt to create one)\n\nUser sends photo\n    → Bot analyzes aspect ratio\n    → Determines split type:\n        • ratio ≥ 2.5  → horizontal strip (1 row × N cols)\n        • ratio ≤ 0.4  → vertical strip (N rows × 1 col)\n        • otherwise    → grid\n    → Sharp generates preview (photo with dashed grid overlay)\n    → Sends preview + inline keyboard:\n\n      For grid:\n        [✅ Split 3×4]                          ← recommended (highlighted)\n        [2×3 · 6pcs] [4×6 · 24pcs] [5×7 · 35pcs]  ← alternatives\n        [✏️ Custom size]\n\n      For strip (e.g. panorama 5:1):\n        [✅ Split 1×5]\n        [1×4] [1×6] [1×8]\n        [✏️ Custom size]\n\n    → If not enough space in pack — warn user, suggest smaller grid or new pack\n\nUser taps a button\n    → Sharp splits photo into parts (each → 100×100 WEBP)\n    → Uploads all parts to current custom_emoji pack\n    → Sends:\n        • Message with mosaic (custom emoji entities in grid with newlines)\n        • For strips: emoji in a single line\n        • Link to pack (t.me/addemoji/{packName})\n    → Scene waits for next photo (loop)\n\n/mosaic or \"Exit\" button\n    → Leave scene\n```\n\n## Grid Selection Algorithm\n\n```\nInput: width × height of photo\n\n1. Compute ratio = width / height\n\n2. Determine type:\n   • ratio ≥ 2.5  → horizontal strip (1 row)\n   • ratio ≤ 0.4  → vertical strip (1 column)\n   • otherwise    → grid\n\n3. For strips:\n   • count = round(ratio) for horizontal, round(1/ratio) for vertical\n   • Clamp to 3..10\n   • Alternatives: ±1, ±2 from recommended\n\n4. For grids:\n   • Find all combinations rows × cols where:\n     - rows: 2..10, cols: 2..10\n     - rows × cols ≤ 50\n     - (cols/rows) close to ratio (proportionality)\n   • Sort by how close each cell's aspect ratio is to 1:1\n     (square emoji look best)\n   • Recommendation = best balance of proportionality and count\n   • Alternatives = one smaller, one medium, one larger grid\n\n5. Pack space check:\n   • freeSlots = 200 - currentEmojiCount\n   • Filter out options where rows × cols > freeSlots\n   • If recommendation doesn't fit — pick largest that fits\n   • If none fit (freeSlots < 4) — notify user\n```\n\n## Image Processing Pipeline (Sharp)\n\n### Preview Generation (before user chooses grid)\n\n1. Load photo via Sharp\n2. Resize to max 512px on longest side\n3. Draw dashed grid lines via SVG overlay composite\n4. Output as WEBP → send as photo message\n\n### Splitting (after user chooses grid)\n\n1. Load original at full resolution\n2. **Min cell size check**: if `width/cols < 80` or `height/rows < 80` — warn user\n   that result may be blurry, suggest fewer divisions\n3. `cellWidth = floor(width / cols)`, `cellHeight = floor(height / rows)`\n4. For each cell `[r, c]`:\n   - `sharp.extract({ left: c*cellWidth, top: r*cellHeight, width: cellWidth, height: cellHeight })`\n   - Resize to 100×100 (custom emoji size)\n   - Convert to WEBP\n5. Result: `Buffer[]` left-to-right, top-to-bottom\n\n### Upload to Pack\n\n1. Send progress message: \"Uploading 0/{total}...\"\n2. For each buffer sequentially:\n   - `uploadStickerFile` (format: static)\n   - `addStickerToSet` with `emoji_list: [\"🔲\"]`, `keywords: [\"mosaic\", \"r{row}c{col}\"]`\n   - Edit progress message every 3-5 uploads: \"Uploading 12/35...\"\n   - `sendChatAction(\"upload_document\")` to keep typing indicator\n3. Store `file_id` of each added emoji\n4. Store list of added sticker file_ids in scene state (for undo)\n\n### Mosaic Message\n\n1. Build text with custom_emoji entities:\n   - Row 1: `emoji[0] emoji[1] emoji[2] emoji[3]`\n   - Row 2: `emoji[4] emoji[5] emoji[6] emoji[7]`\n   - Rows separated by `\\n`\n2. Telegram Bot API: `sendMessage` with `entities` array, each entry:\n   - `type: \"custom_emoji\"`\n   - `custom_emoji_id` from the uploaded sticker\n3. Append pack link: `t.me/addemoji/{packName}`\n4. Add inline button: `[🗑 Remove this mosaic]` → deletes all stickers\n   from this mosaic from the pack via `deleteStickerFromSet`\n\n## Scene Structure\n\n```\nScene: \"mosaic\"\n\nEntry:\n  • Command /mosaic\n  • Callback button from pack menu\n  • Validate: user has a current custom_emoji pack\n    → if not: offer to create one inline (enter pack title → create → continue)\n    → not a dead end — seamless onboarding\n\nScene state (ctx.scene.state):\n  • photoFileId    — file_id of current photo\n  • photoWidth     — width\n  • photoHeight    — height\n  • messageId      — preview message id (for editing)\n  • gridRows       — chosen rows (null until chosen)\n  • gridCols       — chosen columns\n  • lastMosaicIds  — file_ids of last uploaded mosaic (for undo)\n\nSteps (non-linear loop):\n\n  waitPhoto:\n    → on(\"photo\") → save fileId/dimensions to state\n    → generate preview\n    → send with inline keyboard\n    → transition to waitGrid\n\n  waitGrid:\n    → on callback \"mosaic:grid:{rows}:{cols}\"\n      → split, upload, send mosaic\n      → clear state\n      → back to waitPhoto\n\n    → on callback \"mosaic:custom\"\n      → send \"Enter size (e.g. 3x4):\"\n      → transition to waitCustom\n\n    → on callback \"mosaic:cancel\"\n      → back to waitPhoto\n\n  waitCustom:\n    → on text → parse flexible formats: \"RxC\", \"R×C\", \"R*C\", \"R:C\", \"R на C\"\n      → validate (2-10 each dimension, space in pack)\n      → split, upload, send mosaic\n      → back to waitPhoto\n\n  Exit:\n    → /mosaic again or \"Exit\" button\n    → ctx.scene.leave()\n\nCallback data format:\n  \"mosaic:grid:3:4\"     — select 3×4 grid\n  \"mosaic:custom\"       — enter custom size\n  \"mosaic:cancel\"       — cancel current photo\n  \"mosaic:undo\"         — remove last mosaic from pack\n  \"mosaic:exit\"         — leave scene\n```\n\n## File Structure\n\n### New files\n\n| File | Purpose |\n|------|---------|\n| `scenes/mosaic.js` | Scene logic (waitPhoto → waitGrid → loop) |\n| `utils/mosaic-split.js` | Sharp: split photo into grid cells |\n| `utils/mosaic-preview.js` | Sharp: generate preview with grid overlay |\n| `utils/mosaic-grid.js` | Grid recommendation algorithm |\n\n### Modified files\n\n| File | Change |\n|------|--------|\n| `bot.js` | Register mosaic scene + `/mosaic` command |\n| `handlers/packs.js` | Add \"🔲 Mosaic\" button in pack menu (custom_emoji packs only) |\n| `locales/*.yaml` | Mosaic-related text strings |\n\n### Not modified\n\n| File | Reason |\n|------|--------|\n| `utils/add-sticker.js` | Mosaic upload logic differs enough to warrant its own module |\n| `database/models/*` | No new models needed — mosaic emoji are regular stickers in existing packs |\n\n## Constraints\n\n- Custom emoji are 100×100px and render small in chat\n- Max 200 emoji per pack\n- Grid max 50 cells (practical limit for usability)\n- Individual dimensions: 2–10 for grid, 3–10 for strips\n- If pack has insufficient space: warn user, suggest smaller grid or new pack\n- Sequential upload required (Telegram rate limits)\n- Min source cell size: 80×80px before resize (warn if smaller — blurry result)\n- Custom emoji render with small gaps in Telegram — mosaic won't be pixel-perfect seamless\n- Note in onboarding: emoji appear small in chat (~20px per emoji visually)\n"
  },
  {
    "path": "docs/superpowers/specs/2026-04-15-mosaic-input-types-design.md",
    "content": "# Mosaic Input Types — Design Spec\n\n## Overview\n\nExtend the mosaic feature (`scenes/mosaic.js`) to accept more input types beyond `message.photo`. Specifically: image documents (JPEG/PNG/WebP) and static stickers. Animated and video stickers, GIFs, and videos are explicitly **out of scope** for this iteration.\n\n## Motivation\n\n- `message.photo` is recompressed by Telegram to ~1280px max, hurting mosaic quality. Users who want crisp mosaics currently have no path.\n- People naturally try to mosaic an existing sticker; the bot silently ignores it (no feedback).\n- Sharp (already in the pipeline) decodes JPEG/PNG/WebP natively, so the processing pipeline needs **zero changes**.\n\n## Scope\n\n### In scope\n\n| Input | Condition | Source field |\n|---|---|---|\n| `message.photo` | any | largest variant `file_id` |\n| `message.document` | `mime_type` ∈ `image/jpeg`, `image/png`, `image/webp` | `document.file_id` |\n| `message.sticker` | `!is_animated && !is_video` | `sticker.file_id` |\n\n### Out of scope (reject with clear message)\n\n- Animated stickers (`.tgs`)\n- Video stickers (`.webm`)\n- `message.animation`, `message.video`, `message.video_note` (GIFs/videos)\n- Documents with non-image MIME types\n\n### Non-goals\n\n- Producing an animated/video mosaic\n- Converting `.tgs`/`.webm` to static before processing\n- Respecting `has_spoiler` flag (treat as regular image)\n\n## Architecture\n\n### Single handler, multiple types\n\nReplace:\n```js\nmosaic.on('photo', async (ctx) => { ... })\n```\n\nWith a unified handler bound to `['photo', 'document', 'sticker']` that delegates validation to `getMosaicSource`. Add a second lightweight handler for `['animation', 'video', 'video_note']` that replies with `cmd.mosaic.reject_media` and returns — it never calls `getMosaicSource`.\n\n### Source normalization helper\n\nAn inline private function `getMosaicSource(message)` returns one of:\n\n- `{ fileId, width, height }` — on success\n- `{ error: '<i18n-key>' }` — on rejection\n\nKeeps the scene handler linear; no new file until we need one (YAGNI — if Option B/C land later, extract to `utils/mosaic-source.js` then).\n\n| Input | Return |\n|---|---|\n| `message.photo` present | `{ fileId: largest.file_id, width: largest.width, height: largest.height }` |\n| `message.document` with image MIME | `{ fileId, width: document.thumb?.width, height: document.thumb?.height }` — see note below |\n| `message.sticker`, static only | `{ fileId, width: sticker.width, height: sticker.height }` |\n| `message.sticker`, animated/video | `{ error: 'cmd.mosaic.reject_animated' }` |\n| `message.document`, non-image MIME | `{ error: 'cmd.mosaic.reject_document' }` |\n\n**Note on document dimensions:** `message.document` doesn't carry width/height directly — only the thumbnail does, and it's optional. When dimensions aren't available from the message, read them from the downloaded buffer via `sharp(buffer).metadata()` before the grid-suggestion step. This is a single extra `sharp` call, no extra download.\n\n### Processing pipeline — unchanged\n\n`cropToAspectRatio`, `splitImage`, `generatePreview`, upload flow — all untouched. Sharp already handles JPEG/PNG/WebP transparently.\n\n## UX\n\n### New rejection messages (i18n)\n\nAdded under `cmd.mosaic.*` in all locales (following existing mosaic key structure in `locales/*.yaml:318`):\n\n```yaml\ncmd.mosaic.reject_animated: |\n  Анімовані/відео стікери поки не підтримую. Надішліть статичний стікер, фото або PNG/JPEG/WebP файлом.\ncmd.mosaic.reject_document: |\n  Підтримую тільки зображення (JPEG/PNG/WebP). Надішліть файл у цьому форматі.\ncmd.mosaic.reject_media: |\n  Анімації та відео поки не підтримую. Надішліть статичний стікер, фото або PNG/JPEG/WebP файлом.\n```\n\nUkrainian and English locales get proper translations; other 15 locales fall back to English (existing pattern).\n\n### Rejection behaviour\n\n- Reply with the appropriate message\n- **Do not leave the scene** — user can immediately resend a valid input\n- No cleanup of scene state needed (the failed input never wrote any state)\n\n### Success behaviour\n\nIdentical to current photo flow: compute grid suggestions, send preview, await button tap.\n\n## Error handling\n\n- **Document download fails** → existing `downloadFile` error path applies (20MB cap, 3 retries)\n- **Sharp fails to decode** (corrupted WebP, e.g.) → existing try/catch around `generatePreview` applies. Add a specific reply `cmd.mosaic.invalid_image` only if testing reveals this is a common path; otherwise leave to existing error handler.\n- **Document with image MIME but actually not an image** (spoofed) → sharp throws, falls through to existing error handling. Good enough.\n\n## Risks & mitigations\n\n| Risk | Likelihood | Mitigation |\n|---|---|---|\n| Large PNG (20MB) causes sharp OOM | Low | Existing 20MB download cap. If OOM observed in prod, add `sharp.metadata()` pre-check and reject if decoded pixels > threshold. Not doing preemptively. |\n| WebP with alpha produces emoji with transparent edges | Medium | Acceptable — Telegram custom emoji support alpha. Verify visually after first build. |\n| User sends a static sticker that's 512×512 — mosaic tiles become 100×100 crops of that | Low | Current pipeline handles this fine (same as a 512×512 photo). No special case. |\n| Telegram adds new sticker format | Unknown | `is_animated`/`is_video` flags are the documented API; if a new one appears, it'll fall through to the sticker-accepted path. Monitor. |\n\n## Testing\n\nManual smoke after implementation:\n\n1. Send a photo → works as before\n2. Send a PNG as document → processes, produces mosaic\n3. Send a JPEG as document → works\n4. Send a WebP as document → works\n5. Send a static sticker → works\n6. Send an animated (.tgs) sticker → rejection message, scene stays open\n7. Send a video (.webm) sticker → rejection message, scene stays open\n8. Send a GIF (animation) → rejection message\n9. Send a PDF document → rejection message\n10. After rejection, send a valid photo → works (scene didn't leave)\n\nNo automated tests added (consistent with existing mosaic code — no tests exist for the scene today).\n\n## Files touched\n\n- `scenes/mosaic.js` — replace the `mosaic.on('photo')` handler with multi-type handler + add `getMosaicSource` helper + add animation/video reject handler\n- `locales/uk.yaml` — add 3 new keys under `cmd.mosaic`\n- `locales/en.yaml` — add 3 new keys under `cmd.mosaic`\n\n## Out of scope for this spec (explicit)\n\n- Video mosaic (split `.webm` sticker into NxM video custom emoji) — separate spec when/if demand appears\n- Animated Lottie (`.tgs`) rendering — would require Lottie renderer, separate spec\n- GIF/video inputs — same as above\n- Changing the grid algorithm or upload pipeline\n- Adding automated tests for the mosaic scene\n"
  },
  {
    "path": "docs/superpowers/specs/2026-04-15-security-sweep-pr1-design.md",
    "content": "# Security Sweep PR-1 — Design Spec\n\n## Overview\n\nClose 22 Dependabot alerts (including 5 critical: cipher-base, elliptic, form-data, pbkdf2, sha.js) by running `npm audit fix` on transitive dependencies and bumping two direct dependencies with published security fixes.\n\nThis is PR-1 of a two-PR security cleanup. **PR-2 is a separate mongoose 5 → 6 migration** — out of scope here.\n\n## Scope\n\n### In scope\n\n**Direct dependency bumps in `package.json`:**\n- `moment` `^2.29.2` → `^2.30.1` — closes CVE (high), no breaking API changes\n- `@pm2/io` `^5.0.0` → `^6.1.0` — closes CVE (high), public API (`metric`, `.set()`) stable across 5→6\n\n**Transitive fixes via `npm audit fix` (no `--force`):**\n- cipher-base, elliptic, form-data, pbkdf2, sha.js (critical)\n- cross-spawn, flatted, lodash, minimatch, semver, socks, tar-fs (high)\n- ajv, bn.js, brace-expansion, js-yaml, store2 (moderate)\n\n**Not attempted** (`ip` high + elliptic-low + tmp-low): Dependabot marks these as `no-fix`. `npm audit` may speculatively suggest `ip` is fixable, but no patched upstream version exists. These will remain open after the sweep.\n\n### Out of scope\n\n- **mongoose 5 → 6** — PR-2, separate spec/plan\n- **stegcloak chain** (inquirer/tmp/external-editor low alerts) — no fix available upstream, stegcloak is used in 4 files, not tractable as a single-PR fix\n- **`ip` package (no-fix alert)** — comes in via `telegram` or `socks-proxy-agent`, nothing we can do without upstream fix\n- **telegraf 3 → 4** — explicit user decision, stay on v3\n- **ioredis, bull, sharp, openai** — not flagged, don't touch\n\n## Call sites (dependency surface)\n\n| Dep | File | Line | Usage |\n|---|---|---|---|\n| `moment` | `handlers/admin/messaging.js` | 4 | `require('moment')` |\n| `moment` | `scenes/messaging.js` | 6 | `require('moment')` |\n| `@pm2/io` | `utils/stats.js` | 1 | `io.metric({ name, unit })` + `.set(value)` |\n\nAll three sites use stable public APIs. No code changes needed alongside the bumps.\n\n## Procedure\n\n1. `npm install moment@^2.30.1 @pm2/io@^6.1.0` — updates `package.json` and `package-lock.json`\n2. `npm audit fix` (no `--force`) — sub-range bumps in lock only\n3. `npm audit` — verify criticals cleared (mongoose will remain — by design)\n4. `npm run lint` — full lint passes\n5. `node -c index.js` + `node -c utils/stats.js` + `node -c scenes/messaging.js` + `node -c handlers/admin/messaging.js` — syntax check\n6. Optional: `node -e \"require('./utils/stats.js')\"` — confirms @pm2/io loads without runtime error\n7. Commit as single change: `chore(deps): security sweep (npm audit fix + moment + @pm2/io bumps)`\n\n## Verification\n\nAfter PR-1 lands:\n\n```bash\nnpm audit --audit-level=high 2>&1 | tail -10\n```\n\nExpected: only mongoose remains as high/critical (it's the PR-2 target).\n\n```bash\ngh api repos/LyoSU/fStikBot/dependabot/alerts --paginate | \\\n  jq '[.[] | select(.state==\"open\")] | length'\n```\n\nExpected: drop from 31 open → 3–6 remaining (mongoose + `ip` + `tmp` + elliptic-low + any others marked `no-fix`). Exact count confirmed post-merge.\n\n## Rollback\n\nIf anything breaks:\n\n```bash\ngit checkout HEAD~1 -- package.json package-lock.json\nnpm install\n```\n\nRestores prior state exactly. No code was modified, so no logic to revert.\n\n## Risks\n\n| Risk | Likelihood | Mitigation |\n|---|---|---|\n| `@pm2/io` v5 → v6 changes a signature we rely on | Low | Only 2 call sites use 2 stable methods; lint + syntax check + manual `require` test catches it |\n| `npm audit fix` pulls a sub-range bump that breaks a transitive consumer | Low | Not using `--force`; all bumps stay within declared package.json ranges |\n| moment 2.30 deprecation warnings | None that matter | No breaking API changes in minor bumps |\n| Something loads but breaks in prod only under specific Telegram events | Medium | No tests — rely on manual monitoring after push. Easy rollback above. |\n\n## Success criteria\n\n- `npm audit` shows only mongoose as critical/high\n- `npm run lint` passes\n- Bot starts (`node index.js` connects to Mongo and Telegram) — done as manual post-push verification by user\n- Dependabot open-alerts count drops by ~20 within hours\n\n## Non-goals\n\n- Zero Dependabot alerts\n- Modernizing any library (telegraf, ioredis, sharp, mongoose — all deferred)\n- Adding tests\n- Changing any application code\n\n---\n\n## After PR-1\n\nProceed to brainstorm PR-2 (mongoose 5 → 6.13.9 migration) as a separate spec. That work will modify ~11 files and requires careful breaking-change review.\n"
  },
  {
    "path": "ecosystem.config.js",
    "content": "module.exports = {\n  apps: [{\n    name: 'fStikBot',\n    script: './index.js',\n    max_memory_restart: '2000M',\n    watch: false,\n    cron_restart: '0 */6 * * *', // restart every 6 hours\n    // sharp (mosaic split, removebg, quote renderer) and fs I/O share Node's\n    // libuv thread pool. Default size is 4 — a single mosaic upload fans out\n    // 25+ sharp extract/resize/webp ops that pin all 4 threads and starve\n    // everything else (sticker conversion, file reads). 16 is a safe bump\n    // for a bot box with >=4 CPU cores.\n    env: {\n      NODE_ENV: 'development',\n      UV_THREADPOOL_SIZE: '16'\n    },\n    env_production: {\n      NODE_ENV: 'production',\n      UV_THREADPOOL_SIZE: '16'\n    }\n  }]\n}\n"
  },
  {
    "path": "handlers/admin/_helpers.js",
    "content": "// Shared admin guards / introspection. Single source of truth for the\n// rights model — every command and action must funnel through here.\n//\n// Rights:\n//   messaging — broadcast wizard\n//   pack      — pack/emoji-set management (transfer, remove, bulk-delete)\n//   finance   — /credit, /refund, financial ops menu, stars/outgoing CSV\n//   users     — /ban, View User Info, user management menu\n// Main admin (config.mainAdminId) implicitly has all rights.\n\nconst ADMIN_RIGHTS = ['messaging', 'pack', 'finance', 'users']\n\nconst isMainAdmin = (ctx) => ctx.config.mainAdminId === ctx.from?.id\n\n// Defensive: Redis-cached session may carry old userInfo without adminRights.\nconst getAdminRights = (ctx) => {\n  const r = ctx.session?.userInfo?.adminRights\n  return Array.isArray(r) ? r : []\n}\n\nconst hasRight = (ctx, right) => isMainAdmin(ctx) || getAdminRights(ctx).includes(right)\n\nconst isAnyAdmin = (ctx) => isMainAdmin(ctx) || getAdminRights(ctx).length > 0\n\n// Reply helper that picks the right channel for the update type.\nconst sendDeny = async (ctx, message) => {\n  if (ctx.callbackQuery) {\n    return ctx.answerCbQuery(message, { show_alert: true }).catch(() => {})\n  }\n  return ctx.replyWithHTML(message).catch(() => {})\n}\n\n// Outsider gate: silently swallow updates from non-admins so the admin\n// panel's existence isn't confirmed. Use for /admin and the panel root.\nconst requireAnyAdmin = (ctx, next) => {\n  if (isAnyAdmin(ctx)) return next()\n}\n\n// Per-right gate: main admin OR holders of `right` pass; sub-admins who\n// have *some* rights but not this one get a clear deny; outsiders stay\n// silent. Use for any command that mutates state.\nconst requireRight = (right) => async (ctx, next) => {\n  if (hasRight(ctx, right)) return next()\n  if (isAnyAdmin(ctx)) {\n    return sendDeny(ctx, `⛔ This action requires the <b>${right}</b> admin right.`)\n  }\n}\n\n// Main-admin-only gate (e.g. dangerous global ops). Same fidelity pattern.\nconst requireMainAdmin = async (ctx, next) => {\n  if (isMainAdmin(ctx)) return next()\n  if (isAnyAdmin(ctx)) {\n    return sendDeny(ctx, '⛔ This action is restricted to the main admin.')\n  }\n}\n\nmodule.exports = {\n  ADMIN_RIGHTS,\n  isMainAdmin,\n  isAnyAdmin,\n  hasRight,\n  getAdminRights,\n  requireAnyAdmin,\n  requireRight,\n  requireMainAdmin,\n  sendDeny\n}\n"
  },
  {
    "path": "handlers/admin/index.js",
    "content": "const path = require('path')\nconst Composer = require('telegraf/composer')\nconst Markup = require('telegraf/markup')\nconst I18n = require('telegraf-i18n')\nconst { escapeHTML: escape } = require('../../utils')\nconst {\n  ADMIN_RIGHTS,\n  isMainAdmin,\n  isAnyAdmin,\n  hasRight,\n  getAdminRights,\n  requireAnyAdmin,\n  requireRight,\n  sendDeny\n} = require('./_helpers')\n\nconst i18n = new I18n({\n  directory: path.resolve(__dirname, '../../locales'),\n  defaultLanguage: 'en',\n  sessionName: 'session',\n  useSession: true,\n  allowMissing: false,\n  skipPluralize: true\n})\n\nconst composer = new Composer()\n\n// --- Awaiting-input state machine -------------------------------------------\n// One key per operation. Each entry declares the right it needs and the\n// handler invoked once the admin replies. This kills the previous\n// switch-case + hardcoded sensitiveOps list in two places.\n\nconst AWAITING = {\n  ban_user: { right: 'users', handler: (ctx, input) => handleBanUser(ctx, input) },\n  set_premium: { right: 'finance', handler: (ctx, input) => handleSetPremium(ctx, input) },\n  refund_payment: { right: 'finance', handler: (ctx, input) => handleRefundPayment(ctx, input) },\n  view_user_info: { right: 'users', handler: (ctx, input) => handleViewUserInfo(ctx, input) }\n}\n\nconst cancelInputKeyboard = Markup.inlineKeyboard([\n  [Markup.callbackButton('✖️ Cancel', 'admin:input:cancel')]\n])\n\nconst promptInput = async (ctx, key, text) => {\n  await ctx.answerCbQuery().catch(() => {})\n  ctx.session.awaitingInput = key\n  await ctx.replyWithHTML(text, { reply_markup: cancelInputKeyboard })\n}\n\n// --- Pagination helper for getStarTransactions ------------------------------\n// Caps total transactions to avoid unbounded admin flow on high-traffic bots.\n// filterKey: 'source' (incoming) or 'receiver' (outgoing).\nconst fetchTransactions = async (tg, filterKey, maxTransactions = 10000) => {\n  const transactions = []\n  const limit = 100\n  let offset = 0\n  let truncated = false\n\n  while (true) {\n    const result = await tg.callApi('getStarTransactions', { limit, offset })\n    if (!result.transactions || result.transactions.length === 0) break\n    transactions.push(...result.transactions.filter(item => item[filterKey]))\n    if (result.transactions.length < limit) break\n    offset += limit\n    if (transactions.length >= maxTransactions) {\n      truncated = true\n      break\n    }\n  }\n\n  if (transactions.length > maxTransactions) transactions.length = maxTransactions\n  return { transactions, truncated }\n}\n\n// --- Menus ------------------------------------------------------------------\n// Each menu builds inline keyboard reactively from the current admin's\n// rights, so a sub-admin only ever sees buttons they can actually use.\n\nconst sectionLabel = (right) => {\n  switch (right) {\n    case 'messaging': return '📣 Broadcasts'\n    case 'pack': return '📦 Pack management'\n    case 'finance': return '💰 Financial ops'\n    case 'users': return '👥 User management'\n    default: return `⚙️ ${right}`\n  }\n}\n\nconst sectionCallback = (right) => {\n  switch (right) {\n    case 'messaging': return 'admin:messaging'\n    case 'pack': return 'admin:pack'\n    case 'finance': return 'admin:financial_ops'\n    case 'users': return 'admin:user_management'\n    default: return `admin:${right}`\n  }\n}\n\nconst renderMessage = async (ctx, text, replyMarkup) => {\n  const opts = {\n    parse_mode: 'HTML',\n    disable_web_page_preview: true,\n    reply_markup: replyMarkup\n  }\n  if (ctx.callbackQuery) {\n    return ctx.editMessageText(text, opts).catch(() => ctx.replyWithHTML(text, opts))\n  }\n  return ctx.replyWithHTML(text, opts)\n}\n\nconst displayAdminPanel = async (ctx) => {\n  const rights = isMainAdmin(ctx) ? ADMIN_RIGHTS : getAdminRights(ctx)\n  const visibleRights = ADMIN_RIGHTS.filter(r => rights.includes(r))\n\n  const showTransactions = isMainAdmin(ctx) || rights.includes('finance')\n\n  const text = [\n    '🔐 <b>Admin Panel</b>',\n    '',\n    isMainAdmin(ctx) ? '👑 You are the main admin.' : `🛡 Your rights: <b>${rights.join(', ') || 'none'}</b>`\n  ].join('\\n')\n\n  const buttons = visibleRights.map(r => [Markup.callbackButton(sectionLabel(r), sectionCallback(r))])\n  if (showTransactions) {\n    buttons.push([Markup.callbackButton('📊 Transaction history', 'admin:transactions')])\n  }\n\n  await renderMessage(ctx, text, Markup.inlineKeyboard(buttons))\n}\n\nconst displayUserManagement = async (ctx) => {\n  const text = '👥 <b>User management</b>\\n\\nPick an action:'\n  const buttons = Markup.inlineKeyboard([\n    [Markup.callbackButton('🚫 Ban / Unban user', 'admin:user:ban')],\n    [Markup.callbackButton('ℹ️ View user info', 'admin:user:info')],\n    [Markup.callbackButton('« Admin panel', 'admin:back')]\n  ])\n  await renderMessage(ctx, text, buttons)\n}\n\nconst displayFinancialOps = async (ctx) => {\n  const text = '💰 <b>Financial operations</b>\\n\\nPick an action:'\n  const buttons = Markup.inlineKeyboard([\n    [Markup.callbackButton('💸 Refund payment', 'admin:finance:refund')],\n    [Markup.callbackButton('💳 Add / Remove credits', 'admin:finance:credits')],\n    [Markup.callbackButton('📜 Payment history', 'admin:finance:history')],\n    [Markup.callbackButton('« Admin panel', 'admin:back')]\n  ])\n  await renderMessage(ctx, text, buttons)\n}\n\nconst displayTransactionHistory = async (ctx) => {\n  const text = '📊 <b>Transaction history</b>\\n\\nPick a report:'\n  const buttons = Markup.inlineKeyboard([\n    [Markup.callbackButton('⭐️ Incoming (stars)', 'admin:history:stars')],\n    [Markup.callbackButton('📤 Outgoing', 'admin:history:out')],\n    [Markup.callbackButton('« Admin panel', 'admin:back')]\n  ])\n  await renderMessage(ctx, text, buttons)\n}\n\n// --- Awaiting-input prompts -------------------------------------------------\n\nconst promptBanUser = (ctx) => promptInput(ctx, 'ban_user',\n  '🚫 Send the user ID or @username to ban / unban.')\n\nconst promptSetPremium = (ctx) => promptInput(ctx, 'set_premium',\n  '⭐️ Send <code>user_id amount</code> (negative to remove). E.g. <code>123456 100</code> or <code>@username -50</code>.')\n\nconst promptRefund = (ctx) => promptInput(ctx, 'refund_payment',\n  '💸 Send the Telegram payment charge ID to refund.')\n\nconst promptViewUserInfo = (ctx) => promptInput(ctx, 'view_user_info',\n  'ℹ️ Send the user ID or @username to view info.')\n\n// --- Reports ----------------------------------------------------------------\n\nconst renderTransactionsReport = async (ctx, { kind, transactions, truncated }) => {\n  const direction = kind === 'source' ? 'Stars' : 'Outgoing'\n  const csvFilename = kind === 'source' ? 'stars_transactions.csv' : 'outgoing_transactions.csv'\n  const userKey = kind === 'source' ? 'source' : 'receiver'\n  const partyLabel = kind === 'source' ? 'From' : 'To'\n\n  const csvHeader = (truncated ? `# truncated to first ${transactions.length} transactions\\n` : '') +\n    `Date,Transaction ID,Amount,USD,${partyLabel} Name,${partyLabel} ID`\n\n  const csvBody = transactions.map((item) => {\n    const u = item[userKey]?.user\n    const name = (u?.first_name || '').replace(/\"/g, '\"\"')\n    return `\"${new Date(item.date * 1000).toLocaleString()}\",\"${item.id}\",${item.amount},${(item.amount * 0.013).toFixed(2)},\"${name}\",${u?.id || ''}`\n  })\n\n  await ctx.replyWithDocument({\n    source: Buffer.from([csvHeader, ...csvBody].join('\\n'), 'utf-8'),\n    filename: csvFilename\n  })\n\n  const last20 = transactions.slice(0, 20)\n  const list = last20.map((item, i) => {\n    const u = item[userKey]?.user\n    const userLink = u\n      ? `<a href=\"tg://user?id=${u.id}\">${escape(u.first_name || '')}</a>`\n      : '<i>unknown</i>'\n    return `${i + 1}. <b>${item.amount} ⭐️</b> ($${(item.amount * 0.013).toFixed(2)})\\n` +\n           `   🆔 <code>${item.id}</code>\\n` +\n           `   👤 ${partyLabel}: ${userLink}\\n` +\n           `   🕒 ${new Date(item.date * 1000).toLocaleString()}`\n  }).join('\\n\\n')\n\n  const truncatedNote = truncated\n    ? `\\n\\n⚠️ <i>List truncated to first ${transactions.length} transactions.</i>`\n    : ''\n\n  await renderMessage(\n    ctx,\n    `<b>📊 Last 20 ${direction} transactions</b>\\n\\n${list || '<i>No transactions.</i>'}\\n\\nFull CSV attached.${truncatedNote}`,\n    Markup.inlineKeyboard([[Markup.callbackButton('« Transaction history', 'admin:transactions')]])\n  )\n}\n\nconst getStarsTransactions = async (ctx) => {\n  await ctx.answerCbQuery().catch(() => {})\n  try {\n    const { transactions, truncated } = await fetchTransactions(ctx.tg, 'source')\n    transactions.sort((a, b) => b.date - a.date)\n    await renderTransactionsReport(ctx, { kind: 'source', transactions, truncated })\n  } catch (error) {\n    console.error('Error fetching stars transactions:', error)\n    await ctx.replyWithHTML('❌ Failed to fetch stars transactions. Try again later.')\n  }\n}\n\nconst getOutgoingTransactions = async (ctx) => {\n  await ctx.answerCbQuery().catch(() => {})\n  try {\n    const { transactions, truncated } = await fetchTransactions(ctx.tg, 'receiver')\n    transactions.sort((a, b) => b.date - a.date)\n    await renderTransactionsReport(ctx, { kind: 'receiver', transactions, truncated })\n  } catch (error) {\n    console.error('Error fetching outgoing transactions:', error)\n    await ctx.replyWithHTML('❌ Failed to fetch outgoing transactions. Try again later.')\n  }\n}\n\n// --- User lookup ------------------------------------------------------------\n\nconst findUser = async (ctx, input) => {\n  if (!input || typeof input !== 'string' || !input.trim()) return null\n  const cleanInput = input.trim().replace(/^@/, '')\n  const numeric = Number(cleanInput)\n  const isNumeric = !Number.isNaN(numeric) && Number.isInteger(numeric) && cleanInput !== ''\n\n  // Numeric input → username could legitimately be all digits, so query both.\n  // Non-numeric → username only (avoids accidental telegram_id:0 match).\n  const orClauses = isNumeric\n    ? [{ telegram_id: parseInt(cleanInput, 10) }, { username: cleanInput }]\n    : [{ username: cleanInput }]\n\n  return ctx.db.User.findOne({ $or: orClauses })\n}\n\n// --- Mutations --------------------------------------------------------------\n\nconst handleBanUser = async (ctx, input) => {\n  const user = await findUser(ctx, input)\n  if (!user) return ctx.replyWithHTML('❌ User not found. Check the ID or username and try again.')\n\n  const updated = await ctx.db.User.findByIdAndUpdate(\n    user._id,\n    { $set: { banned: !user.banned } },\n    { new: true }\n  )\n\n  const status = updated.banned ? '🚫 banned' : '✅ unbanned'\n  await ctx.replyWithHTML(\n    `User <code>${escape(updated.telegram_id)}</code> ` +\n    `${updated.username ? `(@${escape(updated.username)})` : ''} is now ${status}.`\n  )\n}\n\nconst handleSetPremium = async (ctx, input) => {\n  if (!input || !input.trim()) {\n    return ctx.replyWithHTML('❌ Empty input. Format: <code>user_id amount</code>')\n  }\n\n  const parts = input.trim().split(/\\s+/)\n  if (parts.length < 2) {\n    return ctx.replyWithHTML('❌ Invalid format. Use: <code>user_id amount</code>')\n  }\n\n  const [userId, creditStr] = parts\n  const credit = parseInt(creditStr, 10)\n  if (Number.isNaN(credit)) {\n    return ctx.replyWithHTML('❌ Invalid credit amount. Send an integer (negative to subtract).')\n  }\n\n  const user = await findUser(ctx, userId)\n  if (!user) return ctx.replyWithHTML('❌ User not found. Check the ID or username and try again.')\n\n  const updated = await ctx.db.User.findByIdAndUpdate(\n    user._id,\n    { $inc: { balance: credit } },\n    { new: true }\n  )\n\n  const sign = credit >= 0 ? '+' : ''\n  await ctx.replyWithHTML(\n    `✅ User <code>${escape(updated.telegram_id)}</code> ` +\n    `${updated.username ? `(@${escape(updated.username)}) ` : ''}` +\n    `balance: <b>${updated.balance}</b> credits (${sign}${credit}).`\n  )\n\n  if (credit !== 0) {\n    await ctx.telegram.sendMessage(\n      updated.telegram_id,\n      i18n.t(updated.locale, 'donate.update', { amount: credit, balance: updated.balance }),\n      { parse_mode: 'HTML' }\n    ).catch((err) => console.error('Failed to notify user about credit change:', err.message))\n  }\n}\n\nconst handleRefundPayment = async (ctx, paymentId) => {\n  if (!paymentId || !paymentId.trim()) {\n    return ctx.replyWithHTML('❌ Empty payment ID.')\n  }\n\n  const trimmed = paymentId.trim()\n  const payment = await ctx.db.Payment.findOne({\n    'resultData.telegram_payment_charge_id': trimmed\n  })\n\n  if (!payment) return ctx.replyWithHTML('❌ Payment not found.')\n  if (payment.status === 'refunded') return ctx.replyWithHTML('❌ Payment already refunded.')\n\n  const refundUser = await ctx.db.User.findOne({ _id: payment.user })\n  if (!refundUser) return ctx.replyWithHTML('❌ User attached to that payment was not found.')\n\n  try {\n    await ctx.telegram.callApi('refundStarPayment', {\n      user_id: refundUser.telegram_id,\n      telegram_payment_charge_id: trimmed\n    })\n\n    // Idempotency guard: only one concurrent refund flips status.\n    const refunded = await ctx.db.Payment.findOneAndUpdate(\n      { _id: payment._id, status: { $ne: 'refunded' } },\n      { $set: { status: 'refunded' } },\n      { new: true }\n    )\n    if (!refunded) {\n      return ctx.replyWithHTML('❌ Payment was already refunded by another operation.')\n    }\n\n    await ctx.db.User.findByIdAndUpdate(refundUser._id, { $inc: { balance: -payment.amount } })\n\n    await ctx.replyWithHTML(`✅ Payment <code>${escape(trimmed)}</code> refunded successfully.`)\n  } catch (error) {\n    console.error('Refund failed:', error)\n    await ctx.replyWithHTML(`❌ Refund failed: <code>${escape(error.description || error.message || 'unknown error')}</code>`)\n  }\n}\n\nconst handleViewUserInfo = async (ctx, input) => {\n  const user = await findUser(ctx, input)\n  if (!user) return ctx.replyWithHTML('❌ User not found. Check the ID or username and try again.')\n\n  const lines = [\n    '👤 <b>User information</b>',\n    '',\n    `🆔 <code>${escape(user.telegram_id)}</code>`,\n    `👤 ${escape(user.first_name || '')}${user.last_name ? ' ' + escape(user.last_name) : ''}`,\n    `🏷 ${user.username ? '@' + escape(user.username) : '<i>no username</i>'}`,\n    `💰 Balance: <b>${user.balance}</b>`,\n    `🌍 Locale: ${user.locale || '<i>unset</i>'}`,\n    `🚫 Banned: ${user.banned ? 'yes' : 'no'}`,\n    `🔒 Blocked: ${user.blocked ? 'yes' : 'no'}`,\n    `👑 Admin rights: ${(user.adminRights && user.adminRights.length) ? user.adminRights.join(', ') : 'none'}`,\n    `🛡 Moderator: ${user.moderator ? 'yes' : 'no'}`,\n    `🚷 Public ban: ${user.publicBan ? 'yes' : 'no'}`,\n    '',\n    `📦 Sticker set: ${user.stickerSet ? `<code>${escape(user.stickerSet)}</code>` : '<i>unset</i>'}`,\n    `🔠 Inline sticker set: ${user.inlineStickerSet ? `<code>${escape(user.inlineStickerSet)}</code>` : '<i>unset</i>'}`,\n    `📊 Inline type: ${user.inlineType || '<i>unset</i>'}`\n  ]\n\n  if (user.webapp && (user.webapp.country || user.webapp.platform)) {\n    lines.push('', '🌐 <b>WebApp:</b>')\n    if (user.webapp.country) lines.push(`  Country: ${escape(user.webapp.country)}`)\n    if (user.webapp.platform) lines.push(`  Platform: ${escape(user.webapp.platform)}`)\n    if (user.webapp.os) lines.push(`  OS: ${escape(user.webapp.os)}`)\n    if (user.webapp.browser) lines.push(`  Browser: ${escape(user.webapp.browser)} ${escape(user.webapp.version || '')}`)\n  }\n\n  lines.push('')\n  if (user.createdAt) lines.push(`📅 Joined: ${new Date(user.createdAt).toLocaleString()}`)\n  if (user.updatedAt) lines.push(`🔄 Updated: ${new Date(user.updatedAt).toLocaleString()}`)\n\n  await ctx.replyWithHTML(lines.join('\\n'), { disable_web_page_preview: true })\n}\n\n// --- Awaiting-input dispatcher ---------------------------------------------\n\nconst handleAwaitingInput = async (ctx, next) => {\n  const key = ctx.session.awaitingInput\n  if (!key) return next()\n\n  const text = ctx.message?.text || ''\n\n  // Slash commands always escape the awaiting state — otherwise typing\n  // /admin while in \"send me a user_id\" would feed the command to the\n  // input handler and be silently discarded.\n  if (text.startsWith('/')) {\n    ctx.session.awaitingInput = null\n    return next()\n  }\n\n  const op = AWAITING[key]\n  if (!op) {\n    ctx.session.awaitingInput = null\n    return next()\n  }\n\n  // Re-check the right at apply-time: a sub-admin could have lost the right\n  // (or had it revoked) between prompt and reply.\n  if (!hasRight(ctx, op.right)) {\n    ctx.session.awaitingInput = null\n    return sendDeny(ctx, `⛔ This action requires the <b>${op.right}</b> admin right.`)\n  }\n\n  ctx.session.awaitingInput = null\n  try {\n    await op.handler(ctx, text)\n  } catch (err) {\n    console.error(`Admin awaiting-input handler \"${key}\" failed:`, err)\n    await ctx.replyWithHTML('❌ Something went wrong. Check the logs.').catch(() => {})\n  }\n}\n\n// --- Wiring -----------------------------------------------------------------\n\n// Entry points\ncomposer.command('admin', requireAnyAdmin, displayAdminPanel)\ncomposer.hears([I18n.match('start.menu.admin')], requireAnyAdmin, displayAdminPanel)\n// Returning to the panel must also escape any active scene — otherwise\n// the user is silently re-entered into the broadcast wizard on their\n// next message.\nconst backToPanel = async (ctx) => {\n  await ctx.answerCbQuery().catch(() => {})\n  if (ctx.scene && ctx.scene.current) await ctx.scene.leave().catch(() => {})\n  ctx.session.scene = null\n  return displayAdminPanel(ctx)\n}\ncomposer.action('admin:main', requireAnyAdmin, backToPanel)\ncomposer.action('admin:back', requireAnyAdmin, backToPanel)\ncomposer.action('admin:menu', requireAnyAdmin, backToPanel)\n\n// Cancel an awaiting-input prompt.\ncomposer.action('admin:input:cancel', async (ctx) => {\n  ctx.session.awaitingInput = null\n  await ctx.answerCbQuery('Cancelled').catch(() => {})\n  await ctx.editMessageText('✖️ Cancelled.', { parse_mode: 'HTML' }).catch(() => {})\n})\ncomposer.command('admincancel', (ctx) => {\n  if (!ctx.session.awaitingInput) {\n    return ctx.replyWithHTML('Nothing to cancel.')\n  }\n  ctx.session.awaitingInput = null\n  return ctx.replyWithHTML('✖️ Cancelled.')\n})\n\n// Direct commands\ncomposer.command('ban', requireRight('users'), async (ctx) => {\n  const userId = ctx.message.text.split(' ').slice(1).join(' ').trim()\n  if (!userId) {\n    return ctx.replyWithHTML('Usage: <code>/ban &lt;user_id or @username&gt;</code>')\n  }\n  await handleBanUser(ctx, userId)\n})\ncomposer.hears(/^\\/credit\\s+(\\S+)\\s+(-?\\d+)$/, requireRight('finance'), async (ctx) => {\n  const [, userId, amount] = ctx.match\n  await handleSetPremium(ctx, `${userId} ${amount}`)\n})\ncomposer.hears(/^\\/refund\\s+(.+)$/, requireRight('finance'), async (ctx) => {\n  const [, paymentId] = ctx.match\n  await handleRefundPayment(ctx, paymentId)\n})\ncomposer.command('stars', requireRight('finance'), getStarsTransactions)\n\n// Submenus\ncomposer.action('admin:user_management', requireRight('users'), displayUserManagement)\ncomposer.action('admin:financial_ops', requireRight('finance'), displayFinancialOps)\ncomposer.action('admin:transactions', requireRight('finance'), displayTransactionHistory)\n\n// User-management actions\ncomposer.action('admin:user:ban', requireRight('users'), promptBanUser)\ncomposer.action('admin:user:info', requireRight('users'), promptViewUserInfo)\n\n// Finance actions\ncomposer.action('admin:finance:refund', requireRight('finance'), promptRefund)\ncomposer.action('admin:finance:credits', requireRight('finance'), promptSetPremium)\ncomposer.action('admin:finance:history', requireRight('finance'), getStarsTransactions)\n\n// Transaction reports\ncomposer.action('admin:history:stars', requireRight('finance'), getStarsTransactions)\ncomposer.action('admin:history:out', requireRight('finance'), getOutgoingTransactions)\n\n// Sub-section composers (messaging / pack). Composer.optional silently drops\n// the update when the predicate is false, so sub-admins without the right\n// fall through to the catch-all below for proper feedback.\nconst sectionRights = ['messaging', 'pack']\nsectionRights.forEach((right) => {\n  composer.use(Composer.optional((ctx) => hasRight(ctx, right), require(`./${right}`)))\n})\n\n// Awaiting-input dispatcher — must come AFTER section composers so that\n// scenes that read text don't get short-circuited.\ncomposer.on('text', handleAwaitingInput)\n\n// Catch-all for unrecognised admin:* callbacks. Silent for outsiders;\n// gentle reroute to the panel for actual admins (no scary \"not implemented\"\n// toast — the user just lands back at the menu).\ncomposer.action(/^admin:/, async (ctx) => {\n  if (!isAnyAdmin(ctx)) return\n  await ctx.answerCbQuery().catch(() => {})\n  return displayAdminPanel(ctx)\n})\n\nmodule.exports = composer\n"
  },
  {
    "path": "handlers/admin/messaging.js",
    "content": "const Composer = require('telegraf/composer')\nconst Markup = require('telegraf/markup')\nconst replicators = require('telegraf/core/replicators')\nconst moment = require('moment')\nconst escapeHTML = require('../../utils/html-escape')\nconst { tolerantEditMessage } = require('../../utils/safe-edit')\n\nconst composer = new Composer()\n\ncomposer.action(/admin:messaging:select_group/, async (ctx) => ctx.scene.enter('adminMessagingSelectGroup'))\ncomposer.action(/admin:messaging:publish/, async (ctx) => ctx.scene.enter('adminMessagingPublish'))\n\ncomposer.action(/admin:messaging:view:(.*)/, async (ctx, next) => {\n  await ctx.answerCbQuery()\n\n  const messaging = await ctx.db.Messaging.findOne({ _id: ctx.match[1] })\n\n  if (messaging) {\n    const method = replicators.copyMethods[messaging.message.type]\n    const opts = Object.assign(messaging.message.data, {\n      chat_id: ctx.chat.id\n    })\n\n    await ctx.telegram.callApi(method, opts).catch((error) => {\n      console.error('Failed to send messaging preview:', error.message)\n    })\n  }\n})\n\ncomposer.action(/admin:messaging:edit:(.*)/, async (ctx, next) => {\n  ctx.session.scene.edit = ctx.match[1]\n  ctx.scene.enter('adminMessagingMessageData')\n})\n\ncomposer.action(/admin:messaging:change_name:(.*)/, async (ctx, next) => {\n  ctx.session.scene.edit = ctx.match[1]\n  ctx.scene.enter('adminMessagingName')\n})\n\ncomposer.action(/admin:messaging:cancel:(.*)/, async (ctx, next) => {\n  const messaging = await ctx.db.Messaging.findOne({ _id: ctx.match[1] })\n\n  if (!messaging) {\n    return ctx.answerCbQuery('Messaging not found', true)\n  }\n\n  messaging.status = 2\n  messaging.result = {\n    waiting: 0\n  }\n  await messaging.save()\n\n  const resultText = `Message ${messaging.name} canceled`\n\n  const replyMarkup = Markup.inlineKeyboard([\n    [\n      Markup.callbackButton('Show message status', `admin:messaging:status:${ctx.match[1]}`)\n    ],\n    [\n      Markup.callbackButton('Back', 'admin:messaging'),\n      Markup.callbackButton('Admin', 'admin:back')\n    ]\n  ])\n\n  await tolerantEditMessage(ctx, resultText, {\n    parse_mode: 'HTML',\n    reply_markup: replyMarkup\n  })\n})\n\ncomposer.action(/admin:messaging:list:(.*):(.*)/, async (ctx, next) => {\n  const resultText = 'Messaging campaigns list'\n\n  let messagingQuery\n\n  if (ctx.match[1] === 'archive') messagingQuery = { status: 2 }\n  else messagingQuery = { status: { $lt: 2 } }\n\n  const messagingTotal = await ctx.db.Messaging.countDocuments(messagingQuery)\n\n  const pageCount = 10\n  let page = parseInt(ctx.match[2], 10) || 1\n\n  if (page <= 0 || !Number.isFinite(page)) page = 1\n  if (pageCount * page > messagingTotal) page = Math.ceil(messagingTotal / pageCount)\n\n  const prevPage = page - 1\n  const nextPage = page + 1\n\n  let pageSkip = pageCount * (page - 1)\n  if (pageSkip < 0) pageSkip = 0\n\n  const messagingList = await ctx.db.Messaging.find(messagingQuery).sort({ createdAt: -1 }).skip(pageSkip).limit(pageCount)\n\n  const messagingKeyboard = []\n\n  Object.keys(messagingList).forEach((key) => {\n    const messaging = messagingList[key]\n    messagingKeyboard.push([Markup.callbackButton(messaging.name, `admin:messaging:status:${messaging.id}`)])\n  })\n\n  let inlineKeyboard = []\n\n  const keyboardNavigation = []\n\n  if (prevPage > 0) keyboardNavigation.push(Markup.callbackButton(`‹ ${prevPage}`, `admin:messaging:list:${ctx.match[1]}:${prevPage}`))\n  if (pageCount * page < messagingTotal) keyboardNavigation.push(Markup.callbackButton(`${nextPage} ›`, `admin:messaging:list:${ctx.match[1]}:${nextPage}`))\n\n  inlineKeyboard = inlineKeyboard.concat(messagingKeyboard)\n  inlineKeyboard = inlineKeyboard.concat([keyboardNavigation])\n  inlineKeyboard = inlineKeyboard.concat([\n    [\n      Markup.callbackButton('Messaging', 'admin:messaging'),\n      Markup.callbackButton('Admin', 'admin:back')\n    ]\n  ])\n\n  const replyMarkup = Markup.inlineKeyboard(inlineKeyboard)\n\n  await tolerantEditMessage(ctx, resultText, {\n    parse_mode: 'HTML',\n    reply_markup: replyMarkup\n  })\n})\n\ncomposer.action(/admin:messaging:status:(.*)/, async (ctx, next) => {\n  const messaging = await ctx.db.Messaging.findOne({ _id: ctx.match[1] }).populate('creator', '_id telegram_id first_name').lean()\n\n  let resultText, replyMarkup\n\n  const statusTypes = ['📝 Draft', '⏳ In progress', '✅ Completed', '❌ Failed']\n  const statusColors = ['🔵', '🟡', '🟢', '🔴']\n\n  if (messaging) {\n    // Calculate percentages for progress indicators\n    const totalMessages = messaging.result.total || 0\n    const sentMessages = messaging.result.state || 0\n    const deliveredMessages = sentMessages - (messaging.result.error || 0)\n    const errorMessages = messaging.result.error || 0\n\n    const completionPercent = totalMessages > 0 ? Math.round((sentMessages / totalMessages) * 100) : 0\n    const deliveryPercent = sentMessages > 0 ? Math.round((deliveredMessages / sentMessages) * 100) : 0\n    const errorPercent = sentMessages > 0 ? Math.round((errorMessages / sentMessages) * 100) : 0\n\n    // Create progress bar\n    const progressBarLength = 10\n    const filledBars = Math.round((completionPercent / 100) * progressBarLength)\n    const progressBar = '▓'.repeat(filledBars) + '░'.repeat(progressBarLength - filledBars)\n\n    // Format date nicely\n    const scheduledDate = moment(messaging.date)\n    const createdDate = moment(messaging.createdAt)\n    const now = moment()\n\n    const scheduledFormatted = scheduledDate.format('DD MMM YYYY [at] HH:mm')\n    const scheduledRelative = scheduledDate.isAfter(now) ? `(${scheduledDate.fromNow()})` : ''\n    const createdFormatted = createdDate.format('DD MMM YYYY [at] HH:mm')\n\n    // Collect user errors in a cleaner way\n    let userErrors = ''\n    if (messaging.sendErrors && messaging.sendErrors.length > 0) {\n      userErrors = '\\n<b>📋 Last Error Details:</b>\\n'\n      const errorLimit = Math.min(5, messaging.sendErrors.length)\n\n      for (let i = 0; i < errorLimit; i++) {\n        const error = messaging.sendErrors[i]\n        if (error && error.telegram_id) {\n          userErrors += `• <a href=\"tg://user?id=${error.telegram_id}\">User ${error.telegram_id}</a>: ${error.errorMessage || 'Unknown error'}\\n`\n        }\n      }\n\n      if (messaging.sendErrors.length > errorLimit) {\n        userErrors += `<i>...and ${messaging.sendErrors.length - errorLimit} more errors</i>\\n`\n      }\n    }\n\n    resultText = '<b>📊 Message Campaign Status</b>\\n\\n'\n    resultText += `<b>🏷 Name:</b> ${escapeHTML(messaging.name)}\\n`\n    resultText += `<b>⏰ Scheduled for:</b> ${scheduledFormatted} ${scheduledRelative}\\n`\n    resultText += `<b>🗓 Created on:</b> ${createdFormatted}\\n`\n    resultText += `<b>📊 Status:</b> ${statusColors[messaging.status] || '⚪️'} ${statusTypes[messaging.status] || 'Unknown'}\\n\\n`\n\n    resultText += `<b>📈 Progress:</b> ${completionPercent}% ${progressBar}\\n`\n    resultText += `<b>📨 Total Recipients:</b> ${totalMessages.toLocaleString()}\\n`\n    resultText += `<b>✓ Processed:</b> ${sentMessages.toLocaleString()} (${completionPercent}%)\\n`\n    resultText += `<b>📬 Delivered:</b> ${deliveredMessages.toLocaleString()} (${deliveryPercent}%)\\n`\n    resultText += `<b>📭 Remaining:</b> ${(totalMessages - sentMessages).toLocaleString()}\\n`\n    resultText += `<b>⚠️ Errors:</b> ${errorMessages.toLocaleString()} (${errorPercent}%)\\n`\n\n    resultText += userErrors\n\n    let cancelButton = []\n    if (messaging.status < 2) {\n      cancelButton = [Markup.callbackButton('❌ Cancel messaging', `admin:messaging:cancel:${ctx.match[1]}`)]\n    }\n\n    replyMarkup = Markup.inlineKeyboard([\n      [\n        Markup.callbackButton('🔄 Refresh', `admin:messaging:status:${ctx.match[1]}`),\n        Markup.callbackButton('👁 View message', `admin:messaging:view:${ctx.match[1]}`)\n      ],\n      [\n        Markup.callbackButton('✏️ Edit message', `admin:messaging:edit:${ctx.match[1]}`),\n        Markup.callbackButton('📝 Change name', `admin:messaging:change_name:${ctx.match[1]}`)\n      ],\n      cancelButton,\n      [\n        Markup.callbackButton('← Messaging', 'admin:messaging'),\n        Markup.callbackButton('⚙️ Admin', 'admin:back')\n      ]\n    ])\n  } else {\n    resultText = '⚠️ Message not found'\n    replyMarkup = Markup.inlineKeyboard([\n      [\n        Markup.callbackButton('← Messaging', 'admin:messaging'),\n        Markup.callbackButton('⚙️ Admin', 'admin:back')\n      ]\n    ])\n  }\n\n  await tolerantEditMessage(ctx, resultText, {\n    parse_mode: 'HTML',\n    reply_markup: replyMarkup,\n    disable_web_page_preview: true\n  })\n})\n\ncomposer.action(/admin:messaging:create/, async (ctx, next) => {\n  ctx.scene.enter('adminMessagingName')\n})\n\ncomposer.action(/admin:messaging/, async (ctx, next) => {\n  const resultText = 'Messaging administration panel'\n\n  const replyMarkup = Markup.inlineKeyboard([\n    [Markup.callbackButton('Create new messaging', 'admin:messaging:create')],\n    [Markup.callbackButton('Scheduled messagings', 'admin:messaging:list:scheduled:1')],\n    [Markup.callbackButton('Messaging archive', 'admin:messaging:list:archive:1')],\n    [Markup.callbackButton('Back to admin', 'admin:back')]\n  ])\n\n  await tolerantEditMessage(ctx, resultText, {\n    parse_mode: 'HTML',\n    reply_markup: replyMarkup\n  })\n})\n\nmodule.exports = composer\n"
  },
  {
    "path": "handlers/admin/pack.js",
    "content": "const Composer = require('telegraf/composer')\nconst Markup = require('telegraf/markup')\n\nconst composer = new Composer()\n\ncomposer.action(/admin:pack:edit/, (ctx) => ctx.scene.enter('adminPackFind'))\n\ncomposer.action(/admin:pack:bulk_delete/, (ctx) => ctx.scene.enter('adminPackBulkDelete'))\n\ncomposer.action(/admin:pack/, async (ctx) => {\n  const resultText = `\n<b>Admin Pack Management</b>\n\nChoose an option:\n• Edit or remove individual packs\n• Bulk delete packs by user ID\n  `\n\n  const replyMarkup = Markup.inlineKeyboard([\n    [Markup.callbackButton('🖊 Edit/Remove Pack', 'admin:pack:edit')],\n    [Markup.callbackButton('🗑 Bulk Delete Packs', 'admin:pack:bulk_delete')],\n    [Markup.callbackButton('🔙 Back to Admin Menu', 'admin:back')]\n  ])\n\n  await ctx.editMessageText(resultText, {\n    parse_mode: 'HTML',\n    reply_markup: replyMarkup\n  }).catch(() => {})\n})\n\nmodule.exports = composer\n"
  },
  {
    "path": "handlers/catalog.js",
    "content": "const { replyOrEditBanner } = require('../banners')\n\nmodule.exports = async (ctx) => {\n  const caption = ctx.i18n.t('cmd.start.catalog')\n  const extra = {\n    reply_markup: JSON.stringify({\n      inline_keyboard: [\n        [\n          {\n            text: ctx.i18n.t('cmd.start.btn.catalog'),\n            url: ctx.config.catalogUrl\n          }\n        ],\n        [\n          {\n            text: ctx.i18n.t('cmd.start.btn.catalog_app'),\n            url: ctx.config.catalogAppUrl\n          }\n        ],\n        [\n          {\n            text: ctx.i18n.t('cmd.start.commands.publish'),\n            callback_data: 'publish'\n          }\n        ]\n      ]\n    })\n  }\n\n  await replyOrEditBanner(ctx, 'catalog', caption, extra)\n}\n"
  },
  {
    "path": "handlers/catch.js",
    "content": "const fs = require('fs')\nconst path = require('path')\nconst util = require('util')\nconst execFile = util.promisify(require('child_process').execFile)\nconst errorStackParser = require('error-stack-parser')\nconst { escapeHTML, isRateLimitError, getRetryAfter } = require('../utils')\nconst log = require('../utils/logger').scope('error-handler')\n\n// Probe once at module load: is .git available at project root?\n// Skip git blame entirely in environments without .git (e.g. Docker deploys)\n// to avoid spawning a failing git process on every error.\nconst PROJECT_ROOT = path.resolve(__dirname, '..')\nconst HAS_GIT_DIR = (() => {\n  try {\n    return fs.existsSync(path.join(PROJECT_ROOT, '.git'))\n  } catch (e) {\n    return false\n  }\n})()\n\n/**\n * Pick the first stack frame that's inside the project AND not in\n * node_modules — git blame against node_modules always fails with\n * \"no such path in HEAD\" and spams the logs.\n */\nfunction pickBlameFrame (errorInfo) {\n  for (const frame of errorInfo) {\n    const file = frame.fileName\n    if (!file || typeof file !== 'string') continue\n    if (!file.startsWith(PROJECT_ROOT)) continue\n    if (file.includes(`${path.sep}node_modules${path.sep}`)) continue\n    if (!frame.lineNumber) continue\n    return frame\n  }\n  return null\n}\n\nasync function errorLog (error, ctx) {\n  const errorInfo = errorStackParser.parse(error)\n\n  let gitBlame\n  const frame = HAS_GIT_DIR ? pickBlameFrame(errorInfo) : null\n\n  if (frame) {\n    // Silent on failure — any noise here would fire on every caught\n    // error in prod and drown out real signals.\n    gitBlame = await execFile(\n      'git',\n      ['blame', '-L', `${frame.lineNumber},${frame.lineNumber}`, '--', frame.fileName],\n      { timeout: 2000, cwd: PROJECT_ROOT }\n    ).catch(() => null)\n  }\n\n  let errorText = `<b>error for ${ctx.updateType}:</b>`\n  if (ctx.match) errorText += `\\n<code>${ctx.match[0]}</code>`\n  if (ctx.from && ctx.from.id) errorText += `\\n\\nuser: <a href=\"tg://user?id=${ctx.from.id}\">${escapeHTML(ctx.from.first_name)}</a> #user_${ctx.from.id}`\n  if (ctx?.session?.chainActions && ctx?.session.chainActions.length > 0) errorText += '\\n\\n🔗 ' + ctx?.session.chainActions.map(v => `<code>${v}</code>`).join(' ➜ ')\n\n  if (gitBlame && !gitBlame.stderr) {\n    const parsedBlame = gitBlame.stdout.match(/^(?<SHA>[0-9a-f]+)\\s+\\((?<USER>.+)(?<DATE>\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} [+-]\\d{4}\\s+)(?<line>\\d+)\\) ?(?<code>.*)$/m)\n\n    if (parsedBlame?.groups) {\n      errorText += `\\n\\n<u>${parsedBlame.groups.USER.trim()}</u>`\n      errorText += `\\n<i>commit:</i> ${parsedBlame.groups.SHA}`\n      errorText += `\\n\\n<code>${parsedBlame.groups.code}</code>`\n    }\n  }\n\n  errorText += `\\n\\n\\n<code>${escapeHTML(error.stack)}</code>`\n\n  if (error.description && error.description.includes('timeout')) return\n\n  if (!ctx.config) return log.error(errorText)\n\n  await ctx.telegram.sendMessage(ctx.config.logChatId, errorText, {\n    parse_mode: 'HTML'\n  }).catch((error) => {\n    log.error('send log error:', error)\n  })\n\n  if (ctx?.chat?.type === 'private') {\n    await ctx.replyWithHTML(ctx.i18n.t('error.unknown')).catch(() => {})\n  }\n}\n\n// Errors that aren't actionable — we expect them in normal operation\n// and logging each one just drowns out real signals.\nfunction isExpectedNoise (error) {\n  if (!error) return false\n\n  // retry-api short-circuited a send to a blocked user — already handled\n  if (error.__cachedBlock) return true\n\n  // retry-api short-circuited a 429 cooldown — already logged once when\n  // Telegram first told us retry_after > maxWait, every subsequent call\n  // in the same window is the same story.\n  if (error.__cachedRateLimit) return true\n\n  const method = error?.on?.method\n  const description = error?.description || ''\n\n  // answerCallbackQuery expiry: callback_query_id has a ~5–10 min TTL\n  // at Telegram. Handlers with handlerTimeout=60s rarely overrun this\n  // directly, but a handler that sleeps on a 429 retry + does slow I/O\n  // can. When it eventually answers, Telegram replies 400 \"query is\n  // too old\". Not actionable — user already saw the button press.\n  if (method === 'answerCallbackQuery' && /query is too old|query ID is invalid/i.test(description)) {\n    return true\n  }\n\n  return false\n}\n\nmodule.exports = async (error, ctx) => {\n  if (isExpectedNoise(error)) return\n\n  log.error(error?.stack || error)\n\n  // Handle 429 rate limit errors gracefully.\n  // Note: withRetry already logs `[Retry] 429 on <method>` — no dup here.\n  if (isRateLimitError(error)) {\n    const retryAfter = getRetryAfter(error)\n\n    if (ctx?.chat?.type === 'private') {\n      const waitText = retryAfter\n        ? ctx.i18n.t('error.rate_limit_seconds', { seconds: retryAfter })\n        : ctx.i18n.t('error.rate_limit')\n      await ctx.replyWithHTML(waitText).catch(() => {})\n    }\n    return\n  }\n\n  errorLog(error, ctx).catch(e => {\n    log.error('errorLog itself failed:', e?.stack || e)\n  })\n}\n"
  },
  {
    "path": "handlers/coedit.js",
    "content": "const StegCloak = require('stegcloak')\nconst Composer = require('telegraf/composer')\nconst crypto = require('crypto')\nconst { escapeHTML } = require('../utils')\n\nconst generatePasscode = () => {\n  return crypto.randomBytes(16).toString('hex')\n}\n\nconst composer = new Composer()\n\ncomposer.action(/coedit:reset:(.*)/, async (ctx) => {\n  const stickerSetId = ctx.match[1]\n\n  const stickerSet = await ctx.db.StickerSet.findById(stickerSetId)\n\n  if (!stickerSet) {\n    return ctx.answerCbQuery(ctx.i18n.t('callback.pack.answerCbQuer.not_found'), true)\n  }\n\n  if (stickerSet?.owner.toString() !== ctx.session.userInfo.id.toString()) {\n    return ctx.answerCbQuery(ctx.i18n.t('callback.pack.answerCbQuer.not_owner'), true)\n  }\n\n  stickerSet.passcode = generatePasscode()\n\n  await stickerSet.save()\n\n  await ctx.db.User.updateMany({\n    stickerSet: stickerSet._id\n  }, {\n    stickerSet: null\n  })\n\n  return ctx.replyWithHTML(ctx.i18n.t('coedit.reset', {\n    colink: `t.me/${ctx.botInfo.username}?start=s_${stickerSet.passcode}`,\n    title: escapeHTML(stickerSet.title),\n    link: `${ctx.config.stickerLinkPrefix}${stickerSet.name}`\n  }))\n})\n\ncomposer.action(/coedit:(.*)/, async (ctx) => {\n  const stickerSetId = ctx.match[1]\n\n  const stickerSet = await ctx.db.StickerSet.findById(stickerSetId)\n\n  if (!stickerSet) {\n    return ctx.answerCbQuery(ctx.i18n.t('callback.pack.answerCbQuer.not_found'), true)\n  }\n\n  if (stickerSet?.owner.toString() !== ctx.session.userInfo.id.toString()) {\n    return ctx.answerCbQuery(ctx.i18n.t('callback.pack.answerCbQuer.not_owner'), true)\n  }\n\n  if (!stickerSet.passcode) {\n    stickerSet.passcode = generatePasscode()\n\n    await stickerSet.save()\n  }\n\n  const editorsList = await ctx.db.User.find({\n    stickerSet: stickerSet._id\n  }).select('_id telegram_id first_name').limit(100).lean()\n\n  const editors = editorsList.map((user) => {\n    return `<a href=\"tg://user?id=${user.telegram_id}\">${escapeHTML(user.first_name)}</a>`\n  }).join(', ') || ctx.i18n.t('coedit.no_editors')\n\n  return ctx.replyWithHTML(ctx.i18n.t('coedit.info', {\n    colink: `t.me/${ctx.botInfo.username}?start=s_${stickerSet.passcode}`,\n    title: escapeHTML(stickerSet.title),\n    link: `${ctx.config.stickerLinkPrefix}${stickerSet.name}`,\n    editors\n  }), {\n    reply_markup: {\n      inline_keyboard: [\n        [{\n          text: ctx.i18n.t('coedit.btn.send'),\n          url: `https://t.me/share/url?url=t.me/${ctx.botInfo.username}?start=s_${stickerSet.passcode}&text=${\n            encodeURIComponent(\n              ctx.i18n.t('coedit.share', {\n                title: escapeHTML(stickerSet.title)\n              })\n            )\n          }`\n        }],\n        [{\n          text: ctx.i18n.t('coedit.btn.reset'),\n          callback_data: `coedit:reset:${stickerSet._id}`\n        }]\n      ]\n    }\n  })\n})\n\nmodule.exports = composer\n"
  },
  {
    "path": "handlers/donate.js",
    "content": "const Composer = require('telegraf/composer')\nconst { match } = require('telegraf-i18n')\n\nconst composer = new Composer()\n\nconst donateMenu = async (ctx) => {\n  return ctx.scene.enter('donate')\n}\n\ncomposer.on('pre_checkout_query', async (ctx) => {\n  const telegramPayment = await ctx.db.Payment.findOne({\n    _id: ctx.preCheckoutQuery.invoice_payload\n  })\n\n  if (!telegramPayment || telegramPayment.status !== 'pending') {\n    return ctx.answerPreCheckoutQuery(false, ctx.i18n.t('donate.error.already_donated'))\n  }\n\n  await ctx.answerPreCheckoutQuery(true)\n})\n\ncomposer.on('successful_payment', async (ctx) => {\n  const telegramPayment = await ctx.db.Payment.findOne({\n    _id: ctx.message.successful_payment.invoice_payload\n  })\n\n  if (!telegramPayment || telegramPayment.status !== 'pending') {\n    return ctx.replyWithHTML(ctx.i18n.t('donate.error.already_donated'))\n  }\n\n  const updated = await ctx.db.Payment.findOneAndUpdate(\n    { _id: telegramPayment._id, status: 'pending' },\n    { $set: { status: 'paid', resultData: ctx.message.successful_payment } },\n    { new: true }\n  )\n  if (!updated) {\n    return ctx.replyWithHTML(ctx.i18n.t('donate.error.already_donated'))\n  }\n\n  // Use atomic $inc to prevent race conditions\n  const updatedUser = await ctx.db.User.findByIdAndUpdate(\n    ctx.session.userInfo._id,\n    { $inc: { balance: updated.amount } },\n    { new: true }\n  )\n\n  if (!updatedUser) {\n    console.error('User not found after payment:', ctx.session.userInfo._id)\n    return ctx.replyWithHTML(ctx.i18n.t('donate.error.user_not_found'))\n  }\n\n  ctx.session.userInfo.balance = updatedUser.balance\n\n  return ctx.replyWithHTML(ctx.i18n.t('donate.update', {\n    amount: updated.amount,\n    balance: updatedUser.balance\n  }))\n})\n\ncomposer.hears(['/donate', '/boost', '/start boost', match('cmd.start.btn.club')], Composer.privateChat(donateMenu))\n\ncomposer.action('donate:topup', async (ctx) => {\n  return ctx.scene.enter('donate')\n})\n\ncomposer.start(async (ctx, next) => {\n  if (ctx.startPayload === 'donate') {\n    return donateMenu(ctx)\n  }\n\n  return next()\n})\n\nmodule.exports = composer\n"
  },
  {
    "path": "handlers/emoji.js",
    "content": "const emojiRegex = require('emoji-regex')\nconst { sendBanner } = require('../banners')\n\nmodule.exports = async (ctx) => {\n  const uncleanUserInput = ctx.message.text.substring(0, 15)\n  const emojiSymbols = uncleanUserInput.match(emojiRegex())\n  if (emojiSymbols) {\n    const emoji = emojiSymbols.join('')\n    if (ctx.session.userInfo.stickerSet) {\n      await ctx.db.StickerSet.updateOne(\n        { _id: ctx.session.userInfo.stickerSet._id },\n        { emojiSuffix: emoji }\n      )\n      ctx.session.userInfo.stickerSet.emojiSuffix = emoji\n      await ctx.replyWithHTML(ctx.i18n.t('cmd.emoji.done'), {\n        reply_to_message_id: ctx.message.message_id,\n        allow_sending_without_reply: true\n      })\n    } else {\n      await ctx.replyWithHTML(ctx.i18n.t('cmd.emoji.no_pack_selected'), {\n        reply_to_message_id: ctx.message.message_id,\n        allow_sending_without_reply: true\n      })\n    }\n  } else {\n    await sendBanner(ctx, 'emoji', ctx.i18n.t('cmd.emoji.info'), {\n      reply_to_message_id: ctx.message.message_id,\n      allow_sending_without_reply: true\n    })\n  }\n}\n"
  },
  {
    "path": "handlers/group-settings.js",
    "content": "const Composer = require('telegraf/composer')\n\nconst composer = new Composer()\n\nasync function onlyGroupAdmin (ctx, next) {\n  if (!ctx.chat) {\n    return\n  }\n\n  if (ctx.chat.type !== 'group' && ctx.chat.type !== 'supergroup') {\n    return\n  }\n\n  if (!ctx.from || !ctx.from.id) {\n    return\n  }\n\n  const isAdmin = await ctx.telegram.getChatAdministrators(ctx.chat.id)\n    .then((admins) => admins.some((admin) => admin.user.id === ctx.from.id))\n\n  if (!isAdmin) {\n    return\n  }\n\n  return next()\n}\n\ncomposer.command('group_settings', onlyGroupAdmin, async (ctx) => {\n  const type = ctx.message.text.split(' ')[1]\n  const rights = ctx.message.text.split(' ')[2]\n\n  const allowedTypes = ['add', 'delete']\n  if (!type || !rights || !allowedTypes.includes(type)) {\n    return\n  }\n\n  const group = await ctx.db.Group.findOne({ telegram_id: ctx.chat.id })\n\n  if (!group) {\n    return\n  }\n\n  group.settings.rights[type] = rights\n\n  group.updatedAt = new Date()\n\n  await group.save()\n\n  return ctx.replyWithHTML(ctx.i18n.t('callback.group_settings.success'))\n})\n\nmodule.exports = composer\n"
  },
  {
    "path": "handlers/help.js",
    "content": "const Markup = require('telegraf/markup')\nconst { replyOrEditBanner } = require('../banners')\n\nmodule.exports = async (ctx) => {\n  const keyboard = Markup.inlineKeyboard([\n    [Markup.urlButton(ctx.i18n.t('cmd.guide.btn.open'), 'https://fstik.app/guides')]\n  ])\n\n  await replyOrEditBanner(ctx, 'help', ctx.i18n.t('cmd.guide.web'), {\n    reply_markup: keyboard\n  })\n}\n"
  },
  {
    "path": "handlers/index.js",
    "content": "module.exports = {\n  handleError: require('./catch'),\n  handleStats: require('./stats'),\n  handlePing: require('./ping'),\n  handleStart: require('./start'),\n  handleHelp: require('./help'),\n  handleDonate: require('./donate'),\n  handleSticker: require('./sticker'),\n  handleDeleteSticker: require('./sticker-delete'),\n  handleRestoreSticker: require('./sticker-restore'),\n  handlePacks: require('./packs'),\n  handleSelectPack: require('./pack-select'),\n  handleSelectGroupPack: require('./pack-select-group'),\n  handleHidePack: require('./pack-hide'),\n  handleRestorePack: require('./pack-restore'),\n  handleCopyPack: require('./pack-copy'),\n  handleCoedit: require('./coedit'),\n  handleCatalog: require('./catalog'),\n  handleSearchCatalog: require('./search-catalog'),\n  handleLanguage: require('./language'),\n  handleEmoji: require('./emoji'),\n  handleStickerUpdate: require('./sticker-update'),\n  handleInlineQuery: require('./inline-query'),\n  handleBoostPack: require('./pack-boost'),\n  handleGroupSettings: require('./group-settings')\n}\n"
  },
  {
    "path": "handlers/inline-query.js",
    "content": "const StegCloak = require('stegcloak')\nconst Composer = require('telegraf/composer')\nconst { tenor, escapeRegex } = require('../utils')\n\nconst stegcloak = new StegCloak(false, false)\n\nconst INLINE_QUERY_LIMIT = 50\n\n// ===================\n// HELPER FUNCTIONS\n// ===================\n\n/**\n * Get file ID from sticker (supports both old and new schema).\n * Works with both Mongoose documents and lean objects.\n */\nfunction getStickerFileId (sticker) {\n  if (typeof sticker.getFileId === 'function') {\n    return sticker.getFileId()\n  }\n  return sticker.fileId || (sticker.info && sticker.info.file_id)\n}\n\n/**\n * Get sticker type (supports both old and new schema).\n *\n * At 488M docs (94% legacy, 100% missing top-level stickerType in our\n * sample), per-request Telegram getFile detection used to burn through\n * the API rate limit to \"upgrade\" the default. Now we trust the stored\n * value (new docs have it set) or fall back to 'sticker'. Non-sticker\n * media that never had its type stored will be answered as 'sticker' —\n * Telegram accepts it for most cases, and the worst outcome is a skipped\n * result, not a crash.\n */\nfunction getStickerType (sticker) {\n  if (typeof sticker.getStickerType === 'function') {\n    return sticker.getStickerType()\n  }\n  return sticker.stickerType || (sticker.info && sticker.info.stickerType) || 'sticker'\n}\n\n/**\n * Get caption (supports both old and new schema).\n */\nfunction getStickerCaption (sticker) {\n  if (typeof sticker.getCaption === 'function') {\n    return sticker.getCaption()\n  }\n  return sticker.caption || (sticker.info && sticker.info.caption)\n}\n\n/**\n * Resolve sticker type for every doc in the input list.\n *\n * Previously this would call telegram.getFile() for any doc whose\n * stickerType wasn't stored — at 488M docs with ~0% stickerType set,\n * that turned inline queries into Telegram rate-limit bombs.\n * Simplified to a synchronous lookup: trust the stored value or\n * default to 'sticker'. No API calls, no cache, no DB writes.\n */\nfunction detectStickerTypes (stickers) {\n  const results = new Map()\n  for (const sticker of stickers) {\n    const fileId = getStickerFileId(sticker)\n    if (!fileId) continue\n    results.set(sticker._id.toString(), getStickerType(sticker))\n  }\n  return results\n}\n\n/**\n * Build inline query result item from sticker\n */\nfunction buildInlineResult (sticker, stickerType) {\n  const fileId = getStickerFileId(sticker)\n  const caption = getStickerCaption(sticker)\n\n  // Normalize type for Telegram API\n  let type = stickerType\n  if (type === 'video_note') type = 'document'\n  if (type === 'animation') type = 'mpeg4_gif'\n\n  // Map type to correct file_id field name\n  const fileIdFieldMap = {\n    mpeg4_gif: 'mpeg4_file_id',\n    gif: 'gif_file_id'\n  }\n  const fieldName = fileIdFieldMap[type] || type + '_file_id'\n\n  const result = {\n    type,\n    id: sticker._id.toString(),\n    [fieldName]: fileId\n  }\n\n  // Add metadata for documents and media\n  if (type === 'document' || type === 'video') {\n    result.title = caption || 'File'\n    result.description = caption || ''\n  } else if (['photo', 'mpeg4_gif', 'gif'].includes(type) && caption) {\n    result.title = caption\n    result.description = caption\n  }\n\n  return result\n}\n\n// ===================\n// INLINE QUERY HANDLERS\n// ===================\n\nconst composer = new Composer()\n\n/**\n * Handle pack selection inline query\n */\ncomposer.on('inline_query', async (ctx, next) => {\n  const { query, offset: rawOffset } = ctx.inlineQuery\n  if (!query || !query.includes('select_group_pack')) return next()\n\n  const offset = parseInt(rawOffset) || 0\n  const limit = INLINE_QUERY_LIMIT\n\n  const stickerSets = await ctx.db.StickerSet.find({\n    owner: ctx.session.userInfo.id,\n    inline: false,\n    hide: false\n  })\n    .select('_id title name')\n    .sort({ updatedAt: -1 })\n    .limit(limit)\n    .skip(offset)\n    .lean()\n\n  if (!stickerSets || stickerSets.length === 0) {\n    return ctx.answerInlineQuery([], {\n      is_personal: true,\n      cache_time: 30,\n      next_offset: offset + limit,\n      switch_pm_text: ctx.i18n.t('cmd.inline.switch_pm'),\n      switch_pm_parameter: 'pack'\n    })\n  }\n\n  const results = stickerSets.map((set) => ({\n    type: 'article',\n    id: set._id.toString(),\n    title: set.title,\n    description: set.name,\n    input_message_content: {\n      message_text: `/pack ${set.name}`,\n      parse_mode: 'HTML'\n    }\n  }))\n\n  await ctx.answerInlineQuery(results, {\n    is_personal: true,\n    cache_time: 30,\n    next_offset: offset + limit\n  })\n})\n\n/**\n * Handle group settings inline query\n */\ncomposer.on('inline_query', async (ctx, next) => {\n  const { query } = ctx.inlineQuery\n  if (!query || !query.includes('group_settings')) return next()\n\n  const type = query.split(' ')[1]\n\n  const results = [\n    {\n      type: 'article',\n      id: 'everyone',\n      title: ctx.i18n.t('callback.pack.select_group.access_rights.rights.all'),\n      input_message_content: {\n        message_text: `/group_settings ${type} all`,\n        parse_mode: 'HTML'\n      }\n    },\n    {\n      type: 'article',\n      id: 'admins',\n      title: ctx.i18n.t('callback.pack.select_group.access_rights.rights.admins'),\n      input_message_content: {\n        message_text: `/group_settings ${type} admins`,\n        parse_mode: 'HTML'\n      }\n    }\n  ]\n\n  await ctx.answerInlineQuery(results, {\n    is_personal: true,\n    cache_time: 30\n  })\n})\n\n/**\n * Main sticker/GIF inline query handler\n */\ncomposer.on('inline_query', async (ctx) => {\n  const { query, offset: rawOffset } = ctx.inlineQuery\n  const offset = parseInt(rawOffset) || 0\n  const limit = INLINE_QUERY_LIMIT\n\n  let nextOffset = offset + limit\n  const results = []\n\n  // Try to decode hidden data in query\n  let hiddenData\n  try {\n    hiddenData = stegcloak.reveal(`: ${query}`, '')\n  } catch (err) {\n    // No hidden data\n  }\n\n  const isGifMode = ctx.session.userInfo.inlineType !== 'packs' || hiddenData === '{gif}'\n\n  if (!isGifMode) {\n    // ===================\n    // STICKER PACK MODE\n    // ===================\n\n    let inlineSet = ctx.session.userInfo.inlineStickerSet\n\n    if (!inlineSet) {\n      inlineSet = await ctx.db.StickerSet.findOne({\n        owner: ctx.session.userInfo.id,\n        inline: true\n      })\n    }\n\n    let searchStickers = []\n\n    // Search by query if provided\n    if (query.length >= 1) {\n      const searchSet = await ctx.db.StickerSet.findOne({\n        owner: ctx.session.userInfo.id,\n        inline: true,\n        $or: [\n          { title: { $regex: escapeRegex(query), $options: 'i' } },\n          { name: { $regex: escapeRegex(query), $options: 'i' } }\n        ]\n      }).maxTimeMS(2000)\n\n      if (searchSet) {\n        inlineSet = searchSet\n      } else {\n        // Search across all user's stickers\n        const userSetIds = await ctx.db.StickerSet.find({\n          owner: ctx.session.userInfo.id,\n          hide: false\n        }).select('_id').lean()\n\n        searchStickers = await ctx.db.Sticker.find({\n          deleted: false,\n          stickerSet: { $in: userSetIds.map(s => s._id) },\n          $or: [\n            { caption: { $regex: escapeRegex(query), $options: 'i' } },\n            { emojis: { $regex: escapeRegex(query), $options: 'i' } }\n          ]\n        })\n          .select('_id fileId stickerType caption fileUniqueId emojis info')\n          .limit(limit)\n          .skip(offset)\n          .maxTimeMS(2000)\n          .lean()\n      }\n    }\n\n    // Fallback to inline set stickers\n    if (searchStickers.length === 0 && inlineSet) {\n      searchStickers = await ctx.db.Sticker.find({\n        deleted: false,\n        stickerSet: inlineSet._id || inlineSet\n      })\n        .select('_id fileId stickerType caption fileUniqueId emojis info')\n        .limit(limit)\n        .skip(offset)\n        .lean()\n    }\n\n    // Resolve sticker type for every result (synchronous — no API calls)\n    const stickerTypes = detectStickerTypes(searchStickers)\n\n    // Build results\n    for (const sticker of searchStickers) {\n      try {\n        const fileId = getStickerFileId(sticker)\n        if (!fileId) continue\n\n        const type = stickerTypes.get(sticker._id.toString()) || getStickerType(sticker)\n        results.push(buildInlineResult(sticker, type))\n      } catch (error) {\n        console.error('Error processing sticker:', {\n          sticker_id: sticker._id,\n          error: error.message\n        })\n      }\n    }\n\n    // Send response\n    try {\n      await ctx.answerInlineQuery(results, {\n        is_personal: true,\n        cache_time: 30,\n        next_offset: offset + limit,\n        switch_pm_text: ctx.i18n.t('cmd.inline.switch_pm'),\n        switch_pm_parameter: 'inline_pack'\n      })\n    } catch (error) {\n      console.error('Error answering inline query:', {\n        error: error.message,\n        user: ctx.from.id,\n        results_count: results.length\n      })\n\n      // Fallback to empty response\n      await ctx.answerInlineQuery([], {\n        is_personal: true,\n        cache_time: 30,\n        switch_pm_text: ctx.i18n.t('cmd.inline.switch_pm'),\n        switch_pm_parameter: 'inline_pack'\n      }).catch(() => {})\n    }\n  } else {\n    // ===================\n    // GIF MODE (Tenor)\n    // ===================\n\n    let queryText = query\n    const match = query.match(/:(.*)/)\n    if (match) {\n      queryText = match[1]\n    }\n\n    let tenorResult\n    if (queryText.length >= 1) {\n      tenorResult = await tenor.search(queryText, limit, offset)\n    } else {\n      tenorResult = await tenor.trending(offset || false, ctx.session.userInfo.locale)\n    }\n\n    nextOffset = tenorResult.next\n\n    for (const item of tenorResult.results) {\n      results.push({\n        type: 'mpeg4_gif',\n        id: item.id,\n        thumb_url: item.media[0].gif.url,\n        mpeg4_url: item.media[0].mp4.url,\n        caption: item.media[0].gif_transparent.url\n      })\n    }\n\n    await ctx.answerInlineQuery(results, {\n      is_personal: true,\n      cache_time: 30,\n      next_offset: nextOffset\n    })\n  }\n})\n\nmodule.exports = composer\n"
  },
  {
    "path": "handlers/language.js",
    "content": "const fs = require('fs')\nconst path = require('path')\nconst Markup = require('telegraf/markup')\nconst I18n = require('telegraf-i18n')\nconst handleStart = require('./start')\nconst { sendBanner } = require('../banners')\n\nconst i18n = new I18n({\n  directory: path.resolve(__dirname, '../locales'),\n  defaultLanguage: 'ru',\n  defaultLanguageOnMissing: true\n})\n\nconst localseFile = fs.readdirSync('./locales/')\n\nmodule.exports = async (ctx) => {\n  const locales = {}\n\n  localseFile.forEach((fileName) => {\n    const localName = fileName.split('.')[0]\n    if (localName === 'ru' || i18n.t('ru', 'language_name') !== i18n.t(localName, 'language_name')) {\n      locales[localName] = {\n        flag: i18n.t(localName, 'language_name')\n      }\n    }\n  })\n\n  if (ctx.updateType === 'callback_query' && ctx.match[1] !== 'null') {\n    if (locales[ctx.match[1]]) {\n      await ctx.answerCbQuery(locales[ctx.match[1]].flag)\n\n      ctx.session.userInfo.locale = ctx.match[1]\n      ctx.i18n.locale(ctx.match[1])\n      await handleStart(ctx)\n    }\n  } else {\n    const button = []\n\n    Object.keys(locales).map((key) => {\n      button.push(Markup.callbackButton(locales[key].flag, `set_language:${key}`))\n    })\n\n    await sendBanner(ctx, 'language', ctx.i18n.t('cmd.lang.choose'), {\n      reply_markup: Markup.inlineKeyboard(button, { columns: 2 })\n    })\n  }\n}\n"
  },
  {
    "path": "handlers/news-channel.js",
    "content": "const Composer = require('telegraf/composer')\nconst Markup = require('telegraf/markup')\nconst handleStart = require('./start')\n\nconst composer = new Composer()\n\ncomposer.on('message', Composer.optional((ctx) => ctx?.chat?.type === 'private', async (ctx, next) => {\n  // if ru locale\n  if (ctx.session.userInfo.locale !== 'ru' || ctx.from.language_code !== 'ru') {\n    return next()\n  }\n\n  if (!ctx?.config?.ruNewsChannel?.id) return next()\n\n  // if not command\n  if (ctx.message.text && ctx.message.text.indexOf('/') === 0) return next()\n\n  // if createdAt < 14 days\n  if (ctx.session.userInfo.createdAt > new Date().getTime() - 1000 * 60 * 60 * 24 * 14) {\n    return next()\n  }\n\n  if (ctx.session.userInfo.newsSubscribedDate > new Date().getTime() - 1000 * 60 * 60 * 24 * 7) {\n    return next()\n  }\n\n  // check subscribe to channel\n  const getChatMember = await ctx.telegram.getChatMember(ctx?.config?.ruNewsChannel?.id, ctx.from.id).catch((error) => {\n    console.error('getChatMember error', error)\n    return {\n      status: 'error',\n      error\n    }\n  })\n\n  if (['member', 'administrator', 'creator'].indexOf(getChatMember.status) === -1) {\n    await ctx.replyWithHTML(ctx.i18n.t('news.join', {\n      link: ctx?.config?.ruNewsChannel?.link\n    }), {\n      disable_web_page_preview: true,\n      reply_markup: {\n        inline_keyboard: [\n          [{\n            text: ctx.i18n.t('news.join_btn'),\n            url: ctx?.config?.ruNewsChannel?.link\n          }],\n          [{\n            text: ctx.i18n.t('news.continue'),\n            callback_data: 'start'\n          }]\n        ]\n      }\n    })\n\n    // return next()\n  } else {\n    ctx.session.userInfo.newsSubscribedDate = new Date()\n    return next()\n  }\n}))\n\ncomposer.action('start', async (ctx, next) => {\n  if (!ctx?.config?.ruNewsChannel?.id) return next()\n\n  const getChatMember = await ctx.telegram.getChatMember(ctx?.config?.ruNewsChannel?.id, ctx.from.id).catch((error) => {\n    console.error('getChatMember error', error)\n    return {\n      status: 'error',\n      error\n    }\n  })\n\n  if (['member', 'administrator', 'creator'].indexOf(getChatMember.status) === -1) {\n    return ctx.answerCbQuery(ctx.i18n.t('news.not_joined'), true)\n  } else {\n    ctx.session.userInfo.newsSubscribedDate = new Date()\n    await ctx.deleteMessage().catch(err => console.error('Failed to delete message:', err.message))\n    return handleStart(ctx)\n  }\n})\n\nmodule.exports = composer\n"
  },
  {
    "path": "handlers/pack-boost.js",
    "content": "const Composer = require('telegraf/composer')\nconst Markup = require('telegraf/markup')\nconst rateLimit = require('telegraf-ratelimit')\nconst { escapeHTML } = require('../utils')\n\nconst composer = new Composer()\n\ncomposer.action(/boost:(yes|no):(.*)/, rateLimit({\n  window: 3000,\n  limit: 1,\n  onLimitExceeded: async (ctx) => {\n    await ctx.answerCbQuery(ctx.i18n.t('scenes.boost.error.too_fast'), true)\n  }\n}), async (ctx) => {\n  const stickerSet = await ctx.db.StickerSet.findById(ctx.match[2])\n\n  if (!stickerSet) return ctx.answerCbQuery(ctx.i18n.t('scenes.error.notFound'))\n\n  if (stickerSet.owner.toString() !== ctx.session.userInfo.id.toString()) {\n    return ctx.answerCbQuery(ctx.i18n.t('callback.pack.answerCbQuer.not_owner'), true)\n  }\n\n  if (ctx.match[1] === 'yes') {\n    if (ctx.session.userInfo.balance < 1) return ctx.answerCbQuery(ctx.i18n.t('scenes.boost.error.not_enough_credits'), true)\n\n    if (stickerSet.boost) return ctx.answerCbQuery(ctx.i18n.t('scenes.boost.error.already_boosted'), true)\n\n    // Use atomic operations to prevent race conditions\n    const updateResult = await ctx.db.StickerSet.updateOne(\n      { _id: stickerSet._id, boost: { $ne: true } },\n      { $set: { boost: true } }\n    )\n\n    if (updateResult.modifiedCount === 0) {\n      return ctx.answerCbQuery(ctx.i18n.t('scenes.boost.error.already_boosted'), true)\n    }\n\n    await ctx.db.User.updateOne(\n      { _id: ctx.session.userInfo._id },\n      { $inc: { balance: -1 } }\n    )\n    ctx.session.userInfo.balance -= 1\n\n    const linkPrefix = stickerSet.packType === 'custom_emoji' ? ctx.config.emojiLinkPrefix : ctx.config.stickerLinkPrefix\n    const titleSuffix = ` :: @${ctx.options.username}`\n\n    await ctx.answerCbQuery()\n    await ctx.editMessageText(ctx.i18n.t('scenes.boost.success', {\n      title: escapeHTML(stickerSet.title),\n      link: `${linkPrefix}${stickerSet.name}`,\n      titleSuffix: escapeHTML(titleSuffix)\n    }), {\n      parse_mode: 'HTML',\n      disable_web_page_preview: true\n    }).catch(() => {}) // benign: message-not-modified / best-effort UI refresh\n    return\n  }\n\n  if (ctx.match[1] === 'no') {\n    await ctx.answerCbQuery(ctx.i18n.t('scenes.boost.canceled'), true)\n    await ctx.deleteMessage().catch(err => console.error('Failed to delete message:', err.message))\n  }\n})\n\ncomposer.action(/boost:(.*)/, async (ctx) => {\n  const stickerSet = await ctx.db.StickerSet.findById(ctx.match[1])\n\n  if (!stickerSet) return ctx.answerCbQuery(ctx.i18n.t('scenes.error.notFound'))\n\n  const resultText = ctx.i18n.t('scenes.boost.sure', {\n    title: escapeHTML(stickerSet.title),\n    link: `https://t.me/addstickers/${stickerSet.name}`,\n    balance: ctx.session.userInfo.balance\n  })\n\n  const replyMarkup = Markup.inlineKeyboard([\n    [\n      { ...Markup.callbackButton(ctx.i18n.t('scenes.boost.btn.yes'), `boost:yes:${stickerSet._id}`), style: 'success' },\n      { ...Markup.callbackButton(ctx.i18n.t('scenes.boost.btn.no'), `boost:no:${stickerSet._id}`), style: 'danger' }\n    ]\n  ])\n\n  if (ctx.callbackQuery) {\n    await ctx.editMessageText(resultText, {\n      parse_mode: 'HTML',\n      reply_markup: replyMarkup\n    }).catch(() => {}) // benign: message-not-modified / best-effort UI refresh\n  } else {\n    await ctx.replyWithHTML(resultText, {\n      reply_markup: replyMarkup\n    })\n  }\n})\n\nmodule.exports = composer\n"
  },
  {
    "path": "handlers/pack-copy.js",
    "content": "const Markup = require('telegraf/markup')\nconst { humanizeTelegramError, matchTelegramErrorReason } = require('../utils/telegram-error')\n\nmodule.exports = async (ctx) => {\n  if (!ctx.session.userInfo) ctx.session.userInfo = await ctx.db.User.getData(ctx.from)\n\n  let getStickerSet\n  let fetchError\n  try {\n    getStickerSet = await ctx.telegram.getStickerSet(ctx.match[2])\n  } catch (err) {\n    fetchError = err\n    console.error('pack-copy: getStickerSet failed:', err.message)\n  }\n\n  if (getStickerSet && getStickerSet.stickers.length > 0) {\n    ctx.session.scene.copyPack = getStickerSet\n    // Determine pack format from stickers (StickerSet doesn't have is_video/is_animated)\n    const hasVideo = getStickerSet.stickers.some(s => s.is_video)\n    const hasAnimated = getStickerSet.stickers.some(s => s.is_animated)\n    ctx.session.scene.newPack = {\n      packType: getStickerSet.sticker_type,\n      video: hasVideo,\n      animated: hasAnimated,\n      fillColor: getStickerSet.stickers[0].needs_repainting\n    }\n\n    await ctx.replyWithHTML(ctx.i18n.t('scenes.copy.enter'), {\n      reply_to_message_id: ctx.message.message_id,\n      allow_sending_without_reply: true,\n      reply_markup: Markup.keyboard([\n        [\n          { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n        ]\n      ]).resize()\n    })\n\n    return ctx.scene.enter('newPack')\n  }\n\n  // Surface the specific cause (rate-limited, pack deleted, etc.) when we\n  // have a Telegram error to interpret; otherwise fall back to the\n  // generic \"pack not found\" copy.\n  const errorText = fetchError && matchTelegramErrorReason(fetchError)\n    ? humanizeTelegramError(ctx, fetchError)\n    : ctx.i18n.t('callback.pack.error.copy')\n\n  await ctx.replyWithHTML(errorText, {\n    reply_to_message_id: ctx.message.message_id,\n    allow_sending_without_reply: true\n  })\n}\n"
  },
  {
    "path": "handlers/pack-hide.js",
    "content": "const Markup = require('telegraf/markup')\n\nmodule.exports = async (ctx) => {\n  if (!ctx.session.userInfo) ctx.session.userInfo = await ctx.db.User.getData(ctx.from)\n  const stickerSet = await ctx.db.StickerSet.findById(ctx.match[2])\n\n  if (!stickerSet) {\n    return ctx.answerCbQuery(ctx.i18n.t('callback.pack.answerCbQuer.not_found'), true)\n  }\n\n  let answerCbQuer = ''\n\n  if (stickerSet.owner.toString() !== ctx.session.userInfo.id.toString()) {\n    return ctx.answerCbQuery(ctx.i18n.t('callback.pack.answerCbQuer.not_owner'), true)\n  }\n\n  const wasHidden = stickerSet.hide === true\n  const newHideValue = !wasHidden\n  const updatedSet = await ctx.db.StickerSet.findOneAndUpdate(\n    { _id: stickerSet._id },\n    { $set: { hide: newHideValue } },\n    { new: true }\n  )\n\n  // Update user's pack count\n  const countField = stickerSet.inline\n    ? 'packsCount.inline'\n    : `packsCount.${stickerSet.packType || 'regular'}`\n  await ctx.db.User.updateOne(\n    { _id: stickerSet.owner },\n    { $inc: { [countField]: wasHidden ? 1 : -1 } }\n  )\n\n  if (updatedSet.hide === true) {\n    answerCbQuer = ctx.i18n.t('callback.pack.answerCbQuer.hidden')\n\n    const userSet = await ctx.db.StickerSet.findOne({\n      owner: ctx.session.userInfo.id,\n      create: true,\n      hide: false\n    }).sort({ updatedAt: -1 })\n\n    if (userSet) {\n      ctx.session.userInfo.stickerSet = userSet\n      await ctx.session.userInfo.save()\n    }\n  } else {\n    answerCbQuer = ctx.i18n.t('callback.pack.answerCbQuer.restored')\n  }\n  await ctx.answerCbQuery(answerCbQuer)\n\n  const inlineKeyboard = []\n\n  if (updatedSet.hide === true) {\n    inlineKeyboard.push([\n      { ...Markup.callbackButton(ctx.i18n.t('callback.pack.btn.delete'), `delete_pack:${ctx.match[2]}`), style: 'danger' }\n    ])\n  }\n\n  inlineKeyboard.push([\n    Markup.callbackButton(ctx.i18n.t(updatedSet.hide === true ? 'callback.pack.btn.restore' : 'callback.pack.btn.hide'), `hide_pack:${ctx.match[2]}`)\n  ])\n\n  try {\n    await ctx.editMessageReplyMarkup(Markup.inlineKeyboard(inlineKeyboard))\n  } catch (err) {\n    // Updating reply markup is best-effort UI sync. The DB state is already\n    // committed and the user got a toast, so silent log is fine here.\n    console.error('Failed to update pack visibility markup:', err.message)\n  }\n}\n"
  },
  {
    "path": "handlers/pack-restore.js",
    "content": "const { escapeHTML } = require('../utils')\n\nmodule.exports = async (ctx, next) => {\n  let messageText = ctx.i18n.t('callback.pack.error.restore')\n\n  let restored = false\n  const findStickerSet = await ctx.db.StickerSet.findOne({\n    name: ctx.match[2],\n    owner: ctx.session.userInfo.id,\n    thirdParty: false\n  })\n\n  if (!findStickerSet) {\n    return next()\n  }\n\n  const getStickerSet = await ctx.telegram.getStickerSet(ctx.match[2]).catch(() => null)\n\n  if (!getStickerSet) {\n    return ctx.replyWithHTML(ctx.i18n.t('callback.pack.error.restore'), {\n      reply_to_message_id: ctx.message.message_id,\n      allow_sending_without_reply: true\n    })\n  }\n\n  if (getStickerSet.name.split('_').pop() === ctx.options.username) {\n    if (findStickerSet) {\n      findStickerSet.title = getStickerSet.title\n      if (findStickerSet.create === true) {\n        if (findStickerSet.hide === true) {\n          findStickerSet.hide = false\n        } else {\n          const packOwner = await ctx.db.User.findById(findStickerSet.owner)\n          if (!packOwner) {\n            findStickerSet.owner = ctx.session.userInfo.id\n          }\n        }\n        await findStickerSet.save()\n        restored = true\n      }\n    }\n\n    if (restored) {\n      // Mark all stickers as deleted with TTL timestamp before re-syncing\n      await ctx.db.Sticker.updateMany(\n        { stickerSet: findStickerSet },\n        { $set: { deleted: true, deletedAt: new Date() } }\n      )\n\n      // Batch fetch existing stickers (single query instead of N queries)\n      const fileUniqueIds = getStickerSet.stickers.map(s => s.file_unique_id)\n      const existingStickers = await ctx.db.Sticker.find({\n        fileUniqueId: { $in: fileUniqueIds }\n      }).lean()\n      const stickerMap = new Map(existingStickers.map(s => [s.fileUniqueId, s]))\n\n      // Prepare bulk operations\n      const bulkOps = getStickerSet.stickers.map(sticker => {\n        const existing = stickerMap.get(sticker.file_unique_id)\n\n        if (existing) {\n          // Update existing sticker\n          return {\n            updateOne: {\n              filter: { _id: existing._id },\n              update: {\n                $set: {\n                  deleted: false,\n                  deletedAt: null,\n                  fileId: sticker.file_id,\n                  stickerType: sticker.type || null,\n                  stickerSet: findStickerSet._id\n                }\n              }\n            }\n          }\n        } else {\n          // Insert new sticker\n          return {\n            insertOne: {\n              document: {\n                fileUniqueId: sticker.file_unique_id,\n                emojis: sticker.emoji + findStickerSet.emojiSuffix,\n                deleted: false,\n                deletedAt: null,\n                fileId: sticker.file_id,\n                stickerType: sticker.type || null,\n                stickerSet: findStickerSet._id\n              }\n            }\n          }\n        }\n      })\n\n      // Execute all operations in single batch\n      if (bulkOps.length > 0) {\n        const result = await ctx.db.Sticker.bulkWrite(bulkOps, { ordered: false })\n        if (result.hasWriteErrors && result.hasWriteErrors()) {\n          console.error('Partial bulk write failure:', result.getWriteErrors())\n        }\n      }\n\n      messageText = ctx.i18n.t('callback.pack.restored', {\n        title: escapeHTML(findStickerSet.title),\n        link: `${ctx.config.stickerLinkPrefix}${findStickerSet.name}`\n      })\n    }\n  }\n\n  await ctx.replyWithHTML(messageText, {\n    reply_to_message_id: ctx.message.message_id,\n    allow_sending_without_reply: true\n  })\n}\n"
  },
  {
    "path": "handlers/pack-select-group.js",
    "content": "const Markup = require('telegraf/markup')\nconst { escapeHTML } = require('../utils')\n\nmodule.exports = async (ctx, next) => {\n  const packsName = ctx.message.text.split(' ')[1]\n\n  if (!packsName) {\n    return next()\n  }\n\n  await ctx.deleteMessage().catch(err => console.error('Failed to delete message:', err.message))\n\n  const { userInfo } = ctx.session\n\n  if (ctx.chat.type !== 'group' && ctx.chat.type !== 'supergroup') {\n    return\n  }\n\n  if (!ctx.message.from || !ctx.message.from.id) {\n    return\n  }\n\n  const isAdmin = await ctx.telegram.getChatAdministrators(ctx.chat.id)\n    .then((admins) => admins.some((admin) => admin.user.id === ctx.message.from.id))\n\n  if (!isAdmin) {\n    return\n  }\n\n  const stickerSet = await ctx.db.StickerSet.findOne({\n    name: packsName,\n    owner: userInfo.id\n  })\n\n  if (!stickerSet) {\n    return ctx.replyWithHTML(ctx.i18n.t('callback.pack.select_group.error'))\n  }\n\n  const group = await ctx.db.Group.findOne({ telegram_id: ctx.chat.id })\n\n  if (!group) {\n    return ctx.replyWithHTML(ctx.i18n.t('callback.pack.select_group.error'))\n  }\n\n  group.stickerSet = stickerSet\n  group.updatedAt = new Date()\n\n  await group.save()\n\n  const inlineKeyboard = Markup.inlineKeyboard([\n    [Markup.switchToCurrentChatButton(ctx.i18n.t('callback.pack.select_group.access_rights.add'), 'group_settings add')],\n    [Markup.switchToCurrentChatButton(ctx.i18n.t('callback.pack.select_group.access_rights.delete'), 'group_settings delete')]\n  ])\n\n  return ctx.replyWithHTML(ctx.i18n.t('callback.pack.select_group.success', {\n    link: `t.me/addstickers/${stickerSet.name}`,\n    title: escapeHTML(stickerSet.title)\n  }), {\n    reply_markup: inlineKeyboard,\n    disable_web_page_preview: true\n  })\n}\n"
  },
  {
    "path": "handlers/pack-select.js",
    "content": "const Markup = require('telegraf/markup')\nconst { escapeHTML } = require('../utils')\n\nmodule.exports = async (ctx) => {\n  const { userInfo } = ctx.session\n\n  let passcode\n\n  if (ctx.startPayload) passcode = ctx.startPayload.match(/s_(.*)/)?.[1]\n  if (ctx?.message?.text === '/public') passcode = 'public'\n\n  const stickerSet = await ctx.db.StickerSet.findOne({\n    passcode\n  })\n\n  if (!stickerSet) {\n    return ctx.replyWithHTML(ctx.i18n.t('callback.pack.answerCbQuer.not_found'))\n  }\n\n  if (stickerSet.owner.toString() === userInfo.id.toString() || stickerSet.passcode === passcode) {\n    if (stickerSet.inline) {\n      userInfo.inlineStickerSet = stickerSet\n    }\n\n    userInfo.stickerSet = stickerSet\n\n    const btnName = stickerSet.hide === true ? 'callback.pack.btn.restore' : 'callback.pack.btn.hide'\n\n    if (stickerSet.inline) {\n      await ctx.replyWithHTML(ctx.i18n.t('callback.pack.set_inline_pack', {\n        title: escapeHTML(stickerSet.title),\n        botUsername: ctx.options.username\n      }), {\n        reply_markup: Markup.inlineKeyboard([\n          [\n            Markup.switchToChatButton(ctx.i18n.t('callback.pack.btn.use_pack'), '')\n          ],\n          [\n            Markup.callbackButton(ctx.i18n.t(btnName), `hide_pack:${stickerSet.id}`)\n          ]\n        ]),\n        parse_mode: 'HTML'\n      })\n    } else {\n      await ctx.replyWithHTML(ctx.i18n.t('callback.pack.set_pack', {\n        title: escapeHTML(stickerSet.title),\n        link: `${ctx.config.stickerLinkPrefix}${stickerSet.name}`\n      }), {\n        disable_web_page_preview: true,\n        reply_markup: Markup.inlineKeyboard([\n          [\n            Markup.urlButton(ctx.i18n.t('callback.pack.btn.use_pack'), `${ctx.config.stickerLinkPrefix}${stickerSet.name}`)\n          ]\n        ]),\n        parse_mode: 'HTML'\n      })\n    }\n  } else {\n    await ctx.replyWithHTML(ctx.i18n.t('callback.pack.answerCbQuer.not_owner'))\n  }\n}\n"
  },
  {
    "path": "handlers/packs.js",
    "content": "const StegCloak = require('stegcloak')\nconst Markup = require('telegraf/markup')\nconst { escapeHTML } = require('../utils')\nconst { sendBanner, editBanner } = require('../banners')\n\nconst stegcloak = new StegCloak(false, false)\n\nmodule.exports = async (ctx) => {\n  const { userInfo } = ctx.session\n\n  // if its in group\n  if (ctx.chat.type !== 'private') {\n    const replyMarkup = Markup.inlineKeyboard([\n      Markup.switchToCurrentChatButton(ctx.i18n.t('cmd.packs.select_group_pack'), 'select_group_pack')\n    ])\n\n    return ctx.replyWithHTML(ctx.i18n.t('cmd.packs.select_group_pack_info'), {\n      reply_markup: replyMarkup,\n      reply_to_message_id: ctx.message.message_id,\n      allow_sending_without_reply: true\n    })\n  }\n\n  if (!userInfo) ctx.session.userInfo = await ctx.db.User.getData(ctx.from)\n\n  let packType = userInfo.stickerSet?.packType || 'regular'\n  if (userInfo.stickerSet?.inline || ctx.state.type) packType = 'inline'\n\n  if (ctx.callbackQuery && ctx.match && ctx.match[1] === 'type') {\n    if (ctx.match[2] === 'inline') {\n      const findStickerSet = await ctx.db.StickerSet.findOne({\n        owner: userInfo.id,\n        deleted: { $ne: true },\n        inline: true\n      }).sort({\n        updatedAt: -1\n      })\n\n      if (findStickerSet) {\n        userInfo.stickerSet = findStickerSet\n        userInfo.inlineStickerSet = findStickerSet\n        userInfo.inlineType = 'packs'\n      } else {\n        userInfo.stickerSet = null\n      }\n\n      packType = 'inline'\n    } else {\n      const findStickerSet = await ctx.db.StickerSet.findOne({\n        owner: userInfo.id,\n        deleted: { $ne: true },\n        packType: ctx.match[2]\n      }).sort({\n        updatedAt: -1\n      })\n\n      if (findStickerSet) {\n        userInfo.stickerSet = findStickerSet\n      } else {\n        userInfo.stickerSet = null\n      }\n\n      packType = ctx.match[2]\n    }\n  }\n\n  const query = {\n    owner: userInfo.id,\n    create: true,\n    hide: { $ne: true }\n  }\n\n  let page = 0\n  const limit = 10\n\n  if (ctx.callbackQuery) {\n    page = parseInt(ctx.match[1]) || 0\n  }\n  if (page < 0) page = 0\n\n  if (ctx.callbackQuery && ctx.match && ctx.match[1] === 'set_pack') {\n    if (ctx.match[2] === 'gif') {\n      ctx.session.userInfo.inlineType = 'gif'\n      if (userInfo?.stickerSet?.inline) userInfo.stickerSet = null\n      userInfo.inlineStickerSet = null\n    } else {\n      const stickerSet = await ctx.db.StickerSet.findById(ctx.match[2])\n\n      if (!stickerSet) {\n        return ctx.answerCbQuery(ctx.i18n.t('callback.pack.answerCbQuer.not_found'), true)\n      }\n\n      packType = stickerSet.inline ? 'inline' : stickerSet.packType\n\n      stickerSet.updatedAt = new Date()\n      await ctx.db.StickerSet.updateOne({ _id: stickerSet._id }, { updatedAt: stickerSet.updatedAt })\n\n      if (stickerSet?.owner.toString() === userInfo.id.toString()) {\n        await ctx.answerCbQuery()\n\n        if (stickerSet.inline) {\n          ctx.session.userInfo.inlineType = 'packs'\n          userInfo.inlineStickerSet = stickerSet\n        }\n\n        userInfo.stickerSet = stickerSet\n\n        const btnName = stickerSet.hide === true ? 'callback.pack.btn.restore' : 'callback.pack.btn.hide'\n\n        if (stickerSet.inline) {\n          await ctx.replyWithHTML(ctx.i18n.t('callback.pack.set_inline_pack', {\n            title: escapeHTML(stickerSet.title),\n            botUsername: ctx.options.username\n          }), {\n            reply_markup: Markup.inlineKeyboard([\n              [\n                Markup.switchToChatButton(ctx.i18n.t('callback.pack.btn.use_pack'), '')\n              ],\n              [\n                Markup.callbackButton(ctx.i18n.t(btnName), `hide_pack:${stickerSet.id}`)\n              ]\n            ]),\n            parse_mode: 'HTML'\n          })\n        } else {\n          let inlineData = ''\n          if (ctx.session.userInfo.inlineType === 'packs') {\n            inlineData = stegcloak.hide('{gif}', '', ' : ')\n          }\n\n          const searchGifButton = [Markup.switchToCurrentChatButton(ctx.i18n.t('callback.pack.btn.search_gif'), inlineData)]\n\n          let coeditButton = []\n\n          if (stickerSet.owner.toString() === userInfo.id.toString()) {\n            coeditButton = [Markup.callbackButton(ctx.i18n.t('callback.pack.btn.coedit'), `coedit:${stickerSet.id}`)]\n          }\n\n          let catalogButton = []\n\n          const stickersCount = await ctx.db.Sticker.countDocuments({\n            stickerSet: stickerSet.id,\n            deleted: false\n          })\n\n          if (stickerSet.public) {\n            catalogButton = [\n              [\n                Markup.callbackButton(ctx.i18n.t('callback.pack.btn.catalog_edit'), `catalog:publish:${stickerSet.id}`),\n                Markup.callbackButton(ctx.i18n.t('callback.pack.btn.catalog_delete'), `catalog:unpublish:${stickerSet.id}`)\n              ],\n              [\n                Markup.urlButton(ctx.i18n.t('callback.pack.btn.catalog_share'), `https://t.me/share/url?url=https://t.me/${ctx.options.username}/catalog?startapp=set=${stickerSet.name}`),\n                Markup.urlButton(ctx.i18n.t('callback.pack.btn.catalog_open'), `https://t.me/${ctx.options.username}/catalog?startApp=set=${stickerSet.name}&startapp=set=${stickerSet.name}`)\n              ]\n            ]\n          } else if (stickersCount >= 10 && !stickerSet.public) {\n            catalogButton = [[Markup.callbackButton(ctx.i18n.t('callback.pack.btn.catalog_add'), `catalog:publish:${stickerSet.id}`)]]\n          }\n\n          const linkPrefix = stickerSet.packType === 'custom_emoji' ? ctx.config.emojiLinkPrefix : ctx.config.stickerLinkPrefix\n\n          const boostText = ctx.i18n.t('callback.pack.boost.info', {\n            botUsername: ctx.options.username,\n            boostStatus: stickerSet.boost ? ctx.i18n.t('callback.pack.boost.status.on') : ctx.i18n.t('callback.pack.boost.status.off')\n          })\n\n          await ctx.replyWithHTML(ctx.i18n.t('callback.pack.set_pack', {\n            title: escapeHTML(stickerSet.title),\n            link: `${linkPrefix}${stickerSet.name}`\n          }) + boostText, {\n            disable_web_page_preview: true,\n            reply_markup: Markup.inlineKeyboard([\n              [\n                Markup.urlButton(ctx.i18n.t('callback.pack.btn.use_pack'), `${linkPrefix}${stickerSet.name}`)\n              ],\n              [\n                Markup.callbackButton(ctx.i18n.t('callback.pack.btn.boost'), `boost:${stickerSet.id}`, stickerSet.boost)\n              ],\n              [\n                Markup.callbackButton(ctx.i18n.t('callback.pack.btn.rename'), `rename_pack:${stickerSet.id}`)\n              ],\n              [\n                Markup.callbackButton(ctx.i18n.t('callback.pack.btn.frame'), 'set_frame')\n              ],\n              ...(stickerSet.packType === 'custom_emoji'\n                ? [[Markup.callbackButton(ctx.i18n.t('callback.pack.btn.mosaic'), 'mosaic:enter')]]\n                : []\n              ),\n              searchGifButton,\n              coeditButton,\n              ...catalogButton,\n              [\n                Markup.callbackButton(ctx.i18n.t(btnName), `hide_pack:${stickerSet.id}`)\n              ]\n            ]),\n            parse_mode: 'HTML'\n          })\n        }\n      } else {\n        await ctx.answerCbQuery(ctx.i18n.t('callback.pack.answerCbQuer.not_owner'), true)\n      }\n    }\n  }\n\n  if (packType === 'inline') {\n    query.inline = true\n  } else {\n    query.inline = { $ne: true }\n    if (packType === 'regular') {\n      query.packType = {\n        $in: [packType, null]\n      }\n    } else {\n      query.packType = packType\n    }\n  }\n\n  // Fetch limit+1 to check if there's a next page (avoids separate countDocuments query)\n  const stickerSets = await ctx.db.StickerSet.find(query)\n    .sort({ updatedAt: -1 })\n    .limit(limit + 1)\n    .skip(page * limit)\n    .lean()\n\n  const hasNextPage = stickerSets.length > limit\n  if (hasNextPage) stickerSets.pop()\n\n  // Use cached count from user document, fallback to countDocuments for old users\n  let totalCount = userInfo.packsCount?.[packType] ?? 0\n  if (totalCount === 0 && stickerSets.length > 0) {\n    // Fallback for users without packsCount (lazy migration)\n    totalCount = await ctx.db.StickerSet.countDocuments(query)\n    // Save for future requests (non-blocking)\n    if (!userInfo.packsCount) userInfo.packsCount = {}\n    userInfo.packsCount[packType] = totalCount\n    ctx.db.User.updateOne(\n      { _id: userInfo._id },\n      { $set: { [`packsCount.${packType}`]: totalCount } }\n    ).then(() => {})\n  }\n\n  if (packType === 'inline' && stickerSets.length <= 0) {\n    let inlineSet = await ctx.db.StickerSet.findOne({\n      owner: userInfo.id,\n      inline: true\n    })\n\n    if (!inlineSet) {\n      inlineSet = await ctx.db.StickerSet.newSet({\n        owner: userInfo.id,\n        ownerTelegramId: ctx.from.id,\n        name: 'inline_' + ctx.from.id,\n        title: ctx.i18n.t('cmd.packs.inline_title'),\n        emojiSuffix: '💫',\n        create: true,\n        inline: true\n      })\n    }\n\n    stickerSets.unshift(inlineSet)\n  }\n\n  let messageText = ''\n  const keyboardMarkup = []\n\n  if (stickerSets.length > 0) {\n    const totalPages = Math.ceil(totalCount / limit)\n    const statsText = totalCount > limit\n      ? `\\n<i>${page + 1}/${totalPages} (${totalCount})</i>\\n`\n      : ''\n    messageText = ctx.i18n.t('cmd.packs.info') + statsText\n\n    stickerSets.forEach((pack) => {\n      let { title } = pack\n\n      // if (pack.video === true) title = `📹 ${title}`\n      // else if (pack.animated === true) title = `✨ ${title}`\n      // else if (pack.inline === true) title = `💫 ${title}`\n      // else title = `🌟 ${title}`\n\n      if (\n        userInfo.stickerSet?._id?.toString() === pack._id.toString()\n      ) title += ' ✅'\n\n      keyboardMarkup.push([Markup.callbackButton(title, `set_pack:${pack._id}`)])\n    })\n  } else {\n    messageText = ctx.i18n.t('cmd.packs.empty')\n  }\n\n  if (packType === 'inline') {\n    const title = ctx.session.userInfo.inlineType !== 'gif' ? 'GIF' : '✅ GIF'\n    keyboardMarkup.push([Markup.callbackButton(title, 'set_pack:gif')])\n  }\n\n  const paginationKeyboard = []\n\n  if (page > 0) {\n    paginationKeyboard.push(Markup.callbackButton(`‹ ${page}`, `packs:${page - 1}`))\n  }\n  if (hasNextPage) {\n    paginationKeyboard.push(Markup.callbackButton(`${page + 2} ›`, `packs:${page + 1}`))\n  }\n\n  keyboardMarkup.push(paginationKeyboard)\n\n  keyboardMarkup.push([\n    Markup.callbackButton(\n      (packType === 'regular' ? '✅ ' : '') +\n      ctx.i18n.t('cmd.packs.types.regular'),\n      'packs:type:regular'\n    ),\n    Markup.callbackButton(\n      (packType === 'custom_emoji' ? '✅ ' : '') +\n      ctx.i18n.t('cmd.packs.types.custom_emoji'),\n      'packs:type:custom_emoji'\n    ),\n    Markup.callbackButton(\n      (packType === 'inline' ? '✅ ' : '') +\n      ctx.i18n.t('cmd.packs.types.inline'),\n      'packs:type:inline'\n    )\n  ])\n\n  keyboardMarkup.push([Markup.callbackButton(ctx.i18n.t('cmd.start.btn.new'), `new_pack:${packType}`)])\n\n  const replyMarkup = Markup.inlineKeyboard(keyboardMarkup)\n\n  if (ctx.updateType === 'message') {\n    await sendBanner(ctx, 'packs', messageText, {\n      reply_to_message_id: ctx.message.message_id,\n      allow_sending_without_reply: true,\n      reply_markup: replyMarkup\n    })\n  } else if (ctx.updateType === 'callback_query') {\n    // Swap whatever banner was shown (welcome, or packs from a prior nav) to\n    // the packs banner + updated caption/keyboard. editBanner internally uses\n    // editMessageMedia, which works whether the prior message is text or photo.\n    await editBanner(ctx, 'packs', messageText, { reply_markup: replyMarkup })\n  }\n}\n"
  },
  {
    "path": "handlers/ping.js",
    "content": "const Composer = require('telegraf/composer')\nconst { convertQueue } = require('../utils/queues')\n\nconst composer = new Composer()\n\ncomposer.command('ping', async (ctx) => {\n  const webhookInfo = await ctx.telegram.getWebhookInfo()\n\n  const total = await convertQueue.getJobCounts()\n\n  await ctx.replyWithHTML(`🏓 pong\\n\\nrps: ${ctx.stats.rps.toFixed(0)}\\nresponse time: ${ctx.stats.rta.toFixed(2)}\\nupdates in the queue: ${webhookInfo.pending_update_count}\\n\\nConverting queue: ${total.waiting}`)\n})\n\nmodule.exports = composer\n"
  },
  {
    "path": "handlers/search-catalog.js",
    "content": "const { replyOrEditBanner } = require('../banners')\n\nmodule.exports = async (ctx) => {\n  const caption = ctx.i18n.t('cmd.start.search_catalog')\n  const extra = {\n    reply_markup: JSON.stringify({\n      inline_keyboard: [\n        [\n          {\n            text: ctx.i18n.t('cmd.start.btn.catalog'),\n            url: ctx.config.catalogUrl\n          }\n        ],\n        [\n          {\n            text: ctx.i18n.t('cmd.start.btn.catalog_app'),\n            url: ctx.config.catalogAppUrl\n          }\n        ],\n        [\n          {\n            text: ctx.i18n.t('cmd.start.commands.publish'),\n            callback_data: 'publish'\n          }\n        ]\n      ]\n    })\n  }\n\n  await replyOrEditBanner(ctx, 'catalog', caption, extra)\n}\n"
  },
  {
    "path": "handlers/start.js",
    "content": "const Markup = require('telegraf/markup')\nconst { userName } = require('../utils')\nconst { sendBanner } = require('../banners')\n\nmodule.exports = async (ctx) => {\n  if (ctx.chat.type === 'private' && ctx.from.is_bot) {\n    return ctx.deleteMessage()\n  }\n\n  if (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup') {\n    return ctx.replyWithHTML(ctx.i18n.t('cmd.start.group', {\n      groupTitle: ctx.chat.title\n    }), {\n      reply_markup: Markup.inlineKeyboard([\n        [\n          Markup.switchToCurrentChatButton(ctx.i18n.t('cmd.packs.select_group_pack'), 'select_group_pack')\n        ]\n      ])\n    })\n  }\n\n  const countStickerSets = await ctx.db.StickerSet.countDocuments({\n    owner: ctx.session.userInfo.id\n  })\n\n  const isNewUser = countStickerSets <= 0\n\n  const keyboard = []\n\n  // Adaptive menu based on user experience\n  if (isNewUser) {\n    // For new users - focus on creating first pack\n    keyboard.push([\n      Markup.callbackButton(ctx.i18n.t('cmd.start.commands.new'), 'new_pack:null')\n    ])\n  } else {\n    // For experienced users - both manage and create\n    keyboard.push([\n      Markup.callbackButton(ctx.i18n.t('cmd.start.commands.packs'), 'packs:null'),\n      Markup.callbackButton(ctx.i18n.t('cmd.start.commands.new'), 'new_pack:null')\n    ])\n  }\n\n  // Discovery row — find packs & identify stickers\n  keyboard.push([\n    Markup.callbackButton(ctx.i18n.t('cmd.start.commands.search_catalog'), 'search_catalog'),\n    Markup.callbackButton(ctx.i18n.t('cmd.start.commands.info'), 'pack_about')\n  ])\n\n  // Help row\n  keyboard.push([\n    Markup.urlButton(ctx.i18n.t('cmd.start.commands.guide'), 'https://fstik.app/guides')\n  ])\n\n  // Add to group\n  keyboard.push([\n    Markup.urlButton(ctx.i18n.t('cmd.start.commands.add_to_group'), `https://t.me/${ctx.botInfo.username}?startgroup=bot`)\n  ])\n\n  // Build message text with optional advertising\n  let messageText = ctx.i18n.t('cmd.start.enter', {\n    name: userName(ctx.from)\n  })\n\n  if (ctx.config?.advertising?.text && ctx.config?.advertising?.link) {\n    messageText += `\\n\\n<a href=\"${ctx.config.advertising.link}\">${ctx.config.advertising.text}</a>`\n  }\n\n  await sendBanner(ctx, 'welcome', messageText, {\n    reply_markup: Markup.inlineKeyboard(keyboard)\n  })\n\n  if (ctx.config.catalogUrl && ctx.startPayload === 'catalog') {\n    await sendBanner(ctx, 'catalog', ctx.i18n.t('cmd.start.catalog'), {\n      reply_markup: JSON.stringify({\n        inline_keyboard: [\n          [\n            {\n              text: ctx.i18n.t('cmd.start.btn.catalog'),\n              url: ctx.config.catalogUrl\n            }\n          ],\n          [\n            {\n              text: ctx.i18n.t('cmd.start.btn.catalog_app'),\n              url: ctx.config.catalogAppUrl\n            }\n          ]\n          // [\n          //   {\n          //     text: ctx.i18n.t('cmd.start.btn.catalog_browser'),\n          //     login_url: {\n          //       url: ctx.config.catalogUrl,\n          //       request_write_access: true\n          //     }\n          //   }\n          // ]\n        ]\n      })\n    })\n  }\n\n  ctx.telegram.callApi('deleteMyCommands', {\n    scope: {\n      type: 'chat',\n      chat_id: ctx.chat.id\n    }\n  }).catch(err => console.error('Failed to delete chat commands:', err.message))\n}\n"
  },
  {
    "path": "handlers/stats.js",
    "content": "const Composer = require('telegraf/composer')\n\nconst composer = new Composer()\n\ncomposer.use(async (ctx, next) => {\n  if (ctx.updateType === 'message' && ctx.updateSubTypes.includes('text') && ctx.message.text.startsWith('/start')) {\n    const params = ctx.message.text.split(' ')\n    if (params.length > 1) {\n      const deepLink = await ctx.db.DeepLink.findOne({ deepLink: params[1], user: ctx.session.userInfo._id })\n\n      if (!deepLink) {\n        await ctx.db.DeepLink.create({\n          user: ctx.session.userInfo._id,\n          deepLink: params[1]\n        })\n      }\n    }\n  }\n\n  return next()\n})\n\nmodule.exports = composer\n"
  },
  {
    "path": "handlers/sticker-delete.js",
    "content": "const Markup = require('telegraf/markup')\nconst escapeHTML = require('../utils/html-escape')\nconst { humanizeTelegramError } = require('../utils/telegram-error')\nconst { safeEditMessage } = require('../utils/safe-edit')\n\nmodule.exports = async (ctx) => {\n  let packBotUsername\n  let deleteSticker\n  let sticker\n\n  if (!ctx.session.userInfo) ctx.session.userInfo = await ctx.db.User.getData(ctx.from)\n\n  const { message } = ctx.callbackQuery\n\n  sticker = await ctx.db.Sticker.findOne({\n    fileUniqueId: ctx.match[2]\n  }).populate('stickerSet', '_id name title owner inline passcode')\n\n  if (!sticker) {\n    let setName\n\n    const { reply_to_message } = message\n\n    if (message?.reply_to_message?.sticker) {\n      setName = reply_to_message.sticker.set_name\n\n      deleteSticker = reply_to_message.sticker.file_id\n    } else if (reply_to_message?.entities && reply_to_message?.entities?.[0] && reply_to_message?.entities?.[0]?.type === 'custom_emoji') {\n      const customEmoji = reply_to_message.entities.find((e) => e.type === 'custom_emoji')\n\n      if (!customEmoji) return ctx.answerCbQuery(ctx.i18n.t('callback.sticker.error.not_found'), true)\n\n      const emojiStickers = await ctx.telegram.callApi('getCustomEmojiStickers', {\n        custom_emoji_ids: [customEmoji.custom_emoji_id]\n      })\n\n      if (!emojiStickers) return ctx.answerCbQuery(ctx.i18n.t('callback.sticker.error.not_found'), true)\n\n      setName = emojiStickers[0].set_name\n      deleteSticker = emojiStickers[0].file_id\n    }\n\n    if (!setName) {\n      return ctx.answerCbQuery(ctx.i18n.t('callback.sticker.error.not_found'), true)\n    }\n\n    packBotUsername = setName.split('_').pop()\n\n    if (!message?.reply_to_message || !packBotUsername || packBotUsername !== ctx?.options?.username) {\n      return ctx.answerCbQuery(ctx.i18n.t('callback.sticker.error.not_found'), true)\n    }\n\n    const stickerSet = await ctx.db.StickerSet.findOne({\n      name: setName,\n      owner: ctx.session.userInfo.id\n    })\n\n    if (!stickerSet) {\n      return ctx.answerCbQuery(ctx.i18n.t('callback.sticker.error.not_found'), true)\n    }\n  } else {\n    if (!sticker.stickerSet) {\n      return ctx.answerCbQuery(ctx.i18n.t('callback.sticker.error.not_found'), true)\n    }\n\n    // cat delete in group\n    let canDelete = false\n\n    if (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup') {\n      const group = await ctx.db.Group.findOne({ telegram_id: ctx.chat.id })\n\n      if (group && group.stickerSet && group.stickerSet._id.toString() === sticker.stickerSet._id.toString()) {\n        if (group.settings.rights.delete === 'all') {\n          canDelete = true\n        } else {\n          const chatMember = await ctx.telegram.getChatMember(ctx.chat.id, ctx.from.id)\n\n          if (['creator', 'administrator'].includes(chatMember.status)) {\n            canDelete = true\n          }\n        }\n      } else {\n        return ctx.answerCbQuery(ctx.i18n.t('callback.sticker.error.not_found'), true)\n      }\n    }\n\n    if (\n      sticker.stickerSet.owner.toString() === ctx.session.userInfo.id.toString() || // if sticker owner is the same as the user\n      (ctx.session.userInfo?.stickerSet && sticker.stickerSet.id === ctx.session.userInfo?.stickerSet?.id) || // if selected sticker pack by user is the same as the sticker pack\n      canDelete // if user have rights to delete sticker\n    ) {\n      deleteSticker = sticker.getFileId()\n    } else {\n      return ctx.answerCbQuery(ctx.i18n.t('callback.sticker.error.not_found'), true)\n    }\n  }\n\n  if (!deleteSticker) {\n    return ctx.answerCbQuery(ctx.i18n.t('callback.sticker.error.not_found'), true)\n  }\n\n  if (ctx.session?.userInfo?.stickerSet?.passcode === 'public') {\n    const stickerSet = await ctx.tg.getStickerSet(sticker.stickerSet.name).catch(() => null)\n\n    if (stickerSet && stickerSet.stickers[0].file_unique_id === sticker.fileUniqueId) {\n      return ctx.answerCbQuery(ctx.i18n.t('callback.sticker.error.not_found'), true)\n    }\n  }\n\n  if (!sticker?.stickerSet?.inline) {\n    try {\n      await ctx.deleteStickerFromSet(deleteSticker)\n    } catch (error) {\n      return ctx.answerCbQuery(humanizeTelegramError(ctx, error), true)\n    }\n  }\n\n  await ctx.answerCbQuery(ctx.i18n.t('callback.sticker.answerCbQuery.delete'))\n\n  const packTitle = sticker?.stickerSet?.title\n  const successText = packTitle\n    ? `${ctx.i18n.t('callback.sticker.delete')}\\n\\n📦 <i>${escapeHTML(packTitle)}</i>`\n    : ctx.i18n.t('callback.sticker.delete')\n\n  await safeEditMessage(ctx, successText, {\n    parse_mode: 'HTML',\n    reply_markup: Markup.inlineKeyboard([\n      { ...Markup.callbackButton(ctx.i18n.t('callback.sticker.btn.restore'), `restore_sticker:${sticker?.fileUniqueId}`, !sticker), style: 'success' }\n    ])\n  })\n\n  if (sticker) {\n    sticker.deleted = true\n    sticker.deletedAt = new Date()\n    await sticker.save()\n  }\n}\n"
  },
  {
    "path": "handlers/sticker-restore.js",
    "content": "const Markup = require('telegraf/markup')\nconst {\n  addSticker\n} = require('../utils')\nconst { humanizeTelegramError } = require('../utils/telegram-error')\nconst { safeEditMessage } = require('../utils/safe-edit')\n\nmodule.exports = async (ctx) => {\n  const sticker = await ctx.db.Sticker.findOne({\n    fileUniqueId: ctx.match[2]\n  }).populate('stickerSet', '_id name title inline animated video packType emojiSuffix frameType boost owner')\n\n  if (!sticker) {\n    return ctx.answerCbQuery(ctx.i18n.t('callback.sticker.error.not_found'), true)\n  }\n\n  if (sticker.stickerSet.owner.toString() !== ctx.session.userInfo.id.toString()) {\n    return ctx.answerCbQuery(ctx.i18n.t('callback.pack.answerCbQuer.not_owner'), true)\n  }\n\n  let newFileUniqueId\n\n  if (sticker.stickerSet.inline === true) {\n    // Inline stickers - just mark as not deleted\n    sticker.deleted = false\n    sticker.deletedAt = null\n    await sticker.save()\n\n    await ctx.answerCbQuery(ctx.i18n.t('callback.sticker.answerCbQuery.restored'), true)\n    newFileUniqueId = sticker.fileUniqueId\n  } else {\n    // Regular stickers - need to re-add to Telegram\n    const currentFileId = sticker.getFileId()\n    const stickerFile = await ctx.telegram.getFile(currentFileId)\n    const fileExtension = stickerFile.file_path.split('.').pop()\n\n    // Build file object for addSticker\n    const originalFileId = sticker.getOriginalFileId() || currentFileId\n    const originalFileUniqueId = sticker.getOriginalFileUniqueId() || sticker.fileUniqueId\n\n    const fileForRestore = {\n      file_id: originalFileId,\n      file_unique_id: originalFileUniqueId\n    }\n\n    // Determine format and set flags\n    if (fileExtension === 'tgs') {\n      fileForRestore.is_animated = true\n    } else if (['png', 'webp', 'jpg', 'jpeg'].includes(fileExtension)) {\n      // Static - no additional flags needed\n    } else {\n      // Video format\n      fileForRestore.is_video = true\n      // For video, use current file_id and skip re-encoding\n      fileForRestore.file_id = currentFileId\n      fileForRestore.skip_reencode = true\n    }\n\n    const result = await addSticker(ctx, fileForRestore, sticker.stickerSet)\n\n    if (result.error) {\n      if (result.error.type === 'duplicate') {\n        return ctx.answerCbQuery(ctx.i18n.t('sticker.add.error.have_already'), true)\n      } else if (result.error.telegram && result.error.telegram.description.includes('STICKERSET_INVALID')) {\n        return ctx.answerCbQuery(ctx.i18n.t('callback.pack.error.copy'), true)\n      } else if (result.error.telegram) {\n        return ctx.answerCbQuery(humanizeTelegramError(ctx, result.error.telegram), true)\n      } else if (result.error.i18nKey) {\n        return ctx.answerCbQuery(ctx.i18n.t(result.error.i18nKey), true)\n      }\n    }\n\n    newFileUniqueId = result.ok && result.ok.stickerInfo && result.ok.stickerInfo.file_unique_id\n  }\n\n  await ctx.answerCbQuery(ctx.i18n.t('callback.sticker.answerCbQuery.restored'))\n\n  await safeEditMessage(ctx, ctx.i18n.t('callback.sticker.restored'), {\n    reply_markup: Markup.inlineKeyboard([\n      { ...Markup.callbackButton(ctx.i18n.t('callback.sticker.btn.delete'), `delete_sticker:${newFileUniqueId}`, !!newFileUniqueId), style: 'danger' },\n      { ...Markup.callbackButton(ctx.i18n.t('callback.sticker.btn.copy'), `restore_sticker:${newFileUniqueId}`, !!newFileUniqueId), style: 'primary' }\n    ])\n  })\n}\n"
  },
  {
    "path": "handlers/sticker-update.js",
    "content": "const emojiRegex = require('emoji-regex')\n\nmodule.exports = async (ctx, next) => {\n  if (ctx.session.previousSticker && ctx.session?.userInfo?.stickerSet?.inline) {\n    if (ctx.message.text.startsWith('/')) {\n      ctx.session.previousSticker = null\n      return next()\n    }\n\n    const sticker = await ctx.db.Sticker.findById(ctx.session.previousSticker.id)\n\n    if (sticker) {\n      sticker.emojis = ctx.message.text\n      await sticker.save()\n\n      ctx.session.previousSticker = null\n\n      return ctx.replyWithHTML(ctx.i18n.t('cmd.emoji.done'), {\n        reply_to_message_id: ctx.message.message_id,\n        allow_sending_without_reply: true\n      })\n    } else {\n      return next()\n    }\n  } else if (ctx.session?.userInfo?.stickerSet?.inline) {\n    return next()\n  }\n\n  if (\n    ctx.message.text.match(/[a-zA-Zа-яА-Я]/)\n  ) return next()\n\n  let sticker\n\n  if (ctx.session.previousSticker) {\n    sticker = await ctx.db.Sticker.findById(ctx.session.previousSticker.id)\n  } else if (ctx.session.userInfo.stickerSet) {\n    const stickerSetInfo = await ctx.tg.getStickerSet(ctx.session.userInfo.stickerSet.name).catch(() => null) // STICKERSET_INVALID / deleted pack → caller handles null below\n\n    if (!stickerSetInfo || stickerSetInfo.stickers.length < 1) {\n      return next()\n    }\n\n    const stickerInfo = stickerSetInfo.stickers[stickerSetInfo.stickers.length - 1]\n\n    sticker = await ctx.db.Sticker.findOne({\n      stickerSet: ctx.session.userInfo.stickerSet,\n      fileUniqueId: stickerInfo.file_unique_id,\n      deleted: false\n    })\n\n    if (!sticker) {\n      return next()\n    }\n  } else {\n    return next()\n  }\n\n  const regex = emojiRegex()\n  const emojis = ctx.message.text.match(regex)\n\n  if (!emojis || emojis.length === 0) {\n    return next()\n  }\n\n  const updateResult = await ctx.tg.callApi('setStickerEmojiList', {\n    sticker: sticker.getFileId(),\n    emoji_list: emojis\n  }).catch((error) => {\n    console.error('setStickerEmojiList failed:', error?.description || error?.message)\n  })\n\n  if (updateResult) {\n    sticker.emojis = emojis.join(' ')\n    await sticker.save()\n\n    await ctx.replyWithHTML(ctx.i18n.t('cmd.emoji.done'), {\n      reply_to_message_id: ctx.message.message_id,\n      allow_sending_without_reply: true\n    })\n  } else {\n    await ctx.replyWithHTML(ctx.i18n.t('cmd.emoji.error'), {\n      reply_to_message_id: ctx.message.message_id,\n      allow_sending_without_reply: true\n    })\n  }\n}\n"
  },
  {
    "path": "handlers/sticker.js",
    "content": "const Markup = require('telegraf/markup')\nconst {\n  escapeHTML,\n  showGramAds,\n  countUncodeChars,\n  substrUnicode,\n  addSticker,\n  addStickerText,\n  getRateLimitRemaining\n} = require('../utils')\nconst stickerInflight = require('../utils/sticker-inflight')\nconst handleError = require('./catch')\n\nmodule.exports = async (ctx, next) => {\n  if (ctx.message?.text?.startsWith('/ss') && !ctx.message?.reply_to_message) {\n    return ctx.replyWithHTML(ctx.i18n.t('sticker.add.error.reply'), {\n      reply_to_message_id: ctx.message.message_id,\n      allow_sending_without_reply: true\n    })\n  }\n\n  ctx.replyWithChatAction('upload_document').catch(() => {}) // UX only — 429/blocked noise silenced\n\n  let messageText = ''\n  const replyMarkup = {}\n\n  if (!ctx.session.userInfo) ctx.session.userInfo = await ctx.db.User.getData(ctx.from)\n\n  const message = ctx.message || ctx.callbackQuery.message\n\n  let stickerFile\n  let stickerType = ctx.updateSubTypes[0]\n  if (ctx.callbackQuery) {\n    if (message.document) {\n      stickerType = 'document'\n    } else if (message.sticker) {\n      stickerType = 'sticker'\n    }\n\n    // if message send less than 2 seconds ago\n    if (message.date > Math.floor(Date.now() / 1000) - 2) {\n      await new Promise((resolve) => setTimeout(resolve, 2000))\n    }\n  }\n\n  if (ctx.message?.text?.startsWith('/ss') && ctx.message?.reply_to_message) {\n    if (ctx.message.reply_to_message.sticker) stickerType = 'sticker'\n    else if (ctx.message.reply_to_message.document) stickerType = 'document'\n    else if (ctx.message.reply_to_message.animation) stickerType = 'animation'\n    else if (ctx.message.reply_to_message.video) stickerType = 'video'\n    else if (ctx.message.reply_to_message.video_note) stickerType = 'video_note'\n    else if (ctx.message.reply_to_message.photo) stickerType = 'photo'\n    else stickerType = undefined\n  }\n\n  switch (stickerType) {\n    case 'sticker':\n      stickerFile = message.sticker\n      break\n\n    case 'document':\n      if (\n        (message?.document?.mime_type.match('image') ||\n        message?.document?.mime_type?.match('video')) &&\n        !message.document.mime_type.match(/heic|heif/)\n      ) {\n        stickerFile = message.document\n        if (message.caption) stickerFile.emoji = message.caption\n      }\n      break\n\n    case 'animation':\n      // if caption tenor gif\n      if (message.caption && message.caption.match('tenor.com')) {\n        stickerFile = message.animation\n        stickerFile.fileUrl = message.caption\n      } else {\n        stickerFile = message.animation\n        if (message.caption) stickerFile.emoji = message.caption\n      }\n      break\n\n    case 'video':\n      stickerFile = message.video\n      if (message.caption) stickerFile.emoji = message.caption\n      break\n\n    case 'video_note':\n      stickerFile = message?.video_note\n      if (message?.video_note) stickerFile.video_note = true\n      break\n\n    case 'photo':\n      // eslint-disable-next-line prefer-destructuring\n      if (message.photo) stickerFile = message.photo.slice(-1)[0]\n      if (message.caption) stickerFile.emoji = message.caption\n      break\n\n    default:\n      break\n  }\n\n  if (ctx.message?.text?.startsWith('/ss') && ctx.message?.reply_to_message && stickerType && !stickerFile) {\n    stickerFile = ctx.message.reply_to_message[stickerType]\n    if (Array.isArray(stickerFile)) {\n      stickerFile = stickerFile.slice(-1)[0]\n    }\n    if (stickerType === 'video_note') stickerFile.video_note = true\n  }\n\n  if (stickerType === 'text') {\n    const customEmoji = message.entities?.find((e) => e.type === 'custom_emoji')\n\n    if (!customEmoji) return next()\n\n    const emojiStickers = await ctx.telegram.callApi('getCustomEmojiStickers', {\n      custom_emoji_ids: [customEmoji.custom_emoji_id]\n    })\n\n    if (!emojiStickers || !emojiStickers.length || !emojiStickers[0]?.file_unique_id) return next()\n\n    stickerFile = emojiStickers[0]\n  }\n\n  let { stickerSet } = ctx.session.userInfo\n\n  if (!stickerSet) {\n    if (ctx.chat.type === 'private') {\n      return ctx.replyWithHTML(ctx.i18n.t('sticker.add.error.no_selected_pack'), {\n        reply_to_message_id: message.message_id,\n        allow_sending_without_reply: true\n      })\n    } else {\n      return ctx.replyWithHTML(ctx.i18n.t('sticker.add.error.no_selected_pack'), {\n        reply_markup: Markup.inlineKeyboard([\n          Markup.switchToCurrentChatButton(ctx.i18n.t('cmd.packs.select_pack'), 'select_pack')\n        ]),\n        reply_to_message_id: message.message_id,\n        allow_sending_without_reply: true\n      })\n    }\n  }\n\n  if (!stickerSet?.inline) {\n    const stickerSetInfo = await ctx.telegram.getStickerSet(stickerSet.name).catch(() => null) // STICKERSET_INVALID / deleted pack → caller handles null below\n\n    if (stickerSetInfo) {\n      // if user not premium and not boosed pack and title not have bot username\n      if (!stickerSet.boost && !stickerSetInfo.title.includes(ctx.options.username)) {\n        const titleSuffix = ` :: @${ctx.options.username}`\n        const charTitleMax = ctx.config.charTitleMax\n\n        let newTitle = stickerSetInfo.title\n\n        if (countUncodeChars(newTitle) > charTitleMax) {\n          newTitle = substrUnicode(newTitle, 0, charTitleMax)\n        }\n\n        newTitle += titleSuffix\n\n        await ctx.telegram.callApi('setStickerSetTitle', {\n          name: stickerSet.name,\n          title: newTitle\n        }).catch((err) => {\n          console.log('setStickerSetTitle', err)\n        })\n\n        const linkPrefix = stickerSet.packType === 'custom_emoji' ? ctx.config.emojiLinkPrefix : ctx.config.stickerLinkPrefix\n\n        const text = ctx.i18n.t('scenes.rename.success', {\n          title: escapeHTML(stickerSet.title),\n          link: `${linkPrefix}${stickerSet.name}`\n        }) + '\\n' + ctx.i18n.t('scenes.rename.boost_notice', {\n          titleSuffix: escapeHTML(titleSuffix)\n        })\n\n        await ctx.replyWithHTML(text)\n      }\n\n      if (stickerSet.title !== stickerSetInfo.title) {\n        stickerSet.title = stickerSetInfo.title\n        await ctx.db.StickerSet.updateOne({ _id: stickerSet._id }, { title: stickerSetInfo.title })\n      }\n    }\n  }\n\n  if (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup') {\n    const group = await ctx.db.Group.findOne({ telegram_id: ctx.chat.id }).populate('stickerSet')\n\n    if (!group || !group.stickerSet) {\n      return ctx.replyWithHTML(ctx.i18n.t('sticker.add.error.no_selected_group_pack'), {\n        reply_markup: Markup.inlineKeyboard([\n          Markup.switchToCurrentChatButton(ctx.i18n.t('cmd.packs.select_group_pack'), 'select_group_pack')\n        ]),\n        reply_to_message_id: message.message_id,\n        allow_sending_without_reply: true\n      })\n    }\n\n    // if have rights to add stickers\n    if (group.settings?.rights?.add !== 'all') {\n      const chatMember = await ctx.telegram.getChatMember(ctx.chat.id, ctx.from.id)\n\n      if (!['creator', 'administrator'].includes(chatMember.status)) {\n        return ctx.replyWithHTML(ctx.i18n.t('sticker.add.error.no_rights'), {\n          reply_to_message_id: message.message_id,\n          allow_sending_without_reply: true\n        })\n      }\n    }\n\n    if (group) {\n      stickerSet = group.stickerSet\n    }\n  }\n\n  if (!stickerSet) {\n    return ctx.replyWithHTML(ctx.i18n.t('sticker.add.error.no_selected_pack'), {\n      reply_to_message_id: message.message_id,\n      allow_sending_without_reply: true\n    })\n  }\n\n  if (stickerSet.inline) {\n    // Use slice(-1)[0] instead of pop() to avoid mutating the original array\n    if (stickerType === 'photo') stickerFile = message[stickerType].slice(-1)[0]\n    else stickerFile = message[stickerType]\n\n    // Always set stickerType for inline packs based on detected type\n    if (stickerFile) stickerFile.stickerType = stickerType\n\n    if (message.caption) stickerFile.caption = message.caption\n    stickerFile.file_unique_id = stickerSet.id + '_' + stickerFile.file_unique_id\n  }\n\n  if (ctx.callbackQuery) {\n    if (ctx.callbackQuery.message.document) {\n      stickerFile = ctx.callbackQuery.message.document\n    } else if (ctx.callbackQuery.message.sticker) {\n      stickerFile = ctx.callbackQuery.message.sticker\n    }\n  }\n\n  if (stickerFile) {\n    // Set stickerType for all packs (used when storing original file)\n    if (!stickerFile.stickerType && stickerType) {\n      stickerFile.stickerType = stickerType\n    }\n\n    if (message.caption && message.caption.includes('roundit')) stickerFile.video_note = true\n    if (message.caption && message.caption.includes('cropit')) stickerFile.forceCrop = true\n    if (message.photo && message.caption && message.caption.includes('!')) stickerFile.removeBg = true\n\n    // Check for duplicates: by fileUniqueId, original.fileUniqueId, or legacy file.file_unique_id.\n    // .lean() — read-only path, only reads .id and .fileUniqueId; skips Mongoose doc hydration.\n    const sticker = await ctx.db.Sticker.findOne({\n      stickerSet,\n      deleted: false,\n      $or: [\n        { fileUniqueId: stickerFile.file_unique_id },\n        { 'original.fileUniqueId': stickerFile.file_unique_id },\n        { 'file.file_unique_id': stickerFile.file_unique_id }\n      ]\n    }).lean()\n\n    if (sticker) {\n      ctx.session.previousSticker = {\n        id: sticker._id.toString()\n      }\n\n      await ctx.replyWithHTML(ctx.i18n.t('sticker.add.error.have_already'), {\n        reply_to_message_id: message.message_id,\n        allow_sending_without_reply: true,\n        reply_markup: Markup.inlineKeyboard([\n          { ...Markup.callbackButton(ctx.i18n.t('callback.sticker.btn.delete'), `delete_sticker:${sticker.fileUniqueId}`), style: 'danger' },\n          Markup.callbackButton(ctx.i18n.t('callback.sticker.btn.copy'), `restore_sticker:${sticker.fileUniqueId}`)\n        ])\n      })\n    } else {\n      // Pre-check: if the user's addStickerToSet is in a 429 cooldown from\n      // a recent attempt on the same pack, bail BEFORE downloading and\n      // re-uploading a file that would only trip the same limit again.\n      // Saves 1-3s of wasted Telegram bandwidth per attempt.\n      const cooldown = getRateLimitRemaining('addStickerToSet', ctx.from.id)\n      if (cooldown > 0) {\n        return ctx.replyWithHTML(ctx.i18n.t('error.rate_limit_seconds', { seconds: cooldown }), {\n          reply_to_message_id: message.message_id,\n          allow_sending_without_reply: true\n        })\n      }\n\n      // Per-user cap on in-flight sticker adds. Without it, one user\n      // spamming 20 stickers launches 20 concurrent uploads that all 429\n      // each other anyway. The cap mirrors Telegram's practical tolerance.\n      if (!stickerInflight.acquire(ctx.from.id)) {\n        return ctx.replyWithHTML(ctx.i18n.t('sticker.add.error.wait_load'), {\n          reply_to_message_id: message.message_id,\n          allow_sending_without_reply: true\n        })\n      }\n\n      if (ctx.session.userInfo.locale === 'ru' && !stickerSet?.boost) {\n        showGramAds(ctx.chat.id)\n      }\n\n      ctx.session.previousSticker = null\n\n      ctx.telegram.sendChatAction(ctx.chat.id, 'choose_sticker').catch(() => {})\n\n      // Fire-and-forget: handler returns now so the polling-batch slot\n      // frees immediately and the user sees chat_action feedback. The\n      // upload + reply happens in this detached chain. Because addSticker\n      // never calls ctx.reply directly (see its return contract), we\n      // always render via addStickerText — one code path, no special\n      // cases.\n      ;(async () => {\n        try {\n          const stickerInfo = await addSticker(ctx, stickerFile, stickerSet)\n\n          // Video path: enqueued to convertQueue. The worker's\n          // global:completed handler (utils/add-sticker.js) will reply.\n          if (stickerInfo.wait) return\n\n          const { messageText, replyMarkup } = addStickerText(stickerInfo, ctx.i18n.locale())\n\n          if (messageText) {\n            try {\n              await ctx.replyWithHTML(messageText, {\n                reply_to_message_id: message.message_id,\n                allow_sending_without_reply: true,\n                reply_markup: replyMarkup\n              })\n            } catch (err) {\n              console.error('[sticker.bg] reply with reply_to failed:', err.message)\n              // Retry without reply_to_message_id — covers the case where the\n              // user deleted their original sticker while bg removal was running.\n              await ctx.replyWithHTML(messageText, { reply_markup: replyMarkup })\n                .catch(retryErr => console.error('[sticker.bg] retry reply failed:', retryErr.message))\n            }\n          }\n\n          if (\n            stickerInfo.ok &&\n            typeof stickerSet?.publishDate === 'undefined' &&\n            stickerSet?.packType === 'regular'\n          ) {\n            const countStickers = await ctx.db.Sticker.countDocuments({\n              stickerSet,\n              deleted: false\n            })\n\n            if ([50, 90].includes(countStickers)) {\n              setTimeout(async () => {\n                await ctx.replyWithHTML(ctx.i18n.t('sticker.add.catalog_offer', {\n                  title: escapeHTML(stickerSet.title),\n                  link: `${ctx.config.stickerLinkPrefix}${stickerSet.name}`\n                }), {\n                  reply_markup: Markup.inlineKeyboard([\n                    { ...Markup.callbackButton(ctx.i18n.t('callback.pack.btn.catalog_add'), `catalog:publish:${stickerSet.id}`), style: 'primary' }\n                  ])\n                }).catch(() => {})\n              }, 1000 * 2)\n            }\n          }\n        } catch (err) {\n          // Unexpected throw inside the detached chain. Route through\n          // the normal error pipeline so the log channel gets git blame\n          // + stack + chainActions, same as any sync handler error.\n          handleError(err, ctx).catch(e => console.error('[sticker.bg] handleError itself failed:', e))\n        } finally {\n          stickerInflight.release(ctx.from.id)\n        }\n      })()\n    }\n  } else {\n    if (ctx.chat.type === 'private') {\n      messageText = ctx.i18n.t('sticker.add.error.file_type.unknown')\n    } else {\n      return ctx.replyWithHTML(ctx.i18n.t('sticker.add.quote'), {\n        reply_markup: {\n          inline_keyboard: [\n            [{ text: ctx.i18n.t('cmd.start.commands.add_to_group'), url: 'https://t.me/QuotLyBot?startgroup=bot' }]\n          ]\n        },\n        reply_to_message_id: ctx.message.message_id,\n        allow_sending_without_reply: true\n      })\n    }\n  }\n\n  if (messageText) {\n    await ctx.replyWithHTML(messageText, {\n      reply_to_message_id: message.message_id,\n      allow_sending_without_reply: true,\n      reply_markup: replyMarkup\n    })\n  }\n}\n"
  },
  {
    "path": "index.js",
    "content": "require('dotenv').config({ path: './.env' })\nrequire('./bot')\n"
  },
  {
    "path": "locales/ar.yaml",
    "content": "---\nlanguage_name: '🇸🇦 عربي'\nname: fStik — ملصقات وإيموجي\ndescription:\n  long: |\n    أنشئ ملصقات وإيموجي من الصور والفيديوهات وملفات GIF بدون تحويل يدوي - البوت يتولى كل شيء!\n\n    المميزات:\n    • إدارة الحزم\n    • ملصقات فيديو وإيموجي مخصصة\n    • تنزيل الملفات الأصلية\n    • تحويل إلى صورة\n    • كتالوج الملصقات\n\n    بحث الملصقات: play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    أنشئ ملصقات وإيموجي من الصور والفيديو وGIF. كتالوج وبحث. 🇺🇦\nratelimit: ليس بهذه السرعة!\ncmd:\n  start:\n    enter: |\n      🧙 مرحبًا ${name}! أنا معالج حزمة الإيموجي والملصقات.\n      أقوم بتحويل الصور ومقاطع الفيديو وملفات GIF إلى ملصقات رائعة في لمح البصر.\n\n      أرسل الأمر /help لمزيد من المعلومات\n\n      💬 مجموعة الدعم: @fStikCommunity (باللغة الإنجليزية فقط)\n    group: |\n      🧙 مرحبًا، ‏${groupTitle}! أنا ساحر حزم الوجوه التعبيرية والملصقات.\n\n       لإضافة ملصق إلى حزمة جماعية، استخدم الأمر /ss في الرد على صورة، فيديو، GIF، أو ملصق.\n    catalog: |\n      <b>😻 يمكنك العثور على حزم ملصقات جديدة في الكتالوج الخاص بنا</b>\n\n      • انقر فوق الزر أدناه للوصول إلى كتالوج ضخم من حزم الملصقات لكل ذوق\n      • ابحث عن طريق الكلمات الرئيسية أو في علامات التبويب المعدة\n      • لا تنسى التقييم للترويج أو خفض حزمة الملصقات في الترتيب\n    commands:\n      ss: '🌟 حفظ الملصق'\n      start: '📜 قائمة البدء'\n      help: '📖 مساعدة'\n      packs: '📁 إدارة الحزم'\n      new: '🌝 إنشاء حزمة ملصقات'\n      catalog: '📖 كاتالوج'\n      publish: '📤 نشر الحزمة'\n      delete: '❌ حذف الملصق'\n      original: '🔍 ابحث عن الملصق الأصلي'\n      restore: '🔀 استعادة الحزمة'\n      copy: '📋 نسخ حزمة'\n      emoji: '📝 تغيير لاحقة الأيموجي'\n      round: '🎥 فيديو بشكل دائري'\n      clear: '🖼️ إزالة الخلفية من الصورة'\n      about: '📦 معلومات الحزمة'\n      user_about: '🧑‍🎨 معلومات المنشئ'\n      lang: '🌐 تغيير اللغة'\n      report: '🚨 التبليغ عن الحزمة'\n      donate: '☕ أدعم المبرمج'\n      add_to_group: '👥 إضافة إلى المجموعة'\n      privacy: '🔒 سياسة الخصوصية'\n    btn:\n      new: '📥 إنشاء جديد'\n      catalog: '💖 فتح الكتالوج'\n      catalog_mini: '💖 كاتالوج'\n      catalog_browser: '🌐 فتح في المتصفح'\n      catalog_browser_mini: '🌐 في المتصفح'\n      catalog_app: '📱 تحميل تطبيق الاندرويد'\n      catalog_app_mini: '📱 تطبيق الأندرويد'\n  inline:\n    switch_pm: '📁 اختر حزمة'\n  restore: |\n    <b>🗃 استعادة الحزمة</b>\n\n    لاستعادة الحزمة، يجب أن ترسل لي رابطًا للحزمة التي تريد استعادتها\n  copy: |\n    <b>🗄️ نسخ الحزمة</b>\n\n    لنسخ حزمة أخرى جديدة تحتاج فقط لإرسال رابط ملصق أو حزمة إيموجي\n  report: |\n    <b>🚨 أبلغ</b>\n\n    إذا صادفت حزمة ملصقات تعتقد أنها قد تنتهك القانون أو تتعارض مع شروط خدمة Telegram، فيرجى إبلاغنا بها عن طريق إرسال الرابط الخاص بها إلى @SticksReportBot\n\n    <i>تذكر أن البوت غير مسؤول عن محتوى الحزم وليس لديه القدرة على التحكم فيه</i>\n  packs:\n    info: |\n      <b>📁 الحزم</b>\n    types:\n      regular: الملصقات\n      custom_emoji: ايموجي\n      inline: مضمنة\n    empty: |\n      <b>ليس لديك حزم بعد.</b>\n      للإنشاء، اكتب الأمر /new\n    inline_title: حزمة مضمنة\n    select_group_pack_info: |\n      <b>📁 اختر حزمة</b>\n\n       لاستخدام الحزمة في المجموعة، يجب على المسؤولين اختيارها باستخدام الزر أدناه\n    select_group_pack: اختر حزمة\n  emoji:\n    info: |\n      لتغيير الرموز التعبيرية الافتراضية للحزمة الحالية، أرسل <code>/emoji</code> متبوعة بالرموز التعبيرية مفصولة بمسافة\n\n      على سبيل المثال - <code>/emoji 🌟</code>\n    done: تم تغيير الإيموجي بنجاح.\n    error: حدث خطأ أثناء تغيير الإيموجي!\n  round_video:\n    enabled: |\n      سيكون لمقاطع الفيديو الآن شكل مستدير\n    disabled: |\n      لن تكون مقاطع الفيديو بشكل مستدير بعد الآن\n  paysupport: |\n    <b>👨‍💻 دعم الدفع</b>\n\n    في جميع المسائل المتعلقة بتشغيل البوت، بما في ذلك المدفوعات والتبرعات، يمكنك الاتصال بالمطور مباشرةً\n\n    <b>الاتصالات:</b>\n    🧑‍💻 المطور: @ly_oBot\ndonate:\n  menu: |\n    <b>☕ دعم تطوير البوت</b>\n    من خلال دعم تطوير البوت، سوف تتلقى الائتمانات.\n\n    <b>الرصيد:</b> <code>${balance}</code> رصيد\n    مع 1 رصيد، لديك الفرصة لتعزيز حزمة واحدة.\n\n    <b>يوفر التعزيز المزايا التالية:</b>\n    ➖ بدون \"<code>${titleSuffix}</code>\" في اسم الحزمة <i>(ليس في الرابط)</i>\n    ➖ عنوان يصل إلى 64 حرفًا (بدلاً من 35)\n    ➖ مقاطع فيديو حتى 35 ثانية\n    ➖ الأولوية في قائمة انتظار التحويل\n    ➖ إضافة عدة ملصقات في نفس الوقت\n    ➖ بدون إعلانات\n\n    <b>حدد مبلغ الائتمانات التي تريد شراؤها:</b>\n  btn:\n    donate: '☕ التبرع'\n  topup: |\n    <b>أدخل مبلغ الائتمانات التي تريد شراؤها:</b>\n  invalid_amount: |\n    <b>كمية غير صالحة</b>\n\n    الحد الأدنى للمبلغ هو 1 نقطة\n  paymenu: |\n    تريد شراء <b>${amount} نقاط</b> مقابل <b>${price}$</b>\n\n    ⚠️ يتم إصدار النقاط يدويًا بواسطة المسؤول.\n    فترة الانتظار تتراوح من 5 دقائق إلى ساعة واحدة\n\n    <u>اختر طريقة الدفع:</u>\n  description: |\n    عند شراء النقاط، تدعم تطوير البوت وتحصل على فرصة استخدام ميزات إضافية\n  update: |\n    <b>🔄 تحديث الرصيد</b>\n\n    الرصيد: <code>${balance}</code> نقاط (تمت إضافة <code>${amount}</code> نقاط)\n  error:\n    already_donated: |\n      لقد تلقيت النقاط بالفعل لهذا الدفع\n    error: |\n      <b>خطأ!</b>\\nحدث خطأ أثناء معالجة الدفعة\n    canceled: |\n      تم إلغاء الدفع\ncoedit:\n  info: |\n    <b>👥 الاشتراك في تحرير</b>\n\n    رابط للتحرير المشترك <a href=\"${link}\">${title}</a>: <code>${colink}</code>\n\n    <b>كيفية الاستخدام:</b>\n    1. أرسل الرابط إلى الشخص الذي تريد إعطاء حق الوصول إلى الحزمة\n    2. بعد النقر على الرابط، يجب عليهم الضغط على \"بدء\" وسيتم إضافتها إلى المحررين\n    3. يمكن للمحرر أن يضيف، حذف وتحرير الملصقات في الحزمة\n\n    <b>محرر:</b>\n    ${editors}\n\n    <i>لإزالة المحررين، تحتاج إلى إعادة تعيين الرابط</i>\n  no_editors: |\n    لا يوجد محررين بعد\n  btn:\n    send: '📤 إرسال الرابط'\n    reset: '🔁 إعادة تعيين الرابط'\n  share: |\n    يتبع الرابط واضغط على \"بدء\" لتشارك في تحرير الحزمة \"${title}\"\n  reset: |\n    <b>🔁 إعادة تعيين الرابط بنجاح</b>\n\n    رابط جديد للتحرير المشترك <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: الحزمة غير موجودة\n      not_owner: هذه ليست حزمتك\n      hidden: تم إخفاء الباقة بنجاح\n      restored: تم إستعادة الباقة بنجاح\n    set_pack: |\n      🌟 مختارة <a href=\"${link}\">${title}</a> حزمة\n\n      <b>❔ كيف تضيف؟</b>\n      أرسل صورة أو فيديو أو ملصقًا لإضافته إلى الحزمة\n    set_inline_pack: |\n      تم اختيار <u>${title}</u> باقة\n\n      لاستخدامها، الكتابة في أي دردشة <code>@${botUsername} </code>والفضاء\n      يمكنك أيضًا استخدامها بالضغط على الزر أدناه\n    boost:\n      info: |\n        \\n⚡ <b><a href=\"https:\\/\\/t.me\\/${botUsername}?start=boost\">Boost</a></b>: ${boostStatus}\n      status:\n        on: تمكين\n        off: معطل\n    hidden: حزمة <a href=\"${link}\">${title}</a> مخفية من قائمتك.\n    restored: حزمة <a href=\"${link}\">${title}</a> تم استعادتها إلى قائمتك.\n    btn:\n      hide: '❌ إخفاء الحزمة'\n      delete: '🗑️ حذف الحزمة'\n      restore: '✅ استعادة'\n      use_pack: '📦 استخدام الحزمة'\n      boost: '⚡ تعزيز'\n      frame: '🖼 إطار'\n      rename: '✏️ إعادة تسمية'\n      search_gif: '🔎 بحث عن GIF'\n      coedit: '👥 تحرير مشترك'\n      catalog_add: '🗂 إضافة إلى الكتالوج'\n      catalog_edit: '📝 تعديل في الكتالوج'\n      catalog_delete: '🗑️ حذف من الكتالوج'\n      catalog_share: '🔗 المشاركة'\n      catalog_open: '📂 فتح في الكتالوج'\n    error:\n      not_found: |\n        خطأ!\n        لا يمكن العثور على ملصق.\n      invalid_png: |\n        خطأ!\n        الملف ليس صورة PNG صالحة. يرجى تحويله إلى تنسيق PNG قبل الإرسال.\n      invalid_dimensions: |\n        خطأ!\n        أبعاد الملصق غير صالحة. يجب أن تكون الملصقات بحجم 512x512 بكسل.\n      invalid_animated: |\n        خطأ!\n        ملف الملصق المتحرك ليس بتنسيق TGS الصحيح.\n      invalid_video: |\n        خطأ!\n        ملف الفيديو ليس بتنسيق WEBM الصحيح.\n      restore: |\n        خطأ!\n        لا يمكن استعادة الحزمة.\n      copy: |\n        خطأ!\n        لا يمكن العثور على الحزمة.\n    select_group:\n      success: |\n        تم اختيار الحزمة <a href=\"${link}\">${title} </a> بنجاح للمجموعة.\n      access_rights:\n        add: من يمكنه إضافة ملصقات إلى حزمة المجموعة؟\n        delete: من يمكنه حذف ملصقات من حزمة المجموعة؟\n        rights:\n          all: الجميع\n          admins: فقط المسؤولين\n      error: |\n        خطأ!\n         set غير موجود.\n  sticker:\n    answerCbQuery:\n      delete: تمت إزالة الملصق من الباقة بنجاح.\n      restored: تم حفظ الملصق بنجاح في الحزمة الحالية.\n    delete: تمت إزالة الملصق من الباقة بنجاح.\n    restored: تم حفظ الملصق بنجاح في الحزمة الحالية.\n    btn:\n      delete: '🗑 حذف'\n      copy: '🌟 نسخ'\n      restore: '✅ استعادة'\n    error:\n      not_found: |\n        خطأ!\n        لا يمكن العثور على ملصق.\n      invalid_png: |\n        <b>خطأ!</b>\n        الملف ليس صورة PNG صحيحة. يرجى تحويله إلى صيغة PNG قبل الإرسال.\n      invalid_dimensions: |\n        <b>خطأ!</b>\n        أبعاد الملصق غير صحيحة. يجب أن تكون الملصقات 512x512 بكسل.\n      invalid_animated: |\n        <b>خطأ!</b>\n        ملف الملصق المتحرك ليس بالتنسيق الصحيح TGS.\n      invalid_video: |\n        <b>خطأ!</b>\n        ملف الفيديو ليس بالتنسيق الصحيح WEBM.\n  group_settings:\n    success: |\n      تم تحديث إعدادات المجموعة بنجاح.\nsticker:\n  add:\n    ok: |\n      <b>تمت إضافته بنجاح إلى الحزمة:</b>\n      <a href=\"${link}\">${title}</a>\n\n      سيتم تحديث هذه الحزمة لجميع المستخدمين خلال ساعة واحدة.\n\n      <i>أرسل واحد أو أكثر من رمز تعبيري يتوافق مع الملصق، إذا كنت ترغب في إضافتها</i>\n    ok_inline: |\n      <b>تمت الإضافة إلى الحزمة بنجاح:</b>\n      <u>${title}</u>\n    send_emoji: رائع، الآن أرسل الإيموجي الذي يتطابق مع\n    converting_process: |\n      <b>انتظر...</b>\n      ملفك في قائمة انتظار التحويل. انتظر الانتهاء. هذا قد يستغرق بعض الوقت.\n\n      التقدم: ${progress} / ${total}\n\n      <i>يحصل المستخدمون الذين دعموا الروبوت على الأولوية في قائمة الانتظار (المزيد: /donate)</i>\n    catalog_offer: |\n      <b>😲 واو، لقد قمت بتكوين مجموعة رائعة!</b>\n\n      هل ترغب في إضافة <a href=\"${link}\">${title}</a> إلى كتالوج الملصقات العام حتى يتمكن مستخدمو الروبوت الآخرون من رؤيته أيضًا؟\n      <i>لا يستغرق الأمر الكثير من الوقت</i>\n    quote: |\n      استخدم @QuotlyBot لإنشاء اقتباس من هذه الرسالة\n    error:\n      reply: |\n        <b>خطأ!</b>\n        من فضلك قم بالرد على الملصق.\n      no_selected_pack: |\n        <b>لم تقم باختيار حزمة</b>\n\n        من فضلك، قم بإنشاء (/new) أو اختر (/packs)\n      no_selected_group_pack: |\n        <b>لم تقم باختيار حزمة المجموعة</b>\n\n         يرجى اختيار حزمة باستخدام أمر /packs\n      no_rights: |\n        <b>خطأ!</b>\n        ليس لديك الحق في إضافة ملصقات إلى هذه الحزمة.\n      stickers_too_much: |\n        تحتوي هذه الحزمة على الحد الأقصى لعدد الملصقات.\n\n        يمكنك إنشاء حزمة جديدة باستخدام الأمر /new.\n      have_already: |\n        <b>هذا الملصق موجود بالفعل في الحزمة</b>\n\n        إذا كنت تريد تغيير الرموز التعبيرية، فأرسلها في الرسالة التالية.\n      stickerset_invalid: |\n        <b>خطأ!</b>\n        لا يستطيع الروبوت الوصول إلى الحزمة الحالية التي اخترتها.\n\n        من فضلك قم بإنشاء (/جديد) أو اختيار (/حزم) حزمة أخرى.\n      invalid_png: |\n        <b>خطأ!</b>\n        الملف ليس صورة PNG صحيحة. يرجى تحويله إلى صيغة PNG قبل الإرسال.\n      invalid_dimensions: |\n        <b>خطأ!</b>\n        أبعاد الملصق غير صحيحة. يجب أن تكون الملصقات 512x512 بكسل.\n      invalid_animated: |\n        <b>خطأ!</b>\n        ملف الملصق المتحرك ليس بالتنسيق الصحيح TGS.\n      invalid_video: |\n        <b>خطأ!</b>\n        ملف الفيديو ليس بالتنسيق الصحيح WEBM.\n      file_type:\n        static: |\n          <b>خطأ!</b>\n          نوع الملف هذا غير مدعوم\n          يمكنك إضافة هذه الصورة أو الملصق الثابت إلى الحزمة الثابتة\n\n          <i>إنشاء (/ جديد) أو اختيار (/ حزم) حزمة أخرى</i>\n        video: |\n          <b>خطأ!</b>\n          هذا النوع من الملفات غير مدعوم\n          يمكنك إضافة ملفات الفيديو هذه إلى حزمة الفيديو\n\n          <i>إنشاء (/ جديد) أو اختيار (/ حزم) حزمة أخرى</i>\n        animated: |\n          <b>خطأ!</b>\n          هذا النوع من الملفات غير مدعوم\n          يمكنك إضافة هذه الملفات المتحركة إلى حزمة المتجهات\n\n          <i>إنشاء (/ جديد) أو اختيار (/ حزم) حزمة أخرى</i>\n        unknown: |\n          <b>خطأ!</b>\n          نوع الملف هذا غير مدعوم\n\n          <i>قم بإنشاء (/ جديد) أو اختر (/ حزم) حزمة أخرى</i>\n      wait_load: |\n        <b>انتظر!</b>\n\n        لا يزال الروبوت يعالج الملف السابق...\n        يمكنك دعم تطوير الروبوت (\\/donate) لزيادة أولوية المعالجة والقدرة على إضافة أكثر من ملصق واحد إلى القائمة.\n      timeout: |\n        <b>في الوقت الحالي، يواجه الروبوت حملًا كبيرًا</b>\n        لذلك، تحويل الفيديو متاح فقط للحزم ذات التعزيز النشط\n\n        لمزيد من التفاصيل، اتبع / يتبرع\n      convert: |\n        <b>خطأ!</b>\n        لسوء الحظ، لم يتمكن الروبوت من تحويل الفيديو الخاص بك.\n\n        ربما تم حفظ الفيديو الخاص بك بتنسيق غير مفهوم للروبوت. تأكد من أنه بتنسيق mp4.\n        قد يكون أيضًا خطأ داخلي في البوت، حاول إرسال هذا الفيديو مرة أخرى.\n      too_big: |\n        <b>خطأ!</b>.\n\n        الملف أكبر من أن يعالج. الرجاء تقليل الجودة والمدة قبل الإرسال.\n      sticker_not_found: |\n        <b>خطأ!</b>\n\n        تعذر العثور على هذه الملصق. يرجى التأكد من أنه في الحزمة الصحيحة أو حاول إضافته مرة أخرى.\nnews:\n  join: |\n    (ط) <a href=\"${link}\">انضم لقناتنا</a> للحصول على آخر الأخبار عن البوت.\n\n    <i>اشترك في القناة للحصول على آخر الأخبار حول البوت، وكذلك التحديثات والميزات الجديدة.</i>\n  join_btn: '📢 الانضمام لقناة'\n  not_joined: '🙅 أنت غير مشترك في القناة'\n  continue: '✅ استمرار'\nuserAbout:\n  help: |\n    <b>🧑‍🎨 المستخدم حول</b>\n\n    باستخدام هذه القائمة يمكنك معرفة المعلومات حول المستخدم وحزم الملصقات\n\n    للحصول على معلومات حول المستخدم، استخدام الزر أدناه أو إعادة توجيه رسالته\n  result: |\n    <b>🧑‍🎨 معلومات المستخدم</b>\n    <b>🆔 معرف المستخدم:</b> <code>${userId}</code>\n    <b>🎨 حزم من هذا المستخدم:</b>\n    ${packs}\n  no_packs: |\n    <i>ليس لدينا معلومات حول ملصقات هذا المالك</i>\n  forward_hidden: |\n    قام المستخدم بإخفاء القدرة على إرسال الرسائل. استخدم الزر أدناه لعرض حزم الملصقات.\n  select_user: '🧑‍🎨 اختر المستخدم'\nscenes:\n  new_pack:\n    pack_type: |\n      <b><u>اختر نوع الحزمة</u></b>\n    regular: '😊 ملصق'\n    custom_emoji: '🌟 Emoji (Premium)'\n    static: '🌟 ثابت'\n    animated: '✨ ناقل'\n    video: '📹 فيديو'\n    pack_format: |\n      <b><u>اختر نوع الحزمة</u></b>\n\n      <b>الشائع</b> - ثابت (لا تتحرك)، raster, تنسيق الملف - قبل إضافة PNG (البوت قيد المعالجة)، بعد إضافة - WEBP.\n      مثال على حزمة عادية - t.me/addstickers/Animals\n\n      <b>فيديو</b> - حزمة فيديو متحركة. يمكنك إضافة أي فيديو، هدية وصورة.\n      عينة من حزمة الفيديو - ت. / ملصقات/TheMascot\n\n      <b></b> - متحركة، متجهة (لديها وصف دقيق للأشياء داخل الملف, بسبب عرضها بوضوح في أي مقياس)، شكل الملف - TGS, وهو نوع من تنسيق Lottie.\n      مثال على حزمة متحركة - t.me/addstickers/IsabelleShizue\n\n      <i>يمكن أن تحتوي مجموعات ملصقات متحركة وملصقات فيديو على ما يصل إلى 50 ملصقة. مجموعات الملصقات الثابتة يمكن أن تحتوي على ما يصل إلى 120 ملصقا.</i>\n    pack_title: |\n      <b>أدخل اسم جديد لحزمة الملصقات:</b>\n      <i>يمكنك اختيار اسم عشوائي على زر.</i>\n    pack_name: |\n      <b>أدخل رابطًا قصيرًا لحزمة الملصقات الجديدة:</b>\n\n      <i>على سبيل المثال، تستخدم هذه الحزمة \"الحيوانات\" كرابط قصير: https://t.me/ addstickers/<u>الحيوانات</u></i>\n      <i>يمكنك اختيار رابط قصير عشوائي على الزر.</i>\n    ok: |\n      حزمة <a href=\"${link}\">${title}</a> تم إنشاؤها بنجاح!\n\n      <b>رابط الحزمة:</b> <pre>${link}</pre>\n\n      إرسال ملف، صورة أو فيديو أو ملصق بحيث أضيفها إلى المجموعة الخاصة بك\n    error:\n      title_long: لا يمكن أن يتجاوز الاسم ${max} رمزًا.\n      name_long: لا يمكن أن يتجاوز العنوان ${max} رمزًا.\n      telegram:\n        name_invalid: لا يمكن استخدام هذا العنوان.\n        name_occupied: تم استخدام هذا العنوان بالفعل.\n        upload_failed: |\n          <b>خطأ!</b>\n          لا يستطيع الروبوت تحميل الملصقات على Telegram.\n\n          من فضلك حاول مرة أخرى لاحقا.\n  copy:\n    enter: |\n      يمكنني نسخها، ولكن قبل ذلك، دعونا ننشئ حزمة جديدة\n    progress: |\n      نسخ الباقة من <a href=\"${originalLink}\">${originalTitle}</a> إلى <a href=\"${link}\">${title}</a>\n\n      التقدم: ${current}/${total}\n    done: |\n      تم بنجاح نسخ الحزمة من <a href=\"${originalLink}\">${originalTitle}</a> إلى <a href=\"${link}\">${title}</a>\n    pay: |\n      <b>تحويل الحزمة</b>\n\n      يكلف تحويل الحزمة من نوع إلى آخر 1 نقطة\n\n      <b>الرصيد الحالي:</b> ${balance} نقاط\n\n      شراء النقاط: /donate\n    pay_btn: '✅ تأكيد'\n    error:\n      premium: |\n        <b>خطأ!</b>\n        هذه الميزة متاحة فقط للأعضاء المتبرعين.\n\n        يمكنك القيام بذلك عن طريق إرسال الأمر /donate.\n  original:\n    enter: |\n      أرسل الملصق الذي تمت إضافته من خلال هذا البوت وسأريك الملصق الأصلي الخاص به.\n    error:\n      not_found: |\n        <b>خطأ!</b>\n        لم أتمكن من العثور على الملصق الأصلي.\n  delete:\n    enter: |\n      أرسل الملصق الذي تمت إضافته من خلال هذا البوت وسأحذفه من الحزمة.\n    confirm: |\n      هل أنت متأكد من أنك تريد حذف هذا الملصق؟\n    error:\n      not_found: |\n        <b>خطأ!</b>\n        لم أتمكن من العثور على الملصق.\n  rename:\n    enter_name: |\n      <b>أدخل عنوان جديد ل <a href=\"${link}\">${title}</a>:</b>\n    success: |\n      <b>تم تغيير العنوان بنجاح!</b>\n\n      جديد: <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      ❕ لإزالة اللاحقة \"<code>${titleSuffix}</code>\", تحتاج إلى تعزيز الحزمة. المزيد من التفاصيل في القائمة بزيارة: /donate\n  packAbout:\n    enter: |\n      <b>أرسل لي ملصقًا أو رمزًا تعبيريًا مخصصًا للبحث عن معلومات عنه:</b>\n    not_found: |\n      لم أستطع العثور على الملصق\n    result: |\n      <b>📦 حزمة:</b> <a href=\"${link}\">${name}</a>\n      🆔 <code>${setId}</code> <i>(الرقم الفريد لحزم المالك)، زيادة لكل حزمة)</i>\n\n      🧑‍🎨 ID المالك: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 باقات أخرى من هذا المالك:</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>ليس لدينا معلومات عن الملصقات الأخرى لهذا المالك</i>\n  boost:\n    sure: |\n      <b>هل أنت متأكد أنك تريد تعزيز <a href=\"${link}\">${title}؟</a></b>\n\n      سيؤدي التعزيز إلى زيادة أولوية المعالجة والقدرة على إضافة أكثر من ملصق واحد إلى قائمة الانتظار\n      يمكنك العثور على معلومات أكثر تفصيلاً حول التحسينات في القائمة عن طريق زيارة: /donate\n\n      <b>السعر:</b> 1 نقطة\n      <b>الرصيد الحالي:</b> ${balance} نقاط\n    btn:\n      yes: نعم، تعزيز!\n      no: لا، إلغاء\n    canceled: |\n      تم إلغاء التعزيز\n    success: |\n      اكتمل التعزيز بنجاح!\n\n      ${title} تم تعزيزه الآن\n    error:\n      not_enough_credits: |\n        ليس لديك نقاط كافية لتعزيز هذه الحزمة.\n\n        يمكنك تعبئة رصيدك عن طريق إرسال الأمر /donate.\n      already_boosted: |\n        تم تعزيز هذه الحزمة بالفعل.\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>أرسل لي الملصق من الحزمة التي ترغب في نشرها</b>\n\n        <i>يمكنك نشر أي حزمة تخصك، حتى لو تم إنشاؤها من مصدر آخر</i>\n      owner_proof: |\n        <b>للتحقق من ملكية هذه الحزمة، تحتاج إلى اتباع بعض الخطوات البسيطة:</b>\n        1. فتح @Stickers bot\n        2. إرسال الأمر <code>/packstats</code>\n        3. ابحث واختيار الحزمة\n        4. إعادة توجيه الرسالة المتلقاة إلى البوت\n      publish_new_access_denied: |\n        <b>خطأ!</b>\n        هذه الحزمة ليست لك.\n\n        يمكنك فقط نشر الحزم الخاصة بك\n      banned: |\n        <b>خطأ!</b>\n        أنت ممنوع من استخدام هذه الميزة.\n        من فضلك اتصل بالمسؤول.\n      enter: |\n        أنت على وشك نشر حزمة \"<a href=\"${link}\">${title}</a>في الدليل العام للبوت\n        يمكن العثور عليها من قبل أي مستخدم للبوت عن طريق الاسم، بسبب ذلك العلامة أو الفلتر\n        سوف يقوم المزيد من الناس بتثبيت الحزمة\n        الخاصة بك، محاولة إرسال حزم عالية الجودة فقط التي قد تكون ذات أهمية لعدد كبير من الناس\n\n        <b>لقواعد نشر الحزم:</b>\n        • لا تنشر الحزم الشخصية الخاصة بك لدائرة ضيقة من الناس. على سبيل المثال مثل وجوه أصدقائك أو اقتباسات من رسائلك\n        • لا تنشر ضغوط الملصقات التي تنتهك قوانين الاتحاد الأوروبي أو القوانين المحلية الأخرى\n\n        سوف تحتاج إلى تقديم معلومات إضافية لنشرها في الكتالوج\n      continue_button: متابعة\n      enter_description: |\n        <b>وصف باقتك بإيجاز حتى يتمكن الآخرون من العثور عليها</b>\n\n        <i>يمكنك أيضًا استخدام الهاشتاق لتصنيف [#]</i>\n        <i>على سبيل المثال: #anime #eme #animals #cute #kpop #funny #cat #game </i>\n      select_language: |\n        <b>اختر اللغات التي تحتوي عليها حزمتك:</b>\n        <i>يمكنك تحديد لغات متعددة</i>\n      button_all_languages: جميع اللغات\n      button_confirm_language: تأكيد\n      set_safe: |\n        <b>هل حزمتك آمنة للمستخدمين؟</b>\n        <i>أي أنها لا تحتوي على مواد جنسية أو محتويات مذهلة أخرى</i>\n      button_safe:\n        safe: نعم، إنه آمن\n        not_safe: لا، إنه غير آمن\n      no_tags: لم يتم تحديد\n      confirm: |\n        <b>قم بتأكيد نشر الحزمة \"<a href=\"${link}\">${title}</a>\"</b>\n\n        <b>الوصف:</b> <i>${description}</i>\n\n        <b>العلامات:</b> ${tags}\n        <b>اللغات:</b> ${languages}\n      button_confirm: '✅ تأكيد النشر'\n      success: |\n        تهانينا، تم نشر حزمتك في الدليل العام للبوت الخاص بنا حيث يمكن للمستخدمين الآخرين العثور عليه!\n\n        يمكنك تعديل معلومات البحث في الباقة عن طريق تحديد الباقة مع أمر /packs.\n\n        <i>نذكركم بأنه يمكن الاطلاع على إحصائيات حزمتك من خلال البوت @Stickers</i> الرسمي\n    unpublish:\n      success: |\n        تم إلغاء نشر الحزمة بنجاح من كاتالوج البوت.\n  delete_pack:\n    enter: |\n      هل أنت متأكد من أنك تريد حذف الحزمة <a href=\"${link}\">${title}</a>؟\n      سيتم حذفه بشكل دائم ولا يمكن استرداده.\n\n      إذا كنت ترغب في حذف ملصق واحد فقط, استخدم الأمر /حذفه.\n\n      أرسل <code>${confirm}</code> لتأكيد أنك تريد حقاً حذف هذه الحزمة.\n    confirm: نعم، أنا متأكد تماما.\n    success: |\n      <b>تم حذف الحزمة بنجاح!</b>\n    error:\n      - <b>خطأ!</b>\n      - عفواً، حدث خطأ ما.\n  frame:\n    no_video: |\n      <b>خطأ!</b>\n      يمكنك فقط إضافة إطارات إلى حزم الفيديو.\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>اختر نوع الإطار:</b>\n      الإطار هو خلفية شفافة حول الملصق\n\n      <code>lite</code> — سيتم قص الزوايا قليلاً\n      <code>medium</code> — سيتم قص الزوايا بشكل أكبر\n      <code>rounded</code> — ستكون الزوايا مستديرة\n      <code>square</code> — شكل مستطيلي للإطار، وهذا يعني أنه لن يتغير بأي شكل\n      <code>circle</code> — سيكون الإطار على شكل دائرة\n\n      <i>في المستقبل، يمكنك استخدام أمر /frame لتعيين نوع الإطار</i>\n    types:\n      lite: '1. خفيف'\n      medium: '2. متوسط'\n      rounded: '3. مدور'\n      square: '4. مربع'\n      circle: '5. دائرة'\n    selected: |\n      <b>نوع الإطار المحدد:</b> ${type}\n  photoClear:\n    enter: |\n      أرسل صورة <u></u> التي تريد إزالة الخلفية منها وسأرسل الملف بدون الخلفية\n\n      <i>يعمل بشكل أفضل مع الصور. أسوأ من ذلك مع الرسوم، الرسوم التوضيحية، إلخ.</i>\n    enter_anime: |\n      إرسال صورة <u></u> التي تريد إزالة الخلفية منها وسأرسل الملف بدون الخلفية\n\n      <i>يعمل بشكل أفضل مع صور أنمي</i>\n    choose_model: |\n      <b>اختر الموديل:</b>\n    web_app: WebApp - للصور مع الناس\n    model:\n      ordinary: شائع - للصور مع الناس\n      general: عام - لأي صور\n      anime: أنمي - لصور أنمي\n      birefnet_general: BiRefNet - لأي صور\n    add_to_set_btn: '🌟 إضافة إلى المجموعة'\n    error: |\n      <b>خطأ!</b>\n      عفوًا، حدث خطأ ما.\n  leave: |\n    تم إلغاء العملية.\n  btn:\n    cancel: '❌ إلغاء'\nerror:\n  telegram: |\n    <b>أعاد تيليجرام خطأ!</b>\n    <code>${error}</code>\n  answerCbQuery:\n    telegram: |\n      أعاد تيليجرام خطأ:\n      ${error}\n  banned: |\n    <b>خطأ!</b>\n    أنت ممنوع من استخدام هذه الميزة.\n\n    <i>إذا كنت تعتقد أن هذا خطأ، يرجى الاتصال بالمسؤول: @ly_oBot</i>\n  unknown: |\n    <b>حدث خطأ غير معروف، يرجى المحاولة مرة أخرى.</b>\n\n    إذا استمرت المشكلة، فاكتب إلى @Ly_oBot.\n    يرجى كتابة فورًا عن الروبوت الذي تتحدث عنه ووصف المشكلة بالتفصيل في رسالة واحدة.\n"
  },
  {
    "path": "locales/az.yaml",
    "content": "---\nlanguage_name: '🇦🇿 Azərbaycanca'\nname: fStik — Stikerlər və Emoji\ndescription:\n  long: |\n    Foto, video və GIF-lərdən çevirmə olmadan stikerlər və emoji yaradın!\n\n    Xüsusiyyətlər:\n    • Asan paket idarəsi\n    • Video stikerlər və fərdi emoji\n    • Orijinal faylları yükləmək\n    • Stiker/video/GIF-i şəkilə çevirmək\n    • Stikerlər kataloqu\n\n    Stiker axtar: play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    Foto, video, GIF'lərdən stiker və emoji yaradın. Katalog və axtarış. 🇺🇦\nratelimit: Çox vaxt deyil!\ncmd:\n  start:\n    enter: |\n      🧙 Salam, ${name}! Mən emoji və stiker paketi sehrbazıyam.\n      Mən bir neçə kliklə şəkillərinizi, videolarınızı və GIF-lərinizi gözəl stikerlərə çevirə bilərəm\n\n      Nə edə biləcəyim haqqında ətraflı öyrənmək üçün /help əmrini göndərin\n\n      💬 Yardım lazımdır? @fStikCommunity ünvanında dəstək söhbətimizə qoşulun (yalnız ingiliscə)\n    group: |\n      🧙 Salam, ${groupTitle}! Mən emoji və stiker paketləri sehrbazıyam.\n\n      Bir stikerı qrup paketinə əlavə etmək üçün, foto, video, gif və ya stikerə cavab olaraq /ss əmrdən istifadə edin.\n    catalog: |\n      <b>😻 Siz yeni stiker paketlərini kataloqumuzda tapa bilərsiniz</b>\n\n      • Aşağıdakı düyməyə klikləyin və hər zövqə uyğun stiker paketlərinin böyük kataloquna daxil olun\n      • Axtarın açar sözlər və ya hazırlanmış tablarda\n      • Reytinqlərdə stiker paketini tanıtmaq və ya aşağı salmaq üçün qiymət verməyi unutmayın\n    commands:\n      ss: '🌟 Stikeri yadda saxlayın'\n      start: '📜 Start menyu'\n      help: '📖 Istinad'\n      packs: '📁 Paketləri idarə edin'\n      new: '🌝 Etiket paketi yaradın'\n      catalog: '📖 Kataloq'\n      publish: '📤 Paketi dərc edin'\n      delete: '❌ Stikeri silin'\n      original: '🔍 Orijinal stiker tap'\n      restore: '🔀 Paketi bərpa edin'\n      copy: '📋 Paketi kopyalayın'\n      emoji: '📝 Emoji şəkilçisini dəyişdirin'\n      round: '🎥 Dairəvi formalı video'\n      clear: '🖼️ Şəkildən fonu silin'\n      about: '📦 Paket məlumatı'\n      user_about: '🧑‍🎨 Yaradıcı məlumatı'\n      lang: '🌐 Dili dəyişdirin'\n      report: '🚨 Şikayət paketi'\n      donate: '☕️ İnkişaf etdiricini dəstəkləyin'\n      add_to_group: '👥 Qrupa əlavə edin'\n      privacy: '🔒 Məxfilik siyasəti'\n    btn:\n      new: '📥 Yenisi yaradın'\n      catalog: '💖 Kataloqu açın'\n      catalog_mini: '💖 Kataloq'\n      catalog_browser: '🌐 Brauzerdə açın'\n      catalog_browser_mini: '🌐 Brauzerdə'\n      catalog_app: '📱 Android proqramını yükləyin'\n      catalog_app_mini: '📱 Android proqramı'\n  inline:\n    switch_pm: '📁 Paketi seçin'\n  restore: |\n    <b>🗃 Paketin bərpası</b>\n\n    Paketi bərpa etmək üçün siz mənə bərpa etmək istədiyiniz paketin linkini göndərməlisiniz\n  copy: |\n    <b>🗄 Paketi kopyalayın</b>\n\n    Başqa paketi yenisinə köçürmək üçün sadəcə mənə stiker və ya emoji paketinə keçid göndərməlisiniz\n  report: |\n    <b>🚨 Şikayət edin</b>\n\n    Qanunu poza biləcəyini və ya Telegram-ın Xidmət Şərtlərinə zidd olduğunu düşündüyünüz stiker paketi ilə qarşılaşsanız, lütfən, onun linkini @ ünvanına göndərərək bizə bildirin. StickersReportBot\n\n    <i>Unutmayın ki, bot paketlərin məzmununa görə məsuliyyət daşımır və ona nəzarət etmək imkanı yoxdur</i>\n  packs:\n    info: |\n      <b>📁 Paketlər</b>\n    types:\n      regular: Stikerlər\n      custom_emoji: Emojilər\n      inline: İnline\n    empty: |\n      <b>Hələ heç bir paketiniz yoxdur.</b>\n      Yaratmaq üçün /new əmri yazın\n    inline_title: Daxili paket\n    select_group_pack_info: |\n      <b>📁 Paketi seçin</b>\n\n      Paketdən qrupda istifadə etmək üçün inzibatçılar aşağıdakı düyməni istifadə edərək onu seçməlidirlər\n    select_group_pack: Paketi seçin\n  emoji:\n    info: |\n      Cari paket üçün default emojini dəyişdirmək üçün, <code>/emoji</code> yazın və sonra emojiyi bir boşluqla ayıraraq göndərin\n\n      Məsələn - <code>/emoji 🌟</code>\n    done: Emoji uğurla dəyişdirildi.\n    error: Emoji dəyişdirilməsi zamanı xəta baş verdi!\n  round_video:\n    enabled: |\n      Videolar indi dairəvi formada olacaq\n    disabled: |\n      Videoların daha yuvarlaq forması olmayacaq\n  paysupport: |\n    <b>👨‍💻 Ödəniş Dəstəyi</b>\n\n    Botun fəaliyyəti, ödənişlər və ianələrlə bağlı bütün məsələlər üzrə birbaşa olaraq tərtibatçı ilə əlaqə saxlaya bilərsiniz\n\n    <b>Əlaqə:</b>\n    🧑‍💻 Tərtibatçı: @ly_oBot\ndonate:\n  menu: |\n    <b>☕️ Bot inkişaf etdirilməsinə dəstək olun</b>\n    Botun inkişafına dəstək verərək, Kredit alacaqsınız\n\n    <b>Balans:</b> <code>${balance}</code> Kreditlər\n    1 Kredit ilə, bir paketi yüksəltmək imkanı var.\n\n    <b>Yüksəltmənin təmin etdiyi üstünlüklər:</b>\n    ➖ Paket adında \"<code>${titleSuffix}</code>\" yoxdur <i>(linkdə deyil)</i>\n    ➖ Başlıq 64 simvola qədər (35 əvəzinə)\n    ➖ Videolar 35 saniyəyə qədər\n    ➖ Prioritet çevrilmə növbəsi\n    ➖ Eyni zamanda bir neçə stiker\n    ➖ Reklamsız\n\n    <b>Almaq istədiyiniz Kreditlərin miqdarını seçin:</b>\n  btn:\n    donate: '☕️ Bağışlayın'\n  topup: |\n    <b>Almaq istədiyiniz Kreditlərin miqdarını daxil edin:</b>\n  invalid_amount: |\n    <b>Yanlış miqdar</b>\n\n    Ən az miqdar 1 Kreditdir\n  paymenu: |\n    <b>${amount} Kredit</b> üçün <b>${price}$</b> almaq istəyirsiniz\n\n    ⚠️ Kreditlər idarəçi tərəfindən əl ilə verilir.\n    Gözləmə müddəti 5 dəqiqədən 1 saata qədər dəyişir\n\n    <u>Ödəniş üsulunu seçin:</u>\n  description: |\n    Kredit alaraq, botun inkişafına dəstək olursunuz və əlavə funksiyalardan istifadə etmək imkanı əldə edirsiniz\n  update: |\n    <b>🔄 Balansı yenilə</b>\n\n    Balans: <code>${balance}</code> Kredit (əlavə edilib <code>${amount}</code> Kredit)\n  error:\n    already_donated: |\n      Bu ödəniş üçün artıq Kredit almışsınız\n    error: |\n      <b>Xəta!</b>\n      Ödəniş işlənərkən xəta baş verdi\n    canceled: |\n      Ödəniş ləğv edildi\ncoedit:\n  info: |\n    <b>👥 Birgə-redaktə</b>\n\n    Birgə-redaktə üçün link <a href=\"${link}\">${title}</a>: <code>${colink}</code>\n\n    <b>Necə istifadə etmək olar:</b>\n    1. Paketə giriş hüququ vermek istədiyiniz şəxsə linki göndərin\n    2. Linkə kliklədikdən sonra, onlar \"start\" düyməsini basmalı və redaktorlar siyahısına əlavə ediləcəklər\n    3. Redaktor paketdə stikerləri əlavə edə, silə və redaktə edə bilər\n\n    <b>Redaktorlar:</b>\n    ${editors}\n\n    <i>Redaktorları silmək üçün, linki sıfırlamalısınız</i>\n  no_editors: |\n    Hələ redaktor yoxdur\n  btn:\n    send: '📤 Linki göndərin'\n    reset: '🔁 Linki sıfırlayın'\n  share: |\n    Linki izləyin və paketi birgə redaktə etmək üçün \"start\" düyməsini basın \"${title}\"\n  reset: |\n    <b>🔁 Link sıfırlandı</b>\n\n    Birgə redaktə üçün yeni keçid <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: Paket tapılmadı\n      not_owner: Bu sizin paketiniz deyil\n      hidden: Paket uğurla gizlədilib\n      restored: Paket uğurla bərpa edildi\n    set_pack: |\n      🌟 Seçilmiş <a href=\"${link}\">${title}</a> paket\n\n      <b>❔ Necə əlavə etmək olar?</b>\n      Paketə əlavə etmək üçün foto, video və ya stiker göndərin\n    set_inline_pack: |\n      Seçilmiş <u>${title}</u> paketi\n\n      İstifadə etmək üçün, hər hansı bir söhbətdə yazın <code>@${botUsername} </code>və boşluq\n      Aşağıdakı düyməni basaraq da istifadə edə bilərsiniz\n    boost:\n      info: |\n        \\n⚡ <b><a href=\"https://t.me/${botUsername}?start=boost\">Artırın</a></b>: ${boostStatus}\n      status:\n        on: Aktivdir\n        off: Əlil\n    hidden: Paket <a href=\"${link}\">${title}</a> siyahınızdan gizlədilib.\n    restored: Paket <a href=\"${link}\">${title}</a> siyahınıza bərpa edildi.\n    btn:\n      hide: '❌ Paketi gizlədin'\n      delete: '🗑 Paketi silin'\n      restore: 'Bərpa et'\n      use_pack: '📦 Paketdən istifadə edin'\n      boost: '⚡ Gücləndirin'\n      frame: '🖼 Çərçivə'\n      rename: '✏️ Adını dəyişdirin'\n      search_gif: '🔎 GIF axtarın'\n      coedit: '👥 Birgə redaktə'\n      catalog_add: '🗂 Kataloqa əlavə edin'\n      catalog_edit: '📝 Kataloqda redaktə edin'\n      catalog_delete: '🗑 Kataloqdan silin'\n      catalog_share: '🔗️️ Paylaşın'\n      catalog_open: '📂 Kataloqda açın'\n    error:\n      not_found: |\n        Xəta!\n        Sticker tapılmadı.\n      invalid_png: |\n        Xəta!\n        Fayl düzgün PNG şəkli deyil. Göndərməzdən əvvəl onu PNG formatına çevirin.\n      invalid_dimensions: |\n        Xəta!\n        Stiker ölçüləri qeyri-düzgündür. Stikerlər 512x512 piksel olmalıdır.\n      invalid_animated: |\n        Xəta!\n        Animasiya edilmiş stiker faylı düzgün TGS formatında deyil.\n      invalid_video: |\n        Xəta!\n        Video faylı düzgün WEBM formatında deyil.\n      restore: |\n        Xəta!\n        Paketi bərpa etmək mümkün deyil.\n      copy: |\n        Xəta!\n        Paketi tapmaq mümkün deyil.\n    select_group:\n      success: |\n        Paket <a href=\"${link}\">${title}</a> uğurla qrup üçün seçildi.\n      access_rights:\n        add: Qrup paketinə stickerləri kim əlavə edə bilər?\n        delete: Qrup paketindən stickerləri kim silə bilər?\n        rights:\n          all: Hər kəs\n          admins: Yalnız adminlər\n      error: |\n        Xəta!\n        Yığımı tapılmadı.\n  sticker:\n    answerCbQuery:\n      delete: Stiker paketdən uğurla çıxarıldı.\n      restored: Stiker uğurla cari paketdə saxlanıldı.\n    delete: Stiker paketdən uğurla çıxarıldı.\n    restored: Stiker uğurla cari paketdə saxlanıldı.\n    btn:\n      delete: '🗑 Sil'\n      copy: '🌟 Kopyala'\n      restore: '✅ bərpa edin'\n    error:\n      not_found: |\n        Xəta!\n        Sticker tapılmadı.\n      invalid_png: |\n        <b>Xəta!</b>\n        Fayl etibarlı PNG şəkli deyil. Göndərməzdən əvvəl onu PNG formatına çevirin.\n      invalid_dimensions: |\n        <b>Xəta!</b>\n        Stikerin ölçüləri etibarlı deyil. Stikerlər 512x512 piksel olmalıdır.\n      invalid_animated: |\n        <b>Xəta!</b>\n        Animasiya edilmiş stiker faylı düzgün TGS formatında deyil.\n      invalid_video: |\n        <b>Xəta!</b>\n        Video faylı düzgün WEBM formatında deyil.\n  group_settings:\n    success: |\n      Grup parametrləri uğurla yeniləndi.\nsticker:\n  add:\n    ok: |\n      <b>Uğurla paketə əlavə edildi:</b>\n      <a href=\"${link}\">${title}</a>\n\n      Bir saat ərzində, bu paket bütün istifadəçilər üçün yenilənəcəkdir.\n\n      <i>Stikerə uyğun bir neçə emoji göndərin, əgər onları əlavə etmək istəyirsinizse.</i>\n    ok_inline: |\n      <b>Paketə uğurla əlavə edildi:</b>\n      <u>${title}</u>\n    send_emoji: Əla, indi uyğun olan emoji göndərin\n    converting_process: |\n      <b>Gözləyin...</b>\n      Fayl konversiya növbəsindədir. Tamamlanmasını gözləyin. Bu bir az vaxt ala bilər.\n\n      Gedişat: ${progress} / ${total}\n\n      <i>Botu dəstəkləyən istifadəçilər növbədə prioritet əldə edirlər (daha çox məlumat: /donate)</i>\n    catalog_offer: |\n      <b>😲 Vay, əla paket hazırladın!</b>\n\n      İctimai stikerlər kataloquna <a href=\"${link}\">${title}</a> əlavə etmək istərdiniz ki, botun digər istifadəçiləri də bunu görə bilsin?\n      <i>Çox vaxt tələb etmir</i>\n    quote: |\n      Bu mesajdan sitat yaratmaq üçün @QuotlyBot istifadə edin\n    error:\n      reply: |\n        <b>Xəta!</b>\n        Lütfən, stikerə cavab yazın.\n      no_selected_pack: |\n        <b>Siz paketi seçməmisiniz</b>\n\n        Zəhmət olmasa, paket yaradın (/yeni) və ya (/paketlər) seçin\n      no_selected_group_pack: |\n        <b>Qrup paketi seçməmisiniz</b>\n\n        Zəhmət olmasa, /packs əmrindən istifadə edərək paket seçin\n      no_rights: |\n        <b>Xəta!</b>\n        Bu paketi əlav etmək hüququnuz yoxdur.\n      stickers_too_much: |\n        Bu paketdə stikerlərin maksimum sayı var.\n\n        Siz /new əmrindən istifadə edərək yeni paket yarada bilərsiniz.\n      have_already: |\n        <b>Bu stiker artıq paketdədir</b>\n\n        Emoji dəyişdirmək istəyirsinizsə, onu aşağıdakı mesajda göndərin.\n      stickerset_invalid: |\n        <b>Xəta!</b>\n        Bot cari seçdiyiniz paketə daxil ola bilmir.\n\n        Zəhmət olmasa, (/yeni) yaradın və ya başqa paket seçin (/paketlər).\n      invalid_png: |\n        <b>Xəta!</b>\n        Fayl etibarlı PNG şəkli deyil. Göndərməzdən əvvəl onu PNG formatına çevirin.\n      invalid_dimensions: |\n        <b>Xəta!</b>\n        Stikerin ölçüləri etibarlı deyil. Stikerlər 512x512 piksel olmalıdır.\n      invalid_animated: |\n        <b>Xəta!</b>\n        Animasiya edilmiş stiker faylı düzgün TGS formatında deyil.\n      invalid_video: |\n        <b>Xəta!</b>\n        Video faylı düzgün WEBM formatında deyil.\n      file_type:\n        static: |\n          <b>Xəta!</b>\n          Bu fayl növü dəstəklənmir\n          Bu foto və ya statik stikeri statik paketə əlavə edə bilərsiniz\n\n          <i>Yaradın (/yeni) və ya seçin (/paketlər) başqa paket</i>\n        video: |\n          <b>Xəta!</b>\n          Bu fayl növü dəstəklənmir\n          Siz bu video faylları video paketinə əlavə edə bilərsiniz\n\n          <i>Yaradın (/yeni) və ya seçin (/ paketlər) başqa bir paket</i>\n        animated: |\n          <b>Xəta!</b>\n          Bu fayl növü dəstəklənmir\n          Siz bu cizgi fayllarını vektor paketinə əlavə edə bilərsiniz\n\n          <i>Yaradın (/yeni) və ya seçin (/ paketlər) başqa bir paket</i>\n        unknown: |\n          <b>Xəta!</b>\n          Bu fayl növü dəstəklənmir\n\n          <i>Yaradın (/yeni) və ya başqa paket seçin (/paketlər)</i>\n      wait_load: |\n        <b>Gözləyin!</b>\n\n        Bot hələ də əvvəlki faylı emal edir...\n        Emalın prioritetini artırmaq və növbəyə bir dəfədən artıq stikeri əlavə etmək üçün botun inkişafına dəstək ola bilərsiniz (/donate).\n      timeout: |\n        <b>Hazırda bot böyük yüklə üzləşir</b>\n        Buna görə də, video çevrilmə yalnız aktiv gücləndirici paketlər üçün mövcuddur\n\n        Ətraflı məlumat üçün / bağışla\n      convert: |\n        <b>Xəta!</b>\n        Təəssüf ki, bot videonuzu çevirə bilmədi.\n\n        Ola bilsin ki, videonuz bot üçün anlaşılmaz formatda saxlanılıb. mp4 formatında olduğuna əmin olun.\n        Bu, botun daxili xətası da ola bilər, bu videonu yenidən göndərməyə cəhd edin.\n      too_big: |\n        <b>Xəta!</b>.\n\n        Fayl emal etmək üçün çox böyükdür. Zəhmət olmasa, göndərməzdən əvvəl keyfiyyəti və müddəti azaldın.\n      sticker_not_found: |\n        <b>Səhv!</b>\n\n        Bu stikeri tapmaq mümkün olmadı. Zəhmət olmasa onun doğru paketdə olub-olmadığını yoxlayın və ya yenidən əlavə etməyə çalışın.\nnews:\n  join: |\n    ℹ️ <a href=\"${link}\">Bot haqqında ən son xəbərləri əldə etmək üçün</a> kanalımıza qoşulun.\n\n    <i>Bot haqqında ən son xəbərləri, həmçinin yeniləmələri və yeni funksiyaları əldə etmək üçün kanala abunə olun.</i>\n  join_btn: '📢 Kanala qoşulun'\n  not_joined: '🙅 Siz kanala abunə deyilsiniz'\n  continue: '✅ Davam edin'\nuserAbout:\n  help: |\n    <b>🧑‍🎨 İstifadəçi haqqında</b>\n\n    Bu menyudan istifadə etməklə siz istifadəçi və onun stiker paketləri haqqında məlumat əldə edə bilərsiniz\n\n    İstifadəçi haqqında məlumat əldə etmək üçün düymədən istifadə edin aşağıda və ya mesajını yönləndirin\n  result: |\n    <b>🧑‍🎨 İstifadəçi məlumatı</b>\n    <b>🆔 İstifadəçi ID-si:</b> <code>${userId}</code>\n    <b>🎨 Bu istifadəçidən paketlər:</b>\n    ${packs}\n  no_packs: |\n    <i>Bu sahibin stikerləri haqqında məlumatımız yoxdur</i>\n  forward_hidden: |\n    İstifadəçi mesajları yönləndirmək imkanını gizlədib. Onun stiker paketlərinə baxmaq üçün aşağıdakı düymədən istifadə edin.\n  select_user: '🧑‍🎨 İstifadəçi seçin'\nscenes:\n  new_pack:\n    pack_type: |\n      <b><u>Paket növünü seçin</u></b>\n    regular: '😊 Etiket'\n    custom_emoji: '🌟 Emoji (mükafat)'\n    static: '🌟 Statik'\n    animated: '✨ Vektor'\n    video: '📹 Video'\n    pack_format: |\n      <b><u>Paket növünü seçin</u></b>\n\n      <b>Ümumi</b> - statik (hərəkət etməyin), rastr, fayl format - PNG əlavə etməzdən əvvəl (bot emal olunur), əlavə etdikdən sonra - WEBP.\n      Adi paketin nümunəsi - t.me/addstickers/Animals\n\n      <b>Video</b> - animasiya video paketi. İstənilən video, gif və foto əlavə edə bilərsiniz.\n      Nümunə video paketi - t.me/addstickers/TheMascot\n\n      <b>Animasiyalı</b> - animasiyalı, vektor (onlarda faylın daxilində olan obyektlərin dəqiq təsviri var. hər hansı bir miqyasda aydın şəkildə göstərildiyi), fayl formatı - TGS, Lottie formatının bir variasiyası.\n      Animasiya paketinə nümunə - t.me/addstickers/IsabelleShizue\n\n      <i>Animasiyalı və video stiker dəstlərində 50-yə qədər stiker ola bilər. Statik stiker dəstlərində 120-yə qədər stiker ola bilər.</i>\n    pack_title: |\n      <b>Yeni stiker paketi üçün bir ad daxil edin:</b>\n      <i>Aşağıda təsadüfi olaraq yaradılan bir ad da seçə bilərsiniz.</i>\n    pack_name: |\n      <b>Yeni stikerlər paketi üçün qısa link daxil edin:</b>\n\n      <i>Məsələn, bu paket qısa link kimi \"Heyvanlar\"dan istifadə edir: https://t.me/ addstickers/<u>Heyvanlar</u></i>\n      <i>Düymədə təsadüfi qısa keçid seçə bilərsiniz.</i>\n    ok: |\n      Paket <a href=\"${link}\">${title}</a> uğurla yaradıldı!\n\n      <b>Paket linki:</b> <pre>${link}</pre>\n\n      Fayl, foto, video və ya stiker göndərin ki, mən dəstinizə əlavə edin\n    error:\n      title_long: Ad ${max} simvoldan çox olmamalıdır\n      name_long: Ünvan ${max} simvoldan çox olmamalıdır.\n      telegram:\n        name_invalid: Bu ünvan istifadə edilə bilməz.\n        name_occupied: Bu ünvan artıq alınmışdır.\n        upload_failed: |\n          <b>Xəta!</b>\n          Bot Telegram-a stiker yükləyə bilməz.\n\n          Zəhmət olmasa, sonra yenidən cəhd edin.\n  copy:\n    enter: |\n      Mən onu kopyalaya bilərəm, amma bundan əvvəl yeni paket yaradaq\n    progress: |\n      <a href=\"${originalLink}\">${originalTitle}</a>-dan <a href=\"${link}\">${title}</a>-a paketi kopyalamaq\n\n      Proqres: ${current}/${total}\n    done: |\n      Paketin <a href=\"${originalLink}\">${originalTitle}</a> -dən <a href=\"${link}\">${title}</a> -ə kopyalanması uğurla tamamlandı.\n    pay: |\n      <b>Paketin çevrilməsi</b>\n\n      Bir paketi bir növdən digərinə çevirmək 1 kreditə başa gəlir\n\n      <b>Cari balans:</b> ${balance} Kredit\n\n      Kredit almaq: /donate\n    pay_btn: '✅ Təsdiqlə'\n    error:\n      premium: |\n        <b>səhv!</b>\n        Təəssüf ki, bu xüsusiyyət yalnız botu dəstəkləyənlər üçün mövcuddur.\n\n        You can do this by sending the /donate command.\n  original:\n    enter: |\n      Bu bot vasitəsilə əlavə olunan stikeri göndərin, sizə orijinalını göstərim.\n    error:\n      not_found: |\n        <b>səhv!</b>\n        Bu stikerin əslini tapa bilmədim.\n  delete:\n    enter: |\n      Bu bot vasitəsilə əlavə edilmiş stiker göndərin, mən onu paketdən siləcəyəm.\n    confirm: |\n      Bu stikeri silmək istədiyinizə əminsiniz?\n    error:\n      not_found: |\n        <b>Xəta!</b>\n        Stikeri tapa bilmədim.\n  rename:\n    enter_name: |\n      <b> <a href=\"${link}\">${title}</a>üçün yeni başlıq daxil edin:</b>\n    success: |\n      <b>Başlıq uğurla dəyişdirildi!</b>\n\n      Yeni başlıq: <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      ❕ \"<code>${titleSuffix}</code>\" sonluğunu aradan qaldırmaq üçün paketi yüksəltməlisiniz. Daha ətraflı məlumat üçün menyuda: \\/donate\n  packAbout:\n    enter: |\n      <b>Bu barədə məlumat axtarmaq üçün mənə stiker və ya fərdi emoji göndərin:</b>\n    not_found: |\n      Stikeri tapa bilmədim\n    result: |\n      <b>📦 Paket:</b> <a href=\"${link}\">${name}</a>\n      🆔 <code>${setId}</code> <i>(Sahibin paketləri üçün unikal nömrə, hər paket üçün artır)</i>\n\n      🧑‍🎨 Sahibin ID-i: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 Sahibin digər paketləri:</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>Bu sahibin digər stikerləri haqqında məlumatımız yoxdur</i>\n  boost:\n    sure: |\n      <b>Əminsiniz ki, <a href=\"${link}\">${title}</a> paketini yüksəltmək istəyirsiniz?</b>\n\n      Yüksəltmə emal üstünlüyünü və emal növbəsinə birdən çox stiker əlavə etmək imkanını artırır\n      Yüksəltmə haqqında daha ətraflı məlumatı menyuda /donate komandasını göndərərək tapa bilərsiniz\n\n      <b>Qiymət:</b> 1 Kredit\n      <b>Cari balans:</b> ${balance} Kredit\n    btn:\n      yes: Bəli, gücləndirin!\n      no: Xeyr, ləğv edin\n    canceled: |\n      Artırma ləğv edildi\n    success: |\n      Artırma uğurla tamamlandı!\n\n      ${title} indi gücləndirilib\n    error:\n      not_enough_credits: |\n        Bu paketi yüksəltmək üçün kifayət qədər Kreditiniz yoxdur.\n\n        Balansınızı artırmaq üçün /donate komandasını göndərə bilərsiniz.\n      already_boosted: |\n        Bu paket artıq gücləndirilib.\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>Mənə dərc etmək istədiyiniz paketdən stiker göndərin</b>\n\n        <i>Sizə məxsus istənilən paketi dərc edə bilərsiniz, hətta başqa yerlərdə yaradılsalar belə</i>\n      owner_proof: |\n        <b>Bu paketin sahibliyini yoxlamaq üçün bir neçə sadə addımı yerinə yetirməlisiniz:</b>\n        1. @Stickers botunu açın\n        2. Göndər <code>/packstats</code> əmri\n        3. Lazım olan paketi tapın və seçin\n        4. Alınan mesajı bota yönləndirin\n      publish_new_access_denied: |\n        <b>Xəta!</b>\n        Bu paket sizin deyil.\n\n        Siz yalnız öz paketlərinizi dərc edə bilərsiniz\n      banned: |\n        <b>Xəta!</b>\n        Sizə bu funksiyadan istifadə qadağan olunub.\n        Zəhmət olmasa, administratorla əlaqə saxlayın.\n      enter: |\n        Siz \"<a href=\"${link}\">${title}</a>\" paketini botun ictimai kataloqunda dərc etmək üzrəsiniz\n        Onu botun istənilən istifadəçisi ad, teq və ya filtrlə tapa bilər.\n        Buna görə daha çox insan paketinizi quraşdıracaq\n        Çox sayda insan üçün maraqlı ola biləcək yalnız yüksək keyfiyyətli paketləri göndərməyə çalışın\n\n        <b>Qaydalar paketlərin nəşri üçün:</b>\n        • Dar bir dairə üçün nəzərdə tutulmuş şəxsi paketlərinizi dərc etməyin. Məsələn, dostlarınızın üzləri və ya mesajlarınızdan sitatlar\n        • Aİ qanunlarını və ya digər yerli qanunları pozan stiker təzyiqləri dərc etməyin\n\n        Bunun olması üçün əlavə məlumat təqdim etməlisiniz. kataloqunda dərc edilmişdir\n      continue_button: Davam et\n      enter_description: |\n        <b>Paketinizi qısaca təsvir edin ki, başqaları onu tapa bilsin</b>\n\n        <i>Siz həmçinin [#]</i>\n        kateqoriyalara ayırmaq üçün hashtaglardan istifadə edə bilərsiniz.<i>Məsələn: #anime #meme #heyvanlar #cute #kpop #funny #cat #oyun </i>\n      select_language: |\n        <b>Paketinizin hansı dillər üçün olduğunu seçin:</b>\n        <i>Siz çoxlu dil seçə bilərsiniz</i>\n      button_all_languages: Bütün dillər\n      button_confirm_language: Təsdiq edin\n      set_safe: |\n        <b>Paketiniz istifadəçilər üçün təhlükəsizdirmi?</b>\n        <i>Yəni erotik və digər şokedici məzmun yoxdur</i>\n      button_safe:\n        safe: Bəli, bu təhlükəsizdir\n        not_safe: Xeyr, bu təhlükəsiz deyildir\n      no_tags: müəyyən edilməmişdir\n      confirm: |\n        <b>\"<a href=\"${link}\">${title}</a>\" paketinin dərc olunmasını təsdiqləyin</b>\n\n        <b>Təsvir:</b> <i>${description}</i>\n\n        <b>Teqlər:</b> ${tags}\n        <b>Dillər:</b> ${languages}\n      button_confirm: '✅ Nəşri təsdiqləyin'\n      success: |\n        Təbrik edirik, paketiniz digər istifadəçilərin tapa biləcəyi botumuzun ümumi kataloqunda dərc olundu!\n\n        Siz /packs əmri ilə paketi seçməklə paket axtarış məlumatını redaktə edə bilərsiniz.\n\n        <i>Sizə xatırladırıq ki, paketinizin statistikasına @Stickers rəsmi botu vasitəsilə baxmaq olar</i>\n    unpublish:\n      success: |\n        Paket bot kataloqundan müvəffəqiyyətlə dərc edilib.\n  delete_pack:\n    enter: |\n      Paketi silmək istədiyinizə əminsiniz <a href=\"${link}\">${title}</a>?\n      O, həmişəlik silinəcək və bərpa edilə bilməz.\n\n      Yalnız bir stiker silmək istəyirsinizsə, /delete əmrindən istifadə edin.\n\n      Bu paketi həqiqətən silmək istədiyinizi təsdiqləmək üçün <code>${confirm}</code> göndərin.\n    confirm: Bəli, tam əminəm.\n    success: |\n      <b>Paket uğurla silindi!</b>\n    error:\n      - <b>Xəta!</b>\n      - Təəssüf ki, nəsə xəta baş verdi.\n  frame:\n    no_video: |\n      <b>Xəta!</b>\n      Siz yalnız video paketlərə çərçivələr əlavə edə bilərsiniz.\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>Çərçivə tipini seçin:</b>\n      Çərçivə stikerin ətrafında şəffaf fon deməkdir\n\n      <code>lite</code> — künclər bir az kəsiləcək\n      <code>medium</code> — künclər daha çox kəsiləcək\n      <code>rounded</code> — künclər yuvarlaq ediləcək\n      <code>square</code> — çərçivənin düzbucaqlı forması, yəni, heç bir dəyişiklik olmayacaq\n      <code>circle</code> — çərçivə dairəvi formada olacaq\n\n      <i>Gələcəkdə, /frame əmrindən çərçivə tipini təyin etmək üçün istifadə edə bilərsiniz</i>\n    types:\n      lite: '1. Lite'\n      medium: '2. Orta'\n      rounded: '3. Dairəvi'\n      square: '4. Kvadrat'\n      circle: '5. Dairə'\n    selected: |\n      <b>Seçilmiş çərçivə növü:</b> ${type}\n  photoClear:\n    enter: |\n      Arxa fonu silmək istədiyiniz <u>şəkli</u> göndərin və mən faylı fonsuz göndərim\n\n      <i>Fotolarla ən yaxşı işləyir. Rəsmlər, illüstrasiyalar və s. ilə daha pis işləyir.</i>\n    enter_anime: |\n      Arxa fonunu silmək istədiyiniz <u>şəkil</u> göndərin və mən sizə arxa fonu olmayan faylı göndərəcəyəm\n\n      <i>Anime şəkilləri ilə daha yaxşı işləyir</i>\n    choose_model: |\n      <b>Model seçin:</b>\n    web_app: WebApp - insanlarla fotoşəkillər üçün\n    model:\n      ordinary: Ümumi — insanlarla fotoşəkillər üçün\n      general: Ümumi — istənilən fotoşəkillər üçün\n      anime: Anime — anime şəkilləri üçün\n      birefnet_general: BirefNet - istənilən fotoşəkillər üçün\n    add_to_set_btn: '🌟 Dəstə əlavə edin'\n    error: |\n      <b>Xəta!</b>\n      Təəssüf, nəsə xəta baş verdi.\n  leave: |\n    Fəaliyyət uğurla ləğv edildi.\n  btn:\n    cancel: '❌ Ləğv et'\nerror:\n  telegram: |\n    <b>Telegram xəta!</b>\n    <code>${error}</code>\n  answerCbQuery:\n    telegram: |\n      Telegram xəta qaytardı:\n      ${error}\n  banned: |\n    <b>Xəta!</b>\n    Sizə bu funksiyadan istifadə qadağan olunub.\n\n    <i>Bu bir səhv olduğunu düşünürsünüzsə, administratorla əlaqə saxlayın: @ly_oBot</i>\n  unknown: |\n    <b>Naməlum xəta baş verdi, zəhmət olmasa yenidən cəhd edin.</b>\n\n    Problem davam edərsə @Ly_oBot-a yazın.\n    Zəhmət olmasa, hansı bot barədə danışdığınızı və problemi bir mesajda ətraflı şəkildə təsvir edin.\n"
  },
  {
    "path": "locales/be.yaml",
    "content": "---\nlanguage_name: '🇧🇾 Беларуская'\nname: fStik — Стыкеры і эмодзі\ndescription:\n  long: |\n    Ствараеце стыкеры і эмодзі з фота, відэа і гіфак без канвертацыі!\n\n    Магчымасці:\n    • Простае кіраванне пакамі\n    • Відэа-стыкеры і свае эмодзі\n    • Загрузка арыгінальных файлаў\n    • Ператварэнне стыкера/відэа/гіфкі ў малюнак\n    • Каталог стыкераў\n\n    Пошук стыкераў: play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    Ствараеце стыкеры і эмодзі з фота, відэа, GIF. Каталог і пошук. 🇺🇦\nratelimit: Не так часта!\ncmd:\n  start:\n    enter: |\n      🧙 Вітаю цябе, ${name}! Я чарадзейны бот для стварэння пакаў эмодзі і стыкераў.\n      Я ў вокамгненне магу ператварыць фота, відэа і гіфкі ў файныя стыкеры.\n\n      Каб даведацца больш, напішыце /help.\n\n      💬 Чат падтрымкі: @fStikCommunity (дазволена толькі англійская мова)\n    group: |\n      🧙 Вітаю, ${groupTitle}! Я чараўнік эмодзі і стыкераў.\n\n      Каб дадаць стыкер у групавы пак, адпраўце каманду /ss у адказ на фота, відэа, гіфку або стыкер.\n    catalog: |\n      <b>😻 Вы можаце знайсці новыя наборы стыкераў у нашым каталозе</b>\n\n      • Націсніце кнопку ніжэй і атрымайце доступ да вялізнага каталога набораў стыкераў на любы густ\n      • Шукайце па ключавых словах або праз падрыхтаваныя ўкладкі\n      • Не забывайце ацэньваць пакі, каб прасунуць або апусціць рэйтынг пака стыкераў\n    commands:\n      ss: '🌟 Захаваць стыкер'\n      start: '📜 Стартавае меню'\n      help: '📖 Даведка'\n      packs: '📁 Кіраванне пакамі'\n      new: '🌝 Стварыць пак стыкераў'\n      catalog: '📖 Каталог'\n      publish: '📤 Апублікаваць пак'\n      delete: '❌ Выдаліць стыкер'\n      original: '🔍 Знайсці арыгінальны стыкер'\n      restore: '🔀 Аднавіць пак'\n      copy: '📋 Скапіяваць пак'\n      emoji: '📝 Змяніць суфікс эмодзі'\n      round: '🎥 Відэа круглай формы'\n      clear: '🖼️ Выдаліць фон з фотаздымку'\n      about: '📦 Звесткі аб паке'\n      user_about: '🧑‍🎨 Пра стваральніка'\n      lang: '🌐 Змяніць мову'\n      report: '🚨 Паскардзіцца на пак'\n      donate: '☕️ Падтрымаць распрацоўшчыка'\n      add_to_group: '👥 Дадаць у групу'\n      privacy: '🔒 Палітыка прыватнасці'\n    btn:\n      new: '📥 Стварыць новы'\n      catalog: '💖 Адкрыць каталог'\n      catalog_mini: '💖 Каталог'\n      catalog_browser: '🌐 Адкрыць у браўзеры'\n      catalog_browser_mini: '🌐 У браўзеры'\n      catalog_app: '📱 Спампаваць праграму для Android'\n      catalog_app_mini: '📱 Праграма для Android'\n  inline:\n    switch_pm: '📁 Выбраць пак'\n  restore: |\n    <b>🗃 Аднаўленне пакета</b>\n\n    Каб аднавіць пакет, трэба даслаць мне спасылку на пакет, які вы хочаце аднавіць\n  copy: |\n    <b>🗄️ Капіяваць пак</b>\n\n    Каб скапіяваць пак у новы, проста адпраўце сюды спасылку на патрэбны пак стыкераў ці эмодзі\n  report: |\n    <b>🚨 Паскардзіцца</b>\n\n    Калі вы знайшлі пак стыкераў, які, на вашу думку, можа парушаць закон або ўмовы выкарыстання Telegram, паведаміце пра гэта, адправіўшы спасылку на яго сюды: @StickersReportBot\n\n    <i>Памятайце, што гэты бот не нясе адказнасці за змесціва пакаў і не можа яго кантраляваць</i>\n  packs:\n    info: |\n      <b>📁 Пакі</b>\n    types:\n      regular: Стыкеры\n      custom_emoji: Эмодзі\n      inline: Inline\n    empty: |\n      <b>Вы яшчэ не стварылі ніводнага пака.</b>\n      Каб стварыць пак, адпраўце каманду /new\n    inline_title: Убудаваны пак\n    select_group_pack_info: |\n      <b>📁 Выбраць пак</b>\n\n      Каб выкарыстоўваць пак у групе, адміністратары павінны выбраць яго, націснуўшы кнопку ніжэй\n    select_group_pack: Выбраць пак\n  emoji:\n    info: |\n      Каб змяніць стандартнае эмодзі для бягучага пака, адпраўце каманду <code>/emoji</code>, дадаўшы праз прабел патрэбнае эмодзі\n\n      Напрыклад: <code>/emoji 🌟</code>\n    done: Эмодзі паспяхова зменена.\n    error: Пры змяненні эмодзі ўзнікла памылка!\n  round_video:\n    enabled: |\n      Цяпер відэа будуць мець круглую форму\n    disabled: |\n      Цяпер відэа не будуць мець круглую форму\n  paysupport: |\n    <b>👨‍💻 Плацежная падтрымка</b>\n\n    Наконт любой праблемы, звязанай з працай бота, у тым ліку з плацяжамі і данатамі, вы можаце звярнуцца да распрацоўшчыка непасрэдна.\n\n    <b>Кантакты:</b>\n    🧑‍💻 Распрацоўшчык: @ly_oBot\ndonate:\n  menu: |\n    <b>☕ Падтрымка распрацоўкі бота</b>\n    Падтрымаўшы распрацоўку бота, вы атрымаеце крэдыты.\n    За кожны долар вы атрымаеце па 5 крэдытаў.\n\n    <b>Баланс:</b> <code>${balance}</code> кр.\n    За 1 крэдыт можна забусціць адзін пак.\n\n    <b>Буст дае наступныя прывілеі:</b>\n    ➖ Няма \"<code>${titleSuffix}</code>\" у назве пака <i>(не ў спасылцы)</i>\n    ➖ Назва да 64 сімвалаў (замест 35)\n    ➖ Відэа да 35 секунд\n    ➖ Прыярытэтная чарга канвертацыі\n    ➖ Некалькі стыкераў адначасова\n    ➖ Без рэкламы\n\n    <b>Выберыце, колькі крэдытаў вы хочаце купіць:</b>\n  btn:\n    donate: '☕ Падтрымка'\n  topup: |\n    <b>Увядзіце суму крэдытаў, якую вы хочаце купіць:</b>\n  invalid_amount: |\n    <b>Памылковая колькасць</b>\n\n    Мінімальная колькасць крэдытаў — 1\n  paymenu: |\n    Вы хочаце купіць <b>${amount} кр.</b> за <b>${price}$</b>\n\n    ⚠️ Крэдыты выдае адміністратар уручную.\n    Час чакання: ад 5 хвілін да 1 гадзіны\n\n    <u>Выберыце спосаб аплаты:</u>\n  description: |\n    Купляючы крэдыты, вы падтрымліваеце распрацоўку бота і атрымліваеце магчымасць карыстацца дадатковымі функцыямі\n  update: |\n    <b>🔄 Абнаўленне балансу</b>\n\n    Баланс: <code>${balance}</code> кр. (дададзена <code>${amount}</code> кр.)\n  error:\n    already_donated: |\n      Вы ўжо атрымалі крэдыты за гэты плацеж\n    error: |\n      <b>Памылка!</b>\n      Падчас апрацоўкі плацяжу ўзнікла памылка\n    canceled: |\n      Плацеж скасаваны\ncoedit:\n  info: |\n    <b>👥 Сумеснае рэдагаванне</b>\n\n    Спасылка для сумеснага рэдагавання пака <a href=\"${link}\">${title}</a>: <code>${colink}</code>\n\n    <b>Як выкарыстоўваць:</b>\n    1. Адпраўце спасылку чалавеку, якому вы хочаце даць доступ да пака\n    2. Перайшоўшы па спасылцы, чалавек павінен націснуць \"Запусціць\", пасля чаго ён стане рэдактарам\n\n    <b>Рэдактары:</b>\n    ${editors}\n\n    <i>Каб выдаліць рэдактараў, скіньце спасылку</i>\n  no_editors: |\n    Пакуль няма рэдактараў\n  btn:\n    send: '📤 Адправіць спасылку'\n    reset: '🔁 Скінуць спасылку'\n  share: |\n    Перайдзіце па спасылцы і націсніце «Запусціць», каб стаць рэдактарам пака «${title}»\n  reset: |\n    <b>🔁 Спасылка паспяхова скінута</b>\n\n    Новая спасылка для сумеснага рэдагавання пака <a href=\"${link}\">${title}</a>: <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: Пак не знойдзены\n      not_owner: Не твій пакунок\n      hidden: Пак паспяхова схаваны\n      restored: Пак паспяхова адноўлены\n    set_pack: |\n      🌟 Выбраны пак <a href=\"${link}\">${title}</a>\n\n      <b>❔ Як дадаць?</b>\n      Адпраўце фота, відэа або стыкер, каб дадаць іх у пак\n    set_inline_pack: |\n      Выбраны пак <u>${title}</u>\n\n      Каб яго выкарыстаць, напішыце ў любым чаце <code>@${botUsername}</code> і дадайце прабел.\n      Вы таксама можаце выкарыстаць яго, націснуўшы кнопку ніжэй\n    boost:\n      info: |\n        \\n⚡ <b><a href=\"https://t.me/${botUsername}?start=boost\">Буст</a></b>: ${boostStatus}\n      status:\n        on: Укл.\n        off: Выкл.\n    hidden: Пак <a href=\"${link}\">${title}</a> схаваны з вашага спісу.\n    restored: Пак <a href=\"${link}\">${title}</a> адноўлены ў вашым спісе.\n    btn:\n      hide: '❌ Схаваць пак'\n      delete: '🗑️ Выдаліць пак'\n      restore: '✅ Аднавіць'\n      use_pack: '📦 Выкарыстаць пак'\n      boost: '⚡ Буст'\n      frame: '🖼️ Рамка'\n      rename: '✏️ Перайменаваць'\n      search_gif: '🔎 Пошук GIF'\n      coedit: '👥 Сумеснае рэдагаванне'\n      catalog_add: '🗂️ Дадаць у каталог'\n      catalog_edit: '📝 Рэдагаваць у каталозе'\n      catalog_delete: '🗑 Выдаліць з каталога'\n      catalog_share: '🔗️️Абагуліць'\n      catalog_open: '📂 Адкрыць у каталозе'\n    error:\n      not_found: |\n        Памылка!\n        Не атрымалася знайсці стыкер.\n      invalid_png: |\n        Памылка!\n        Файл не з'яўляецца дапушчальным PNG выявы. Калі ласка, пераўтварыце яго ў фармат PNG перад адпраўкай.\n      invalid_dimensions: |\n        Памылка!\n        Памеры стыкера недапушчальныя. Стыкеры павінны быць 512x512 пікселяў.\n      invalid_animated: |\n        Памылка!\n        Файл з аніміраваным стыкерам не ў правільным фармаце TGS.\n      invalid_video: |\n        Памылка!\n        Відэафайл не ў правільным фармаце WEBM.\n      restore: |\n        Памылка!\n        Не атрымалася аднавіць пак.\n      copy: |\n        Памылка!\n        Не атрымалася знайсці пак.\n    select_group:\n      success: |\n        Пак «<a href=\"${link}\">${title}</a>» паспяхова выбраны для групы.\n      access_rights:\n        add: Хто можа дадаваць стыкеры ў групавы пак?\n        delete: Хто можа выдаляць стыкеры з групавога паку?\n        rights:\n          all: Усе\n          admins: Толькі адміны\n      error: |\n        Памылка!\n        Набор не знойдзены.\n  sticker:\n    answerCbQuery:\n      delete: Стыкер паспяхова выдалены з пака.\n      restored: Стыкер паспяхова захаваны ў бягучым паку.\n    delete: Стыкер паспяхова выдалены з пака.\n    restored: Стыкер паспяхова захаваны ў бягучым паку.\n    btn:\n      delete: '🗑 Выдаліць'\n      copy: '🌟 Скапіяваць'\n      restore: '✅ Аднавіць'\n    error:\n      not_found: |\n        ПАМЫЛКА!\n        Не атрымалася знайсці стыкер.\n      invalid_png: |\n        <b>Памылка!</b>\n        Файл не з'яўляецца сапраўдным PNG малюнкам. Калі ласка, канвертуйце яго ў фармат PNG перад адпраўкай.\n      invalid_dimensions: |\n        <b>Памылка!</b>\n        Памеры налепкі недапушчальныя. Налепкі павінны быць 512x512 пікселяў.\n      invalid_animated: |\n        <b>Памылка!</b>\n        Файл аніміраванай налепкі не ў правільным фармаце TGS.\n      invalid_video: |\n        <b>Памылка!</b>\n        Файл відэа не ў правільным фармаце WEBM.\n  group_settings:\n    success: |\n      Налады групы паспяхова абноўлены.\nsticker:\n  add:\n    ok: |\n      <b>Паспяхова дададзена ў пак</b>\n      <a href=\"${link}\">${title}</a>\n\n      У наступную гадзіну гэты пак будзе абноўлены для ўсіх карыстальнікаў.\n\n      <i>Па жаданні адпраўце адзін або некалькі эмодзі, якія будуць адпавядаць стыкеру.</i>\n    ok_inline: |\n      <b>Паспяхова дададзена ў пак:</b>\n      <u>${title}</u>\n    send_emoji: Выдатна, цяпер адпраўце эмодзі, які адпавядае стыкеру\n    converting_process: |\n      <b>Пачакайце…</b>\n      Ваш файл знаходзіцца ў чарзе на канвертацыю. Пачакайце заканчэння канвертацыі. Гэта можа заняць некаторы час.\n\n      Ход выканання: ${progress} / ${total}\n\n      <i>Карыстальнікі, якія падтрымалі распрацоўку бота, атрымліваюць большы прыярытэт у чарзе (падрабязней: /donate)</i>\n    catalog_offer: |\n      <b>😲 Ого, выдатны пак атрымаўся!</b>\n\n      Хочаце дадаць <a href=\"${link}\">${title}</a> у публічны каталог стыкераў, каб іншыя карыстальнікі бота таксама маглі яго ўбачыць?\n      <i>Гэта не зойме шмат часу</i>\n    quote: |\n      Выкарыстоўвайце @QuotlyBot, каб стварыць цытату з гэтага паведамлення\n    error:\n      reply: |\n        <b>Памылка!</b>\n        Адкажыце на стыкер.\n      no_selected_pack: |\n        <b>Вы не выбралі пак</b>\n\n        Калі ласка, стварыце (/new) або выберыце (/packs) пак\n      no_selected_group_pack: |\n        <b>Вы не выбралі групавы пак</b>\n\n        Калі ласка, выберыце пак з дапамогай каманды /packs\n      no_rights: |\n        <b>Памылка!</b>\n        У вас няма правоў на дадаванне стыкераў у гэты пак.\n      stickers_too_much: |\n        У гэтым паку максімальная колькасць стыкераў.\n\n        Вы можаце стварыць новы пак, выкарыстаўшы каманду /new.\n      have_already: |\n        <b>Гэты стыкер ужо ёсць у паку</b>\n\n        Калі вы хочаце змяніць эмодзі, адпраўце яго ў наступным паведамленні.\n      stickerset_invalid: |\n        <b>Памылка!</b>\n        Бот не можа атрымаць доступ да выбранага пака.\n\n        Калі ласка, стварыце новы (/new) або выберыце існуючы (/packs) пак.\n      invalid_png: |\n        <b>Памылка!</b>\n        Файл не з'яўляецца сапраўдным PNG малюнкам. Калі ласка, канвертуйце яго ў фармат PNG перад адпраўкай.\n      invalid_dimensions: |\n        <b>Памылка!</b>\n        Памеры налепкі недапушчальныя. Налепкі павінны быць 512x512 пікселяў.\n      invalid_animated: |\n        <b>Памылка!</b>\n        Файл аніміраванай налепкі не ў правільным фармаце TGS.\n      invalid_video: |\n        <b>Памылка!</b>\n        Файл відэа не ў правільным фармаце WEBM.\n      file_type:\n        static: |\n          <b>Памылка!</b>\n          Такі тып файлаў не падтрымліваецца\n          Вы можаце дадаць гэтае фота або статычны стыкер у статычны пак\n\n          <i>Стварыце новы (/new) або выберыце існуючы (/packs) пак</i>\n        video: |\n          <b>Памылка!</b>\n          Такі тып файлаў не падтрымліваецца\n          Вы можаце дадаць гэты відэафайл у відэапак\n\n          <i>Стварыце новы (/new) або выберыце існуючы (/packs) пак</i>\n        animated: |\n          <b>Памылка!</b>\n          Такі тып файлаў не падтрымліваецца\n          Вы можаце дадаць гэты анімаваны файл у вектарны пак\n\n          <i>Стварыце новы (/new) або выберыце існуючы (/packs) пак</i>\n        unknown: |\n          <b>Памылка!</b>\n          Такі тып файлаў не падтрымліваецца\n\n          <i>Стварыце новы (/new) або выберыце існуючы (/packs) пак</i>\n      wait_load: |\n        <b>Пачакайце!</b>\n\n        Бот усё яшчэ апрацоўвае папярэдні файл...\n        Вы можаце падтрымаць распрацоўку бота (/donate), каб павысіць прыярытэт апрацоўкі і атрымаць магчымасць дадаваць у чаргу больш за адзін стыкер.\n      timeout: |\n        <b>У гэты момант серверы бота перагружаныя</b>\n        Па гэтай прычыне канвертацыя відэа даступная толькі для забушчаных пакаў\n\n        Каб даведацца больш, напішыце /donate\n      convert: |\n        <b>Памылка!</b>\n        На жаль, у бота не атрымалася канвертаваць вашае відэа.\n\n        Верагодна, вашае відэа было захавана ў фармаце, які наш бот не падтрымлівае. Упэўніцеся, што яго фармат — MP4.\n        Таксама магчыма, што гэта ўнутраная памылка бота, паспрабуйце адправіць гэтае відэа яшчэ раз.\n      too_big: |\n        <b>Памылка!</b>\n\n        Файл надта вялікі для апрацоўкі. Калі ласка, паменшыце яго якасць або працягласць перад адпраўкай.\n      sticker_not_found: |\n        <b>Памылка!</b>\n\n        Гэты сцікер не знойдзены. Калі ласка, пераканайцеся, што ён у правільным наборы, або паспрабуйце дадаць яго зноў.\nnews:\n  join: |\n    ℹ️ <a href=\"${link}\">Падпішыцеся на наш канал,</a> каб атрымліваць апошнія навіны пра нашага бота.\n\n    <i>Падпісаўшыся на канал, вы будзеце атрымліваць апошнія навіны пра нашага бота, а таксама абнаўленні і новыя функцыі.</i>\n  join_btn: '📢 Падпісацца на канал'\n  not_joined: '🙅 Вы не падпісаны на канал'\n  continue: '✅ Працягнуць'\nuserAbout:\n  help: |\n    <b>🧑‍🎨 Пра карыстальніка</b>\n\n    У гэтым меню вы зможаце знайсці звесткі пра карыстальніка і яго пакі стыкераў.\n\n    Каб атрымаць звесткі пра карыстальніка, націсніце кнопку ніжэй або перашліце яго паведамленне.\n  result: |\n    <b>🧑‍🎨 Звесткі пра карыстальніка</b>\n    <b>🆔 ID:</b> <code>${userId}</code>\n    <b>🎨 Створаныя пакі:</b>\n    ${packs}\n  no_packs: |\n    <i>Інфармацыя пра пакі гэтага ўладальніка адсутнічае</i>\n  forward_hidden: |\n    Карыстальнік схаваў магчымасць перасылаць паведамленні. Націсніце кнопку ніжэй, каб праглядзець яго пакі стыкераў.\n  select_user: '🧑‍🎨 Выберыце карыстальніка'\nscenes:\n  new_pack:\n    pack_type: |\n      <b><u>Выберыце тып пака</u></b>\n    regular: '😊 Стыкеры'\n    custom_emoji: '🌟 Эмодзі (прэміум)'\n    static: '🌟 Статычны'\n    animated: '✨ Вектарны'\n    video: '📹 Відэа'\n    pack_format: |\n      <b><u>Выберыце тып пака</u></b>\n\n      <b>Звычайны</b> — статычны (стыкеры не рухаюцца), растравы, фармат файлаў: да дадавання — PNG (бот апрацоўвае), пасля дадавання — WEBP.\n      Узор звычайнага пака: t.me/addstickers/Animals\n\n      <b>Відэа</b> — анімаваны відэапак. Вы можаце дадаваць любыя відэа, анімацыі і відарысы.\n      Узор відэапака: t.me/addstickers/TheMascot\n\n      <b>Анімаваны</b> — анімаваны, вектарны (у файлах стыкераў прапісаны менавіта фігуры, а не пікселі, з-за чаго стыкеры адлюстроўваюцца правільна ў любым маштабе), фармат файлаў: TGS — разнастайнасць фармата Lottie.\n      Узор анімаванага пака: t.me/addstickers/IsabelleShizue\n\n      <i>Максімальная колькасць стыкераў у анімаваных і відэапаках — 50; у статычных наборах — 120.</i>\n    pack_title: |\n      <b>Увядзіце назву новага пака стыкераў:</b>\n      <i>Таксама вы можаце выбраць выпадкова згенераваную назву ніжэй.</i>\n    pack_name: |\n      <b>Увядзіце кароткую спасылку на новы пак стыкераў:</b>\n\n      <i>Напрыклад, гэты пак у якасці кароткай спасылкі выкарыстоўвае «Animals»:\n      https://t.me/addstickers/<u>Animals</u></i>\n      <i>Вы таксама можаце выбраць выпадковую спасылку ніжэй.</i>\n    ok: |\n      Пак <a href=\"${link}\">${title}</a> паспяхова створаны!\n\n      <b>Спасылка на пак:</b> <pre>${link}</pre>\n\n      Адпраўце файл, фота відэа або стыкер, каб я дадаў яго да вашага набору\n    error:\n      title_long: 'Максімальная колькасць сімвалаў у назве: ${max}.'\n      name_long: 'Максімальная колькасць сімвалаў у адрасе: ${max}.'\n      telegram:\n        name_invalid: Такі адрас выкарыстаць не атрымаецца.\n        name_occupied: Такі адрас ужо заняты.\n        upload_failed: |\n          <b>Памылка!</b>\n          Бот не можа запампаваць стыкеры ў Telegram.\n\n          Паўтарыце спробу пазней.\n  copy:\n    enter: |\n      Я магу яго скапіяваць, але давайце спачатку створым новы пак\n    progress: |\n      Капіяванне пака з <a href=\"${originalLink}\">${originalTitle}</a> у <a href=\"${link}\">${title}</a>\n\n      Ход выканання: ${current}/${total}\n    done: |\n      Капіяванне пака з <a href=\"${originalLink}\">${originalTitle}</a> у <a href=\"${link}\">${title}</a> паспяхова завершана.\n    pay: |\n      <b>Канвертацыя пака</b>\n\n      Кошт канвертацыі аднаго тыпу пака ў іншы: 1 крэдыт\n\n      <b>Бягучы баланс:</b> ${balance} кр.\n\n      Купіць крэдыты: /donate\n    pay_btn: '✅ Пацвердзіць'\n    error:\n      premium: |\n        <b>Памылка!</b>\n        Гэтая функцыя даступная толькі для данатараў.\n\n        Каб стаць данатарам, адпраўце каманду /donate.\n  original:\n    enter: |\n      Адпраўце стыкер, які быў дададзены праз гэтага бота, і я пакажу арыгінальны стыкер.\n    error:\n      not_found: |\n        <b>Памылка!</b>\n        Не атрымалася знайсці арыгінальны стыкер.\n  delete:\n    enter: |\n      Адпраўце стыкер, які быў дададзены праз гэтага бота, і я яго выдалю з пака.\n    confirm: |\n      Вы ўпэўненыя, што хочаце выдаліць гэты стыкер?\n    error:\n      not_found: |\n        <b>Памылка!</b>\n        Не атрымалася знайсці стыкер.\n  rename:\n    enter_name: |\n      <b>Увядзіце новую назву для пака <a href=\"${link}\">${title}</a>:</b>\n    success: |\n      <b>Назва паспяхова зменена!</b>\n\n      Новая назва: <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      ❕ Каб прыбраць прыпіску «<code>${titleSuffix}</code>», вы павінны забусціць пак. Даведацца больш можна, адправіўшы каманду /donate\n  packAbout:\n    enter: |\n      <b>Адпраўце мне стыкер або эмодзі, каб прагледзець інфармацыю пра яго:</b>\n    not_found: |\n      Не атрымалася знайсці стыкер\n    result: |\n      <b>📦 Пак:</b> <a href=\"${link}\">${name}</a>\n      🆔 <code>${setId}</code> <i>(унікальны нумар пакаў уладальніка, павялічваецца з кожным пакам)</i>\n\n      🧑‍🎨 ID уладальніка: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 Іншыя пакі ад гэтага ўладальніка:</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>Інфармацыя пра іншыя пакі гэтага ўладальніка адсутнічае</i>\n  boost:\n    sure: |\n      <b>Вы ўпэўненыя, што хочаце забусціць пак <a href=\"${link}\">${title}</a>?</b>\n\n      Буст павялічыць прыярытэт пака пры апрацоўцы і дасць магчымасць дадаваць у чаргу больш за адзін стыкер\n      Больш падрабязную інфармацыю пра бусты можна знайсці па камандзе /donate\n\n      <b>Кошт:</b> 1 кр.\n      <b>Бягучы баланс:</b> ${balance} кр.\n    btn:\n      yes: Так, забусціць!\n      no: Не, скасаваць\n    canceled: |\n      Буст скасаваны\n    success: |\n      Буст паспяхова завершаны!\n\n      Пак ${title} цяпер забушчаны\n    error:\n      not_enough_credits: |\n        У вас недастатковая колькасць крэдытаў для бусту.\n\n        Вы можаце папоўніць баланс, адправіўшы каманду /donate.\n      already_boosted: |\n        Гэты пак ужо забушчаны.\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>Адпраўце сюды стыкер з пака, які вы хочаце апублікаваць</b>\n\n        <i>Вы можаце апублікаваць любы пак, які належыць вам, нават калі пак быў створаны не праз гэтага бота</i>\n      owner_proof: |\n        <b>Каб пацвердзіць тое, што вы валодаеце гэтым пакам, выканайце некалькі простых крокаў:</b>\n        1. Запусціце бота @Stickers\n        2. Адпраўце туды каманду <code>/packstats</code>\n        3. Знайдзіце і выберыце патрэбны пак\n        4. Перашліце атрыманае паведамленне нашаму боту\n      publish_new_access_denied: |\n        <b>Памылка!</b>\n        Гэты пак вам не належыць.\n\n        Вы можаце апублікаваць толькі свае пакі\n      banned: |\n        <b>Памылка!</b>\n        Вам забаронена выкарыстоўваць гэтую функцыю.\n        Калі ласка, звярніцеся да адміністратара.\n      enter: |\n        Вы хочаце апублікаваць пак «<a href=\"${link}\">${title}</a>» у публічным каталозе нашага бота\n        Яго зможа знайсці любы карыстальнік па назве, па тэгам або праз фільтр\n        Дзякуючы гэтаму больш людзей усталююць ваш пак\n        Старайцеся публікаваць толькі высакаякасныя пакі, якія могуць зацікавіць шматлікіх людзей\n\n        <b>Правілы публікацыі пакаў:</b>\n        • Не публікуйце пакі, прызначаныя для вузкага круга людзей — напрыклад, твары сваіх сяброў або цытаты з вашых паведамленняў\n        • Не публікуйце стыкеры, якія парушаюць законы ЕС і/або іншыя мясцовыя законы\n\n        Вам будзе патрэбна даць дадатковую інфармацыю, каб яна была апублікавана ў каталозе\n      continue_button: Працягнуць\n      enter_description: |\n        <b>Коратка апішыце свой пак, каб іншыя маглі яго знайсці</b>\n\n        <i>Вы таксама можаце выкарыстаць хэшэтэгі [#]</i>\n        <i>Напрыклад: #анімэ #мемы #жывёлы #любасць #kpop #смешна #кот #гульня </i>\n      select_language: |\n        <b>Выберыце мову вашага пака:</b>\n        <i>Вы можаце выбраць некалькі моў</i>\n      button_all_languages: Усе мовы\n      button_confirm_language: Пацвердзіць\n      set_safe: |\n        <b>Ці бяспечны ваш пак для карыстальнікаў?</b>\n        <i>То-бок не змяшчае эратычнага ці іншага кантэнту, які можа шакіраваць</i>\n      button_safe:\n        safe: Так, пак бяспечны\n        not_safe: Не, пак не бяспечны\n      no_tags: не былі вызначаны\n      confirm: |\n        <b>Пацвердзіце публікацыю пака «<a href=\"${link}\">${title}</a>»</b>\n\n        <b>Апісанне:</b> <i>${description}</i>\n        <b>Тэгі:</b> ${tags}\n        <b>Мовы:</b> ${languages}\n      button_confirm: '✅ Пацвердзіць публікацыю'\n      success: |\n        Віншуем з публікацыяй пака ў публічным каталозе нашага бота, дзе яго могуць знайсці іншыя карыстальнікі!\n\n        Вы можаце адрэдагаваць пошукавую інфармацыю, выбраўшы пак з дапамогай каманды /packs.\n\n        <i>Нагадваем, што статыстыку вашага пака можна прагледзець у афіцыйным боце @Stickers</i>\n    unpublish:\n      success: |\n        Пак паспяхова выдалены з каталога бота.\n  delete_pack:\n    enter: |\n      Вы ўпэўненыя, што хочаце выдаліць пак <a href=\"${link}\">${title}</a>?\n      Ён будзе выдалены назаўжды, і яго будзе нельга аднавіць.\n\n      Калі вы хочаце выдаліць толькі адзін стыкер, выкарыстайце каманду /delete.\n\n      Адпраўце каманду <code>${confirm}</code>, каб пацвердзіць выдаленне ўсяго пака.\n    confirm: Так, я абсалютна ўпэўнены.\n    success: |\n      <b>Пак паспяхова выдалены!</b>\n    error:\n      - <b>Памылка!</b>\n      - Ой, нешта пайшло не так.\n  frame:\n    no_video: |\n      <b>Памылка!</b>\n      Дадаць рамкі можна толькі да відэапакаў.\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>Выберыце тып рамкі:</b>\n      Рамка — гэта празрысты фон вакол стыкера\n\n      <code>Лёгкая</code> — вуглы будуць трохі абрэзаны\n      <code>Сярэдняя</code> — вуглы будуць абрэзаны дужэй\n      <code>Акругленая</code> — вуглы будуць акруглены\n      <code>Квадратная</code> — ніякія змяненні не будуць прыменены\n      <code>Круглая</code> — рамка прыме форму круга\n\n      <i>Вы таксама можаце выкарыстоўваць камнаду /frame для наладжвання тыпу рамкі</i>\n    types:\n      lite: '1. Лёгкая'\n      medium: '2. Сярэдняя'\n      rounded: '3. Акругленая'\n      square: '4. Квадратная'\n      circle: '5. Круглая'\n    selected: |\n      <b>Выбраная рамка:</b> ${type}\n  photoClear:\n    enter: |\n      Адпраўце <u>фотаздымак</u>, з якога вы хочаце выдаліць фон, і я прышлю файл без фону\n\n      <i>Найлепш працуе з фотаздымкамі; горш працуе з малюнкамі, ілюстрацыямі і г. д.</i>\n    enter_anime: |\n      Адпраўце <u>фотаздымак</u>, з якога вы хочаце выдаліць фон, і я прышлю файл без фону\n\n      <i>Найлепш працуе з анімэ-відарысамі</i>\n    choose_model: |\n      <b>Выберыце мадэль:</b>\n    web_app: WebApp — для фотаздымкаў з людзьмі\n    model:\n      ordinary: Агульная — для фотаздымкаў з людзьмі\n      general: Асноўная — для любых фота\n      anime: Анімэ — для анімэ-відарысаў\n      birefnet_general: Асноўная — для любых фота\n    add_to_set_btn: '🌟 Дадаць у набор'\n    error: |\n      <b>Памылка!</b>\n      Ой, нешта пайшло не так.\n  leave: |\n    Дзеянне скасавана.\n  btn:\n    cancel: '❌ Скасаваць'\nerror:\n  telegram: |\n    <b>Telegram вярнуў памылку!</b>\n    <code>${error}</code>\n  answerCbQuery:\n    telegram: |\n      Telegram вярнуў памылку:\n      ${error}\n  banned: |\n    <b>Памылка!</b>\n    Вам забаронена выкарыстоўваць гэтую функцыю.\n\n    <i>Калі вы лічыце, што гэта памылка, калі ласка, звяжыцеся з адміністратарам: @ly_oBot</i>\n  unknown: |\n    <b>Узнікла невядомая памылка, паўтарыце спробу пазней.</b>\n\n    Калі памылка застанецца, напішыце @Ly_oBot.\n    Калі ласка, не забудзьце назваць бота, у якім узнікла памылка, і падрабязна апішыце праблему ў адным паведамленні.\n"
  },
  {
    "path": "locales/de.yaml",
    "content": "---\nlanguage_name: '🇩🇪 Deutsch'\nname: fStik — Sticker & Emoji\ndescription:\n  long: |\n    Erstelle Sticker und Emojis aus Fotos, Videos und GIFs – keine manuelle Konvertierung nötig, der Bot erledigt alles.\n\n    Funktionen:\n    • Paket-Verwaltung\n    • Video-Sticker & benutzerdefinierte Emojis\n    • Originale herunterladen\n    • Zu Bild konvertieren\n    • Sticker-Katalog\n\n    Sticker suchen: play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    Erstelle Sticker und Emojis aus Fotos, Videos, GIFs. Sticker-Katalog und Suche. 🇺🇦\nratelimit: Nicht so oft!\ncmd:\n  start:\n    enter: |\n      🧙 Hallo, ${name}! Ich bin der Emoji- und Sticker-Pack-Zauberer.\n      Ich kann deine Fotos, Videos und GIFs mit nur wenigen Klicks in coole Sticker verwandeln.\n\n      Sende den /help-Befehl, um mehr darüber zu erfahren, was ich tun kann.\n\n      💬 Brauchst du Hilfe? Trete unserer Supportgruppe @fStikCommunity bei (nur Englisch).\n    group: |\n      🧙 Hallo, ${groupTitle}! Ich bin der Emoji- und Stickerpack-Zauberer.\n\n      Um einen Sticker zu einem Gruppenset hinzuzufügen, verwende den /ss-Befehl als Antwort auf ein Foto, Video, Gif oder Sticker.\n    catalog: |\n      <b>😻 Du kannst neue Stickerpacks in unserem Katalog finden</b>\n\n      • Klicke auf den Button unten, um Zugang zu einem riesigen Katalog von Stickerpacks für jeden Geschmack zu erhalten\n      • Suche nach Schlüsselwörtern oder in vorbereiteten Tabs\n      • Vergiss nicht zu bewerten, um das Stickerpack in den Rankings zu fördern oder zu senken\n    commands:\n      ss: '🌟 Sticker speichern'\n      start: '📜 Startmenü'\n      help: '📖 Hilfe'\n      packs: '📁 Packs verwalten'\n      new: '🌝 Stickerpack erstellen'\n      catalog: '📖 Katalog'\n      publish: '📤 Pack veröffentlichen'\n      delete: '❌ Sticker löschen'\n      original: '🔍 Original-Sticker finden'\n      restore: '🔀 Ein Pack wiederherstellen'\n      copy: '📋 Ein Pack kopieren'\n      emoji: '📝 Emoji-Suffix ändern'\n      round: '🎥 Abgerundetes Video'\n      clear: '🖼️ Hintergrund eines Fotos entfernen'\n      about: '📦 Pack-Info'\n      user_about: '🧑‍🎨 Erstellerinfo'\n      lang: '🌐 Sprache ändern'\n      report: '🚨 Pack melden'\n      donate: '☕️ Entwickler unterstützen'\n      add_to_group: '👥 Zur Gruppe hinzufügen'\n      privacy: '🔒 Datenschutzrichtlinie'\n    btn:\n      new: '📥 Neu erstellen'\n      catalog: '💖 Katalog öffnen'\n      catalog_mini: '💖 Katalog'\n      catalog_browser: '🌐 Im Browser öffnen'\n      catalog_browser_mini: '🌐 Im Browser'\n      catalog_app: '📱 Android-App herunterladen'\n      catalog_app_mini: '📱 Android-App'\n  inline:\n    switch_pm: '📁 Pack auswählen'\n  restore: |\n    <b>🗃 Pack-Wiederherstellung</b>\n\n    Um einen Pack wiederherzustellen, musst du mir einen Link zu dem Pack senden, das du wiederherstellen möchtest\n  copy: |\n    <b>🗄 Paket kopieren</b>\n\n    Um ein anderes Pack zu einem neuen zu kopieren, musst du mir einen Link zu einem Sticker- oder Emojipaket senden\n  report: |\n    <b>🚨 Bericht</b>\n\n    Falls du auf ein Stickerpack triffst, das möglicherweise gegen das Gesetz verstößt oder gegen die Nutzungsbedingungen von Telegram verstößt, melde es bitte, indem du den Link an @StickersReportBot sendest\n\n    <i>Denke daran, dass der Bot nicht für den Inhalt der Packs verantwortlich ist und keine Kontrolle darüber hat</i>\n  packs:\n    info: |\n      <b>📁 Packs</b>\n    types:\n      regular: Sticker\n      custom_emoji: Emojis\n      inline: Inline\n    empty: |\n      <b>Du hast noch keine Packs.</b>\n      Zum Erstellen schreibe den Befehl /new\n    inline_title: Inline-Pack\n    select_group_pack_info: |\n      <b>📁 Pack auswählen</b>\n\n      Um das Pack in der Gruppe zu verwenden, müssen Administratoren es mit dem Button unten auswählen\n    select_group_pack: Pack auswählen\n  emoji:\n    info: |\n      Um das Standard-Emoji für das aktuelle Pack zu ändern, sende <code>/emoji</code> gefolgt von dem Emoji, getrennt durch ein Leerzeichen\n\n      Zum Beispiel - <code>/emoji 🌟</code>\n    done: Emoji erfolgreich geändert.\n    error: Beim Ändern von Emoji ist ein Fehler aufgetreten!\n  round_video:\n    enabled: |\n      Videos haben jetzt eine runde Form\n    disabled: |\n      Videos haben jetzt keine runde Form mehr\n  paysupport: |\n    <b>👨‍💻 Zahlungssupport</b>\n\n    Bei allen Fragen im Zusammenhang mit dem Betrieb des Bots, einschließlich Zahlungen und Spenden, können Sie sich direkt an den Entwickler wenden\n\n    <b>Kontakte:</b>\n    🧑‍💻 Entwickler: @ly_oBot\ndonate:\n  menu: |\n    <b>☕️ Unterstützung für die Bot-Entwicklung</b>\n    Durch die Unterstützung der Bot-Entwicklung erhalten Sie Credits\n\n    <b>Saldo:</b> <code>${balance}</code> Credits\n    Mit 1 Credits kannst du ein Pack boosten.\n\n    <b>Das Boost bietet folgende Vorteile:</b>\n    ➖ Deaktivierung von \"<code>${titleSuffix}</code>\" im Pack-Namen <i>(nicht im Link)</i>\n    ➖ Titel bis zu 64 Zeichen (statt 35)\n    ➖ Videosticker bis zu 35 Sekunden\n    ➖ Priorität bei der Konvertierungswarteschlange\n    ➖ Mehrere Sticker gleichzeitig\n    ➖ Keine Werbung\n\n    <b>Wählen Sie die Anzahl der Credits aus, die Sie kaufen möchten:</b>\n  btn:\n    donate: '☕ Spenden'\n  topup: |\n    <b>Geben Sie die Anzahl der Credits ein, die Sie kaufen möchten:</b>\n  invalid_amount: |\n    <b>Ungültiger Betrag</b>\n\n    Der Mindestbetrag beträgt 1 Credit\n  paymenu: |\n    Du möchtest <b>${amount} Credits</b> für <b>${price}$</b> kaufen\n\n    ⚠️ Credits werden manuell vom Administrator vergeben.\n    Die Wartezeit reicht von 5 Minuten bis zu 1 Stunde\n\n    <u>Zahlungsmethode auswählen:</u>\n  description: |\n    Wenn Sie Credits kaufen, unterstützen Sie die Entwicklung des Bots und erhalten die Möglichkeit, zusätzliche Funktionen zu nutzen\n  update: |\n    <b>🔄 Saldo-Update</b>\n\n    Saldo: <code>${balance}</code> Credits (hinzugefügt <code>${amount}</code> Credits)\n  error:\n    already_donated: |\n      Du hast für diese Zahlung bereits Credits erhalten\n    error: |\n      <b>Fehler!</b>\n      Beim Verarbeiten der Zahlung ist ein Fehler aufgetreten\n    canceled: |\n      Zahlung storniert\ncoedit:\n  info: |\n    <b>👥 Gemeinsame Bearbeitung</b>\n\n    Link zur gemeinsamen Bearbeitung <a href=\"${link}\">${title}</a>: <code>${colink}</code>\n\n    <b>Wie benutzt man:</b>\n    1. Sende den Link an die Person, der du Zugriff auf das Pack geben möchtest\n    2. Nach dem Klicken auf den Link müssen sie \"start\" drücken und sie werden zu den Editoren hinzugefügt\n    3. Der Editor kann Sticker im Pack hinzufügen, löschen und bearbeiten\n\n    <b>Editoren:</b>\n    ${editors}\n\n    <i>Um Editoren zu entfernen, musst du den Link zurücksetzen</i>\n  no_editors: |\n    Noch keine Editoren\n  btn:\n    send: '📤 Link senden'\n    reset: '🔁 Link zurücksetzen'\n  share: |\n    Folge dem Link und drücke \"start\", um das Pack \"${title}\" gemeinsam zu bearbeiten\n  reset: |\n    <b>🔁 Link erfolgreich zurückgesetzt</b>\n\n    Neuer Link zur gemeinsamen Bearbeitung <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: Pack nicht gefunden\n      not_owner: Das ist nicht dein Paket\n      hidden: Pack erfolgreich verborgen\n      restored: Pack erfolgreich wiederhergestellt\n    set_pack: |\n      🌟 Ausgewähltes <a href=\"${link}\">${title}</a> Pack\n\n      <b>❔ Wie hinzuzufügen?</b>\n      Sende ein Foto, Video oder Sticker, um es dem Pack hinzuzufügen\n    set_inline_pack: |\n      Ausgewähltes <u>${title}</u> Pack\n\n      Um es zu verwenden, schreibe in einem Chat <code>@${botUsername} </code> und Leerzeichen\n      Du kannst es auch verwenden, indem du auf den Button unten drückst\n    boost:\n      info: |\n\n        ⚡ <b><a href=\"https://t.me/${botUsername}?start=boost\">Boost</a></b>: ${boostStatus}\n      status:\n        on: Eingeschaltet\n        off: Ausgeschaltet\n    hidden: Pack <a href=\"${link}\">${title}</a> aus deiner Liste verborgen.\n    restored: Pack <a href=\"${link}\">${title}</a> in deiner Liste wiederhergestellt.\n    btn:\n      hide: '❌ Pack verbergen'\n      delete: '🗑 Pack löschen'\n      restore: '✅ Wiederherstellen'\n      use_pack: '📦 Pack verwenden'\n      boost: '⚡ Boost'\n      frame: '🖼 Rahmen'\n      rename: '✏️ Umbenennen'\n      search_gif: '🔎 GIF suchen'\n      coedit: '👥 Co-editieren'\n      catalog_add: '🗂 Zum Katalog hinzufügen'\n      catalog_edit: '📝 Im Katalog bearbeiten'\n      catalog_delete: '🗑 Aus dem Katalog löschen'\n      catalog_share: '🔗️️ Teilen'\n      catalog_open: '📂 Im Katalog öffnen'\n    error:\n      not_found: |\n        Fehler!\n        Kann keinen Sticker finden.\n      invalid_png: |\n        Fehler!\n        Die Datei ist kein gültiges PNG-Bild. Bitte konvertieren Sie sie in das PNG-Format, bevor Sie sie senden.\n      invalid_dimensions: |\n        Fehler!\n        Die Stickerabmessungen sind ungültig. Sticker müssen 512x512 Pixel betragen.\n      invalid_animated: |\n        Fehler!\n        Die animierte Sticker-Datei hat nicht das richtige TGS-Format.\n      invalid_video: |\n        Fehler!\n        Die Videodatei hat nicht das richtige WEBM-Format.\n      restore: |\n        Fehler!\n        Kann Paket nicht wiederherstellen.\n      copy: |\n        Fehler!\n        Paket nicht gefunden.\n    select_group:\n      success: |\n        Pack <a href=\"${link}\">${title}</a> erfolgreich für die Gruppe ausgewählt.\n      access_rights:\n        add: Wer darf Sticker zum Gruppenpak hinzufügen?\n        delete: Wer darf Sticker aus dem Gruppenpak löschen?\n        rights:\n          all: Jeder\n          admins: Nur Admins\n      error: |\n        Fehler!\n        Set nicht gefunden.\n  sticker:\n    answerCbQuery:\n      delete: Der Sticker wurde erfolgreich aus dem Pack entfernt.\n      restored: Der Sticker wurde erfolgreich dem aktuellen Pack hinzugefügt.\n    delete: Der Sticker wurde erfolgreich aus dem Pack entfernt.\n    restored: Der Sticker wurde erfolgreich dem aktuellen Pack hinzugefügt.\n    btn:\n      delete: '🗑 Löschen'\n      copy: '🌟 Kopieren'\n      restore: '✅ Wiederherstellen'\n    error:\n      not_found: |\n        Fehler!\n        Sticker nicht gefunden.\n      invalid_png: |\n        <b>Fehler!</b>\n        Die Datei ist kein gültiges PNG-Bild. Bitte konvertieren Sie sie in das PNG-Format, bevor Sie sie senden.\n      invalid_dimensions: |\n        <b>Fehler!</b>\n        Die Stickerabmessungen sind ungültig. Sticker müssen 512x512 Pixel betragen.\n      invalid_animated: |\n        <b>Fehler!</b>\n        Die animierte Sticker-Datei hat nicht das richtige TGS-Format.\n      invalid_video: |\n        <b>Fehler!</b>\n        Die Videodatei hat nicht das richtige WEBM-Format.\n  group_settings:\n    success: |\n      Gruppeneinstellungen erfolgreich aktualisiert.\nsticker:\n  add:\n    ok: |\n      <b>Erfolgreich zum Pack hinzugefügt:</b>\n      <a href=\"${link}\">${title}</a>\n\n      Innerhalb einer Stunde wird dieses Pack für alle Benutzer aktualisiert.\n\n      <i>Senden Sie ein oder mehrere Emojis, die zum Sticker passen, wenn Sie sie hinzufügen möchten</i>\n    ok_inline: |\n      <b>Erfolgreich zum Pack hinzugefügt:</b>\n      <u>${title}</u>\n    send_emoji: Großartig, jetzt sende das Emoji, das zum Sticker passt\n    converting_process: |\n      <b>Warten...</b>\n      Ihre Datei steht zur Konvertierung in der Warteschlange. Warten Sie auf den Abschluss. Dies kann einige Zeit dauern.\n\n      Fortschritt: ${progress} / ${total}\n\n      <i>Benutzer, die den Bot unterstützt haben, erhalten Vorrang in der Warteschlange (mehr: /donate)</i>\n    catalog_offer: |\n      <b>😲 Wow, du hast ein tolles Pack erstellt!</b>\n\n      Möchtest du <a href=\"${link}\">${title}</a> zum öffentlichen Stickerkatalog hinzufügen, damit auch andere Benutzer des Bots es sehen können?\n      <i>Es dauert nicht lange</i>\n    quote: |\n      Verwenden Sie @QuotlyBot, um ein Angebot aus dieser Nachricht zu erstellen\n    error:\n      reply: |\n        <b>Fehler!</b>\n        Bitte, antworte auf den Sticker.\n      no_selected_pack: |\n        <b>Du hast kein Pack ausgewählt</b>\n\n        Bitte erstelle (/new) oder wähle (/packs) ein Pack\n      no_selected_group_pack: |\n        <b>Du hast kein Gruppenpack ausgewählt</b>\n\n        Bitte wähle ein Pack mit dem Befehl /packs\n      no_rights: |\n        <b>Fehler!</b>\n        Du hast nicht das Recht, Sticker zu diesem Pack hinzuzufügen.\n      stickers_too_much: |\n        Dieses Pack hat die maximale Anzahl an Stickern.\n\n        Du kannst ein neues Pack mit dem Befehl /new erstellen.\n      have_already: |\n        <b>Dieser Sticker ist bereits in dem Pack</b>\n\n        Wenn du das Emoji ändern möchtest, sende es in der nächsten Nachricht.\n      stickerset_invalid: |\n        <b>Fehler!</b>\n        Bot kann nicht auf dein aktuell ausgewähltes Pack zugreifen.\n\n        Bitte erstelle (/new) oder wähle (/packs) ein anderes Pack.\n      invalid_png: |\n        <b>Fehler!</b>\n        Die Datei ist kein gültiges PNG-Bild. Bitte konvertieren Sie sie in das PNG-Format, bevor Sie sie senden.\n      invalid_dimensions: |\n        <b>Fehler!</b>\n        Die Stickerabmessungen sind ungültig. Sticker müssen 512x512 Pixel betragen.\n      invalid_animated: |\n        <b>Fehler!</b>\n        Die animierte Sticker-Datei hat nicht das richtige TGS-Format.\n      invalid_video: |\n        <b>Fehler!</b>\n        Die Videodatei hat nicht das richtige WEBM-Format.\n      file_type:\n        static: |\n          <b>Fehler!</b>\n          Dieser Dateityp wird nicht unterstützt\n          Du kannst dieses Foto oder den statischen Sticker zum statischen Pack hinzufügen\n\n          <i>Erstelle (/new) oder wähle (/packs) ein anderes Pack</i>\n        video: |\n          <b>Fehler!</b>\n          Dieser Dateityp wird nicht unterstützt\n          Du kannst diese Videodateien zum Videopack hinzufügen\n\n          <i>Erstelle (/new) oder wähle (/packs) ein anderes Pack</i>\n        animated: |\n          <b>Fehler!</b>\n          Dieser Dateityp wird nicht unterstützt\n          Du kannst diese animierten Dateien zum Vektorpack hinzufügen\n\n          <i>Erstelle (/new) oder wähle (/packs) ein anderes Pack</i>\n        unknown: |\n          <b>Fehler!</b>\n\n          Der Dateityp wird nicht unterstützt\n\n          <i>Erstelle (/new) oder wähle (/packs) ein anderes Pack</i>\n      wait_load: |\n        <b>Warten!</b>\n\n        Der Bot verarbeitet noch die vorherige Datei...\n        Du kannst die Bot-Entwicklung unterstützen (/donate), um die Priorität bei der Bearbeitung zu erhöhen und die Möglichkeit zu erhalten, mehr als einen Sticker in die Warteschlange zu stellen.\n      timeout: |\n        <b>Zur Zeit erlebt der Bot eine enorme Belastung</b>\n        Daher ist die Videokonvertierung nur für Packs mit aktivem Boost verfügbar\n\n        Für weitere Informationen folge dem Befehl /donate\n      convert: |\n        <b>Fehler!</b>\n        Leider konnte der Bot dein Video nicht konvertieren.\n\n        Vielleicht ist dein Video in einem Format gespeichert, das dem Bot nicht verständlich ist. Stelle sicher, dass es im MP4-Format ist.\n        Es könnte auch ein interner Fehler des Bots sein, versuche, dieses Video erneut zu senden\n      too_big: |\n        <b>Fehler!</b>\n\n        Die Datei ist zu groß, um verarbeitet zu werden. Bitte reduziere die Qualität und Dauer, bevor du sie sendest.\n      sticker_not_found: |\n        <b>Fehler!</b>\n\n        Dieser Sticker konnte nicht gefunden werden. Bitte stellen Sie sicher, dass er im richtigen Paket ist oder versuchen Sie, ihn erneut hinzuzufügen.\nnews:\n  join: |\n    ℹ️ <a href=\"${link}\">Tritt unserem Kanal bei</a>, um die neuesten Bot-News zu erhalten.\n\n    <i>Abonniere den Kanal, um die neuesten Nachrichten über den Bot sowie Updates und neue Funktionen zu erhalten.</i>\n  join_btn: '📢 Kanal beitreten'\n  not_joined: '🙅 Du bist nicht dem Kanal beigetreten'\n  continue: '✅ Fortfahren'\nuserAbout:\n  help: |\n    <b>🧑‍🎨 Benutzerinfo</b>\n\n    Mit diesem Menü kannst du Informationen über den Benutzer und seine Stickerpacks herausfinden\n\n    Um Informationen über den Benutzer zu erhalten, verwenden Sie den Button unten oder leiten Sie seine Nachricht weiter\n  result: |\n    <b>🧑‍🎨 Benutzerinfo</b>\n    <b>🆔 Benutzer-ID:</b> <code>${userId}</code>\n    <b>🎨 Packs von diesem Benutzer:</b>\n    ${packs}\n  no_packs: |\n    <i>Wir haben keine Informationen über die Sticker dieses Besitzers</i>\n  forward_hidden: |\n    Der Benutzer hat die Möglichkeit zum Weiterleiten von Nachrichten verborgen. Verwenden Sie den Button unten, um seine Stickerpacks anzusehen.\n  select_user: '🧑‍🎨 Benutzer auswählen'\nscenes:\n  new_pack:\n    pack_type: |\n      <b><u>Pack-Typ wählen</u></b>\n    regular: '😊 Sticker'\n    custom_emoji: '🌟 Emoji (Premium)'\n    static: '🌟 Statisch'\n    animated: '✨ Vektor'\n    video: '📹 Video'\n    pack_format: |\n      <b><u>Pack-Typ wählen</u></b>\n\n      <b>Allgemein</b> - statisch (nicht beweglich), Raster, Dateiformat - vor dem Hinzufügen PNG (der Bot verarbeitet), nach dem Hinzufügen - WEBP.\n      Ein Beispiel für ein reguläres Pack - t.me/addstickers/Animals\n\n      <b>Video</b> - animiertes Video-Pack. Du kannst beliebige Videos, GIFs und Fotos hinzufügen.\n      Beispielvideo-Pack - t.me/addstickers/TheMascot\n\n      <b>Animiert</b> - animiert, Vektor (sie haben eine genaue Beschreibung der Objekte innerhalb der Datei, wodurch sie bei jeder Skalierung klar dargestellt werden), Dateiformat - TGS, eine Variation des Lottie-Formats.\n      Ein Beispiel für ein animiertes Pack - t.me/addstickers/IsabelleShizue\n\n      <i>Animierte und Video-Sticker-Sets können bis zu 50 Sticker enthalten. Statische Sticker-Sets können bis zu 120 Sticker enthalten.</i>\n    pack_title: |\n      <b>Gebe einen neuen Stickernamen ein:</b>\n      <i>Du kannst durch den Knopf einen zufälligen Namen wählen.</i>\n    pack_name: |\n      <b>Gebe einen kurzen Link für das neue Pack ein:</b>\n\n      <i>Zum Beispiel verwendet dieses Pack 'Animals' als Kurzlink: https://t.me/addstickers/<u>Animals</u></i>\n      <i>Du kannst durch den Knopf einen zufälligen Kurzlink wählen.</i>\n    ok: |\n      Pack <a href=\"${link}\">${title}</a> erfolgreich erstellt!\n\n      <b>Pack-Link:</b> <pre>${link}</pre>\n\n      Sende eine Datei, ein Foto, ein Video oder einen Sticker, damit ich es deinem Set hinzufüge\n    error:\n      title_long: Name kann nicht mehr als ${max} Zeichen enthalten.\n      name_long: Adresse kann nicht mehr als ${max} Zeichen enthalten.\n      telegram:\n        name_invalid: Diese Adresse kann nicht verwendet werden.\n        name_occupied: Diese Adresse ist bereits vergeben.\n        upload_failed: |\n          <b>Fehler!</b>\n          Bot kann keine Sticker zu Telegram hochladen.\n\n          Bitte versuche es später erneut.\n  copy:\n    enter: |\n      Ich kann es kopieren, aber vorher lass uns ein neues Pack erstellen\n    progress: |\n      Kopieren des Packs von <a href=\"${originalLink}\">${originalTitle}</a> nach <a href=\"${link}\">${title}</a>\n\n      Fortschritt: ${current}/${total}\n    done: |\n      Pack-Kopieren von <a href=\"${originalLink}\">${originalTitle}</a> nach <a href=\"${link}\">${title}</a> erfolgreich abgeschlossen.\n    pay: |\n      <b>Pack-Konvertierung</b>\n\n      Das Konvertieren eines Pakets von einem Format in ein anderes kostet 1 Credit\n\n      <b>Aktueller Saldo:</b> ${balance} Credits\n\n      Credits kaufen: /donate\n    pay_btn: '✅ Bestätigen'\n    error:\n      premium: |\n        <b>Fehler!</b>\n        Diese Funktion ist nur für Spendermitglieder verfügbar.\n\n        Du kannst es tun, indem du den Befehl /donate sendest.\n  original:\n    enter: |\n      Sende den Sticker, der durch diesen Bot hinzugefügt wurde, und ich zeige dir seinen ursprünglichen Sticker.\n    error:\n      not_found: |\n        <b>Fehler!</b>\n        Ich konnte den Originalsticker nicht finden.\n  delete:\n    enter: |\n      Senden Sie den Sticker, der über diesen Bot hinzugefügt wurde, und ich werde ihn aus dem Paket löschen.\n    confirm: |\n      Sind Sie sicher, dass Sie diesen Sticker löschen möchten?\n    error:\n      not_found: |\n        <b>Fehler!</b>\n        Ich konnte den Sticker nicht finden.\n  rename:\n    enter_name: |\n      <b>Geben Sie einen neuen Titel für <a href=\"${link}\">${title}</a> ein:</b>\n    success: |\n      <b>Titel erfolgreich geändert!</b>\n\n      Neuer Titel: <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      ❕ Um das Suffix \"<code>${titleSuffix}</code>\" zu entfernen, müssen Sie das Paket boosten. Mehr Details im Menü unter: /donate\n  packAbout:\n    enter: |\n      <b>Senden Sie mir einen Sticker oder ein benutzerdefiniertes Emoji, um Informationen darüber zu suchen:</b>\n    not_found: |\n      Ich konnte den Sticker nicht finden\n    result: |\n      <b>📦 Paket:</b> <a href=\"${link}\">${name}</a>\n      🆔 <code>${setId}</code> <i>(Eindeutige Nummer für die Pakete des Besitzers, pro Paket erhöht)</i>\n\n      🧑‍🎨 Besitzer-ID: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 Andere Pakete von diesem Besitzer:</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>Wir haben keine Informationen über andere Sticker dieses Besitzers</i>\n  boost:\n    sure: |\n      <b>Sind Sie sicher, dass Sie <a href=\"${link}\">${title}</a> boosten möchten?</b>\n\n      Das Boosten wird die Priorität der Verarbeitung erhöhen und die Möglichkeit bieten, mehr als einen Sticker zur Warteschlange hinzuzufügen.\n      Detaillierte Informationen über Boosts finden Sie im Menü unter: /donate\n\n      <b>Preis:</b> 1 Credits\n      <b>Aktuelles Guthaben:</b> ${balance} Credits\n    btn:\n      yes: Ja, boosten!\n      no: Nein, abbrechen\n    canceled: |\n      Boosting abgebrochen\n    success: |\n      Boosting erfolgreich abgeschlossen!\n\n      ${title} ist jetzt geboostet\n    error:\n      not_enough_credits: |\n        Sie haben nicht genug Credits, um dieses Paket zu boosten.\n\n        Sie können Ihr Guthaben aufladen, indem Sie den Befehl /donate senden.\n      already_boosted: |\n        Dieses Paket ist bereits geboostet.\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>Senden Sie mir den Sticker aus dem Paket, das Sie veröffentlichen möchten</b>\n\n        <i>Sie können jedes Paket veröffentlichen, das Ihnen gehört, auch wenn es anderswo erstellt wurde</i>\n      owner_proof: |\n        <b>Um die Besitzverhältnisse dieses Pakets zu überprüfen, müssen Sie einige einfache Schritte befolgen:</b>\n        1. Öffnen Sie den Bot @Stickers\n        2. Senden Sie den Befehl <code>/packstats</code>\n        3. Finden und wählen Sie das benötigte Paket aus\n        4. Leiten Sie die erhaltene Nachricht an den Bot weiter\n      publish_new_access_denied: |\n        <b>Fehler!</b>\n        Dieses Paket gehört nicht Ihnen.\n\n        Sie können nur Ihre eigenen Pakete veröffentlichen\n      banned: |\n        <b>Fehler!</b>\n        Sie sind von der Nutzung dieser Funktion ausgeschlossen.\n        Bitte kontaktieren Sie den Administrator.\n      enter: |\n        Sie sind dabei, das „<a href=\"${link}\">${title}</a>“ Paket in unserem öffentlichen Verzeichnis des Bots zu veröffentlichen.\n        Es kann von jedem Bot-Benutzer nach Name, Tags oder Filter gefunden werden.\n        Da durch werden mehr Leute Ihr Paket installieren.\n        Versuchen Sie nur qualitativ hochwertige Pakete zu senden, die für eine große Anzahl von Menschen von Interesse sein können.\n\n        <b>Regeln für das Veröffentlichen von Paketen:</b>\n        • Veröffentlichen Sie keine persönlichen Pakete, die für einen engen Personenkreis gedacht sind. Zum Beispiel Gesichter Ihrer Freunde oder Zitate aus Ihren Nachrichten.\n        • Veröffentlichen Sie keine Sticker, die gegen EU-Rechtsvorschriften oder andere lokale Gesetze verstoßen.\n\n        Sie müssen zusätzliche Informationen einreichen, damit es im Katalog veröffentlicht werden kann.\n      continue_button: Fortfahren\n      enter_description: |\n        <b>Beschreiben Sie Ihr Paket kurz, damit andere es finden können</b>\n\n        <i>Sie können auch Hashtags verwenden, um es zu kategorisieren [#]</i>\n        <i>Zum Beispiel: #anime #meme #tiere #niedlich #kpop #lustig #katze #spiel </i>\n      select_language: |\n        <b>Wählen Sie die Sprachen aus, für die Ihr Paket gedacht ist:</b>\n        <i>Sie können mehrere Sprachen auswählen</i>\n      button_all_languages: Alle Sprachen\n      button_confirm_language: Bestätigen\n      set_safe: |\n        <b>Ist Ihr Paket sicher für Benutzer?</b>\n        <i>Das heißt, es enthält keine Erotik oder anderen schockierenden Inhalte</i>\n      button_safe:\n        safe: Ja, es ist sicher\n        not_safe: Nein, es ist nicht sicher\n      no_tags: wurden nicht angegeben\n      confirm: |\n        <b>Bestätigen Sie die Veröffentlichung des Pakets \"<a href=\"${link}\">${title}</a>\"</b>\n\n        <b>Beschreibung:</b> <i>${description}</i>\n\n        <b>Tags:</b> ${tags}\n        <b>Sprachen:</b> ${languages}\n      button_confirm: '✅ Veröffentlichung bestätigen'\n      success: |\n        Herzlichen Glückwunsch, Ihr Paket wurde im allgemeinen Verzeichnis unseres Bots veröffentlicht, wo andere Benutzer es finden können!\n\n        Sie können die Suchinformationen des Pakets bearbeiten, indem Sie das Paket mit dem Befehl /packs auswählen.\n\n        <i>Wir erinnern Sie daran, dass Sie die Statistiken Ihres Pakets über den offiziellen Bot @Stickers einsehen können</i>\n    unpublish:\n      success: |\n        Das Paket wurde erfolgreich aus dem Bot-Katalog entfernt.\n  delete_pack:\n    enter: |\n      Bist du sicher, dass du das Paket <a href=\"${link}\">${title}</a> löschen möchtest?\n      Es wird dauerhaft gelöscht und kann nicht wiederhergestellt werden.\n\n      Wenn du nur einen Sticker löschen möchtest, verwende den Befehl /delete.\n\n      Sende <code>${confirm}</code>, um zu bestätigen, dass du dieses Paket wirklich löschen möchtest.\n    confirm: Ja, ich bin völlig sicher.\n    success: |\n      <b>Paket erfolgreich gelöscht!</b>\n    error:\n      - <b>Fehler!</b>\n      - Ups, etwas ist schiefgelaufen.\n  frame:\n    no_video: |\n      <b>Fehler!</b>\n      Du kannst nur Frames zu Videopaketen hinzufügen.\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>Rahmentyp wählen:</b>\n      Der Rahmen ist ein transparenter Hintergrund um den Sticker.\n\n      <code>lite</code> — die Ecken werden ein wenig abgeschnitten.\n      <code>medium</code> — die Ecken werden mehr abgeschnitten.\n      <code>rounded</code> — die Ecken werden abgerundet.\n      <code>square</code> — die rechteckige Form des Rahmens, d.h. es wird nicht verändert.\n      <code>circle</code> — der Rahmen wird in Form eines Kreises sein.\n\n      <i>In Zukunft kannst du den Befehl /frame verwenden, um den Rahmentyp festzulegen.</i>\n    types:\n      lite: '1. Leicht'\n      medium: '2. Mittel'\n      rounded: '3. Abgerundet'\n      square: '4. Quadratisch'\n      circle: '5. Kreis'\n    selected: |\n      <b>Ausgewählter Rahmentyp:</b> ${type}\n  photoClear:\n    enter: |\n      Senden Sie ein <u>Foto</u>, von dem Sie den Hintergrund entfernen möchten, und ich sende Ihnen die Datei ohne den Hintergrund zurück\n\n      <i>Funktioniert am besten mit Fotos. Funktioniert schlechter mit Zeichnungen, Illustrationen usw.</i>\n    enter_anime: |\n      Senden Sie ein <u>Foto</u>, von dem Sie den Hintergrund entfernen möchten, und ich sende Ihnen die Datei ohne den Hintergrund zurück\n\n      <i>Funktioniert am besten mit Anime-Bildern</i>\n    choose_model: |\n      <b>Modell wählen:</b>\n    web_app: WebApp - für Fotos mit Personen\n    model:\n      ordinary: Gewöhnlich — für Fotos mit Personen\n      general: Allgemein — für alle Fotos\n      anime: Anime — für Anime-Bilder\n      birefnet_general: BirefNet - für alle Fotos\n    add_to_set_btn: '🌟 Zum Set hinzufügen'\n    error: |\n      <b>Fehler!</b>\n      Ups, etwas ist schiefgelaufen.\n  leave: |\n    Aktion abgebrochen.\n  btn:\n    cancel: '❌ Abbrechen'\nerror:\n  telegram: |\n    <b>Telegram hat einen Fehler zurückgegeben!</b>\n    <code>${error}</code>\n  answerCbQuery:\n    telegram: |\n      Telegram hat einen Fehler zurückgegeben:\n      ${error}\n  banned: |\n    <b>Fehler!</b>\n    Du bist von der Nutzung dieser Funktion ausgeschlossen.\n\n    <i>Wenn du denkst, dass dies ein Fehler ist, kontaktiere bitte den Administrator: @ly_oBot</i>\n  unknown: |\n    <b>Ein unbekannter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.</b>\n\n    Wenn das Problem weiterhin besteht, schreiben Sie an @Ly_oBot.\n    Bitte schreiben Sie sofort, um welchen Bot es sich handelt, und beschreiben Sie das Problem in einer Nachricht ausführlich.\n"
  },
  {
    "path": "locales/en.yaml",
    "content": "---\nlanguage_name: '🇺🇸 English'\nname: fStik — Stickers & Emoji\ndescription:\n  long: |\n    Create stickers and emoji from photos, videos, and GIFs. No manual conversion — bot handles everything. Easier than @Stickers.\n\n    Features:\n    • Easy pack management\n    • Video stickers & custom emoji\n    • Download original files\n    • Convert sticker/video/GIF to image\n    • Sticker catalog\n\n    Search stickers: play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    Create stickers and emoji from photos, videos, GIFs. Sticker catalog and search. 🇺🇦\nratelimit: Not so often!\ncmd:\n  start:\n    enter: |\n      Hey ${name}!\n      I create stickers and emoji from photos, videos, and GIFs.\n\n      💬 @fStikCommunity\n    group: |\n      Hey ${groupTitle}! I create stickers and emoji packs.\n\n      To add a sticker to a group pack, use /ss in reply to a photo, video, gif, or sticker.\n    catalog: |\n      <b>🔍 Sticker Catalog</b>\n\n      Search packs by keywords or browse popular ones.\n      Rate packs to help others discover good ones.\n    search_catalog: |\n      <b>🌐 Pack catalog</b>\n\n      Browse other users' packs or publish your own:\n    commands:\n      ss: '🌟 Save sticker'\n      start: '📜 Menu'\n      help: '❓ Help'\n      packs: '📁 My packs'\n      new: '➕ New pack'\n      search_catalog: '🌐 Catalog'\n      catalog: '🌐 Catalog'\n      publish: '📤 Publish'\n      delete: '🗑 Delete sticker'\n      original: '🔎 Find source'\n      restore: '♻️ Restore pack'\n      copy: '📋 Copy pack'\n      emoji: '😀 Change emoji'\n      round: '⭕ Round video'\n      clear: '✂️ Remove background'\n      info: '🔎 Whose sticker'\n      lang: '🌐 Language'\n      report: '🚨 Report'\n      donate: '⭐ Support'\n      add_to_group: '👥 Add to group'\n      privacy: '🔒 Privacy'\n      guide: '❓ Help'\n    btn:\n      new: '➕ New pack'\n      catalog: '🔍 Catalog'\n      catalog_mini: '🔍 Catalog'\n      catalog_browser: '🌐 Open in browser'\n      catalog_browser_mini: '🌐 In browser'\n      catalog_app: '📱 Download Android app'\n      catalog_app_mini: '📱 Android app'\n  guide:\n    web: |\n      <b>📖 How to use fStikBot</b>\n\n      Full guides, tips, and FAQ on our website.\n    menu: |\n      <b>📖 How to use</b>\n\n      Choose a topic:\n    create: |\n      <b>🎨 Create stickers</b>\n\n      1. Send /new to create a pack\n      2. Choose type: regular, video, or emoji\n      3. Send photos, videos, or GIFs\n      4. Done! Bot converts everything automatically\n\n      <b>Useful commands:</b>\n      • /clear — remove background from photo\n      • /round — convert video to circle (video note)\n      • /frame — set video sticker shape:\n        └ lite, medium, rounded, square, circle\n\n      <b>💡 Tip:</b> Send PNG as <b>File</b> (📎) to keep transparency. \"Photo\" mode compresses and removes it.\n    manage: |\n      <b>📁 Manage packs</b>\n\n      <b>Commands:</b>\n      • /packs — your packs list\n      • /delete — remove a sticker\n      • /copy — copy any pack\n      • /restore — recover hidden packs\n      • /original — find original sticker\n      • /about — pack and creator info\n\n      <b>Change emoji:</b>\n      Send a sticker → send new emoji.\n      Or send emoji right after adding.\n\n      <b>Default pack emoji:</b>\n      /emoji 🔥 — all new stickers get this.\n\n      <b>In groups:</b>\n      /ss (reply to media) — quick add to group pack.\n\n      <b>Inline packs (For search):</b>\n      A separate pack type for quick access via @botname in any chat.\n      • Not a real Telegram pack — stored in bot only\n      • Can contain stickers, photos, GIFs, videos\n      • Create in /packs → \"For search\" tab\n      • Also has GIF search mode (via Tenor)\n\n      <b>Co-edit (share pack editing):</b>\n      Tap pack → Co-edit → get a link.\n      Anyone with link can add/delete stickers in your pack.\n      Reset link to remove all editors.\n\n      <b>Pack options:</b>\n      Tap any pack in /packs → rename, boost, co-edit, delete.\n    catalog: |\n      <b>🔍 Find & share packs</b>\n\n      • /catalog — browse popular packs\n      • /publish — share your pack publicly\n      • Rate packs ⭐ to help others discover good ones\n\n      Good packs get more visibility. Yours can go viral 🔥\n    boost: |\n      <b>⚡ Boost & extras</b>\n\n      <b>Boost benefits:</b>\n      • No \"${titleSuffix}\" suffix in pack name\n      • Priority video processing\n      • Longer videos (up to 30 sec)\n      • Add multiple stickers at once\n\n      <b>Hidden feature:</b>\n      /new fill — create adaptive emoji (changes color with text).\n\n      Get boost → /donate\n    problems: |\n      <b>❓ FAQ & Fixes</b>\n\n      <b>⚫ Background turned black?</b>\n      Telegram removes transparency when you send as \"Photo\".\n      → Send PNG as <b>File</b> (📎 paperclip icon).\n\n      <b>🔄 Changes not showing?</b>\n      Telegram caches stickers. Wait ~1 hour or restart the app.\n\n      <b>📹 Video not animating?</b>\n      Likely a weird format. Try converting to MP4 or send as File instead of Video.\n\n      <b>🗑 How to delete a sticker?</b>\n      Send /delete, then tap the sticker you want to remove.\n\n      <b>🤏 Sticker too small?</b>\n      Crop your image to a square (1:1) before sending.\n\n      <b>🔗 How to remove \"_by_fStikBot\" from link?</b>\n      You can't. Telegram requires this suffix to identify which bot created the pack. It's a platform rule, not ours.\n\n      <b>💎 Do I need Premium?</b>\n      • Create packs: Free\n      • Use stickers: Free\n      • Send custom emoji: Premium only (Telegram rule)\n\n      <b>Still stuck?</b> @fStikCommunity\n    btn:\n      open: '📖 Open Guide'\n      create: '🎨 Create stickers'\n      manage: '📁 Manage packs'\n      catalog: '🔍 Find & share'\n      boost: '⚡ Boost & extras'\n      problems: '❓ Problems?'\n      back: '← Back'\n  inline:\n    switch_pm: '📁 Select pack'\n  lang:\n    choose: |\n      🌐 Choose language\n\n      Help with translation: https://crwd.in/fStikBot\n  restore: |\n    <b>♻️ Restore pack</b>\n\n    Send link to the pack you want to restore.\n  copy: |\n    <b>📋 Copy pack</b>\n\n    Send link to the pack you want to copy.\n  report: |\n    <b>🚨 Report</b>\n\n    Found a pack that violates rules? Send link to @StickersReportBot\n  packs:\n    info: |\n      <b>📁 Packs</b>\n    types:\n      regular: Stickers\n      custom_emoji: Emojis\n      inline: For search\n    empty: |\n      No packs yet. Create one → /new\n    inline_title: Search pack\n    select_group_pack_info: |\n      <b>📁 Select pack</b>\n\n      To use the pack in the group, administrators must select it using the button below\n\n    select_group_pack: Select pack\n  emoji:\n    info: |\n      To change the default emoji for the current pack, send <code>/emoji</code> followed by the emoji separated by a space\n\n      For example - <code>/emoji 🌟</code>\n    done: Emoji successfully changed.\n    error: There was an error changing emoji!\n  round_video:\n    enabled: |\n      Videos will now have a rounded shape\n    disabled: |\n      Videos will no longer have a rounded shape\n  paysupport: |\n    <b>👨‍💻 Pay Support</b>\n\n    On all issues related to the operation of the bot, including payments and donations, you can contact the developer directly\n\n    <b>Contacts:</b>\n    🧑‍💻 Developer: @ly_oBot\n  mosaic:\n    enter: |\n      🔲 Mosaic mode for <b>${packTitle}</b>\n\n      Send a photo to split into custom emoji grid.\n    no_pack: |\n      You need a custom emoji pack first.\n      Use /new to create one and select \"Custom Emoji\" type.\n    choose_grid: |\n      📐 Choose grid size:\n    btn:\n      recommended: \"✅ ${rows}×${cols}\"\n      option: \"${rows}×${cols} · ${total}pcs\"\n      custom: \"✏️ Custom size\"\n      cancel: \"❌ Cancel\"\n      exit: \"🚪 Exit mosaic\"\n      undo: \"🗑 Remove this mosaic\"\n    custom_prompt: |\n      Enter grid size (e.g. 3x4):\n    custom_invalid: |\n      Invalid format. Use e.g. 3x4 (rows from 1 to 10, cols from 1 to 10, max 50 total).\n    no_space: |\n      Not enough space in pack. ${freeSlots} slots left, but ${total} needed.\n      Choose a smaller grid or create a new pack with /new.\n    blurry_warning: |\n      ⚠️ Source image is small — result may be blurry at this grid size.\n    uploading: \"⏳ Uploading ${current}/${total}...\"\n    done: |\n      ✅ Mosaic ${rows}×${cols} added to pack!\n    done_link: \"📦 Use pack\"\n    undo_done: |\n      🗑 Mosaic removed (${count} emoji deleted from pack).\n    undo_failed: |\n      ❌ Could not remove some emoji. Try deleting manually.\n    wait_photo: |\n      Send another photo or tap Exit.\n    reject_animated: |\n      Animated/video stickers aren't supported yet. Send a static sticker, a photo, or a PNG/JPEG/WebP file.\n    reject_document: |\n      Only images are supported (JPEG/PNG/WebP). Please send a file in one of these formats.\n    reject_media: |\n      Animations and videos aren't supported yet. Send a static sticker, a photo, or a PNG/JPEG/WebP file.\ndonate:\n  menu: |\n    <b>⭐ Support the bot</b>\n\n    <b>Balance:</b> ${balance} credits\n    1 credit = boost one pack\n\n    <b>Boost gives you:</b>\n    • No \"<code>${titleSuffix}</code>\" in pack name\n    • Title up to 64 characters (instead of 35)\n    • Videos up to 35 seconds\n    • Priority conversion queue\n    • Multiple stickers at once\n    • No ads\n\n    <b>How many credits?</b>\n  invoice_title: '${amount} Credits'\n  btn:\n    donate: '☕️ Donate'\n  topup: |\n    <b>Enter the amount of Credits you want to buy:</b>\n  invalid_amount: |\n    <b>Invalid amount</b>\n\n    The minimum amount is 1 Credit\n  paymenu: |\n    You want to buy <b>${amount} Credits</b> for <b>${price}$</b>\n\n    ⚠️ Credits are issued manually by the administrator.\n    The waiting time ranges from 5 minutes to 1 hour\n\n    <u>Select payment method:</u>\n  description: |\n    Buying Credits, you support the development of the bot and get the opportunity to use additional features\n  update: |\n    <b>🔄 Balance update</b>\n\n    Balance: <code>${balance}</code> Credits (added <code>${amount}</code> Credits)\n\n  error:\n    already_donated: |\n      You have already received Credits for this payment\n    already_paid: Payment already completed\n    not_found: Payment not found\n    user_not_found: User not found\n    error: |\n      <b>Error!</b>\n      An error occurred while processing the payment\n    canceled: |\n      Payment canceled\ncoedit:\n  info: |\n    <b>👥 Co-editing</b>\n\n    Link: <code>${colink}</code>\n\n    Share it — others can add/delete stickers in <a href=\"${link}\">${title}</a>.\n\n    <b>Editors:</b> ${editors}\n\n    <i>Reset link to remove all</i>\n  no_editors: |\n    none\n  btn:\n    send: '📤 Send link'\n    reset: '🔁 Reset link'\n  share: |\n    Follow the link and press \"start\" to co-edit the pack \"${title}\"\n  reset: |\n    <b>🔁 Link reset successful</b>\n\n    New link for co-editing <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: Pack not found\n      not_owner: Not your pack\n      hidden: Pack hidden\n      restored: Pack restored\n    set_pack: |\n      ✅ Selected <a href=\"${link}\">${title}</a>\n\n      Send photo, video or sticker to add.\n    set_inline_pack: |\n      ✅ Selected <u>${title}</u>\n\n      Use: <code>@${botUsername} </code>in any chat.\n    boost:\n      info: |\n        \\n⚡ <b><a href=\"https://t.me/${botUsername}?start=boost\">Boost</a></b>: ${boostStatus}\n      status:\n        on: Enabled\n        off: Disabled\n    hidden: Pack <a href=\"${link}\">${title}</a> hidden from your list.\n    restored: Pack <a href=\"${link}\">${title}</a> restored to your list.\n    btn:\n      hide: '❌ Hide pack'\n      delete: '🗑 Delete pack'\n      restore: '✅ Restore'\n      use_pack: '📦 Use pack'\n      boost: '⚡ Boost'\n      frame: '🖼 Frame'\n      rename: '✏️ Rename'\n      search_gif: '🔎 Search GIF'\n      coedit: '👥 Co-edit'\n      catalog_add: '🗂 Add to catalog'\n      catalog_edit: '📝 Edit in catalog'\n      catalog_delete: '🗑 Delete from catalog'\n      catalog_share: '🔗️️ Share'\n      catalog_open: '📂 Open in catalog'\n      mosaic: '🧩 Mosaic'\n    error:\n      not_found: |\n        Error!\n        Cannot find a sticker.\n      invalid_png: |\n        Error!\n        The file is not a valid PNG image. Please convert it to PNG format before sending.\n      invalid_dimensions: |\n        Error!\n        The sticker dimensions are invalid. Stickers must be 512x512 pixels.\n      invalid_animated: |\n        Error!\n        The animated sticker file is not in the correct TGS format.\n      invalid_video: |\n        Error!\n        The video file is not in the correct WEBM format.\n      restore: |\n        Error!\n        Cannot restore pack.\n      copy: |\n        Error!\n        Cannot find pack.\n    select_group:\n      success: |\n        Pack <a href=\"${link}\">${title}</a> successfully selected for the group.\n      access_rights:\n        add: Who can add stickers to the group pack?\n        delete: Who can delete stickers from the group pack?\n        rights:\n          all: Everyone\n          admins: Only admins\n      error: |\n        Error!\n        Set not found.\n  sticker:\n    answerCbQuery:\n      delete: The sticker was successfully removed from the pack.\n      restored: The sticker was successfully saved to the current pack.\n    delete: The sticker was successfully removed from the pack.\n    restored: The sticker was successfully saved to the current pack.\n    btn:\n      delete: '🗑 Delete'\n      copy: '🌟 Copy'\n      restore: '✅ Restore'\n    error:\n      not_found: |\n        ERROR!\n        Cannot find a sticker.\n      invalid_png: |\n        <b>Error!</b>\n        The file is not a valid PNG image. Please convert it to PNG format before sending.\n      invalid_dimensions: |\n        <b>Error!</b>\n        The sticker dimensions are invalid. Stickers must be 512x512 pixels.\n      invalid_animated: |\n        <b>Error!</b>\n        The animated sticker file is not in the correct TGS format.\n      invalid_video: |\n        <b>Error!</b>\n        The video file is not in the correct WEBM format.\n  group_settings:\n    success: |\n      Group settings successfully updated.\nsticker:\n  add:\n    ok: |\n      ✅ Added to <a href=\"${link}\">${title}</a>\n\n      <i>You can send emoji for this sticker</i>\n    ok_inline: |\n      ✅ Added to <u>${title}</u>\n    send_emoji: Send emoji for this sticker\n    converting_process: |\n      ⏳ Converting: ${progress}/${total}\n\n      <i>Boost = priority → /donate</i>\n    catalog_offer: |\n      Want to share <a href=\"${link}\">${title}</a>?\n      Add it to the catalog so others can find it.\n    quote: |\n      Use @QuotlyBot to create a quote from this message\n    error:\n      reply: |\n        <b>Error!</b>\n        Please, reply to the sticker.\n      no_selected_pack: |\n        <b>You have not selected a pack</b>\n\n        Please, create (/new) or choose (/packs) pack\n      no_selected_group_pack: |\n        <b>You have not selected a group pack</b>\n\n        Please, select a pack using the /packs command\n      no_rights: |\n        <b>Error!</b>\n        You do not have the right to add stickers to this pack.\n      stickers_too_much: |\n        This pack has the maximum number of stickers.\n\n        You can create a new pack using the /new command.\n      have_already: |\n        <b>This sticker is already in the pack</b>\n\n        If you want to change the emoji, send it in the following message.\n      stickerset_invalid: |\n        <b>Error!</b>\n        Bot cannot access your current chosen pack.\n\n        Please, create (/new) or choose (/packs) another pack.\n      invalid_png: |\n        <b>Error!</b>\n        The file is not a valid PNG image. Please convert it to PNG format before sending.\n      invalid_dimensions: |\n        <b>Error!</b>\n        The sticker dimensions are invalid. Stickers must be 512x512 pixels.\n      invalid_animated: |\n        <b>Error!</b>\n        The animated sticker file is not in the correct TGS format.\n      invalid_video: |\n        <b>Error!</b>\n        The video file is not in the correct WEBM format.\n      file_type:\n        static: |\n          <b>Error!</b>\n          This file type is not supported\n          You can add this photo or static sticker to the regular pack\n\n          <i>Create (/new) or choose (/packs) another pack</i>\n        video: |\n          <b>Error!</b>\n          This file type is not supported\n          You can add these video files to the video pack\n\n          <i>Create (/new) or choose (/packs) another pack</i>\n        animated: |\n          <b>Error!</b>\n          This file type is not supported\n          You can add these animated files to the animated pack\n\n          <i>Create (/new) or choose (/packs) another pack</i>\n        unknown: |\n          <b>Error!</b>\n          This file type is not supported\n\n          <i>Create (/new) or choose (/packs) another pack</i>\n      wait_load: |\n        ⏳ Still processing previous file...\n      timeout: |\n        ⚠️ High load right now. Please try again in a few minutes.\n      convert: |\n        Couldn't convert video.\n\n        Try MP4 format or send again.\n      too_big: |\n        <b>Error!</b>\n\n        The file is too big to process. Please reduce the quality and duration before sending.\n      sticker_not_found: |\n        <b>Error!</b>\n\n        This sticker could not be found. Please ensure it is in the correct pack or try adding it again.\n      invalid_image: |\n        <b>Error!</b>\n\n        Unable to process this image. Please try sending a different file or format.\n\nnews:\n  join: |\n    📢 <a href=\"${link}\">Subscribe</a> for updates and new features.\n  join_btn: '📢 Subscribe'\n  not_joined: '🙅 You are not subscribed to the channel'\n  continue: '✅ Continue'\n\nuserAbout:\n  help: |\n    <b>🧑‍🎨 About user</b>\n\n    Using this menu you can find out information about the user and his sticker packs\n\n    To get information about the user, use the button below or forward his message\n\n  result: |\n    <b>🧑‍🎨 User info</b>\n    <b>🆔 User ID:</b> <code>${userId}</code>\n    <b>🎨 Packs from this user:</b>\n    ${packs}\n\n  no_packs: |\n    <i>We have no information about stickers of this owner</i>\n\n  forward_hidden: |\n    The user has hidden the ability to forward messages. Use the button below to view his sticker packs.\n\n  select_user: 🧑‍🎨 Select user\n\nscenes:\n  new_pack:\n    pack_type: |\n      <b>Pack type:</b>\n    regular: '🖼 Stickers'\n    custom_emoji: '✨ Emoji'\n    pack_title: |\n      <b>Pack name:</b>\n    pack_name: |\n      <b>Short link:</b>\n\n      Example: t.me/addstickers/<u>MyStickers</u>\n    ok: |\n      ✅ Pack created: <a href=\"${link}\">${title}</a>\n\n      Send photo, video or sticker to add.\n    error:\n      title_long: Name cannot be greater than ${max} symbols.\n      name_long: Address cannot be greater than ${max} symbols.\n      telegram:\n        name_invalid: That address cannot be used.\n        name_occupied: This address is already taken.\n        upload_failed: |\n          <b>Error!</b>\n          Bot cannot upload stickers to Telegram.\n\n          Please, try again later.\n  copy:\n    enter: |\n      First, let's create a new pack for the copy.\n    progress: |\n      ⏳ Copying: ${current}/${total}\n    done: |\n      ✅ Copied to <a href=\"${link}\">${title}</a>\n    done_partial: |\n      ⚠️ Copied to <a href=\"${link}\">${title}</a>\n\n      ${success} stickers copied, ${failed} failed to copy.\n    done_pending: |\n      ✅ Copied to <a href=\"${link}\">${title}</a>\n\n      ${success} stickers copied, ${pending} videos still processing.\n    done_partial_pending: |\n      ⚠️ Copied to <a href=\"${link}\">${title}</a>\n\n      ${success} stickers copied, ${failed} failed, ${pending} videos still processing.\n    pay: |\n      <b>Pack conversion</b>\n\n      Converting a pack from one type to another costs 1 credit\n\n      <b>Current balance:</b> ${balance} Credits\n\n      Buy credits: /donate\n    pay_btn: ✅ Confirm\n    error:\n      all_failed: |\n        ❌ Failed to copy any stickers from <a href=\"${originalLink}\">${originalTitle}</a>.\n\n        Pack was not created.\n      premium: |\n        <b>Error!</b>\n        This feature is only available to donate members.\n\n        You can do this by sending the /donate command.\n  original:\n    enter: |\n      <b>🔎 Find source</b>\n\n      Send a sticker — I'll show which pack it was copied from.\n      If no source found — you'll get the file (PNG/WEBM).\n    source_found: |\n      🔎 Copied from: <a href=\"${link}\">${title}</a>\n    error:\n      not_found: |\n        Source not found. Here's the sticker file:\n  delete:\n    enter: |\n      <b>🗑 Delete sticker</b>\n\n      Send the sticker you want to remove from the pack.\n    confirm: |\n      Remove this sticker from the pack?\n    error:\n      not_found: |\n        Sticker not found in database. It may have been created without this bot.\n  rename:\n    enter_name: |\n      New name for <a href=\"${link}\">${title}</a>:\n    success: |\n      ✅ Renamed: <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      Boost will remove \"${titleSuffix}\" → /donate\n  packAbout:\n    enter: |\n      <b>🔎 Whose sticker</b>\n\n      Send a sticker — I'll show the pack, creator, and their other packs.\n      Or forward a message — I'll show that person's packs.\n    not_found: |\n      Sticker not found.\n    btn:\n      download: '📎 Download file'\n      show_all_packs: '📦 All packs (${count})'\n    result: |\n      <b>📦 Pack:</b> <a href=\"${link}\">${name}</a>\n      🔢 Stickers: <code>${stickerCount}</code> | 🏷 #<code>${setId}</code> | ${dcId}\n\n      🧑‍🎨 Owner ID: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 Other packs from this owner:</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>We have no information about other stickers of this owner</i>\n    unknown_owner: '<i>Unknown</i>'\n    hidden: '<i>[hidden]</i>'\n  boost:\n    sure: |\n      Boost <a href=\"${link}\">${title}</a>?\n\n      <b>Price:</b> 1 credit\n      <b>Balance:</b> ${balance}\n    btn:\n      yes: '⚡ Boost'\n      no: Cancel\n    canceled: |\n      Canceled\n    success: |\n      ⚡ ${title} boosted!\n    error:\n      not_enough_credits: |\n        Not enough credits. /donate\n      already_boosted: |\n        Already boosted.\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>Send me the sticker from the pack you want to publish</b>\n\n        <i>You can publish any pack that belong to you, even if they are created elsewhere</i>\n      owner_proof: |\n        <b>To verify ownership of this pack, you need to follow a few simple steps:</b>\n        1. Open @Stickers bot\n        2. Send <code>/packstats</code> command\n        3. Find and choose the required pack\n        4. Forward the received message to the bot\n      publish_new_access_denied: |\n        <b>Error!</b>\n        This pack is not yours.\n\n        You can only publish your own packs\n      banned: |\n        <b>Error!</b>\n        You are banned from using this feature.\n        Please, contact the administrator.\n      enter: |\n        Publish <a href=\"${link}\">${title}</a> to the catalog?\n\n        Other users will be able to find your pack by name or tags.\n\n        <b>Rules:</b>\n        • Quality packs for wide audience only\n        • No personal photos or private content\n        • No illegal content\n      continue_button: Continue\n      enter_description: |\n        <b>Briefly describe your pack so that others can find it</b>\n\n        <i>You can also use hashtags to categorize [#]</i>\n        <i>For example: #anime #meme #animals #cute #kpop #funny #cat #game </i>\n      select_language: |\n        <b>Choose which languages your pack is for:</b>\n        <i>You can select multiple languages</i>\n      button_all_languages: All languages\n      button_confirm_language: Confirm\n      set_safe: |\n        <b>Is your pack safe for users?</b>\n        <i>That is, it does not contain erotica and other shocking content</i>\n      button_safe:\n        safe: Yes, it is safe\n        not_safe: No, it is not safe\n      no_tags: were not specified\n      confirm: |\n        <b>Confirm the publication of the pack \"<a href=\"${link}\">${title}</a>\"</b>\n\n        <b>Description:</b> <i>${description}</i>\n\n        <b>Tags:</b> ${tags}\n        <b>Languages:</b> ${languages}\n      button_confirm: '✅ Confirm publication'\n      success: |\n        Congratulations, your pack has been published in the general directory of our bot where other users can find it!\n\n        You can edit the pack search information by selecting the pack with the /packs command.\n\n        <i>We remind you that the statistics of your pack can be viewed through the official bot @Stickers</i>\n    unpublish:\n      success: |\n        The pack has been successfully unpublished from bot catalog.\n  delete_pack:\n    enter: |\n      ⚠️ Delete <a href=\"${link}\">${title}</a> forever?\n\n      Send <code>${confirm}</code> to confirm.\n    confirm: Yes, delete\n    success: |\n      🗑 Pack deleted\n    error:\n      - Something went wrong.\n  frame:\n    no_video: |\n      <b>Error!</b>\n      You can only add frames to video packs.\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>Choose frame type:</b>\n      Frame is a transparent background around the sticker\n\n      <code>lite</code> — the corners will be cut a little\n      <code>medium</code> — the corners will be cut more\n      <code>rounded</code> — the corners will be rounded\n      <code>square</code> — the rectangular shape of the frame, that is, it will not be changed in any way\n      <code>circle</code> — the frame will be in the form of a circle\n\n      <i>In the future, you may use the /frame command to set the type of frame</i>\n    types:\n      lite: '1. Lite'\n      medium: '2. Medium'\n      rounded: '3. Rounded'\n      square: '4. Square'\n      circle: '5. Circle'\n    selected: |\n      <b>Selected frame type:</b> ${type}\n  photoClear:\n    enter: |\n      <b>✂️ Remove background</b>\n\n      Send a photo — you'll get a PNG without background.\n      <i>Works best with photos of people.</i>\n    enter_anime: |\n      <b>✂️ Remove background</b>\n\n      Send a photo — you'll get a PNG without background.\n      <i>Works best with anime.</i>\n    choose_model: |\n      Model:\n    web_app: WebApp — for photos with people\n    model:\n      ordinary: Common — for photos with people\n      general: General — for any photos\n      anime: Anime — for anime pictures\n      birefnet_general: BirefNet — for any photos\n    add_to_set_btn: '🌟 Add to pack'\n    error: |\n      Something went wrong. Try again.\n    error_timeout: |\n      ⏱ Processing took too long. Try again later or with a smaller photo.\n    error_queue_disabled: |\n      This feature is temporarily unavailable. Please try again later.\n  videoRound:\n    enter: |\n      <b>⭕ Video to Circle</b>\n\n      Send a video — I'll turn it into a circle (video note).\n    not_video: |\n      Send a video, GIF, video sticker, or animated image.\n    error: |\n      Couldn't convert. Try a different video.\n    forbidden: |\n      Can't send video circles to you. Check your privacy settings (voice messages must be allowed).\n    processing: |\n      ⏳ Converting: ${position}/${total}\n\n      <i>Boost = priority → /donate</i>\n    file_too_big: |\n      File is too big (max 20 MB). Send a smaller video.\n  search:\n    enter: |\n      <b>🔍 Search stickers</b>\n\n      Enter keywords — I'll find packs in the catalog.\n  leave: |\n    Canceled\n  btn:\n    cancel: '← Cancel'\nerror:\n  telegram: |\n    Telegram error: <code>${error}</code>\n  telegram_reasons:\n    sticker_not_in_set: |\n      This sticker is no longer in the pack — it may have been removed already.\n    pack_invalid: |\n      The sticker pack is unavailable. It may have been deleted in Telegram.\n    pack_full: |\n      The pack is full (Telegram limit reached). Remove a few stickers and try again.\n    not_pack_owner: |\n      This pack belongs to another user — you can't modify it.\n    pack_name_taken: |\n      That pack address is already taken. Pick another name.\n    pack_name_invalid: |\n      The pack address has an invalid format. Use Latin letters, digits, and underscores.\n    invalid_sticker_format: |\n      The file is not a valid sticker: wrong format or dimensions.\n    invalid_emoji: |\n      The emoji is invalid. Send a standard Unicode emoji.\n    rate_limited: |\n      Telegram is asking to wait — please try again in a few seconds.\n    cannot_reach_user: |\n      Can't message this user: the bot is blocked or the chat is unavailable.\n  answerCbQuery:\n    telegram: |\n      Error: ${error}\n  file_too_big: |\n    File is too big (max 20 MB).\n  download: |\n    Failed to download file. Try again.\n  banned: |\n    🚫 Access denied. Questions → @ly_oBot\n  access_denied: Access denied\n  unknown: |\n    Something went wrong. Try again.\n\n    Still not working? Message @Ly_oBot\n  rate_limit: |\n    ⏳ Too many requests. Please wait a moment and try again.\n  rate_limit_seconds: |\n    ⏳ Too many requests. Please wait ${seconds} seconds.\n"
  },
  {
    "path": "locales/es.yaml",
    "content": "---\nlanguage_name: '🇪🇸 Español'\nname: fStik — Stickers y Emoji\ndescription:\n  long: |\n    Crea stickers y emojis desde fotos, videos y GIFs sin conversión manual. Todo se procesa automáticamente.\n\n    Características:\n    • Gestión de packs\n    • Stickers de video y emoji personalizados\n    • Descarga archivos originales\n    • Convierte a imagen\n    • Catálogo de stickers\n\n    Buscar pegatinas: play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    Crea stickers y emojis desde fotos, videos, GIFs. Catálogo y búsqueda de stickers. 🇺🇦\nratelimit: '¡No tan a menudo!'\ncmd:\n  start:\n    enter: |\n      🧙 Hola, ${name}! Soy el asistente emoji y el pack de pegatinas.\n      Puedo transformar tus fotos, videos, y GIFs en pegatinas frescas con tan solo unos clics\n\n      Envía el comando /help para aprender más sobre lo que puedo hacer\n\n      💬 ¿Necesitas ayuda? Únete a nuestro chat de soporte en @fStikCommunity (solo en inglés)\n    group: |\n      🧙 ¡Hola, ${groupTitle}! Soy el mago de los paquetes de emojis y stickers.\n\n      Para agregar un sticker a un paquete de grupo, utiliza el comando /ss en respuesta a una foto, video, gif o sticker.\n    catalog: |\n      <b>😻 Puede encontrar nuevos paquetes de sticker en nuestro catálogo</b>\n\n      • Haga clic en el botón de abajo y obtener acceso a un enorme catálogo de paquetes de sticker para cada gusto\n      • Buscar por palabras clave o en pestañas preparadas\n      • No olvide calificar para promover o bajar el paquete de sticker en el ranking\n    commands:\n      ss: '🌟 Guardar sticker'\n      start: '📜 Menú de inicio'\n      help: '📖 Ayuda'\n      packs: '📁 Administrar paquetes'\n      new: '🌝 Crear paquete de sticker'\n      catalog: '📖 Catálogo'\n      publish: '📤 Publicar paquete'\n      delete: '❌ Eliminar sticker'\n      original: '🔍 Buscar sticker original'\n      restore: '🔀 Restaurar un paquete'\n      copy: '📋 Copiar un paquete'\n      emoji: '📝 Cambiar sufijo emoji'\n      round: '🎥 Video redondeado'\n      clear: '🖼️ Quitar fondo de la foto'\n      about: '📦 Información del paquete'\n      user_about: '🧑‍🎨 Info del creador'\n      lang: '🌐 Cambiar idioma'\n      report: '🚨 Reportar paquete'\n      donate: '☕️ Apoyar al desarrollador'\n      add_to_group: '👥 Añadir al grupo'\n      privacy: '🔒 Política de privacidad'\n    btn:\n      new: '📥 Crear nuevo'\n      catalog: '💖 Catálogo abierto'\n      catalog_mini: '💖 Catálogo'\n      catalog_browser: '🌐 Abrir en navegador'\n      catalog_browser_mini: '🌐 En navegador'\n      catalog_app: '📱 Descargar aplicación Android'\n      catalog_app_mini: '📱 App Android'\n  inline:\n    switch_pm: '📁 Seleccione pack'\n  restore: |\n    <b>🗃 Restauración de paquete</b>\n\n    Para restaurar un paquete, debes enviarme un enlace al paquete que deseas restaurar\n  copy: |\n    <b>🗄️ Copie el paquete</b>\n\n    Para copiar otro paquete a uno nuevo, solo tienes que enviarme un enlace a una etiqueta o paquete de emojis\n  report: |\n    <b>🚨 Informe</b>\n\n    Si encuentra un paquete de pegatinas que cree que podría infringir la ley o ir en contra de los Términos de servicio de Telegram, infórmenos enviando su enlace a @ StickersReportBot\n\n    <i>Recuerda que el bot no es responsable del contenido de los packs y no tiene la capacidad de controlarlo</i>\n  packs:\n    info: |\n      <b>📁 Paquetes</b>\n    types:\n      regular: Pegatinas\n      custom_emoji: Emojis\n      inline: Inline\n    empty: |\n      <b>Aún no tienes paquetes.</b>\n      Para crear, escribe el comando /new\n    inline_title: Paquete en línea\n    select_group_pack_info: |\n      <b>📁 Seleccionar paquete</b>\n\n      Para usar el paquete en el grupo, los administradores deben seleccionarlo usando el botón a continuación\n    select_group_pack: Seleccionar paquete\n  emoji:\n    info: |\n      Para cambiar el emoji predeterminado del paquete actual, envía <code>/emoji</code> seguido por el emoji separado por un espacio\n\n      Por ejemplo - <code>/emoji 🌟</code>\n    done: Emoji cambiado con éxito.\n    error: '¡Hubo un error al cambiar el emoji!'\n  round_video:\n    enabled: |\n      Los videos ahora tendrán una forma redondeada\n    disabled: |\n      Los videos ya no tendrán una forma redondeada\n  paysupport: |\n    <b>👨‍💻 Soporte de Pago</b>\n\n    En todos los temas relacionados con el funcionamiento del bot, incluidos los pagos y donaciones, puedes contactar directamente al desarrollador\n\n    <b>Contactos:</b>\n    🧑‍💻 Desarrollador: @ly_oBot\ndonate:\n  menu: |\n    <b>☕️ Apoyo al desarrollo del bot</b>\n    Al apoyar el desarrollo del bot, recibirás Créditos\n\n    <b>Saldo:</b> <code>${balance}</code> Créditos\n    Con 1 Crédito, tienes la oportunidad de potenciar un pack.\n\n    <b>El impulso proporciona los siguientes beneficios:</b>\n    ➖ Sin \"<code>${titleSuffix}</code>\" en el nombre del pack <i>(no en el enlace)</i>\n    ➖ Título hasta 64 caracteres (en lugar de 35)\n    ➖ Videos hasta 35 segundos\n    ➖ Prioridad en la cola de conversión\n    ➖ Varios stickers a la vez\n    ➖ Sin anuncios\n\n    <b>Selecciona la cantidad de Créditos que deseas comprar:</b>\n  btn:\n    donate: '☕ Donar'\n  topup: |\n    <b>Ingresa la cantidad de Créditos que deseas comprar:</b>\n  invalid_amount: |\n    <b>Cantidad inválida</b>\n\n    La cantidad mínima es 1 Crédito\n  paymenu: |\n    Quieres comprar <b>${amount} Créditos</b> por <b>${price}$</b>\n\n    ⚠️ Los Créditos son emitidos manualmente por el administrador.\n    El tiempo de espera varía de 5 minutos a 1 hora\n\n    <u>Selecciona el método de pago:</u>\n  description: |\n    Al comprar Créditos, apoyas el desarrollo del bot y obtienes la oportunidad de usar funciones adicionales\n  update: |\n    <b>🔄 Actualización de saldo</b>\n\n    Saldo: <code>${balance}</code> Créditos (añadido <code>${amount}</code> Créditos)\n  error:\n    already_donated: |\n      Ya has recibido Créditos por este pago\n    error: |\n      <b>¡Error!</b>\n      Ocurrió un error al procesar el pago\n    canceled: |\n      Pago cancelado\ncoedit:\n  info: |\n    <b>👥 Co-editando</b>\n\n    Enlace para co-editar <a href=\"${link}\">${title}</a>: <code>${colink}</code>\n\n    <b>Cómo usar:</b>\n    1. Envía el enlace a la persona que quieres dar acceso al paquete\n    2. Después de hacer clic en el enlace, deben presionar \"Iniciar\" y se añadirán a los editores\n    3. El editor puede añadir, borre y edite stickers en el paquete\n\n    <b>Editores:</b>\n    ${editors}\n\n    <i>Para eliminar editores, necesitas reiniciar el enlace</i>\n  no_editors: |\n    Aún no hay editores\n  btn:\n    send: '📤 Enviar enlace'\n    reset: '🔁 Restablecer enlace'\n  share: |\n    Sigue el enlace y pulsa \"Iniciar\" para coeditar el pack \"${title}\"\n  reset: |\n    <b>🔁 Restablecimiento de enlace exitoso</b>\n\n    Nuevo enlace para coedición <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: Paquete no encontrado\n      not_owner: Este no es tu paquete\n      hidden: Paquete escondido correctamente\n      restored: Paquete restaurado correctamente\n    set_pack: |\n      🌟 Seleccionado <a href=\"${link}\">${title}</a> pack\n\n      <b>❔ ¿Cómo añadir?</b>\n      Envía foto, vídeo o pegatinas para añadir al paquete\n    set_inline_pack: |\n      Paquete seleccionado <u>${title}</u>\n\n      Para usarlo, escribe en cualquier chat <code>@${botUsername} </code>y espacio\n      También puedes usarlo presionando el botón debajo\n    boost:\n      info: |\n        \\n⚡ <b><a href=\"https://t.me/${botUsername}?start=boost\">Boost</a></b>: ${boostStatus}\n      status:\n        on: Activado\n        off: Deshabilitado\n    hidden: Pack <a href=\"${link}\">${title}</a> oculto de tu lista.\n    restored: Pack <a href=\"${link}\">${title}</a> restaurado a tu lista.\n    btn:\n      hide: '❌ Ocultar paquete'\n      delete: '🗑️ Eliminar paquete'\n      restore: '✅ Restaurar'\n      use_pack: '📦 Usar paquete'\n      boost: '⚡ Impulsar'\n      frame: '🖼 Marco'\n      rename: '✏️ Renombrar'\n      search_gif: '🔎 Buscar GIF'\n      coedit: '👥 Co-edición'\n      catalog_add: '🗂 Añadir al catálogo'\n      catalog_edit: '📝 Editar en catálogo'\n      catalog_delete: '🗑️ Eliminar del catálogo'\n      catalog_share: '🔗 Compartir'\n      catalog_open: '📂 Abrir en catálogo'\n    error:\n      not_found: |\n        ¡Error!\\nNo se pudo encontrar el pack de stickers.\n      invalid_png: |\n        ¡Error!\\nEl archivo no es una imagen PNG válida. Por favor conviértelo al formato PNG antes de enviarlo.\n      invalid_dimensions: |\n        ¡Error!\\nLas dimensiones del sticker son inválidas. Los stickers deben ser de 512x512 píxeles.\n      invalid_animated: |\n        ¡Error!\\nEl archivo del sticker animado no está en el formato TGS correcto.\n      invalid_video: |\n        ¡Error!\\nEl archivo de video no está en el formato WEBM correcto.\n      restore: |\n        Error!\\nNo se puede restaurar el paquete.\n      copy: |\n        Error!\\nNo se puede encontrar el paquete.\n    select_group:\n      success: |\n        Paquete <a href=\"${link}\">${title} </a> seleccionado exitosamente para el grupo.\n      access_rights:\n        add: '¿Quién puede agregar stickers al paquete del grupo?'\n        delete: '¿Quién puede eliminar stickers del paquete del grupo?'\n        rights:\n          all: Todos\n          admins: Solo administradores\n      error: |\n        ¡Error!\\nConjunto no encontrado.\n  sticker:\n    answerCbQuery:\n      delete: La etiqueta se eliminó con éxito del paquete.\n      restored: La etiqueta se ha guardado correctamente en el paquete actual.\n    delete: La etiqueta se eliminó con éxito del paquete.\n    restored: La etiqueta se ha guardado correctamente en el paquete actual.\n    btn:\n      delete: '🗑 Eliminar'\n      copy: '🌟 Copiar'\n      restore: '✅ Restaurar'\n    error:\n      not_found: |\n        ¡Error!\n        No se pudo encontrar el pack de stickers.\n      invalid_png: |\n        <b>¡Error!</b>\n        El archivo no es una imagen PNG válida. Por favor, conviértelo al formato PNG antes de enviarlo.\n      invalid_dimensions: |\n        <b>¡Error!</b>\n        Las dimensiones del sticker son inválidas. Los stickers deben ser de 512x512 píxeles.\n      invalid_animated: |\n        <b>¡Error!</b>\n        El archivo del sticker animado no está en el formato TGS correcto.\n      invalid_video: |\n        <b>¡Error!</b>\n        El archivo de video no está en el formato WEBM correcto.\n  group_settings:\n    success: |\n      Configuración del grupo actualizada con éxito.\nsticker:\n  add:\n    ok: |\n      <b>Agregado con éxito al paquete:</b>\n      <a href=\"${link}\">${title}</a>\n\n      En una hora, este paquete se actualizará para todos los usuarios.\n\n      <i>Envía uno o más emojis que coincidan con la pegatina, si quieres agregarlos.</i>\n    ok_inline: |\n      <b>Se ha añadido al paquete correctamente:</b>\n      <u>${title}</u>\n    send_emoji: Genial, ahora envía el emoji que corresponde al sticker\n    converting_process: |\n      <b>Espera...</b>\n      Tu archivo está en la cola para la conversión. Espera a que se complete. Esto puede tardar un poco.\n\n      Progreso: ${progress} / ${total}\n\n      <i>Los usuarios que apoyaron al bot tienen prioridad en la cola (más info: /donate)</i>\n    catalog_offer: |\n      <b>😲 ¡Guau, hiciste un gran paquete!</b>\n\n      ¿Te gustaría agregar <a href=\"${link}\">${title}</a> al catálogo público de stickers para que otros usuarios del bot también puedan verlo?\n      <i>No lleva mucho tiempo</i>\n    quote: |\n      Utiliza @QuotlyBot para crear una cita a partir de este mensaje\n    error:\n      reply: |\n        <b>Error!</b>\n        Por favor, responde al sticker.\n      no_selected_pack: |\n        <b>No has seleccionado un pack</b>\n\n        Por favor, crea (/new) o elige (/packs) pack\n      no_selected_group_pack: |\n        <b>No ha seleccionado un paquete de grupo</b>\n\n        Por favor, seleccione un paquete usando el comando /packs\n      no_rights: |\n        <b>¡Error!</b>\n        No tiene derecho a agregar stickers a este paquete.\n      stickers_too_much: |\n        Este paquete tiene el número máximo de pegatinas.\n\n        Puedes crear un nuevo paquete usando el comando /new.\n      have_already: |\n        <b>Esta pegatina ya está en el paquete</b>\n\n        Si quieres cambiar el emoji, envíalo en el siguiente mensaje.\n      stickerset_invalid: |\n        <b>Error!</b>\n        Bot no puede acceder al paquete seleccionado actualmente.\n\n        Por favor, cree (/new) o elija (/packs) otro paquete.\n      invalid_png: |\n        <b>¡Error!</b>\n        El archivo no es una imagen PNG válida. Por favor, conviértelo al formato PNG antes de enviarlo.\n      invalid_dimensions: |\n        <b>¡Error!</b>\n        Las dimensiones del sticker son inválidas. Los stickers deben ser de 512x512 píxeles.\n      invalid_animated: |\n        <b>¡Error!</b>\n        El archivo del sticker animado no está en el formato TGS correcto.\n      invalid_video: |\n        <b>¡Error!</b>\n        El archivo de video no está en el formato WEBM correcto.\n      file_type:\n        static: |\n          <b>¡Error!</b>\n          Este tipo de archivo no es compatible\n          Puedes agregar esta foto o sticker estático al paquete estático\n\n          <i>Crea (/new) o elige (/packs) otro paquete</i>\n        video: |\n          <b>¡Error!</b>\n          Este tipo de archivo no es compatible\n          Puedes agregar estos archivos de video al paquete de videos\n\n          <i>Crea (/new) o elige (/packs) otro paquete</i>\n        animated: |\n          <b>¡Error!</b>\n          Este tipo de archivo no es compatible\n          Puedes agregar estos archivos animados al paquete de vectores\n\n          <i>Crea (/new) o elige (/packs) otro paquete</i>\n        unknown: |\n          <b>¡Error!</b>\n          Este tipo de archivo no es compatible\n\n          <i>Crea (/new) o elige (/packs) otro paquete</i>\n      wait_load: |\n        <b>¡Espera!</b>\n\n        El bot aún está procesando el archivo anterior...\n        Puedes apoyar el desarrollo del bot (/donate) para aumentar la prioridad del procesamiento y la capacidad de añadir más de un sticker a la cola.\n      timeout: |\n        <b>Por el momento, el bot está experimentando una enorme carga</b>\n        Por lo tanto, la conversión de vídeo solo está disponible para paquetes con potenciador activo\n\n        Para más detalles, sigue /donate\n      convert: |\n        <b>Error!</b>\n        Desafortunadamente, el bot no pudo convertir tu video.\n\n        Tal vez tu vídeo se guarde en un formato inviable para el bot. Asegúrese de que está en formato mp4.\n        También puede ser un error interno del bot, intente enviar este video de nuevo.\n      too_big: |\n        <b>¡Error!</b>\n\n        El archivo es demasiado grande para procesar. Por favor, reduce la calidad y duración antes de enviar.\n      sticker_not_found: |\n        <b>¡Error!</b>\n\n        No se pudo encontrar esta pegatina. Por favor, asegúrese de que está en el paquete correcto o intente agregarla de nuevo.\nnews:\n  join: |\n    ℹ️ <a href=\"${link}\">Únete a nuestro canal</a> para obtener las últimas noticias sobre el bot.\n\n    <i>Suscríbete al canal para obtener las últimas noticias sobre el bot, así como actualizaciones y nuevas características.</i>\n  join_btn: '📢 Unirse al canal'\n  not_joined: '🙅 No estás suscrito al canal'\n  continue: '✅ Continuar'\nuserAbout:\n  help: |\n    <b>🧑‍🎨 Información del usuario</b>\n\n    Usando este menú puedes encontrar información sobre el usuario y sus paquetes de stickers\n\n    Para obtener información sobre el usuario, usa el botón abajo o reenviar su mensaje\n  result: |\n    <b>🧑‍🎨 Información del usuario</b>\n    <b>🆔 ID del usuario:</b> <code>${userId}</code>\n    <b>🎨 Packs de este usuario:</b>\n    ${packs}\n  no_packs: |\n    <i>No tenemos información sobre stickers de este propietario</i>\n  forward_hidden: |\n    El usuario ha ocultado la capacidad de reenviar mensajes. Utilice el botón de abajo para ver sus paquetes de etiquetas.\n  select_user: '🧑‍🎨 Seleccionar usuario'\nscenes:\n  new_pack:\n    pack_type: |\n      <b><u>Elija el tipo de paquete</u></b>\n    regular: '😊 Sticker'\n    custom_emoji: '🌟 Emoji (premium)'\n    static: '🌟 Estático'\n    animated: '✨ Vector'\n    video: '📹 Video'\n    pack_format: |\n      <b><u>Elija el tipo de paquete</u></b>\n\n      <b>Común</b> - estático (no se mueva), raster, formato de archivo - antes de añadir PNG (el bot está procesando), después de añadir - WEBP.\n      Un ejemplo de un paquete normal - t.me/addstickers/Animals\n\n      <b>Video</b> - paquete de vídeo de animación. Puede añadir cualquier vídeo, gif y foto.\n      pack de vídeo de muestra - t. e/addstickers/La mascota\n\n      <b>Animada</b> - vector animado, (tienen una descripción exacta de los objetos dentro del archivo, debido a que se muestran claramente en cualquier escala), formato de archivo - TGS, una variación del formato Lottie.\n      Un ejemplo de un paquete animado - t.me/addstickers/IsabelleShizue\n\n      <i>juegos animados y pegatinas de vídeo pueden tener hasta 50 pegatinas. Los conjuntos estáticos de pegatinas pueden tener hasta 120 pegatinas.</i>\n    pack_title: |\n      <b>Introduzca un nombre para el nuevo pack de stickers:</b>\n      <i>También puede elegir un nombre generado aleatoriamente a continuación.</i>\n    pack_name: |\n      <b>Ingrese un enlace corto para un nuevo paquete de stickers:</b>\n\n      <i>Por ejemplo, este paquete usa 'Animales' como enlace corto: https://t.me/ addstickers/<u>Animales</u></i>\n      <i>Puede elegir un enlace corto aleatorio en el botón.</i>\n    ok: |\n      Pack <a href=\"${link}\">${title}</a> creado con éxito!\n\n      <b>Pack enlace:</b> <pre>${link}</pre>\n\n      Envía un archivo, foto, vídeo o sticker para que lo añada a tu conjunto\n    error:\n      title_long: El nombre no debe tener más de ${max} caracteres.\n      name_long: El enlace no debe tener más de ${max} caracteres.\n      telegram:\n        name_invalid: Este enlace no se puede utilizar.\n        name_occupied: Este enlace ya está en uso.\n        upload_failed: |\n          <b>Error!</b>\n          Bot no puede subir stickers a Telegram.\n\n          Por favor, inténtalo de nuevo más tarde.\n  copy:\n    enter: |\n      Puedo copiarlo, pero antes de eso, vamos a crear un nuevo paquete\n    progress: |\n      Copiando paquete de <a href=\"${originalLink}\">${originalTitle}</a> a <a href=\"${link}\">${title}</a>\n\n      Progreso: ${current}/${total}\n    done: |\n      La copia del paquete de <a href=\"${originalLink}\">${originalTitle}</a> a <a href=\"${link}\">${title}</a> se completó con éxito.\n    pay: |\n      <b>Conversión de pack</b>\n\n      Convertir un paquete de un tipo a otro cuesta 1 crédito\n\n      <b>Saldo actual:</b> ${balance} Créditos\n\n      Comprar créditos: /donate\n    pay_btn: '✅ Confirmar'\n    error:\n      premium: |\n        <b>¡Error!</b>\n        Desafortunadamente, esta función solo está disponible para aquellos que han apoyado el bot.\n\n        Puedes hacerlo enviando el comando /donate.\n  original:\n    enter: |\n      Envíe el sticker que se agregó a través de este bot y le mostraré la original.\n    error:\n      not_found: |\n        <b>¡Error!</b>\n        No pude encontrar el sticker original.\n  delete:\n    enter: |\n      Enviar la etiqueta que se añadió a través de este bot y la eliminaré del paquete.\n    confirm: |\n      ¿Está seguro que desea eliminar esta etiqueta?\n    error:\n      not_found: |\n        <b>Error!</b>\n        No pude encontrar el sticker.\n  rename:\n    enter_name: |\n      <b>Ingresa un nuevo título para <a href=\"${link}\">${title}</a>:</b>\n    success: |\n      <b>Título cambiado con éxito!</b>\n\n      Nuevo título: <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      ❕ Para eliminar el sufijo \"<code>${titleSuffix}</code>\", necesitas potenciar el pack. Más detalles en el menú visitando: /donate\n  packAbout:\n    enter: |\n      <b>Envíame una pegatina o un emoji personalizado para buscar información al respecto:</b>\n    not_found: |\n      No pude encontrar la etiqueta\n    result: |\n      <b>📦 Pack:</b> <a href=\"${link}\">${name}</a>\n      🆔 <code>${setId}</code> <i>(Número único para los paquetes del propietario, incrementado por paquete)</i>\n\n      🧑‍🎨 Propietario: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 Otros paquetes de este propietario:</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>No tenemos información sobre otros stickers de este propietario</i>\n  boost:\n    sure: |\n      <b>¿Estás seguro de que quieres impulsar <a href=\"${link}\">${title}</a>?</b>\n\n      Impulsar aumentará la prioridad de procesamiento y la capacidad para agregar más de un sticker a la cola\n      Puedes encontrar información más detallada sobre los impulsos en el menú visitando: /donate\n\n      <b>Precio:</b> 1 Crédito\n      <b>Saldo actual:</b> ${balance} Créditos\n    btn:\n      yes: Sí, ¡optimizar!\n      no: No, cancelar\n    canceled: |\n      Optimización cancelada\n    success: |\n      ¡Optimización completada con éxito!\n\n      ${title} ha sido optimizada\n    error:\n      not_enough_credits: |\n        No tienes suficientes Créditos para impulsar este pack.\n\n        Puedes recargar tu saldo enviando el comando /donate.\n      already_boosted: |\n        Este paquete ya está optimizado.\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>Envíame la pegatina del paquete que quieres publicar</b>\n\n        <i>Puedes publicar cualquier paquete que te pertenezca, incluso si fueron creados en otro lugar</i>\n      owner_proof: |\n        <b>Para verificar la propiedad de este paquete, debe seguir unos sencillos pasos:</b>\n        1. Abra el bot @Stickers\n        2. Envíe <code>/packstats</code> comando\n        3. Busque y elija el paquete requerido\n        4. Reenvíe el mensaje recibido al bot\n      publish_new_access_denied: |\n        <b>Error!</b>\n        Este paquete no es tuyo.\n\n        Solo puede publicar sus propios paquetes\n      banned: |\n        <b>Error!</b>\n        Está prohibido utilizar esta función.\n        Por favor, póngase en contacto con el administrador.\n      enter: |\n        Estás a punto de publicar el paquete \"<a href=\"${link}\">${title}</a>\" en el directorio público de nuestro bot\n        Puede ser encontrado por cualquier usuario del bot por nombre, etiquetas o filtro.\n        Debido a esto, más personas instalarán tu paquete\n        Intenta enviar solo paquetes de alta calidad que puedan ser de interés para un gran número de personas\n\n        <b>Reglas para publicar paquetes:</b>\n        • No publique sus paquetes personales destinados a un círculo reducido de personas. Por ejemplo, como las caras de tus amigos o citas de tus mensajes\n        • No publiques etiquetas adhesivas que violen las leyes de la UE u otras leyes locales\n\n        Deberás enviar información adicional para que sea publicado en el catalogo\n      continue_button: Continuar\n      enter_description: |\n        <b>describe brevemente tu paquete para que otros puedan encontrarlo</b>\n\n        <i>También puedes usar hashtags para categorizar [#]</i>\n        <i>Por ejemplo: #anime #meme #animals #cute #kpop #funny #cat #game </i>\n      select_language: |\n        <b>Elige para qué idiomas es tu paquete:</b>\n        <i>Puedes seleccionar varios idiomas</i>\n      button_all_languages: Todos los idiomas\n      button_confirm_language: Confirmar\n      set_safe: |\n        <b>¿Tu paquete es seguro para los usuarios?</b>\n        <i>Es decir, no contiene erótica y otro contenido impactante</i>\n      button_safe:\n        safe: Sí, es seguro\n        not_safe: No, no es seguro\n      no_tags: no se especificó\n      confirm: |\n        <b>Confirme la publicación del paquete \"<a href=\"${link}\">${title}</a>\"</b>\n\n        <b>Descripción:</b> <i>${description}</i>\n\n        <b>Etiquetas:</b> ${tags}\n        <b>Idiomas:</b> ${languages}\n      button_confirm: '✅ Confirmar publicación'\n      success: |\n        ¡Enhorabuena, tu pack ha sido publicado en el directorio general de nuestro bot donde otros usuarios pueden encontrarlo!\n\n        Puede editar la información de búsqueda del paquete seleccionando el paquete con el comando /packs.\n\n        <i>Te recordamos que las estadísticas de tu pack las puedes ver a través del bot oficial @Stickers</i>\n    unpublish:\n      success: |\n        El paquete se ha despublicado correctamente del catálogo de bots.\n  delete_pack:\n    enter: |\n      ¿Estás seguro de que deseas eliminar el paquete <a href=\"${link}\">${title}</a>?\n      Se eliminará permanentemente y no se podrá recuperar.\n\n      Si desea eliminar solo una etiqueta, use el comando /delete.\n\n      Envía <code>${confirm}</code> para confirmar que realmente deseas eliminar este paquete.\n    confirm: Sí, estoy totalmente seguro.\n    success: |\n      <b>¡Paquete eliminado exitosamente!</b>\n    error:\n      - <b>¡Error!</b>\n      - Ups, algo salió mal.\n  frame:\n    no_video: |\n      <b>Error!</b>\n      Solo puede añadir fotogramas a los paquetes de vídeo.\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>Elegir tipo de marco:</b>\n      El marco es un fondo transparente alrededor de la pegatina\n\n      <code>lite</code> — las esquinas se cortarán un poco\n      <code>medio</code> — las esquinas se cortarán más\n      <code>redondeadas</code> — las esquinas se redondearán\n      <code>cuadradas</code> — la forma rectangular del marco, es decir, no se cambiará de ninguna manera\n      <code>círculo</code> — el marco será en forma de un círculo\n\n      <i>En el futuro, puedes usar el comando /frame para establecer el tipo de frame</i>\n    types:\n      lite: '1. Lite'\n      medium: '2. Medio'\n      rounded: '3. Redondeado'\n      square: '4. Cuadrado'\n      circle: '5. Círculo'\n    selected: |\n      <b>Tipo de fotograma seleccionado:</b> ${type}\n  photoClear:\n    enter: |\n      Envía una <u>foto</u> desde la que quieres eliminar el fondo y enviaré el archivo sin el fondo\n\n      <i>funciona mejor con fotos. Funciona peor con dibujos, ilustraciones, etc.</i>\n    enter_anime: |\n      Envía una <u>foto</u> de la que quieres eliminar el fondo y enviaré el archivo sin el fondo\n\n      <i>Funciona mejor con imágenes de anime</i>\n    choose_model: |\n      <b>Elegir modelo:</b>\n    web_app: WebApp - para fotos con personas\n    model:\n      ordinary: Común — para fotos con personas\n      general: General — para cualquier foto\n      anime: Anime — para imágenes de anime\n      birefnet_general: BirefNet - para cualquier foto\n    add_to_set_btn: '🌟 Añadir al conjunto'\n    error: |\n      <b>¡Error!</b>\n      Ups, algo salió mal.\n  leave: |\n    Acción cancelada.\n  btn:\n    cancel: '❌ Cancelar'\nerror:\n  telegram: |\n    <b>Telegram dió un error!</b>\n    <code>${error}</code>\n  answerCbQuery:\n    telegram: |\n      Telegram dió un error:\n      ${error}\n  banned: |\n    <b>¡Error!</b>\n    Está prohibido utilizar esta función.\n\n    <i>Si piensas que esto es un error, por favor contacta al administrador: @ly_oBot</i>\n  unknown: |\n    <b>Se ha producido un error desconocido, por favor intente nuevamente.</b>\n\n    Si el problema persiste, escriba a @Ly_oBot.\n    Por favor escriba inmediatamente sobre qué bot está hablando y describa el problema en detalle en un solo mensaje.\n"
  },
  {
    "path": "locales/fr.yaml",
    "content": "---\nlanguage_name: '🇫🇷 Français'\nname: fStik — Stickers & Emoji\ndescription:\n  long: |\n    Créez des stickers et emojis depuis photos, vidéos et GIFs – aucune conversion manuelle, le bot gère tout.\n\n    Fonctionnalités :\n    • Gestion des packs\n    • Stickers vidéo & emojis personnalisés\n    • Télécharger les originaux\n    • Convertir en image\n    • Catalogue de stickers\n\n    Rechercher des stickers : play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    Créez stickers et emojis depuis photos, vidéos, GIFs. Catalogue et recherche. 🇺🇦\nratelimit: Pas si souvent!\ncmd:\n  start:\n    enter: |\n      🧙 Bonjour, ${name}! Je suis l'assistant des packs d'emojis et d'autocollants.\n      Je peux transformer vos photos, vidéos et GIF en autocollants sympas en quelques clics\n\n      Envoyez la commande /help pour en savoir plus sur ce que je peux faire\n\n      💬 Besoin d'aide ? Rejoignez notre chat d'assistance à @fStikCommunity (en anglais uniquement)\n    group: |\n      🧙 Bonjour, ${groupTitle} ! Je suis le sorcier des packs d'emojis et de stickers.\n\n      Pour ajouter un sticker à un pack de groupe, utilisez la commande /ss en réponse à une photo, une vidéo, un gif ou un sticker.\n    catalog: |\n      <b>😻 Vous pouvez trouver de nouveaux packs d'autocollants dans notre catalogue</b>\n\n      • Cliquez sur le bouton ci-dessous et accédez à un énorme catalogue de packs d'autocollants pour chaque goût\n      • Recherchez par mots-clés ou dans les onglets\n      • N'oubliez pas de noter pour promouvoir ou abaisser le pack d'autocollants dans les classements\n    commands:\n      ss: '🌟 Enregistrer l''autocollant'\n      start: '📜 Menu de démarrage'\n      help: '📖 Aide'\n      packs: '📁 Gérer les packs'\n      new: '🌝 Créer un pack d''autocollants'\n      catalog: '📖 Catalogue'\n      publish: '📤 Publier le pack'\n      delete: '❌ Supprimer l''autocollant'\n      original: '🔍 Trouver l''autocollant original'\n      restore: '🔀 Restaurer un pack'\n      copy: '📋 Copier un pack'\n      emoji: '📝 Modifier le suffixe émoji'\n      round: '🎥 Vidéo en forme ronde'\n      clear: '🖼️ Supprimer l''arrière-plan de la photo'\n      about: '📦 Informations sur le pack'\n      user_about: '🧑‍🎨 Infos sur le créateur'\n      lang: '🌐 Changer la langue'\n      report: '🚨 Signaler un pack'\n      donate: '☕️ Soutenir le développeur'\n      add_to_group: '👥 Ajouter au groupe'\n      privacy: '🔒 Politique de confidentialité'\n    btn:\n      new: '📥 Créer nouveau'\n      catalog: '💖 Catalogue ouvert'\n      catalog_mini: '💖 Catalogue'\n      catalog_browser: '🌐 Ouvrir dans le navigateur'\n      catalog_browser_mini: '🌐 Dans le navigateur'\n      catalog_app: '📱 Télécharger l''application Android'\n      catalog_app_mini: '📱 Application Android'\n  inline:\n    switch_pm: '📁 Sélectionnez le pack'\n  restore: |\n    <b>🗃️ Restauration du pack</b>\n\n    Pour restaurer un pack, vous devez m'envoyer un lien vers le pack que vous voulez restaurer\n  copy: |\n    <b>🗄️ Copier le pack</b>\n\n    Pour copier un autre pack vers un nouveau, il vous suffit de m'envoyer un lien vers un pack d'autocollants ou d'émoticônes\n  report: |\n    <b>🚨 Signaler</b>\n\n    Si vous tombez sur un pack d'autocollants qui, selon vous, pourrait enfreindre la loi ou aller à l'encontre des conditions d'utilisation de Telegram, veuillez nous le signaler en envoyant son lien à @ StickersReportBot\n\n    <i>N'oubliez pas que le bot n'est pas responsable du contenu des packs et n'a pas la possibilité de le contrôler</i>\n  packs:\n    info: |\n      <b>📁 Packs</b>\n    types:\n      regular: Autocollants\n      custom_emoji: Emojis\n      inline: Inline\n    empty: |\n      <b>Vous n'avez pas encore de packs.</b>\n      Pour créer, écrivez une commande /new\n    inline_title: Pack Inline\n    select_group_pack_info: |\n      <b>📁 Sélectionnez le pack</b>\n\n      Pour utiliser le pack dans le groupe, les administrateurs doivent le sélectionner en utilisant le bouton ci-dessous\n    select_group_pack: Sélectionnez le pack\n  emoji:\n    info: |\n      Pour changer l'émoji par défaut pour le pack actuel, envoyer <code>/emoji</code> suivi par les émojis séparés par un espace\n\n      Par exemple - <code>/emoji 🌟</code>\n    done: Emoji modifié avec succès.\n    error: Une erreur est survenue lors du changement d'emoji !\n  round_video:\n    enabled: |\n      Les vidéos auront maintenant une forme arrondie\n    disabled: |\n      Les vidéos n'auront plus de forme arrondie\n  paysupport: |\n    <b>👨‍💻 Assistance de Paiement</b>\n\n    Pour toutes les questions liées au fonctionnement du bot, y compris les paiements et les dons, tu peux contacter directement le développeur\n\n    <b>Contacts :</b>\n    🧑‍💻 Développeur : @ly_oBot\ndonate:\n  menu: |\n    <b>☕️ Soutien au développement du bot </b>\n    En soutenant le développement du bot, vous recevrez des Crédits\n\n    <b>Solde : </b> <code>${balance} </code> Crédits\n    Avec 1 Crédits, vous avez la possibilité de booster un pack.\n\n    <b>Le boost offre les avantages suivants : </b>\n    ➖ Désactivation de \"<code>${titleSuffix} </code>\" dans le nom des packs <i>(pas dans le lien)</i>\n    ➖ Titre jusqu'à 64 caractères (au lieu de 35)\n    ➖ Vidéos jusqu'à 35 secondes\n    ➖ File de conversion prioritaire\n    ➖ Plusieurs stickers à la fois\n    ➖ Pas de publicités\n\n    <b>Sélectionnez le montant de Crédits que vous souhaitez acheter : </b>\n  btn:\n    donate: '☕ Faire un don'\n  topup: |\n    <b>Entrez le montant de Crédits que vous souhaitez acheter : </b>\n  invalid_amount: |\n    <b>Montant invalide</b>\n\n    Le montant minimum est de 1 Crédit\n  paymenu: |\n    Tu veux acheter <b>${amount} Crédits</b> pour <b>${price}$</b>\n\n    ⚠️ Les Crédits sont délivrés manuellement par l'administrateur.\n    Le temps d'attente varie de 5 minutes à 1 heure\n\n    <u>Sélectionne le mode de paiement :</u>\n  description: |\n    En achetant des Crédits, tu soutiens le développement du bot et tu obtiens la possibilité d'utiliser des fonctionnalités supplémentaires\n  update: |\n    <b>🔄 Mise à jour du solde</b>\n\n    Solde : <code>${balance}</code> Crédits (ajout de <code>${amount}</code> Crédits)\n  error:\n    already_donated: |\n      Tu as déjà reçu des Crédits pour ce paiement\n    error: |\n      <b>Erreur !</b>\n      Une erreur s'est produite lors du traitement du paiement\n    canceled: |\n      Paiement annulé\ncoedit:\n  info: |\n    <b>👥 Co-édition</b>\n\n    Lien pour co-éditer <a href=\"${link}\">${title}</a>: <code>${colink}</code>\n\n    <b>Comment utiliser :</b>\n    1. Envoyez le lien à la personne que vous voulez donner accès au pack\n    2. Après avoir cliqué sur le lien, ils doivent appuyer sur \"démarrer\" et ils seront ajoutés aux éditeurs\n    3. L'éditeur peut ajouter, supprimer et éditer des autocollants dans le pack\n\n    <b>Éditeurs :</b>\n    ${editors}\n\n    <i>Pour supprimer des éditeurs, vous devez réinitialiser le lien</i>\n  no_editors: |\n    Pas encore d'éditeurs\n  btn:\n    send: '📤 Envoyer le lien'\n    reset: '🔁 Réinitialiser le lien'\n  share: |\n    Suit le lien et appuie sur \"Démarrer\" pour co-éditer le pack \"${title}\"\n  reset: |\n    <b>🔁 Lien réinitialisé avec succès</b>\n\n    Nouveau lien pour co-éditer <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: Pack introuvable\n      not_owner: Ce n'est pas votre pack\n      hidden: Pack masqué avec succès\n      restored: Pack restauré avec succès\n    set_pack: |\n      🌟 Sélectionné <a href=\"${link}\">${title}</a> pack\n\n      <b>❔ Comment ajouter ?</b>\n      Envoyer une photo, une vidéo ou un autocollant à ajouter au pack\n    set_inline_pack: |\n      Pack <u>${title}</u> sélectionné\n\n      Pour l'utiliser, écrivez dans n'importe quel chat <code>@${botUsername} </code>et l'espace\n      Vous pouvez également l'utiliser en appuyant sur le bouton ci-dessous\n    boost:\n      info: |\n\n        ⚡ <b><a href=\"https://t.me/${botUsername}?start=boost\">Boost</a></b> : ${boostStatus}\n      status:\n        on: Activé\n        off: Désactivé\n    hidden: Pack <a href=\"${link}\">${title}</a> caché dans votre liste.\n    restored: Pack <a href=\"${link}\">${title}</a> restauré à votre liste.\n    btn:\n      hide: '❌ Cacher le pack'\n      delete: '🗑️ Supprimer le pack'\n      restore: '✅ Restaurer'\n      use_pack: '📦 Utiliser le pack'\n      boost: '⚡ Boost'\n      frame: '🖼 Cadre'\n      rename: '✏️ Renommer'\n      search_gif: '🔎 Rechercher un GIF'\n      coedit: '👥 Co-editer'\n      catalog_add: '🗂 Ajouter au catalogue'\n      catalog_edit: '📝 Modifier dans le catalogue'\n      catalog_delete: '🗑️ Supprimer du catalogue'\n      catalog_share: '🔗 Partager'\n      catalog_open: '📂 Ouvrir dans le catalogue'\n    error:\n      not_found: |\n        ERREUR!\n        Impossible de trouver un autocollant.\n      invalid_png: |\n        Erreur!\\nLe fichier n'est pas une image PNG valide. Veuillez le convertir au format PNG avant de l'envoyer.\n      invalid_dimensions: |\n        Erreur!\\nLes dimensions de l'autocollant sont invalides. Les autocollants doivent être de 512x512 pixels.\n      invalid_animated: |\n        Erreur!\\nLe fichier d'autocollant animé n'est pas dans le format TGS correct.\n      invalid_video: |\n        Erreur!\\nLe fichier vidéo n'est pas dans le format WEBM correct.\n      restore: |\n        Erreur!\\nImpossible de restaurer le pack.\n      copy: |\n        Erreur!\\nImpossible de trouver le pack.\n    select_group:\n      success: |\n        Pack <a href=\"${link}\">${title}</a> sélectionné avec succès pour le groupe.\n      access_rights:\n        add: Qui peut ajouter des stickers au pack de groupe ?\n        delete: Qui peut supprimer des stickers du pack de groupe ?\n        rights:\n          all: Tout le monde\n          admins: Seuls les administrateurs\n      error: |\n        Erreur !\\nEnsemble non trouvé.\n  sticker:\n    answerCbQuery:\n      delete: L'autocollant a été retiré du pack avec succès.\n      restored: L'autocollant a été enregistré avec succès dans le pack actuel.\n    delete: L'autocollant a été retiré du pack avec succès.\n    restored: L'autocollant a été enregistré avec succès dans le pack actuel.\n    btn:\n      delete: '🗑 Supprimer'\n      copy: '🌟 Copier'\n      restore: '✅ Restaurer'\n    error:\n      not_found: |\n        ERREUR!\n        Impossible de trouver un autocollant.\n      invalid_png: |\n        <b>Erreur !</b>\n        Le fichier n'est pas une image PNG valide. Veuillez le convertir au format PNG avant de l'envoyer.\n      invalid_dimensions: |\n        <b>Erreur !</b>\n        Les dimensions de l'autocollant sont invalides. Les autocollants doivent être de 512x512 pixels.\n      invalid_animated: |\n        <b>Erreur !</b>\n        Le fichier d'autocollant animé n'est pas au format TGS correct.\n      invalid_video: |\n        <b>Erreur !</b>\n        Le fichier vidéo n'est pas au format WEBM correct.\n  group_settings:\n    success: |\n      Paramètres du groupe mis à jour avec succès.\nsticker:\n  add:\n    ok: |\n      <b>Ajouté au pack avec succès :</b>\n      <a href=\"${link}\">${title}</a>\n\n      Dans une heure, ce pack sera mis à jour pour tous les utilisateurs.\n\n      <i>Envoyez un ou plusieurs emojis correspondant à l'autocollant si vous souhaitez les ajouter</i>\n    ok_inline: |\n      <b>Ajouté au pack avec succès :</b>\n      <u>${title}</u>\n    send_emoji: Super, maintenant envoyez l'emoji correspondant à l'autocollant\n    converting_process: |\n      <b>Attendez...</b>\n      Votre fichier est en file d'attente pour la conversion. Attendez la fin. Cela peut prendre du temps.\n\n      Progression: ${progress} / ${total}\n\n      <i>Les utilisateurs ayant soutenu le bot sont prioritaires dans la file d'attente (plus d'infos: /donate)</i>\n    catalog_offer: |\n      <b>😲 Wow, tu as fait un super pack !</b>\n\n      Souhaitez-vous ajouter <a href=\"${link}\">${title}</a> au catalogue public des autocollants afin que les autres utilisateurs du bot puissent le voir aussi ?\n      <i>Cela ne prend pas beaucoup de temps</i>\n    quote: |\n      Utilisez @QuotlyBot pour créer une citation de ce message\n    error:\n      reply: |\n        <b>Erreur!</b>\n        S'il vous plaît, répondez à l'autocollant.\n      no_selected_pack: |\n        <b>Vous n'avez pas sélectionné de pack</b>\n\n        Merci de créer (/new) ou de choisir (/packs) pack\n      no_selected_group_pack: |\n        <b>Vous n'avez pas sélectionné un pack de groupe</b>\n\n        Veuillez sélectionner un pack en utilisant la commande /packs\n      no_rights: |\n        <b>Erreur !</b>\n        Vous n'avez pas le droit d'ajouter des stickers à ce pack.\n      stickers_too_much: |\n        Ce pack a le nombre maximum d'autocollants.\n\n        Vous pouvez créer un nouveau pack en utilisant la commande /new.\n      have_already: |\n        <b>Cet autocollant est déjà dans le pack</b>\n\n        Si vous voulez changer l'émoticône, envoyez-le dans le message suivant.\n      stickerset_invalid: |\n        <b>Erreur!</b>\n        Le bot ne peut pas accéder au pack choisi actuel.\n\n        Veuillez créer (/new) ou choisir (/packs) un autre pack.\n      invalid_png: |\n        <b>Erreur !</b>\n        Le fichier n'est pas une image PNG valide. Veuillez le convertir au format PNG avant de l'envoyer.\n      invalid_dimensions: |\n        <b>Erreur !</b>\n        Les dimensions de l'autocollant sont invalides. Les autocollants doivent être de 512x512 pixels.\n      invalid_animated: |\n        <b>Erreur !</b>\n        Le fichier d'autocollant animé n'est pas au format TGS correct.\n      invalid_video: |\n        <b>Erreur !</b>\n        Le fichier vidéo n'est pas au format WEBM correct.\n      file_type:\n        static: |\n          <b>Erreur!</b>\n          Ce type de fichier n'est pas pris en charge\n          Vous pouvez ajouter cet autocollant photo ou statique au pack statique\n\n          <i>Créer (/new) ou choisir (/packs) un autre pack</i>\n        video: |\n          <b>Erreur!</b>\n          Ce type de fichier n'est pas pris en charge\n          Vous pouvez ajouter ces fichiers vidéo au pack vidéo\n\n          <i>Créer (/new) ou choisir (/packs) un autre pack</i>\n        animated: |\n          <b>Erreur!</b>\n          Ce type de fichier n'est pas pris en charge\n          Vous pouvez ajouter ces fichiers animés au pack vectoriel\n\n          <i>Créer (/new) ou choisir (/packs) un autre pack</i>\n        unknown: |\n          <b>Erreur!</b>\n          Ce type de fichier n'est pas pris en charge\n\n          <i>Créer (/new) ou choisir (/packs) un autre pack</i>\n      wait_load: |\n        <b>Attendez !</b>\n\n        Le bot est toujours en train de traiter le fichier précédent...\n        Vous pouvez soutenir le développement du bot (/donate) pour augmenter la priorité du traitement et la possibilité d'ajouter plus d'un sticker à la file d'attente.\n      timeout: |\n        <b>Pour le moment, le bot subit une énorme charge</b>\n        Par conséquent, la conversion vidéo n'est disponible que pour les packs avec un boost\n\n        Pour plus de détails, suivez /donate\n      convert: |\n        <b>Erreur!</b>\n        Malheureusement, le bot n'a pas pu convertir votre vidéo.\n\n        Peut-être que votre vidéo est enregistrée dans un format incompréhensible pour le bot. Assurez-vous qu'il est au format mp4.\n        Il peut également s'agir d'une erreur interne du bot, essayez d'envoyer cette vidéo à nouveau.\n      too_big: |\n        <b>Erreur !</b>\n\n        Le fichier est trop volumineux pour être traité. Veuillez réduire la qualité et la durée avant l'envoi.\n      sticker_not_found: |\n        <b>Erreur !</b>\n\n        Cet autocollant est introuvable. Veuillez vous assurer qu'il se trouve dans le bon pack ou essayez de l'ajouter à nouveau.\nnews:\n  join: |\n    ℹ️ <a href=\"${link}\">Rejoignez notre chaîne</a> pour recevoir les dernières nouvelles sur le bot.\n\n    <i>Abonnez-vous à la chaîne pour recevoir les dernières nouvelles sur le bot, ainsi que les mises à jour et les nouvelles fonctionnalités.</i>\n  join_btn: '📢 Rejoindre le canal'\n  not_joined: '🙅 Vous n''êtes pas abonné au canal'\n  continue: '✅ Continuer'\nuserAbout:\n  help: |\n    <b>🧑‍🎨 À propos de l'utilisateur</b>\n\n    En utilisant ce menu, vous pouvez trouver des informations sur l'utilisateur et ses packs d'autocollants\n\n    Pour obtenir des informations sur l'utilisateur, utilisez le bouton ci-dessous ou transférez son message\n  result: |\n    <b>🧑‍🎨 Info utilisateur</b>\n    <b>🆔 Identifiant utilisateur :</b> <code>${userId}</code>\n    <b>🎨 Packs de cet utilisateur :</b>\n    ${packs}\n  no_packs: |\n    <i>Nous n'avons aucune information sur les autocollants de ce propriétaire</i>\n  forward_hidden: |\n    L'utilisateur a masqué la possibilité de transférer des messages. Utilisez le bouton ci-dessous pour voir ses packs d'autocollants.\n  select_user: '🧑‍🎨 Sélectionner un utilisateur'\nscenes:\n  new_pack:\n    pack_type: |\n      <b><u>Choisissez le type de pack</u></b>\n    regular: '😊 Autocollant'\n    custom_emoji: '🌟 Emoji (premium)'\n    static: '🌟 Statique'\n    animated: '✨ Vecteur'\n    video: '📹 Vidéo'\n    pack_format: |\n      <b><u>Choisissez le type de pack</u></b>\n\n      <b>Commun</b> - statique (ne pas déplacer), raster, fichier format - avant d'ajouter PNG (le bot est en cours de traitement), après avoir ajouté - WEBP.\n      Un exemple de pack régulier - t.me/addstickers/Animals\n\n      <b>Vidéo</b> - pack vidéo d'animation. Vous pouvez ajouter n'importe quelle vidéo, gif et photo.\n      Exemple de pack vidéo - t.me/addstickers/TheMascot\n\n      <b>Animé</b> - animé, vectoriel (ils ont une description exacte des objets à l'intérieur du fichier, en raison auquel ils sont affichés clairement à n'importe quelle échelle), format de fichier - TGS, une variante du format Lottie.\n      Un exemple de pack animé - t.me/addstickers/IsabelleShizue\n\n      <i>Les ensembles d'autocollants animés et vidéo peuvent contenir jusqu'à 50 autocollants. Les jeux d'autocollants statiques peuvent contenir jusqu'à 120 autocollants.</i>\n    pack_title: |\n      <b>Entrez le nouveau nom du pack d'autocollants:</b>\n      <i>Vous pouvez choisir un nom aléatoire sur le bouton.</i>\n    pack_name: |\n      <b>Entrez un lien court pour le nouveau pack d'autocollants :</b>\n\n      <i>Par exemple, ce pack utilise « Animaux » comme lien court : https://t.me/ addstickers/<u>Animaux</u></i>\n      <i>Vous pouvez choisir un lien court aléatoire sur le bouton.</i>\n    ok: |\n      Pack <a href=\"${link}\">${title}</a> créé avec succès !\n\n      <b>Lien Pack :</b> <pre>${link}</pre>\n\n      Envoyer un fichier, photo, vidéo ou autocollant pour que je l'ajoute à votre ensemble\n    error:\n      title_long: Le nom ne peut pas dépasser ${max} symboles.\n      name_long: L'adresse ne peut pas dépasser ${max} symboles.\n      telegram:\n        name_invalid: Cette adresse ne peut pas être utilisée.\n        name_occupied: Cette adresse est déjà prise.\n        upload_failed: |\n          <b>Erreur!</b>\n          Le bot ne peut pas télécharger des autocollants sur Telegram.\n\n          Veuillez réessayer plus tard.\n  copy:\n    enter: |\n      Je peux le copier, mais avant cela, créons un nouveau pack\n    progress: |\n      Copie du pack de <a href=\"${originalLink}\">${originalTitle}</a> vers <a href=\"${link}\">${title}</a>\n\n      Progression : ${current}/${total}\n    done: |\n      Copie du pack de <a href=\"${originalLink}\">${originalTitle}</a> vers <a href=\"${link}\">${title}</a> terminée avec succès.\n    pay: |\n      <b>Conversion de pack</b>\n\n      Convertir un pack d'un type à un autre coûte 1 crédit\n\n      <b>Solde actuel :</b> ${balance} Crédits\n\n      Acheter des crédits : /donate\n    pay_btn: '✅ Confirmer'\n    error:\n      premium: |\n        <b>Erreur !</b>\n        Cette fonctionnalité est uniquement disponible pour les membres donateurs.\n\n        Vous pouvez le faire en envoyant la commande /donate.\n  original:\n    enter: |\n      Envoyez l'autocollant qui a été ajouté via ce bot et je vous montrerai son autocollant d'origine.\n    error:\n      not_found: |\n        <b>Erreur!</b>\n        Je n'ai pas pu trouver l'autocollant d'origine.\n  delete:\n    enter: |\n      Envoyez l'autocollant qui a été ajouté via ce bot et je le supprimerai du pack.\n    confirm: |\n      Êtes-vous sûr de vouloir supprimer cet autocollant ?\n    error:\n      not_found: |\n        <b>Erreur!</b>\n        Je n'ai pas pu trouver l'autocollant.\n  rename:\n    enter_name: |\n      <b>Entrez un nouveau titre pour <a href=\"${link}\">${title}</a>:</b>\n    success: |\n      <b>Le titre a été modifié avec succès !</b>\n\n      Nouveau titre : <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      ❕ Pour supprimer le suffixe \"<code>${titleSuffix} </code>\", vous devez booster le pack. Plus de détails dans le menu en visitant : \\/donate\n  packAbout:\n    enter: |\n      <b>Envoyez-moi un autocollant ou un emoji personnalisé pour rechercher des informations à ce sujet :</b>\n    not_found: |\n      Je n'ai pas pu trouver l'autocollant\n    result: |\n      <b>📦 Pack :</b> <a href=\"${link}\">${name}</a>\n      🆔 <code>${setId}</code> <i>(Numéro unique pour les packs du propriétaire, incrémenté par pack)</i>\n\n      🧑‍🎨 ID du propriétaire : <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 Autres packs de ce propriétaire :</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>Nous n'avons aucune information sur les autres autocollants de ce propriétaire</i>\n  boost:\n    sure: |\n      <b>Es-tu sûr de vouloir booster <a href=\"${link}\">${title}?</a></b>\n\n      Le boost augmentera la priorité de traitement et la possibilité d'ajouter plus d'un sticker à la file d'attente\n      Tu peux trouver plus d'informations sur les boosts dans le menu en visitant: /donate\n\n      <b>Prix :</b> 1 Crédit\n      <b>Solde actuel :</b> ${balance} Crédits\n    btn:\n      yes: Oui, booster!\n      no: Non, annuler\n    canceled: |\n      Boost annulé\n    success: |\n      Boostage terminé avec succès !\n\n      ${title} est maintenant boosté\n    error:\n      not_enough_credits: |\n        Tu n'as pas assez de Crédits pour booster ce pack.\n\n        Tu peux recharger ton solde en envoyant la commande /donate.\n      already_boosted: |\n        Ce pack est déjà boosté.\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>Envoyez-moi l'autocollant du pack que vous voulez publier</b>\n\n        <i>Vous pouvez publier n'importe quel pack qui vous appartient, même si elles sont créées ailleurs</i>\n      owner_proof: |\n        <b>Pour vérifier la propriété de ce pack, vous devez suivre quelques étapes simples :</b>\n        1. Ouvrez le bot @Stickers\n        2. Envoyez <code>/packstats</code> commande\n        3. Recherchez et choisissez le pack requis\n        4. Transférez le message reçu au bot\n      publish_new_access_denied: |\n        <b>Erreur!</b>\n        Ce pack n'est pas le vôtre.\n\n        Vous ne pouvez publier que vos propres packs\n      banned: |\n        <b>Erreur!</b>\n        Il vous est interdit d'utiliser cette fonctionnalité.\n        Veuillez contacter l'administrateur.\n      enter: |\n        Vous êtes sur le point de publier le pack \"<a href=\"${link}\">${title}</a>\" dans le répertoire public de notre bot\n        Il peut être trouvé par n'importe quel utilisateur du bot par son nom, balises ou filtrer\n        À cause de cela, plus de personnes installeront votre pack\n        Essayez d'envoyer uniquement des packs de haute qualité qui peuvent intéresser un grand nombre de personnes\n\n        <b>Règles pour la publication des packs :</b>\n        • Ne publiez pas vos packs personnels destinés à un cercle restreint de personnes. Par exemple, tels que les visages de vos amis ou les citations de vos messages\n        • Ne publiez pas de pressions sur les autocollants qui violent les lois européennes ou d'autres lois locales\n\n        Vous devrez soumettre des informations supplémentaires pour qu'elles soient publiées dans le catalogue\n      continue_button: Continuer\n      enter_description: |\n        <b>Décrivez brièvement votre pack pour que les autres puissent le trouver</b>\n\n        <i>Vous pouvez également utiliser des hashtags pour catégoriser [#]</i>\n        <i>Par exemple : #anime #meme #animals #cute #kpop #drôle #cat #game </i>\n      select_language: |\n        <b>Choisissez les langues pour lesquelles votre pack est destiné :</b>\n        <i>Vous pouvez sélectionner plusieurs langues</i>\n      button_all_languages: Toutes les langues\n      button_confirm_language: Confirmer\n      set_safe: |\n        <b>Votre pack est-il sûr pour les utilisateurs ?</b>\n        <i>Autrement dit, il ne contient pas d'érotisme ni d'autres contenus choquants</i>\n      button_safe:\n        safe: Oui, c'est sûr\n        not_safe: Non, ce n'est pas sûr\n      no_tags: n'ont pas été spécifiés\n      confirm: |\n        <b>Confirmez la publication du pack \"<a href=\"${link}\">${title}</a>\"</b>\n\n        <b>Description :</b> <i>${description}</i>\n\n        <b>Tags :</b> ${tags}\n        <b>Langues :</b> ${languages}\n      button_confirm: '✅ Confirmer la publication'\n      success: |\n        Félicitations, votre pack a été publié dans l'annuaire général de notre bot où les autres utilisateurs peuvent le trouver !\n\n        Vous pouvez modifier les informations de recherche de pack en sélectionnant le pack avec la commande /packs.\n\n        <i>Nous vous rappelons que les statistiques de votre pack sont consultables via le bot officiel @Stickers</i>\n    unpublish:\n      success: |\n        Le pack a été dépublié avec succès du catalogue du bot.\n  delete_pack:\n    enter: |\n      Etes-vous sûr de vouloir supprimer le pack <a href=\"${link}\">${title}</a>?\n      Il sera définitivement supprimé et ne pourra pas être récupéré.\n\n      Si vous souhaitez supprimer un seul autocollant, utilisez la commande /delete.\n\n      Envoyez <code>${confirm}</code> pour confirmer que vous souhaitez vraiment supprimer ce pack.\n    confirm: Oui, je suis tout à fait sûr.\n    success: |\n      <b>Pack supprimé avec succès !</b>\n    error:\n      - <b>Erreur!</b>\n      - Oups, une erreur s'est produite.\n  frame:\n    no_video: |\n      <b>Erreur!</b>\n      Vous ne pouvez ajouter que des images aux packs vidéo.\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>Choisissez le type de cadre :</b>\n      Le cadre est un fond transparent autour de l'autocollant\n\n      <code>lite</code> - les coins seront coupés un peu\n      <code>medium</code> - les coins seront coupés plus\n      <code>arrondis</code> - les coins seront arrondis\n      <code>carrés</code> - la forme rectangulaire du cadre, C'est-à-dire, il ne sera pas modifié en aucune façon\n      <code>Cercle</code> — le cadre sera sous la forme d'un cercle\n\n      <i>À l'avenir, vous pouvez utiliser la commande /frame pour définir le type de cadre</i>\n    types:\n      lite: '1. Lite'\n      medium: '2. Moyenne'\n      rounded: '3. Arrondi'\n      square: '4. Carré'\n      circle: '5. Cercle'\n    selected: |\n      <b>Type de cadre sélectionné :</b> ${type}\n  photoClear:\n    enter: |\n      Envoyez une <u>photo</u> à partir de laquelle vous voulez supprimer l'arrière-plan et je vais envoyer le fichier sans le fond\n\n      <i>Fonctionne mieux avec les photos. Fonctionne moins bien avec les dessins, les illustrations, etc.</i>\n    enter_anime: |\n      Envoyez une <u>photo</u> à partir de laquelle vous voulez supprimer l'arrière-plan et je vais envoyer le fichier sans l'arrière-plan\n\n      <i>Fonctionne mieux avec les images d'anime</i>\n    choose_model: |\n      <b>Choisissez le modèle :</b>\n    web_app: WebApp - pour les photos avec des personnes\n    model:\n      ordinary: Commun — pour les photos avec des personnes\n      general: Général — pour toutes les photos\n      anime: Anime — pour les photos d'anime\n      birefnet_general: BirefNet - pour toutes les photos\n    add_to_set_btn: '🌟 Ajouter au pack'\n    error: |\n      <b>Erreur !</b>\n      Oups, quelque chose s'est mal passé.\n  leave: |\n    Action annulée.\n  btn:\n    cancel: '❌ Annuler'\nerror:\n  telegram: |\n    <b>Télégramme a renvoyé une erreur!</b>\n    <code>${error}</code>\n  answerCbQuery:\n    telegram: |\n      Télégramme a renvoyé une erreur:\n      ${error}\n  banned: |\n    <b>Erreur!</b>\n    Il vous est interdit d'utiliser cette fonctionnalité.\n\n    <i>Si vous pensez qu'il s'agit d'une erreur, veuillez contacter l'administrateur : @ly_oBot</i>\n  unknown: |\n    <b>Une erreur inconnue s'est produite, veuillez réessayer.</b>\n\n    Si le problème persiste, merci d'écrire à @Ly_oBot.\n    Veuillez préciser quel bot vous utilisez et décrire le problème en détail dans un seul message.\n"
  },
  {
    "path": "locales/hy.yaml",
    "content": "---\nlanguage_name: '🇦🇲 Հայերեն'\nname: fStik — Stickers & Emoji\ndescription:\n  long: |\n    Ստեղծեք կպչուկներ և էմոջիներ լուսանկարներից, տեսանյութերից և GIF-երից՝ առանց փոխակերպման։\n\n    Հնարավորություններ՝\n    • Հեշտ փաթեթների կառավարում\n    • Վիդեո սթիքերներ և հատուկ էմոջիներ\n    • Բնօրինակ ֆայլերի ներբեռնում\n    • Կպչուկի/տեսանյութի/GIF-ի վերածում նկարի\n    • Կպչուկների կատալոգ\n\n    Փնտրեք սթիքերներ՝ play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    Ստեղծեք կպչուկներ և էմոջիներ լուսանկարներից, տեսանյութերից, GIF-երից։ Կատալոգ և որոնում։ 🇺🇦\nratelimit: Ոչ այնքան հաճախ!\ncmd:\n  start:\n    enter: |\n      🧙 Բարև, ${name}: Ես էմոջիների և կպչուկների փաթեթի հրաշագործն եմ:\n      Ես կարող եմ ձեր լուսանկարները, տեսանյութերը և GIF-երը վերածել հիանալի կպչուկների ընդամենը մի քանի կտտոցով\n\n      Ուղարկեք /help հրամանը՝ ավելին իմանալու, թե ինչ կարող եմ անել\n\n      💬 Օգնության կարիք ունեք: Միացեք մեր աջակցության զրույցին @fStikCommunity-ում (միայն անգլերեն)\n    group: |\n      🧙 Բարև, ${groupTitle}! Ես եմ սմայլիկների և ստիկերների փաթեթների կախարդը.\n\n      Խմբային փաթեթում ստիկեր ավելացնելու համար օգտագործեք /ss հրահանգը՝ պատասխանելով լուսանկարին, տեսանյութին, GIF-ին կամ ստիկերին։\n    catalog: |\n      <b>😻 Դուք կարող եք գտնել նոր կպչուն փաթեթներ մեր կատալոգում</b>\n\n      • Սեղմեք ստորև գտնվող կոճակը և մուտք գործեք կպչուն փաթեթների հսկայական կատալոգ յուրաքանչյուր ճաշակի համար\n      • Որոնել ըստ հիմնաբառերի կամ պատրաստված ներդիրներում\n      • Մի մոռացեք գնահատել՝ գովազդելու կամ իջեցնելու համար կպչուն պիտակների փաթեթը վարկանիշում\n    commands:\n      ss: '🌟 Պահպանեք կպչուկը'\n      start: '📜 Սկսել մենյու'\n      help: '📖 Օգնություն'\n      packs: '📁 Կառավարեք փաթեթները'\n      new: '🌝 Ստեղծեք կպչուկների փաթեթ'\n      catalog: '📖 Կատալոգ'\n      publish: '📤 Հրապարակեք փաթեթը'\n      delete: '❌ Ջնջել սթիկերը'\n      original: '🔍 Գտեք բնօրինակ կպչուն'\n      restore: '🔀 Վերականգնել փաթեթը'\n      copy: '📋 Պատճենել փաթեթը'\n      emoji: '📝 Փոխել էմոջի վերջածանցը'\n      round: '🎥 Կլոր ձևի տեսանյութ'\n      clear: '🖼️ Հեռացրեք ֆոնը լուսանկարից'\n      about: '📦 Փաթեթի տվյալները'\n      user_about: '🧑‍🎨 Ստեղծողի տվյալներ'\n      lang: '🌐 Փոխել լեզուն'\n      report: '🚨 Հաշվետվությունների փաթեթ'\n      donate: '☕️ Աջակցեք մշակողին'\n      add_to_group: '👥 Ավելացնել խմբին'\n      privacy: '🔒 Գաղտնիության քաղաքականություն'\n    btn:\n      new: '📥 Ստեղծել նոր'\n      catalog: '💖 Բաց կատալոգ'\n      catalog_mini: '💖 Կատալոգ'\n      catalog_browser: '🌐 Բացեք բրաուզերում'\n      catalog_browser_mini: '🌐 Բրաուզերում'\n      catalog_app: '📱 Ներբեռնեք Android հավելվածը'\n      catalog_app_mini: '📱 Android հավելված'\n  inline:\n    switch_pm: '📁 Ընտրեք փաթեթ'\n  restore: |\n    <b>🗃 Փաթեթի վերականգնում</b>\n\n    Փաթեթը վերականգնելու համար դուք պետք է ինձ ուղարկեք այն փաթեթի հղումը, որը ցանկանում եք վերականգնել\n  copy: |\n    <b>🗄 Պատճենել փաթեթը</b>\n\n    Մեկ այլ փաթեթ նորի վրա պատճենելու համար պարզապես անհրաժեշտ է ինձ ուղարկել կպչուկի կամ էմոջի փաթեթի հղումը\n  report: |\n    <b>🚨 Հաղորդեք</b>\n\n    Եթե հանդիպեք կպչուն փաթեթի, որը, ձեր կարծիքով, կարող է խախտել օրենքը կամ հակասել Telegram-ի Ծառայության պայմաններին, խնդրում ենք հայտնել մեզ՝ ուղարկելով դրա հղումը @@ հասցեին: StickersReportBot\n\n    <i>Հիշեք, որ բոտը պատասխանատվություն չի կրում փաթեթների բովանդակության համար և չունի այն կառավարելու հնարավորություն</i>\n  packs:\n    info: |\n      <b>📁 Փաթեթներ</b>\n    types:\n      regular: Կպչուն պիտակներ\n      custom_emoji: Էմոջիներ\n      inline: Համակարգավորում\n    empty: |\n      <b>Դուք դեռ փաթեթներ չունեք:</b>\n      Ստեղծելու համար գրեք հրաման /նոր\n    inline_title: Ներքին փաթեթ\n    select_group_pack_info: |\n      <b>📁 Ընտրեք փաթեթ</b>\n\n      Խմբում սթիքերների փաթեթ օգտագործելու համար ադմինիստրատորները պետք է ընտրեն այն ներքևում գտնվող կոճակը օգտագործելով\n    select_group_pack: Ընտրեք փաթեթ\n  emoji:\n    info: |\n      Համարը փոխելու համար ընթացիկ փաթեթի emoji-ները, ուղարկեք <code>/emoji</code> հրամանը emoji դաշտից հետո բացով\n\n      Օրինակ - <code>/emoji 🌟</code>\n    done: 'Emoji-ն հաջողությամբ փոխվեց:'\n    error: 'Սխալ առաջացավ զմայլիկները փոխելիս:'\n  round_video:\n    enabled: |\n      Տեսանյութերն այժմ կունենան կլորացված ձև\n    disabled: |\n      Տեսանյութերն այլևս չեն ունենա կլորացված ձև\n  paysupport: |\n    <b>👨‍💻 Վճարման աջակցություն</b>\n\n    Բոտի աշխատանքի հետ կապված բոլոր հարցերով, ներառյալ վճարումները և դոնացիանները, կարող եք ուղղակի կապվել մշակողին\n\n    <b>Կոնտակտներ:</b>\n    🧑‍💻 Մշակող: @ly_oBot\ndonate:\n  menu: |\n    <b>☕️ Բոտի զարգացման աջակցություն</b>\n    Բոտի զարգացմանն աջակցելու դեպքում դուք կստանաք Կրեդիտներ\n\n    <b>Մնացորդ:</b> <code>${balance}</code> Կրեդիտներ\n    1 կրեդիտի դեպքում դուք կարող եք նպաստել մեկ փաթեթի:\n\n    <b>Նպաստը տրամադրում է հետևյալ առավելությունները:</b>\n    ➖ Փաթեթի անվանման մեջ «<code>${titleSuffix}</code>» չկա <i>(ոչ հղումի մեջ)</i>\n    ➖ Վերնագիր մինչև 64 նիշ (35-ի փոխարեն)\n    ➖ Տեսանյութեր մինչև 35 վայրկյան\n    ➖ Առաջնահերթ փոխակերպման հերթ\n    ➖ Միաժամանակ մի քանի սթիքեր\n    ➖ Առանց գովազդի\n\n    <b>Ընտրեք կրեդիտների քանակը, որ ցանկանում եք գնել:</b>\n  btn:\n    donate: '☕️ Նվիրիր'\n  topup: |\n    <b>Մուտքագրեք կրեդիտների քանակը, որ ցանկանում եք գնել:</b>\n  invalid_amount: |\n    <b>Անվավեր քանակ</b>\n\n    Նվազագույն քանակը 1 Կրեդիտ\n  paymenu: |\n    Դուք ցանկանում եք գնել <b>${amount} Կրեդիտ</b> համար <b>${price}$</b>\n\n    ⚠️ Կրեդիտները տրամադրվում են ադմինիստրատորի կողմից ձեռքով:\n    Սպասման ժամանակը 5 րոպեից մինչև 1 ժամ է:\n\n    <u>Ընտրեք վճարման մեթոդը:</u>\n  description: |\n    Կրեդիտների գնմամբ դուք աջակցում եք բոտի զարգացմանը և ստանում հավելյալ հնարավորությունների օգտագործման հնարավորություն\n  update: |\n    <b>🔄 Պահեստի թարմացում</b>\n\n    Պահեստ: <code>${balance}</code> Կրեդիտներ (ավելացվել է <code>${amount}</code> Կրեդիտներ)\n  error:\n    already_donated: |\n      Դուք արդեն ստացել եք Կրեդիտներ այս վճարման համար\n    error: |\n      <b>Սխալ!</b>\n      Սխալ է տեղի ունեցել վճարման մշակման ժամանակ\n    canceled: |\n      Վճարումը չեղարկված է\ncoedit:\n  info: |\n    <b>👥 Համախմբագիրում:</b>\n\n    Համախմբագրման հղումը <a href=\"${link}\">${title}</a>: <code>${colink}</code>\n\n    <b>Ինչպես օգտագործել:</b>\n    1. Ուղարկեք հղումը մարդուն, ում ուզում եք տալ հասանելիություն փաթեթին\n    2. Հղմանը սեղմելուց հետո, նրանք պետք է սեղմեն «սկսել» և նրանք կավելացվեն խմբագիրների մեջ\n    3. Խմբագիրը կարող է ավելացնել, հեռացնել և խմբագրել սթիքերները փաթեթում\n\n    <b>Խմբագիրներ:</b>\n    ${editors}\n\n    <i>Խմբագիրներին հեռացնելու համար անհրաժեշտ է նորացնելու հղումը</i>\n  no_editors: |\n    Խմբագիրներ դեռ չկան\n  btn:\n    send: '📤 Ուղարկեք հղումը'\n    reset: '🔁 Վերականգնել հղումը'\n  share: |\n    Հետևեք հղմանը և սեղմեք «սկսել»՝ փաթեթը համատեղ խմբագրելու համար «${title}»\n  reset: |\n    <b>🔁 Հղման վերակայումը հաջողվեց</b>\n\n    Նոր հղում <a href=\"${link}\">${title}</a>՝\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: Փաթեթը չի գտնվել\n      not_owner: Դա ձեր փաթեթը չէ\n      hidden: Փաթեթը հաջողությամբ թաքցվեց\n      restored: Փաթեթը հաջողությամբ վերականգնվեց\n    set_pack: |\n      🌟 Ընտրված է <a href=\"${link}\">${title}</a> փաթեթ\n\n      <b>❔ Ինչպե՞ս ավելացնել:</b>\n      Ուղարկեք լուսանկար, տեսանյութ կամ կպչուկ՝ փաթեթին ավելացնելու համար\n    set_inline_pack: |\n      Ընտրված է <u>${title}</u> փաթեթը\n\n      Օգտագործելու համար, գրեք ցանկացած զրույցում <code>@${botUsername}</code> և բացով\n      Կարող եք նաև օգտագործել ստորև գտնվող կոճակը\n    boost:\n      info: |\n\n        ⚡ <b><a href=\"https://t.me/${botUsername}?start=boost\">Արագացնել</a></b>: ${boostStatus}\n      status:\n        on: Միացված է\n        off: Անաշխատունակ\n    hidden: 'Փաթեթ <a href=\"${link}\">${title}</a> թաքնված ձեր ցուցակից:'\n    restored: 'Փաթեթը <a href=\"${link}\">${title}</a> վերականգնվել է ձեր ցուցակում:'\n    btn:\n      hide: '❌ Թաքցնել փաթեթը'\n      delete: '🗑 Ջնջել փաթեթը'\n      restore: '✅ Վերականգնել'\n      use_pack: '📦 Օգտագործեք փաթեթ'\n      boost: '⚡ Բարձրացնել'\n      frame: '🖼 Շրջանակ'\n      rename: '✏️ Վերանվանել'\n      search_gif: '🔎 Որոնել GIF'\n      coedit: '👥 Համատեղ խմբագրում'\n      catalog_add: '🗂 Ավելացնել կատալոգում'\n      catalog_edit: '📝 Խմբագրել կատալոգում'\n      catalog_delete: '🗑 Ջնջել կատալոգից'\n      catalog_share: '🔗️️ Կիսվել'\n      catalog_open: '📂 Բացեք կատալոգում'\n    error:\n      not_found: |\n        ՍԽԱԼ\\nՉհաջողվեց գտնել կպչուն։\n      invalid_png: |\n        Սխալ։\\nՖայլը ճիշտ PNG պատկեր չէ։ Խնդրում ենք այն վերածել PNG ձևաչափի մինչ ուղարկելը։\n      invalid_dimensions: |\n        Սխալ։\\nԿպչունի չափերը սխալ են։ Կպչունները պետք է լինեն 512x512 պիքսել։\n      invalid_animated: |\n        Սխալ։\\nԱնիմացվող կպչունի ֆայլը ճիշտ TGS ձևաչափով չէ։\n      invalid_video: |\n        Սխալ։\\nՏեսանյութի ֆայլը ճիշտ WEBM ձևաչափով չէ։\n      restore: |\n        Սխալ։\\nՀնարավոր չէ վերականգնել փաթեթը։\n      copy: |\n        Սխալ։\\nՀնարավոր չէ գտնել փաթեթը։\n    select_group:\n      success: |\n        Փաթեթը <a href=\"${link}\">${title}</a> հաջողությամբ ընտրված է խմբի համար։\n      access_rights:\n        add: Ով կարող է ավելացնել սթիքերներ խմբի փաթեթում?\n        delete: Ով կարող է ջնջել սթիքերներ խմբի փաթեթից?\n        rights:\n          all: Բոլորը\n          admins: Միայն ադմինները\n      error: |\n        Սխալ!\\nՓաթեթը չէր գտնվել։\n  sticker:\n    answerCbQuery:\n      delete: 'Կպչուն պիտակը հաջողությամբ հեռացվեց փաթեթից:'\n      restored: 'Կպչուկը հաջողությամբ պահպանվեց ընթացիկ փաթեթում:'\n    delete: 'Կպչուն պիտակը հաջողությամբ հեռացվեց փաթեթից:'\n    restored: 'Կպչուկը հաջողությամբ պահպանվեց ընթացիկ փաթեթում:'\n    btn:\n      delete: '🗑 Ջնջել'\n      copy: '🌟 Պատճենել'\n      restore: '✅ Վերականգնել'\n    error:\n      not_found: |\n        ՍԽԱԼ\n        Չհաջողվեց գտնել կպչուն:\n      invalid_png: |\n        <b>Սխալ!</b>\n        Ֆայլը վավեր PNG պատկեր չի։ Խնդրում ենք փոխարկել այն PNG ձևաչափի՝ մինչև ուղարկելը։\n      invalid_dimensions: |\n        <b>Սխալ!</b>\n        Կպչուն պատկերի չափերը վավեր չեն։ Կպչուն պիտակները պետք է լինեն 512x512 պիքսել։\n      invalid_animated: |\n        <b>Սխալ!</b>\n        Անիմացված կպչուն պատկերի ֆայլը վավեր TGS ձևաչափով չէ։\n      invalid_video: |\n        <b>Սխալ!</b>\n        Տեսագրության ֆայլը վավեր WEBM ձևաչափով չէ։\n  group_settings:\n    success: |\n      Խմբի կարգաբերումները հաջողությամբ թարմացվեցին։\nsticker:\n  add:\n    ok: |\n      <b>Հաջողությամբ ավելացված է փաթեթին:</b>\n      <a href=\"${link}\">${title}</a>\n\n      Մեկ ժամվա ընթացքում այս փաթեթը կթարմացվի բոլոր օգտվողների համար.\n\n      <i>Ուղարկեք մեկ կամ ավելի սմայլիկներ, որոնք համապատասխանում են սթիքերին, եթե ցանկանում եք ավելացնել դրանք</i>\n    ok_inline: |\n      <b>Հաջողությամբ ավելացվել է փաթեթում՝</b>\n      <u>${title}</u>\n    send_emoji: Հիանալի է, հիմա ուղարկեք էմոջիին, որը համապատասխանում է դրան\n    converting_process: |\n      <b>Սպասեք...</b>\n      Ձեր ֆայլը փոխակերպման հերթում է: Սպասեք ավարտին: Սա կարող է որոշ ժամանակ պահանջել:\n\n      Առաջընթաց՝ ${progress} / ${total}\n\n      <i>Բոտին աջակցող օգտատերերը ստանում են առաջնահերթություն հերթում (ավելին՝ /նվիրիր)</i>\n    catalog_offer: |\n      <b>😲 Վայ, դուք հիանալի փաթեթ եք պատրաստել:</b>\n\n      Ցանկանու՞մ եք ավելացնել <a href=\"${link}\">${title}</a> -ը հանրային սթիքերների կատալոգում, որպեսզի բոտի մյուս օգտվողները նույնպես տեսնեն այն:\n      <i>Շատ ժամանակ չի խլում</i>\n    quote: |\n      Կիրառեք @QuotlyBot-ը՝ այս հաղորդագրությունից մեջբերում ստեղծելու համար\n    error:\n      reply: |\n        <b>Սխալ.</b>\n        Խնդրում եմ, պատասխանեք կպչուն:\n      no_selected_pack: |\n        <b>Դուք փաթեթ չեք ընտրել</b>\n\n        Խնդրում ենք ստեղծել (/նոր) կամ ընտրել (/ փաթեթներ) փաթեթ\n      no_selected_group_pack: |\n        <b>Դուք դեռ չեք ընտրել խմբի փաթեթ</b>\n\n        Խնդրում ենք ընտրել պակետը օգտագործելով /packs հրամանը\n      no_rights: |\n        <b>Սխալ!</b>\n        Դուք չունեք իրավունք ավելացնել սթիքերներ այս փաթեթում։\n      stickers_too_much: |\n        Այս փաթեթն ունի կպչուն պիտակների առավելագույն քանակը:\n\n        Դուք կարող եք ստեղծել նոր փաթեթ՝ օգտագործելով /new հրամանը:\n      have_already: |\n        <b>Այս կպչուն արդեն փաթեթում է</b>\n\n        Եթե ցանկանում եք փոխել էմոջիները, ուղարկեք այն հետևյալ հաղորդագրությամբ:\n      stickerset_invalid: |\n        <b>Սխալ.</b>\n        Բոտը չի կարող մուտք գործել ձեր ընթացիկ ընտրված փաթեթը:\n\n        Խնդրում ենք ստեղծեք (/նոր) կամ ընտրեք (/ փաթեթավորում) մեկ այլ փաթեթ:\n      invalid_png: |\n        <b>Սխալ!</b>\n        Ֆայլը վավեր PNG պատկեր չի։ Խնդրում ենք փոխարկել այն PNG ձևաչափի՝ մինչև ուղարկելը։\n      invalid_dimensions: |\n        <b>Սխալ!</b>\n        Կպչուն պատկերի չափերը վավեր չեն։ Կպչուն պիտակները պետք է լինեն 512x512 պիքսել։\n      invalid_animated: |\n        <b>Սխալ!</b>\n        Անիմացված կպչուն պատկերի ֆայլը վավեր TGS ձևաչափով չէ։\n      invalid_video: |\n        <b>Սխալ!</b>\n        Տեսագրության ֆայլը վավեր WEBM ձևաչափով չէ։\n      file_type:\n        static: |\n          <b>Սխալ.</b>\n          Այս ֆայլի տեսակը չի աջակցվում\n          Դուք կարող եք ավելացնել այս լուսանկարը կամ ստատիկ կպչուն ստատիկ փաթեթին\n\n          <i>Ստեղծել (/նոր) կամ ընտրել (/packs) Another pack</i>\n        video: |\n          <b>Սխալ.</b>\n          Այս ֆայլի տեսակը չի աջակցվում\n          Դուք կարող եք ավելացնել այս վիդեո ֆայլերը վիդեո փաթեթում\n\n          <i>Ստեղծել (/նոր) կամ ընտրել (/ փաթեթներ) մեկ այլ փաթեթ</i>\n        animated: |\n          <b>Սխալ.</b>\n          Այս ֆայլի տեսակը չի աջակցվում\n          Դուք կարող եք ավելացնել այս անիմացիոն ֆայլերը վեկտորային փաթեթում\n\n          <i>Ստեղծել (/նոր) կամ ընտրել (/ փաթեթներ) մեկ այլ փաթեթ</i>\n        unknown: |\n          <b>Սխալ.</b>\n          Այս ֆայլի տեսակը չի աջակցվում\n\n          <i>Ստեղծել (/նոր) կամ ընտրել (/ փաթեթավորում) մեկ այլ փաթեթ</i>\n      wait_load: |\n        <b>Սպասեք! </b>\n\n        Բոտը դեռ մշակում է նախորդ ֆայլը...\n        Կարող եք աջակցել բոտի զարգացմանը (/donate)՝ բարձրացնելով մշակման գործընթացի առաջնահերթությունը և ավելացնել մեկից ավելի ստիկեր հերթին:\n      timeout: |\n        <b>Այս պահին բոտը մեծ ծանրաբեռնվածություն է ապրում</b>\n        Հետևաբար, վիդեո փոխակերպումը հասանելի է միայն ակտիվ խթանմամբ փաթեթների համար\n\n        Մանրամասների համար հետևեք / նվիրաբերել\n      convert: |\n        <b>Սխալ.</b>\n        Ցավոք, բոտը չկարողացավ փոխարկել ձեր տեսանյութը:\n\n        Հավանաբար ձեր տեսանյութը պահպանված է բոտի համար անհասկանալի ձևաչափով: Համոզվեք, որ այն mp4 ձևաչափով է:\n        Դա կարող է նաև լինել բոտի ներքին սխալ, փորձեք նորից ուղարկել այս տեսանյութը:\n      too_big: |\n        <b>Սխալ.</b>.\n\n        Ֆայլը չափազանց մեծ է մշակման համար: Խնդրում ենք նվազեցնել որակը և տևողությունը՝ ուղարկելուց առաջ։\n      sticker_not_found: |\n        <b>Սխալ!</b>\n\n        Այս ստիկերը չի գտնվել: Խնդրում ենք համոզվել, որ այն ճիշտ փաթեթում է կամ կրկին փորձեք ավելացնել այն։\nnews:\n  join: |\n    ℹ️ <a href=\"${link}\">Միացեք մեր ալիքին</a> բոտի մասին վերջին նորությունները ստանալու համար:\n\n    <i>Բաժանորդագրվեք ալիքին բոտի մասին վերջին նորություններին, ինչպես նաև թարմացումներին և նոր հնարավորություններին ծանոթանալու համար:</i>\n  join_btn: '📢 Միացեք ալիքին'\n  not_joined: '🙅 Դուք բաժանորդագրված չեք ալիքին'\n  continue: '✅ Շարունակել'\nuserAbout:\n  help: |\n    <b>🧑‍🎨 Օգտատեր</b>\n\n    Այս ընտրացանկից կարող եք տեղեկություններ իմանալ օգտատիրոջ և նրա կպչուկների փաթեթների մասին\n\n    Օգտատիրոջ մասին տեղեկություններ ստանալու համար օգտագործեք կոճակը ստորև կամ փոխանցեք նրա հաղորդագրությունը\n  result: |\n    <b>🧑‍🎨 Օգտագործողի ինֆո</b>\n    <b>🆔 Օգտագործողի ID:</b> <code>${userId}</code>\n    <b>🎨 Փաթեթները այս օգտագործողից:</b>\n    ${packs}\n  no_packs: |\n    <i>Մենք տեղեկություն չունենք այս սեփականատիրոջ կպչուկների մասին</i>\n  forward_hidden: |\n    Օգտատերը թաքցրել է հաղորդագրություններ փոխանցելու հնարավորությունը։ Օգտագործեք ստորև բերված կոճակը՝ նրա կպչուն փաթեթները դիտելու համար:\n  select_user: '🧑‍🎨 Ընտրեք օգտվողին'\nscenes:\n  new_pack:\n    pack_type: |\n      <b><u>Ընտրեք փաթեթի տեսակը</u></b>\n    regular: '😊 Կպչուկ'\n    custom_emoji: '🌟 Էմոջի (պրեմիում)'\n    static: '🌟 Ստատիկ'\n    animated: '✨ Վեկտոր'\n    video: '📹 Տեսանյութ'\n    pack_format: |\n      <b><u>Ընտրեք փաթեթի տեսակը</u></b>\n\n      <b>Ընդհանուր</b> - ստատիկ (չշարժվել), ռաստեր, ֆայլ ձևաչափ՝ նախքան PNG ավելացնելը (բոտը մշակվում է), ավելացնելուց հետո՝ WEBP։\n      Սովորական փաթեթի օրինակ՝ t.me/addstickers/Animals\n\n      <b>Տեսանյութ</b> - անիմացիոն վիդեո փաթեթ: Դուք կարող եք ավելացնել ցանկացած տեսանյութ, gif և լուսանկար:\n      Նմուշ վիդեո փաթեթ - t.me/addstickers/TheMascot\n\n      <b>Անիմացիոն</b> - անիմացիոն, վեկտոր (նրանք ունեն ֆայլի ներսում գտնվող օբյեկտների ճշգրիտ նկարագրությունը, պայմանավորված որոնց վրա դրանք հստակ ցուցադրվում են ցանկացած մասշտաբով), ֆայլի ձևաչափ՝ TGS, Lottie ձևաչափի տարբերակ։\n      Անիմացիոն փաթեթի օրինակ՝ t.me/addstickers/IsabelleShizue\n\n      <i>Անիմացիոն և վիդեո կպչուկների հավաքածուները կարող են ունենալ մինչև 50 կպչուն պիտակներ: Կպչուն պիտակների ստատիկ հավաքածուները կարող են ունենալ մինչև 120 կպչուն պիտակներ:</i>\n    pack_title: |\n      <b>Մուտքագրեք նոր կպչուն պիտակների փաթեթի անունը.</b>\n      <i>Դուք կարող եք ընտրել պատահական անուն կոճակի վրա:</i>\n    pack_name: |\n      <b>Մուտքագրեք նոր սթիքեր փաթեթի կարճ հղումը:</b>\n\n      <i>Օրինակ, այս փաթեթը օգտագործում է 'Կիներ' որպես կարճ հղում: https://t.me/addstickers/<u>Կիներ</u></i>\n      <i>Կարող եք ընտրել պատահական կարճ հղում կոճակից:</i>\n    ok: |\n      Փաթեթ <a href=\"${link}\">${title}</a> հաջողությամբ ստեղծված է:\n\n      <b>Փաթեթի հղում:</b> <pre>${link}</pre>\n\n      Ուղարկեք ֆայլ, լուսանկար, տեսանյութ կամ սթիքեր, որպեսզի այն ավելացնեմ ձեր հավաքածուին\n    error:\n      title_long: 'Անունը չի կարող ավելի մեծ լինել, քան ${max} խորհրդանիշները:'\n      name_long: 'Հասցեն չի կարող ավելի մեծ լինել, քան ${max} խորհրդանիշները:'\n      telegram:\n        name_invalid: Այդ հասցեն չի կարող օգտագործվել։\n        name_occupied: 'Այս հասցեն արդեն վերցված է:'\n        upload_failed: |\n          <b>Սխալ.</b>\n          Բոտը չի կարող պիտակներ վերբեռնել Telegram-ում:\n\n          Խնդրում եմ, փորձեք ավելի ուշ:\n  copy:\n    enter: |\n      Ես կարող եմ պատճենել այն, բայց մինչ այդ, եկեք ստեղծենք նոր փաթեթ\n    progress: |\n      Պատճենվում է փաթեթը <a href=\"${originalLink}\">${originalTitle}</a> ից <a href=\"${link}\">${title}</a>\n\n      Արվել է: ${current} / ${total}\n    done: |\n      Փաթեթի պատճենումը <a href=\"${originalLink}\">${originalTitle}</a> -ից մինչև <a href=\"${link}\">${title}</a> հաջողությամբ ավարտվեց:\n    pay: |\n      <b>Փաթեթի փոխարկում</b>\n\n      Փաթեթը մեկ տեսակից մյուս տեսակին փոխարկելը արժե 1 կրեդիտ.\n\n      <b>Ընթացիկ պահեստ:</b> ${balance} Կրեդիտ\n\n      Գնել կրեդիտներ: /donate\n    pay_btn: '✅ Հաստատել'\n    error:\n      premium: |\n        <b>Սխալ.</b>\n        Այս հատկությունը հասանելի է միայն անդամներին նվիրաբերելու համար:\n\n        Դուք կարող եք դա անել՝ ուղարկելով /donate հրամանը:\n  original:\n    enter: |\n      Ուղարկեք այս բոտի միջոցով ավելացված կպչուն, և ես ձեզ ցույց կտամ դրա բնօրինակ կպչուն:\n    error:\n      not_found: |\n        <b>Սխալ.</b>\n        Ես չկարողացա գտնել բնօրինակ կպչուն:\n  delete:\n    enter: |\n      Ուղարկեք այս բոտի միջոցով ավելացված կպչուն, և ես այն կջնջեմ փաթեթից:\n    confirm: |\n      Իսկապե՞ս ուզում եք ջնջել այս կպչուկը:\n    error:\n      not_found: |\n        <b>Սխալ.</b>\n        Չհաջողվեց գտնել կպչուն:\n  rename:\n    enter_name: |\n      <b>Մուտքագրեք նոր վերնագիր <a href=\"${link}\">${title}</a>-ի համար՝</b>\n    success: |\n      <b>Վերնագիրը հաջողությամբ փոխվեց:</b>\n\n      Նոր վերնագիր՝ <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      ❕ «<code>${titleSuffix}</code>սահմանափակումը հեռացնելու համար անհրաժեշտ է նպաստել փաթեթին։ Մանրամասները գտեք ընտրացանկում՝ այցելեք՝ /donate\n  packAbout:\n    enter: |\n      <b>Ուղարկեք ինձ կպչուկ կամ հատուկ էմոջի՝ դրա մասին տեղեկություններ փնտրելու համար.</b>\n    not_found: |\n      Ես չկարողացա գտնել կպչուն\n    result: |\n      <b>📦 Փաթեթ:</b> <a href=\"${link}\">${name}</a>\n      🆔 <code>${setId}</code> <i>(Սեփականատիրոջ փաթեթների համար յուրահատուկ համար, հերթաչափորեն ավելացող փաթեթի համար)</i>\n\n      🧑‍🎨 Սեփականատիրոջ ID: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 Այլ փաթեթներ այս սեփականատիրոջից:</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>Մենք տեղեկություններ չունենք այս սեփականատիրոջ այլ կպչուն պիտակների մասին</i>\n  boost:\n    sure: |\n      <b>Վստահ ե՞ք, որ ցանկանում եք խթանել <a href=\"${link}\">${title}</a>?</b>\n\n      Խթանումը կբարձրչահասացնի վերամշակման առաջնահերթությունը և բացելու հնարավորությունը ավելացնել մեկից ավելի պիտակ հերթում։\n\n      <b>Գին:</b> 1 Կրեդիտ\n      <b>Ընթացիկ պահեստ:</b> ${balance} Կրեդիտներ\n    btn:\n      yes: 'Այո, խթանել:'\n      no: Ոչ, չեղարկել\n    canceled: |\n      Ակտիվացումը չեղարկվել է\n    success: |\n      Ակտիվացումը հաջողությամբ ավարտվեց:\n\n      ${title} -ն այժմ ուժեղացված է\n    error:\n      not_enough_credits: |\n        Դուք չունեք բավարար Կրեդիտներ այս փաթեթը խթանելու համար:\n\n        Կարող եք համալրել ձեր հաշվեկշիռը, ուղարկելով /donate հրաման.\n      already_boosted: |\n        Այս փաթեթն արդեն ուժեղացված է:\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>Ուղարկեք ինձ փաթեթից ցանկացած սթիքեր, որը ցանկանում եք հրապարակել</b>\n\n        <i>Կարող եք հրապարակել ցանկացած փաթեթ, որը պատկանում է ձեզ, նույնիսկ եթե դրանք ստեղծված են այլուր</i>\n      owner_proof: |\n        <b>Այս փաթեթի սեփականատիրությունը հաստատելու համար, դուք պետք է հետևեք մի քանի պարզ քայլերի:</b>\n        1. Բացեք @Stickers բոտը\n        2. Ուղարկեք <code> /packstats </code> հրամանը\n        3. Հայտնաբերեք և ընտրեք անհրաժեշտ փաթեթը\n        4. Թարգմանեք ստացված հաղորդագրությունը դեպի բոտը\n      publish_new_access_denied: |\n        <b>Սխալ.</b>\n        Այս փաթեթը ձերը չէ:\n\n        Դուք կարող եք հրապարակել միայն ձեր սեփական փաթեթները\n      banned: |\n        <b>Սխալ.</b>\n        Ձեզ արգելված է օգտագործել այս հնարավորությունը:\n        Խնդրում ենք կապվել ադմինիստրատորի հետ:\n      enter: |\n        Դուք պատրաստվում եք հրապարակել «<a href=\"${link}\">${title}</a>» փաթեթը մեր բոտի հանրային գրացուցակում\n        Այն կարող է գտնել բոտի ցանկացած օգտատեր անունով, պիտակներով կամ զտիչով:\n        Այդ պատճառով ավելի շատ մարդիկ կտեղադրեն ձեր փաթեթը\n        Փորձեք ուղարկել միայն բարձրորակ փաթեթներ, որոնք կարող են հետաքրքրել մեծ թվով մարդկանց\n\n        <b>Կանոններ փաթեթներ հրապարակելու համար՝</b>\n        • Մի հրապարակեք ձեր անձնական փաթեթները, որոնք նախատեսված են մարդկանց նեղ շրջանակի համար: Օրինակ՝ ձեր ընկերների դեմքերը կամ մեջբերումները ձեր հաղորդագրություններից\n        • Մի փակցրեք կպչուն պիտակներ, որոնք խախտում են ԵՄ օրենքները կամ այլ տեղական օրենքները\n\n        Դուք պետք է լրացուցիչ տեղեկություններ ներկայացնեք դրա համար: հրապարակված կատալոգում\n      continue_button: Շարունակել\n      enter_description: |\n        <b>Հակիրճ նկարագրեք ձեր փաթեթը, որպեսզի մյուսները կարողանան գտնել այն</b>\n\n        <i>Դուք կարող եք նաև օգտագործել հեշթեգներ՝ [#]</i>\n        դասակարգելու համար<i>Օրինակ՝ #anime #meme #animals #cute #kpop #զվարճալի #կատու #խաղ </i>\n      select_language: |\n        <b>Ընտրեք, թե որ լեզուների համար է ձեր փաթեթը՝</b>\n        <i>Դուք կարող եք ընտրել բազմաթիվ լեզուներ</i>\n      button_all_languages: Բոլոր լեզուները\n      button_confirm_language: Հաստատել\n      set_safe: |\n        <b>Ձեր փաթեթը անվտանգ է օգտատերերի համար:</b>\n        <i>Այսինքն՝ այն չի պարունակում էրոտիկա և այլ ցնցող բովանդակություն</i>\n      button_safe:\n        safe: Այո, դա անվտանգ է\n        not_safe: Ոչ, դա անվտանգ չէ\n      no_tags: չեն հստակեցվել\n      confirm: |\n        <b>Հաստատեք փաթեթի հրապարակումը \"<a href=\"${link}\">${title}</a>\"</b>\n\n        <b>Նկարագրություն:</b> <i>${description}</i>\n\n        <b>Բառեր:</b> ${tags}\n        <b>Լեզուներ:</b> ${languages}\n      button_confirm: '✅ Հաստատել հրապարակումը'\n      success: |\n        Շնորհավորում ենք, ձեր փաթեթը հրապարակվել է մեր բոտի ընդհանուր գրացուցակում, որտեղ այլ օգտվողներ կարող են գտնել այն:\n\n        Դուք կարող եք խմբագրել փաթեթի որոնման տեղեկատվությունը` ընտրելով փաթեթը /packs հրամանով:\n\n        <i>Հիշեցնում ենք, որ ձեր փաթեթի վիճակագրությունը կարելի է դիտել @Stickers</i>պաշտոնական բոտի միջոցով\n    unpublish:\n      success: |\n        Փաթեթը հաջողությամբ չհրապարակվեց բոտերի կատալոգից:\n  delete_pack:\n    enter: |\n      Իսկապե՞ս ուզում եք ջնջել փաթեթը <a href=\"${link}\">${title}</a>:\n      Այն ընդմիշտ կջնջվի և չի կարող վերականգնվել:\n\n      Եթե ցանկանում եք ջնջել միայն մեկ կպչուկ, օգտագործեք /delete հրամանը:\n\n      Ուղարկեք <code>${confirm}</code> ՝ հաստատելու համար, որ իսկապես ցանկանում եք ջնջել այս փաթեթը:\n    confirm: Այո, ես լիովին վստահ եմ.\n    success: |\n      <b>Փաթեթը հաջողությամբ ջնջվեց:</b>\n    error:\n      - <b>Սխալ.</b>\n      - 'Սխալ առաջացավ:'\n  frame:\n    no_video: |\n      <b>Սխալ.</b>\n      Դուք կարող եք միայն շրջանակներ ավելացնել վիդեո փաթեթներին:\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>Ընտրեք շրջանակի տեսակը:</b>\n      Շրջանակն է սթիքերի շուրջի թափանցիկ ֆոնը\n\n      <code>lite</code> — անկյունները մի փոքր կկտրվեն\n      <code>medium</code> — անկյունները ավելի շատ կկտրվեն\n      <code>rounded</code> — անկյունները կկլորացվեն\n      <code>square</code> — շրջանակի աջանաձև ձևը, այսինքն այն ոչ մի ձևով չի փոխվի\n      <code>circle</code> — շրջանակը կլինի շրջանակի տեսքով\n\n      <i>Ապագայում, դուք կարող եք օգտագործել /frame հրամանը սահմանելու համար շրջանակի տեսակը</i>\n    types:\n      lite: '1․ Լայնահուն'\n      medium: '2. Միջին'\n      rounded: '3. Կլորացված'\n      square: '4. Քառակուսի'\n      circle: '5. Շրջանակ'\n    selected: |\n      <b>Ընտրված շրջանակի տեսակը՝</b> ${type}\n  photoClear:\n    enter: |\n      Ուղարկեք <u>լուսանկար</u> , որտեղից ցանկանում եք հեռացնել ֆոնը, և ես կուղարկեմ ֆայլը առանց ֆոնի\n\n      <i>Լավագույնս աշխատում է լուսանկարների հետ: Ավելի վատ է աշխատում գծագրերի, նկարազարդումների և այլնի հետ:</i>\n    enter_anime: |\n      Ուղարկեք <u>լուսանկար</u> որից ցանկանում եք հեռացնել ֆոնը և ես կուղարկեմ ֆայլը ֆոնից առանց\n\n      <i>Լավ է աշխատում անիմեի նկարների հետ</i>\n    choose_model: |\n      <b>Ընտրեք մոդել՝</b>\n    web_app: WebApp - մարդկանց հետ լուսանկարների համար\n    model:\n      ordinary: Ընդհանուր — մարդկանց հետ լուսանկարների համար\n      general: Ընդհանուր — ցանկացած լուսանկարի համար\n      anime: Անիմե — անիմե նկարների համար\n      birefnet_general: Ընդհանուր — ցանկացած լուսանկարի համար\n    add_to_set_btn: '🌟 Ավելացնել հավաքածուի մեջ'\n    error: |\n      <b>Սխալ.</b>\n      Սխալ առաջացավ:\n  leave: |\n    Գործողությունը չեղարկվել է:\n  btn:\n    cancel: '❌ Չեղարկել'\nerror:\n  telegram: |\n    <b>Telegram-ը սխալ է վերադարձրել:</b>\n    <code>${error}</code>\n  answerCbQuery:\n    telegram: |\n      Telegram-ը վերադարձրեց սխալ՝\n      ${error}\n  banned: |\n    <b>Սխալ!</b>\n    Ձեզ արգելված է օգտագործել այս հնարավորությունը:\n\n    <i>Եթե կարծում եք, որ սա սխալ է, խնդրում ենք կապվել ադմինիստրատորի հետ: @ly_oBot</i>\n  unknown: |\n    <b>Անհայտ սխալ է տեղի ունեցել, խնդրում ենք կրկին փորձել:</b>\n\n    Եթե խնդիրը չվերանա, ապա գրեք @Ly_oBot-ին:\n    Խնդրում եմ անմիջապես գրեք, թե որ բոտի մասին է խոսքը և մանրամասն նկարագրեք խնդիրը մեկ հաղորդագրության մեջ։\n"
  },
  {
    "path": "locales/id.yaml",
    "content": "---\nlanguage_name: '🇮🇩 Indonesia'\nname: fStik — Stiker & Emoji\ndescription:\n  long: |\n    Buat stiker dan emoji dari foto, video, dan GIF tanpa perlu konversi manual - bot menangani semuanya!\n\n    Fitur:\n    • Manajemen paket\n    • Stiker video & emoji khusus\n    • Unduh file asli\n    • Konversi ke gambar\n    • Katalog stiker\n\n    Cari stiker: play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    Buat stiker dan emoji dari foto, video, GIF. Katalog dan pencarian stiker. 🇺🇦\nratelimit: Tidak terlalu sering!\ncmd:\n  start:\n    enter: |\n      🧙 Halo, ${name}! Saya adalah ahli paket emoji dan stiker.\n      Saya dapat mengubah foto, video, dan GIF Anda menjadi stiker keren hanya dengan beberapa klik\n\n      Kirim perintah /help untuk mempelajari lebih lanjut tentang apa yang dapat saya lakukan\n\n      💬 Butuh bantuan? Bergabunglah dengan obrolan dukungan kami di @fStikCommunity (hanya dalam bahasa Inggris)\n    group: |\n      🧙 Halo, ${groupTitle}! Aku adalah penyihir paket emoji dan stiker.\n\n      Untuk menambahkan stiker ke paket grup, gunakan perintah /ss dalam membalas foto, video, gif, atau stiker.\n    catalog: |\n      <b>Anda dapat menemukan paket stiker baru di katalog kami</b>\n\n      • Klik tombol di bawah ini dan dapatkan akses ke katalog besar paket stiker untuk setiap selera\n      • Cari berdasarkan kata kunci atau di tab yang sudah disiapkan\n      • Jangan lupa untuk menilai untuk mempromosikan atau turunkan paket stiker di peringkat\n    commands:\n      ss: '🌟 Simpan stikernya'\n      start: '📜 Start menu'\n      help: '📖 Bantuan'\n      packs: '📁 Kelola paket'\n      new: '🌝 Create a new sticker pack'\n      catalog: '📖 Katalog'\n      publish: '📤 Publikasikan paket'\n      delete: '❌ Hapus stiker'\n      original: '🔍 Find original sticker'\n      restore: '🔀 Kembalikan paket'\n      copy: '📋 Salin satu paket'\n      emoji: '📝 Change emoji suffix'\n      round: 'Video bentuk bulat'\n      clear: '🖼️ Hapus latar belakang dari foto'\n      about: '📦 Informasi paket'\n      user_about: '🧑‍🎨 Info pembuat'\n      lang: '🌐 Change language'\n      report: '🚨 Paket laporan'\n      donate: '☕️ Dukungan pengembangan bot'\n      add_to_group: '👥 Tambahkan ke Grup'\n      privacy: '🔒 Kebijakan Privasi'\n    btn:\n      new: '📥 Create new'\n      catalog: 'Buka katalog'\n      catalog_mini: '💖 Katalog'\n      catalog_browser: '🌐 Buka di browser'\n      catalog_browser_mini: '🌐 Di browser'\n      catalog_app: '📱 Unduh aplikasi Android'\n      catalog_app_mini: '📱 Aplikasi Android'\n  inline:\n    switch_pm: '📁 Pilih paket'\n  restore: |\n    <b>🗃 Pemulihan paket</b>\n\n    Untuk memulihkan paket, Anda perlu mengirimi saya tautan ke paket yang ingin Anda pulihkan\n  copy: |\n    <b>🗄 Salin paket</b>\n\n    Untuk menyalin paket lain ke paket baru, Anda hanya perlu mengirimi saya tautan ke stiker atau paket emoji\n  report: |\n    <b>🚨 Laporkan</b>\n\n    Jika Anda menemukan paket stiker yang Anda yakini melanggar hukum atau melanggar Ketentuan Layanan Telegram, harap laporkan kepada kami dengan mengirimkan tautannya ke @ StickersReportBot\n\n    <i>Ingatlah bahwa bot tidak bertanggung jawab atas konten paket dan tidak memiliki kemampuan untuk mengontrolnya</i>\n  packs:\n    info: |\n      <b>📁 Paket</b>\n    types:\n      regular: Stiker\n      custom_emoji: Emoji\n      inline: Tombol sebaris\n    empty: |\n      <b>Anda belum memiliki paket stiker.</b>\n      Untuk membuat, tulis perintah /new\n    inline_title: Paket sebaris\n    select_group_pack_info: |\n      <b>📁 Pilih paket</b>\n\n      Untuk menggunakan paket dalam grup, administrator harus memilihnya menggunakan tombol di bawah\n    select_group_pack: Pilih paket\n  emoji:\n    info: |\n      Untuk mengubah emoji default untuk pack saat ini, kirim <code>/emoji</code> diikuti dengan emoji yang dipisah dengan spasi\n\n      Contoh - <code>/emoji 🌟</code>\n    done: Emoji berhasil diubah.\n    error: Ada kesalahan saat mengubah emoji!\n  round_video:\n    enabled: |\n      Video sekarang akan memiliki bentuk bulat\n    disabled: |\n      Video tidak akan lagi berbentuk bulat\n  paysupport: |\n    <b>👨‍💻 Dukungan Pembayaran</b>\n\n    Untuk semua masalah terkait operasi bot, termasuk pembayaran dan donasi, Anda dapat menghubungi pengembang langsung\n\n    <b>Kontak:</b>\n    🧑‍💻 Pengembang: @ly_oBot\ndonate:\n  menu: |\n    <b>☕️ Dukungan pengembangan Bot</b>\n    Dengan mendukung pengembangan bot, Anda akan menerima Kredit\n\n    <b>Saldo:</b> <code>${balance}</code> Kredit\n    Dengan 1 Kredit, Anda memiliki peluang untuk meningkatkan satu paket.\n\n    <b>Peningkatan memberikan manfaat sebagai berikut:</b>\n    ➖ Tanpa \"<code>${titleSuffix}</code>\" di nama paket <i>(bukan di tautan)</i>\n    ➖ Judul hingga 64 karakter (bukan 35)\n    ➖ Video hingga 35 detik\n    ➖ Prioritas dalam antrian konversi\n    ➖ Beberapa stiker sekaligus\n    ➖ Tanpa iklan\n\n    <b>Pilih jumlah Kredit yang ingin Anda beli:</b>\n  btn:\n    donate: '☕️ Donasi'\n  topup: |\n    <b>Masukkan jumlah Kredit yang ingin Anda beli:</b>\n  invalid_amount: |\n    <b>Jumlah tidak valid</b>\n\n    Jumlah minimum adalah 1 Kredit\n  paymenu: |\n    Anda ingin membeli <b>${amount} Kredit</b> seharga <b>${price}$</b>\n\n    ⚠️ Kredit dikeluarkan secara manual oleh administrator.\n    Waktu tunggu berkisar antara 5 menit hingga 1 jam\n\n    <u>Pilih metode pembayaran:</u>\n  description: |\n    Dengan membeli Kredit, Anda mendukung pengembangan bot dan mendapatkan kesempatan untuk menggunakan fitur tambahan\n  update: |\n    <b>🔄 Pembaruan Saldo</b>\n\n    Saldo: <code>${balance}</code> Kredit (ditambahkan <code>${amount}</code> Kredit)\n  error:\n    already_donated: |\n      Anda sudah menerima Kredit untuk pembayaran ini\n    error: |\n      <b>Kesalahan!</b>\n      Terjadi kesalahan saat memproses pembayaran\n    canceled: |\n      Pembayaran dibatalkan\ncoedit:\n  info: |\n    <b>👥 Co-editing sticker pack</b>\n\n    Tautan untuk co-editing <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\n\n    <b>Cara menggunakan:</b>\n    1. Kirim tautan ke orang yang ingin Anda beri akses ke paket stiker\n    2. Setelah mengklik tautan, mereka perlu menekan \"mulai\" dan mereka akan ditambahkan ke editor\n    3. Editor dapat menambah, menghapus, dan mengedit stiker di paket stiker\n\n    <b>Editor:</b>\n    ${editors}\n\n    <i>Untuk menghapus editor, Anda perlu mengatur ulang tautan</i>\n  no_editors: |\n    Belum ada editor\n  btn:\n    send: '📤 Kirim tautan'\n    reset: '🔁 Setel ulang tautan'\n  share: |\n    Ikuti tautan dan tekan \"mulai\" untuk mengedit bersama paket stiker \"${title}\"\n  reset: |\n    <b>🔁 Link reset berhasil</b>\n\n    Link baru untuk co-editing <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: Paket tidak ada\n      not_owner: Ini bukan paket Anda\n      hidden: Paket stiker berhasil disembunyikan\n      restored: Paket berhasil dipulihkan\n    set_pack: |\n      🌟 Terpilih <a href=\"${link}\">${title}</a> pack\n\n      <b>❔ Bagaimana cara menambahkannya?</b>\n      Kirim foto, video atau stiker untuk ditambahkan ke paket\n    set_inline_pack: |\n      Terpilih <u>${title}</u> pack\n\n      Untuk menggunakannya, tulis di chat apapun <code>@${botUsername} </code>dan spasi\n      Anda juga bisa menggunakannya dengan menekan tombol di bawah\n    boost:\n      info: |\n        \\n⚡ <b><a href=\"https://t.me/${botUsername}?start=boost\">Peningkatan</a></b>: ${boostStatus}\n      status:\n        on: Diaktifkan\n        off: Dengan disabilitas\n    hidden: Kemas <a href=\"${link}\">${title}</a> disembunyikan dari daftar Anda.\n    restored: Kemas <a href=\"${link}\">${title}</a> dikembalikan ke daftar Anda.\n    btn:\n      hide: '❌ Sembunyikan paket'\n      delete: '🗑 Hapus paket'\n      restore: '✅ Mengembalikan'\n      use_pack: '📦 Gunakan paket'\n      boost: '⚡ Tingkatkan'\n      frame: '🖼 Bingkai'\n      rename: '✏️ Ganti nama'\n      search_gif: '🔎 Cari GIF'\n      coedit: '👥 Pengeditan bersama'\n      catalog_add: '🗂 Tambahkan ke katalog'\n      catalog_edit: 'Edit di katalog'\n      catalog_delete: 'Hapus dari katalog'\n      catalog_share: '🔗️️ Bagikan'\n      catalog_open: '📂 Buka di katalog'\n    error:\n      not_found: |\n        Kesalahan!\n        Tidak dapat menemukan stiker.\n      invalid_png: |\n        Kesalahan!\n        File bukan gambar PNG yang valid. Silakan konversi ke format PNG sebelum mengirim.\n      invalid_dimensions: |\n        Kesalahan!\n        Dimensi stiker tidak valid. Stiker harus berukuran 512x512 piksel.\n      invalid_animated: |\n        Kesalahan!\n        File stiker animasi tidak dalam format TGS yang benar.\n      invalid_video: |\n        Kesalahan!\n        File video tidak dalam format WEBM yang benar.\n      restore: |\n        Kesalahan!\n        Tidak dapat memulihkan paket.\n      copy: |\n        Kesalahan!\n        Tidak dapat menemukan paket.\n    select_group:\n      success: |\n        Paket <a href=\"${link}\">${title}</a> berhasil dipilih untuk grup.\n      access_rights:\n        add: Siapa yang dapat menambahkan stiker ke paket grup?\n        delete: Siapa yang dapat menghapus stiker dari paket grup?\n        rights:\n          all: Semua orang\n          admins: Hanya admin\n      error: |\n        Kesalahan!\n        Set tidak ditemukan.\n  sticker:\n    answerCbQuery:\n      delete: Stiker berhasil dikeluarkan dari kemasannya.\n      restored: Stiker berhasil disimpan ke paket saat ini.\n    delete: Stiker berhasil dikeluarkan dari kemasannya.\n    restored: Stiker berhasil disimpan ke paket saat ini.\n    btn:\n      delete: '🗑 Hapus'\n      copy: '🌟 Salin'\n      restore: '✅ Mengembalikan'\n    error:\n      not_found: |\n        KESALAHAN!\n        Tidak dapat menemukan stiker.\n      invalid_png: |\n        <b>Kesalahan!</b>\n        File ini bukan gambar PNG yang valid. Silakan ubah ke format PNG sebelum mengirim.\n      invalid_dimensions: |\n        <b>Kesalahan!</b>\n        Ukuran stiker tidak valid. Stiker harus berukuran 512x512 piksel.\n      invalid_animated: |\n        <b>Kesalahan!</b>\n        File stiker animasi tidak dalam format TGS yang benar.\n      invalid_video: |\n        <b>Kesalahan!</b>\n        File video tidak dalam format WEBM yang benar.\n  group_settings:\n    success: |\n      Pengaturan grup berhasil diperbarui.\nsticker:\n  add:\n    ok: |\n      <b>Berhasil ditambahkan ke paket:</b>\n      <a href=\"${link}\">${title}</a>\n\n      Dalam waktu satu jam, paket ini akan diperbarui untuk semua pengguna.\n\n      <i>Kirim satu atau lebih emoji yang sesuai dengan stiker, jika Anda ingin menambahkannya</i>\n    ok_inline: |\n      <b>Berhasil ditambahkan ke paket:</b>\n      <u>${title}</u>\n    send_emoji: Bagus, sekarang kirim emoji yang cocok dengan stiker\n    converting_process: |\n      <b>Tunggu...</b>\n      File Anda sedang dalam antrian untuk konversi. Tunggu sampai selesai. Ini mungkin memakan waktu.\n\n      Kemajuan: ${progress} / ${total}\n\n      <i>Pengguna yang mendukung bot mendapatkan prioritas dalam antrian (lebih lanjut: /donasi)</i>\n    catalog_offer: |\n      <b>😲 Wow, kamu membuat paket yang hebat!</b>\n\n      Apakah Anda ingin menambahkan <a href=\"${link}\">${title}</a> ke katalog stiker publik sehingga pengguna bot lain dapat melihatnya juga?\n      <i>Tidak memakan banyak waktu</i>\n    quote: |\n      Gunakan @QuotlyBot untuk membuat kutipan dari pesan ini\n    error:\n      reply: |\n        <b>Kesalahan!</b>\n        Tolong balas stikernya.\n      no_selected_pack: |\n        <b>Anda belum memilih paket</b>\n\n        Silakan, buat (/baru) atau pilih (/paket) paket\n      no_selected_group_pack: |\n        <b>Anda belum memilih paket grup</b>\n\n        Silakan, pilih paket menggunakan perintah /packs\n      no_rights: |\n        <b>Kesalahan!</b>\n        Anda tidak memiliki hak untuk menambahkan stiker ke paket ini.\n      stickers_too_much: |\n        Paket ini memiliki jumlah stiker maksimum.\n\n        Anda dapat membuat paket baru menggunakan perintah /new.\n      have_already: |\n        <b>Stiker ini sudah ada dalam kemasan</b>\n\n        Jika ingin mengganti emoji, kirimkan melalui pesan berikut.\n      stickerset_invalid: |\n        <b>Kesalahan!</b>\n        Bot tidak dapat mengakses paket pilihan Anda saat ini.\n\n        Silakan buat (/baru) atau pilih (/paket) paket lain.\n      invalid_png: |\n        <b>Kesalahan!</b>\n        File ini bukan gambar PNG yang valid. Silakan ubah ke format PNG sebelum mengirim.\n      invalid_dimensions: |\n        <b>Kesalahan!</b>\n        Ukuran stiker tidak valid. Stiker harus berukuran 512x512 piksel.\n      invalid_animated: |\n        <b>Kesalahan!</b>\n        File stiker animasi tidak dalam format TGS yang benar.\n      invalid_video: |\n        <b>Kesalahan!</b>\n        File video tidak dalam format WEBM yang benar.\n      file_type:\n        static: |\n          <b>Kesalahan!</b>\n          Jenis file ini tidak didukung\n          Anda dapat menambahkan foto atau stiker statis ini ke paket statis\n\n          <i>Buat (/baru) atau pilih (/paket) paket lain</i>\n        video: |\n          <b>Kesalahan!</b>\n          Jenis file ini tidak didukung\n          Anda dapat menambahkan file video ini ke paket video\n\n          <i>Buat (/baru) atau pilih (/ paket) paket lain</i>\n        animated: |\n          <b>Kesalahan!</b>\n          Jenis file ini tidak didukung\n          Anda dapat menambahkan file animasi ini ke paket vektor\n\n          <i>Buat (/baru) atau pilih (/ paket) paket lain</i>\n        unknown: |\n          <b>Kesalahan!</b>\n          Jenis file ini tidak didukung\n\n          <i>Buat (/baru) atau pilih (/paket) paket lain</i>\n      wait_load: |\n        <b>Tunggu!</b>\n\n        Bot masih memproses file sebelumnya...\n        Anda dapat mendukung pengembangan bot (\\/donasi) untuk meningkatkan prioritas pemrosesan dan kemampuan untuk menambahkan lebih dari satu stiker ke antrian.\n      timeout: |\n        <b>Saat ini, bot sedang mengalami beban yang sangat besar</b>\n        Oleh karena itu, konversi video hanya tersedia untuk paket dengan peningkatan aktif\n\n        Untuk lebih jelasnya, ikuti / menyumbangkan\n      convert: |\n        <b>Kesalahan!</b>\n        Sayangnya, bot tidak dapat mengonversi video Anda.\n\n        Mungkin video Anda disimpan dalam format yang tidak dapat dipahami oleh bot. Pastikan dalam format mp4.\n        Mungkin juga kesalahan internal bot, coba kirim video ini lagi.\n      too_big: |\n        <b>Kesalahan!</b>.\n\n        File terlalu besar untuk diproses. Harap mengurangi kualitas dan durasi sebelum mengirim.\n      sticker_not_found: |\n        <b>Error!</b>\n\n        Stiker ini tidak dapat ditemukan. Pastikan stiker ini ada dalam paket yang benar atau coba tambah lagi.\nnews:\n  join: |\n    ℹ️ <a href=\"${link}\">Bergabunglah dengan saluran kami</a> untuk mendapatkan berita terbaru tentang bot.\n\n    <i>Berlangganan saluran ini untuk mendapatkan berita terbaru tentang bot, serta pembaruan dan fitur baru.</i>\n  join_btn: '📢 Bergabunglah dengan saluran'\n  not_joined: '🙅 Anda tidak berlangganan saluran tersebut'\n  continue: '✅ Melanjutkan'\nuserAbout:\n  help: |\n    <b>🧑‍🎨 Pengguna tentang</b>\n\n    Dengan menggunakan menu ini Anda dapat mengetahui informasi tentang pengguna dan paket stikernya\n\n    Untuk mendapatkan informasi tentang pengguna, gunakan tombol di bawah atau meneruskan pesannya\n  result: |\n    <b>🧑‍🎨 Info pengguna</b>\n    <b>🆔 ID Pengguna:</b> <code>${userId}</code>\n    <b>🎨 Pack dari pengguna ini:</b>\n    ${packs}\n  no_packs: |\n    <i>Kami tidak memiliki informasi tentang stiker pemilik ini</i>\n  forward_hidden: |\n    Pengguna telah menyembunyikan kemampuan untuk meneruskan pesan. Gunakan tombol di bawah untuk melihat paket stikernya.\n  select_user: '🧑‍🎨 Pilih pengguna'\nscenes:\n  new_pack:\n    pack_type: |\n      <b><u>Pilih jenis paket</u></b>\n    regular: '😊 Stiker'\n    custom_emoji: '🌟Emoji (premium)'\n    static: '🌟 Statis'\n    animated: '✨ Vektor'\n    video: '📹 Video'\n    pack_format: |\n      <b><u>Pilih jenis paket</u></b>\n\n      <b>Umum</b> - statis (tidak bergerak), raster, file format - sebelum menambahkan PNG (bot sedang memproses), setelah menambahkan - WEBP.\n      Contoh paket reguler - t.me/addstickers/Animals\n\n      <b>Video</b> - paket video animasi. Anda dapat menambahkan video, gif, dan foto apa pun.\n      Contoh paket video - t.me/addstickers/TheMascot\n\n      <b>Animasi</b> - animasi, vektor (mereka memiliki deskripsi yang tepat tentang objek di dalam file, karena yang ditampilkan dengan jelas pada skala apa pun), format file - TGS, variasi dari format Lottie.\n      Contoh paket animasi - t.me/addstickers/IsabelleShizue\n\n      <i>Kumpulan stiker animasi dan video dapat berisi hingga 50 stiker. Set stiker statis dapat memiliki hingga 120 stiker.</i>\n    pack_title: |\n      <b>Masukkan nama paket stiker baru:</b>\n      <i>Anda dapat memilih nama acak pada tombol.</i>\n    pack_name: |\n      <b>Masukkan tautan pendek untuk paket stiker baru:</b>\n\n      <i>Misalnya, paket ini menggunakan 'Hewan' sebagai tautan pendek: https://t.me/ addstickers/<u>Hewan</u></i>\n      <i>Anda dapat memilih tautan pendek acak pada tombol.</i>\n    ok: |\n      Paket <a href=\"${link}\">${title}</a> berhasil dibuat!\n\n      <b>Tautan paket:</b> <pre>${link}</pre>\n\n      Kirim file, foto, video atau stiker agar saya tambahkan ke set Anda\n    error:\n      title_long: Nama tidak boleh lebih dari ${max} simbol.\n      name_long: Alamat tidak boleh lebih dari ${max} simbol.\n      telegram:\n        name_invalid: Alamat itu tidak dapat digunakan.\n        name_occupied: Alamat ini sudah dipakai.\n        upload_failed: |\n          <b>Kesalahan!</b>\n          Bot tidak dapat mengunggah stiker ke Telegram.\n\n          Silakan coba lagi nanti.\n  copy:\n    enter: |\n      Saya bisa menyalinnya, tapi sebelum itu, mari buat paket baru\n    progress: |\n      Menyalin paket stiker dari <a href=\"${originalLink}\">${originalTitle}</a> ke <a href=\"${link}\">${title}</a>\n\n      Proses: ${current}/${total}\n    done: |\n      Menyalin paket stiker dari <a href=\"${originalLink}\">${originalTitle}</a> ke <a href=\"${link}\">${title}</a> berhasil diselesaikan.\n    pay: |\n      <b>Konversi Paket</b>\n\n      Mengonversi paket dari satu jenis ke jenis lain biaya 1 kredit\n\n      <b>Saldo saat ini:</b> ${balance} Kredit\n\n      Beli kredit: /donate\n    pay_btn: '✅ Konfirmasi'\n    error:\n      premium: |\n        <b>Error!</b>\n        Sayangnya, fitur ini hanya tersedia bagi mereka yang mendukung bot.\n\n        Anda dapat melakukan ini dengan mengirimkan perintah /donate.\n  original:\n    enter: |\n      Kirim stiker yang ditambahkan melalui bot ini dan saya akan menunjukkan stiker aslinya.\n    error:\n      not_found: |\n        <b>Kesalahan!</b>\n        Saya tidak dapat menemukan stiker aslinya.\n  delete:\n    enter: |\n      Kirim stiker yang ditambahkan melalui bot ini dan saya akan menunjukkan stiker aslinya.\n    confirm: |\n      Apakah Anda yakin ingin menghapus ini?\n    error:\n      not_found: |\n        <b>Kesalahan!</b>\n        Saya tidak dapat menemukan paketnya.\n  rename:\n    enter_name: |\n      <b>Masukkan judul baru untuk <a href=\"${link}\">${title}</a>:</b>\n    success: |\n      <b>Judul berhasil diubah!</b>\n\n      Judul baru: <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      ❕ Untuk menghapus akhiran \"<code>${titleSuffix}</code>\", Anda perlu meningkatkan paket. Lebih detail di menu dengan mengunjungi:\\/donate\n  packAbout:\n    enter: |\n      <b>Kirimi saya stiker atau emoji khusus untuk mencari informasi tentangnya:</b>\n    not_found: |\n      Kesalahan! Saya tidak dapat menemukan paketnya\n    result: |\n      <b>📦 Pack:</b> <a href=\"${link}\">${name}</a>\n      🆔 <code>${setId}</code> <i>(Nomor unik untuk pack pemilik, bertambah per pack)</i>\n\n      🧑‍🎨 ID Pemilik: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 Pack lain dari pemilik ini:</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>Kami tidak memiliki informasi tentang stiker lain dari pemilik ini</i>\n  boost:\n    sure: |\n      <b>Apakah Anda yakin ingin meningkatkan <a href=\"${link}\">${title}</a>?</b>\n\n      Meningkatkan akan meningkatkan prioritas pemrosesan dan kemampuan untuk menambahkan lebih dari satu stiker ke antrian\n      Anda dapat menemukan informasi lebih detail tentang peningkatan di menu dengan mengunjungi: /donate\n\n      <b>Harga:</b> 1 Kredit\n      <b>Saldo saat ini:</b> ${balance} Kredit\n    btn:\n      yes: Ya, tingkatkan!\n      no: Tidak, batalkan\n    canceled: |\n      Peningkatan dibatalkan\n    success: |\n      Peningkatan berhasil diselesaikan!\n\n      ${title} kini ditingkatkan\n    error:\n      not_enough_credits: |\n        Anda tidak memiliki cukup Kredit untuk meningkatkan paket ini.\n\n        Anda dapat mengisi kembali saldo Anda dengan mengirimkan perintah /donate.\n      already_boosted: |\n        Paket ini sudah ditingkatkan.\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>Kirimi saya stiker dari paket stiker yang ingin Anda publikasikan</b>\n\n        <i>Anda dapat menerbitkan paket stiker apa pun milik Anda, meskipun dibuat di tempat lain</i>\n      owner_proof: |\n        <b>Untuk memverifikasi kepemilikan paket stiker ini, Anda perlu mengikuti beberapa langkah mudah:</b>\n        1. Buka @Stickers bot\n        2. Perintah Kirim <code>/packstats</code>\n        3. Temukan dan pilih paket stiker yang diperlukan\n        4. Teruskan menerima pesan ke bot\n      publish_new_access_denied: |\n        <b>Kesalahan!</b>\n        Paket stiker ini bukan milik Anda.\n\n        Anda hanya dapat mempublikasikan paket stiker Anda sendiri\n      banned: |\n        <b>Kesalahan!</b>\n        Anda dilarang menggunakan fitur ini.\n        Silakan, hubungi administrator.\n      enter: |\n        Anda akan memublikasikan paket \"<a href=\"${link}\">${title}</a>\" di direktori publik bot kami\n        Paket ini dapat ditemukan oleh pengguna bot mana pun berdasarkan nama, tag, atau filter\n        Karena itu, lebih banyak orang akan menginstal paket Anda\n        Cobalah untuk hanya mengirim paket berkualitas tinggi yang mungkin menarik bagi banyak orang\n\n        <b>Aturan untuk penerbitan paket:</b>\n        • Jangan mempublikasikan paket pribadi Anda yang ditujukan untuk kalangan sempit. Misalnya, seperti wajah teman Anda atau kutipan dari pesan Anda\n        • Jangan memasang stiker bertekanan yang melanggar undang-undang UE atau undang-undang setempat lainnya\n\n        Anda harus mengirimkan informasi tambahan agar dapat diterbitkan dalam katalog\n      continue_button: Melanjutkan\n      enter_description: |\n        <b>Jelaskan secara singkat paket stiker Anda sehingga orang lain dapat menemukannya</b>\n\n        <i>Anda juga dapat menggunakan tagar untuk mengkategorikan [#]</i>\n        <i>Misalnya: #anime #meme #animals #cute #kpop #funny #cat #game </i>\n      select_language: |\n        <b>Pilih bahasa untuk paket stiker Anda:</b>\n        <i>Anda dapat memilih beberapa bahasa</i>\n      button_all_languages: All languages\n      button_confirm_language: Konfirmasi\n      set_safe: |\n        <b>Is your sticker pack safe for users?</b>\n        <i>That is, it does not contain erotica and other shocking content</i>\n      button_safe:\n        safe: Yes, it is safe\n        not_safe: No, it is not safe\n      no_tags: tidak ditentukan\n      confirm: |\n        <b>Konfirmasi publikasi paket stiker \"<a href=\"${link}\">${title}</a>\"</b>\n\n        <b>Deskripsi:</b> <i>${description}</i>\n\n        <b>Tag:</b> ${tags}\n        <b>Bahasa:</b> ${languages}\n      button_confirm: '✅ Confirm publication'\n      success: |\n        Congratulations, your sticker pack has been published in the general directory of our bot where other users can find it!\n\n        You can edit the stickerpack search information by selecting the stickerpack with the /packs command.\n\n        <i>We remind you that the statistics of your sticker pack can be viewed through the official bot @Stickers</i>\n    unpublish:\n      success: |\n        Paket telah berhasil dibatalkan publikasinya dari katalog bot.\n  delete_pack:\n    enter: |\n      Apakah Anda yakin ingin menghapus paket <a href=\"${link}\">${title}</a>?\n      Ini akan dihapus secara permanen dan tidak dapat dipulihkan.\n\n      Jika ingin menghapus satu stiker saja, gunakan perintah /delete.\n\n      Kirim <code>${confirm}</code> untuk mengonfirmasi bahwa Anda benar-benar ingin menghapus paket ini.\n    confirm: Ya, saya sangat yakin.\n    success: |\n      <b>Paket berhasil dihapus!</b>\n    error:\n      - <b>Kesalahan!</b>\n      - Oops, Terjadi suatu kesalahan.\n  frame:\n    no_video: |\n      <b>Kesalahan!</b>\n      Anda hanya dapat menambahkan bingkai ke paket video.\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>Pilih jenis bingkai:</b>\n      Bingkai adalah latar belakang transparan di sekitar stiker\n\n      <code>persegi</code> — bingkai berbentuk persegi panjang, yaitu tidak akan diubah dengan cara apa pun\n      <code>lite</code> — sudutnya akan potong sedikit\n      <code>dibulatkan</code> — sudut akan dibulatkan\n      <code>lingkaran</code> — bingkai akan berbentuk lingkaran\n\n      <i>Di masa mendatang, Anda dapat menggunakan perintah /frame untuk mengatur jenis bingkai</i>\n    types:\n      lite: '1. Ringan'\n      medium: '2. Sedang'\n      rounded: '3. Bulat'\n      square: '4. Kotak'\n      circle: '5. Lingkaran'\n    selected: |\n      <b>Jenis bingkai yang dipilih:</b> ${type}\n  photoClear:\n    enter: |\n      Kirim <u>foto</u> yang ingin Anda hapus latar belakangnya dan saya akan mengirimkan file tanpa latar belakang\n\n      <i>Berfungsi paling baik dengan foto. Bekerja lebih buruk dengan gambar, ilustrasi, dll.</i>\n    enter_anime: |\n      Kirim <u>foto</u> dari mana Anda ingin menghilangkan latar belakang dan saya akan mengirimkan file tanpa latar belakang\n\n      <i>Ini bekerja terbaik dengan gambar anime</i>\n    choose_model: |\n      <b>Pilih model:</b>\n    web_app: WebApp - untuk foto bersama orang\n    model:\n      ordinary: Umum — untuk foto bersama orang\n      general: Umum — untuk foto apa pun\n      anime: Anime — untuk gambar anime\n      birefnet_general: BirefNet - untuk foto apa pun\n    add_to_set_btn: '🌟 Tambahkan ke set'\n    error: |\n      <b>Kesalahan!</b>\n      Ups, ada yang tidak beres.\n  leave: |\n    Aksi dibatalkan.\n  btn:\n    cancel: '❌ Batal'\nerror:\n  telegram: |\n    <b>Telegram mengembalikan kesalahan!</b>\n    <code>${error}</code>\n  answerCbQuery:\n    telegram: |\n      Telegram mengembalikan kesalahan:\n      ${error}\n  banned: |\n    <b>Kesalahan!</b>\n    Anda dilarang menggunakan fitur ini.\n\n    <i>Jika Anda berpikir ini adalah kesalahan, silakan hubungi administrator: @ly_oBot</i>\n  unknown: |\n    <b>Kesalahan yang tidak diketahui telah terjadi, silakan coba lagi.</b>\n\n    Jika masalah berlanjut, maka tulislah untuk @Ly_oBot.\n    Harap segera menulis tentang bot mana yang Anda bicarakan dan jelaskan masalahnya secara rinci dalam satu pesan.\n"
  },
  {
    "path": "locales/ja.yaml",
    "content": "---\nlanguage_name: '🇯🇵 日本語'\nname: fStik — ステッカー＆絵文字\ndescription:\n  long: |\n    写真、動画、GIFからステッカーと絵文字を作成 – 手動変換不要、ボットが全て処理します。\n\n    機能:\n    • パック管理\n    • 動画ステッカーとカスタム絵文字\n    • オリジナルをダウンロード\n    • 画像に変換\n    • ステッカーカタログ\n\n    ステッカーを検索: play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    写真・動画・GIFからステッカーと絵文字を作成。カタログと検索機能。🇺🇦\nratelimit: そう頻繁ではない！\ncmd:\n  start:\n    enter: |\n      🧙 こんにちは、 ${name}！私は絵文字とステッカー パックのウィザードです。\n      写真、ビデオ、GIF を数回クリックするだけでクールなステッカーに変換できます\n\n      私の機能について詳しく知るには、/help コマンドを送信してください\n\n      💬 ヘルプが必要ですか? @fStikCommunity のサポート チャットに参加してください (英語のみ)\n    group: |\n      🧙 こんにちは、${groupTitle}！ 私は絵文字とステッカーパックの魔法使いです。\n\n      グループパックにステッカーを追加するには、写真、ビデオ、GIF、またはステッカーに返信して/ssコマンドを使用してください。\n    catalog: |\n      <b>😻 カタログで新たなステッカーを見つけよう〜</b>\n\n      ・このボタンを押すと、色々なステッカーを手に入れる事ができます\n      ・キーワードまたはタブで検索しよう\n      ・おすすめしたいまたはしたかないステッカーをレートして下さい\n    commands:\n      ss: '🌟 ステッカーを保存'\n      start: '📜 スタートメニュー'\n      help: '📖 ヘルプ'\n      packs: '📁 パックを管理'\n      new: '🌝 ステッカーパックを作成'\n      catalog: '📖 カタログ'\n      publish: '📤 パックを公開'\n      delete: '❌ ステッカーを削除'\n      original: '🎯 元のステッカーを検索'\n      restore: '🔀 パックを復元'\n      copy: '📋 パックをコピー'\n      emoji: '📝 絵文字を変更'\n      round: '丸'\n      clear: '🖼️ 背景を写真から削除する'\n      about: '📦 パック情報'\n      user_about: '🧑‍🎨 クリエイター情報'\n      lang: '🌐 言語を変更'\n      report: '🚨 レポート パック'\n      donate: '☕️ 開発者をサポート'\n      add_to_group: '👥 グループに追加'\n      privacy: '🔒 プライバシーポリシー'\n    btn:\n      new: '📥 新規作成'\n      catalog: '💖 カタログを開く'\n      catalog_mini: '💖 カタログ'\n      catalog_browser: 'ブラウザで開く'\n      catalog_browser_mini: '🌐 ブラウザで'\n      catalog_app: '📱 Android アプリをダウンロード'\n      catalog_app_mini: '📱 Android アプリ'\n  inline:\n    switch_pm: '📁 パックを選択'\n  restore: |\n    <b>🗃️ パックの復元</b>\n\n    パックを復元するには、復元したいパックへのリンクを送信する必要があります\n  copy: |\n    <b>🗄 パックをコピー</b>\n\n    別のパックを新しいパックにコピーするには、ステッカーまたは絵文字パックへのリンクを送信するだけです\n  report: |\n    <b>🚨 報告</b>\n\n    法律に違反する可能性がある、または Telegram の利用規約に違反する可能性があると思われるステッカー パックを見つけた場合は、そのリンクを @ に送信して報告してください。 StickersReportBot\n\n    <i>ボットはパックの内容に対して責任がなく、それを制御する機能もないことに注意してください</i>\n  packs:\n    info: |\n      <b>📁 パック</b>\n    types:\n      regular: ステッカー\n      custom_emoji: 絵文字\n      inline: Inline\n    empty: |\n      <b>パックはまだありません。</b>\n      作成するには/new コマンドを入力してください\n    inline_title: インラインパック\n    select_group_pack_info: |\n      <b>📁 パックを選択する</b>\n\n      グループでパックを使用するには、以下のボタンを使用して管理者がパックを選択する必要があります\n    select_group_pack: パックを選択\n  emoji:\n    info: |\n      現在のパックのデフォルト絵文字を変更するには、<code>/emoji</code> の後にスペースを空けて絵文字を送信してください。\n\n      例 - <code>/emoji 🌟</code>\n    done: 絵文字が正常に変更されました。\n    error: 絵文字の変更中にエラーが発生しました！\n  round_video:\n    enabled: |\n      これから動画の形が丸くなります\n    disabled: |\n      動画の形はもう丸くではありません\n  paysupport: |\n    <b>👨‍💻 支払いサポート</b>\n\n    ボットの操作に関連するすべての問題、支払いと寄付を含む、直接開発者に連絡することができます。\n\n    <b>連絡先:</b>\n    🧑‍💻 開発者: @ly_oBot\ndonate:\n  menu: |\n    <b>☕️ Bot開発支援</b>\\nBotの開発を支援することにより、クレジットを受け取ることができます\\n\\n<b>残高:</b> <code>${balance}</code> クレジット\\n1クレジットで1パックをブーストすることができます。\\n\\n<b>ブーストの利点:</b>\\n➖ パック名から \"<code>${titleSuffix}</code>\" を無効にする <i>(リンク内では無効になりません)</i>\\n➖ タイトルを最大64文字まで (35文字の代わりに)\\n➖ 動画を最大35秒まで\\n➖ 優先変換キュー\\n➖ 複数のステッカーを同時に\\n➖ 広告なし\\n\\n<b>購入したいクレジットの金額を選択してください:</b>\n  btn:\n    donate: '☕ 寄付'\n  topup: |\n    <b>購入したいクレジットの金額を入力してください:</b>\n  invalid_amount: |\n    <b>無効な量</b>\n\n    最小は1クレジットです\n  paymenu: |\n    <b>${amount}クレジット</b> を <b>${price}$</b> で購入する\n\n    ⚠️ クレジットは管理者によって手動で発行されます。\n    待機時間は5分から1時間の範囲です。\n\n    <u>支払い方法を選択してください:</u>\n  description: |\n    クレジットを購入することで、ボットの開発をサポートし、追加の機能を利用する機会が得られます。\n  update: |\n    <b>🔄 残高更新</b>\n\n    残高: <code>${balance}</code> クレジット (追加 <code>${amount}</code> クレジット)\n  error:\n    already_donated: |\n      この支払いに対して既にクレジットを受け取っています。\n    error: |\n      <b>エラー！</b>\n      支払い処理中にエラーが発生しました\n    canceled: |\n      支払いがキャンセルされました\ncoedit:\n  info: |\n    <b>👥 共同編集</b>\n\n    共同編集のリンク <a href=\"${link}\">${title}</a>: <code>${colink}</code>\n\n    <b>使い方:</b>\n    1. パックへのアクセスを許可したい人にリンクを送信します\n    2. リンクをクリックした後、\"start\"を押して編集者に追加されます\n    3. 編集者は、パック内のステッカーを追加、削除、編集できます\n\n    <b>編集者:</b>\n    ${editors}\n\n    <i>編集者を削除するには、リンクをリセットする必要があります</i>\n  no_editors: |\n    編集者はまだいません。\n  btn:\n    send: '📤 リンクを送信'\n    reset: '🔁 リンクをリセット'\n  share: |\n    リンクをフォローし、\"start\"を押してパック \"${title}\" を共同編集します\n  reset: |\n    <b>🔁 リンクのリセットが成功しました</b>\n\n    共同編集用の新しいリンク <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: パックが見つかりません\n      not_owner: これはあなたのパックではありません\n      hidden: パックを非表示にしました\n      restored: パックを復元しました\n    set_pack: |\n      🌟 <a href=\"${link}\">${title}</a> パックを選択しました\n\n      <b>❔ 追加方法</b>\n      パックに追加する写真、ビデオ、ステッカーを送信してください\n    set_inline_pack: |\n      選択された<u>${title}</u>パック\n\n      これを使用するには、任意のチャットで<code>@${botUsername} </code>とスペースを入力してください\n      下のボタンを押しても使用できます\n    boost:\n      info: |\n        \\n⚡ <b><a href=\"https://t.me/${botUsername}?start=boost\">ブースト</a></b>: ${boostStatus}\n      status:\n        on: 有効\n        off: 無効\n    hidden: <a href=\"${link}\">${title}</a> をリストから非表示にします。\n    restored: <a href=\"${link}\">${title}</a> をリストに復元しました。\n    btn:\n      hide: '❌ パックを隠す'\n      delete: '🗑 パックを削除'\n      restore: '✅ リストア'\n      use_pack: '📦 パックを使う'\n      boost: '⚡ ブースト'\n      frame: '🖼 フレーム'\n      rename: '✏️ 名前を変更'\n      search_gif: '🔎 GIFを検索'\n      coedit: '👥 共同編集'\n      catalog_add: '🗂 カタログに追加'\n      catalog_edit: '📝 カタログで編集'\n      catalog_delete: '🗑 カタログから削除'\n      catalog_share: '🔗 共有'\n      catalog_open: '📂 カタログで開く'\n    error:\n      not_found: |\n        エラー！\n        ステッカーが見つかりません。\n      invalid_png: |\n        エラー！\n        ファイルが有効なPNG画像ではありません。送信する前にPNG形式に変換してください。\n      invalid_dimensions: |\n        エラー！\n        ステッカーの寸法が無効です。ステッカーは512x512ピクセルである必要があります。\n      invalid_animated: |\n        エラー！\n        アニメーションステッカーファイルが正しいTGS形式ではありません。\n      invalid_video: |\n        エラー！\n        ビデオファイルが正しいWEBM形式ではありません。\n      restore: |\n        エラー!\n        パックを復元できません。\n      copy: |\n        エラー!\n        パックが見つかりません。\n    select_group:\n      success: |\n        パック <a href=\"${link}\">${title}</a> がグループに正常に選択されました。\n      access_rights:\n        add: 誰がグループパックにステッカーを追加できますか？\n        delete: 誰がグループパックからステッカーを削除できますか？\n        rights:\n          all: 全員\n          admins: 管理者のみ\n      error: |\n        エラー！\n        セットが見つかりません。\n  sticker:\n    answerCbQuery:\n      delete: ステッカーはパックから正常に削除されました。\n      restored: ステッカーを現在のパックに正常に保存しました。\n    delete: ステッカーはパックから正常に削除されました。\n    restored: ステッカーを現在のパックに正常に保存しました。\n    btn:\n      delete: '🗑 削除'\n      copy: '🌟 コピー'\n      restore: '✅ リストア'\n    error:\n      not_found: |\n        エラー！\n        ステッカーが見つかりません。\n      invalid_png: |\n        <b>エラー！</b>\n        ファイルが有効なPNG画像ではありません。送信する前にPNG形式に変換してください。\n      invalid_dimensions: |\n        <b>エラー！</b>\n        ステッカーのサイズが無効です。ステッカーは512x512ピクセルでなければなりません。\n      invalid_animated: |\n        <b>エラー！</b>\n        アニメーションステッカーファイルが正しいTGS形式ではありません。\n      invalid_video: |\n        <b>エラー！</b>\n        ビデオファイルが正しいWEBM形式ではありません。\n  group_settings:\n    success: |\n      グループ設定が正常に更新されました。\nsticker:\n  add:\n    ok: |\n      <b>パックに正常に追加されました：</b>\\n<a href=\"${link}\">${title}</a>\\n\\n1時間以内に、このパックはすべてのユーザーに更新されます。\\n\\n<i>ステッカーに一致する絵文字を1つ以上送信して追加したい場合は追加してください</i>\n    ok_inline: |\n      <b></b>\n      <u>${title}</u> を追加しました\n    send_emoji: わかりました、次に一致する絵文字を送信してください\n    converting_process: |\n      <b>お待ちください...</b>\n      ファイルは変換待ちキューに入っています。完了までお待ちください。これは時間がかかる場合があります。\n\n      進捗: ${progress} / ${total}\n\n      <i>ボットをサポートしたユーザーはキュー内で優先されます（詳細: /donate）</i>\n    catalog_offer: |\n      <b>😲 すごい、素晴らしいパックができました！</b>\n\n      ボットの他のユーザーも見られるように、 <a href=\"${link}\">${title}</a> を公開ステッカーカタログに追加しますか？\n      <i>時間はかかりませんよ</i>\n    quote: |\n      このメッセージから引用を作成するには @QuotlyBot を使用してください\n    error:\n      reply: |\n        <b>エラー!</b>\n        ステッカーに返信してください。\n      no_selected_pack: |\n        <b>パック</b>\n\n        を選択してください(/new) またはパック (/packs) を選択してください\n      no_selected_group_pack: |\n        <b>グループパックを選択していません</b>\n\n        /packs コマンドを使用してパックを選択してください\n      no_rights: |\n        <b>Error!</b>\n        このパックにステッカーを追加する権限がありません。\n      stickers_too_much: |\n        このパックにはステッカーの最大数があります。\n\n        /new コマンドを使用して新しいパックを作成できます。\n      have_already: |\n        <b>このステッカーはすでにパックに入っています</b>\n\n        絵文字を変更したい場合は、次のメッセージで送信してください。\n      stickerset_invalid: |\n        <b>エラー！</b>\n        Botは現在選択されているパックにアクセスできません。\n\n        (/new) を作成するか、別のパックを選択してください。\n      invalid_png: |\n        <b>エラー！</b>\n        ファイルが有効なPNG画像ではありません。送信する前にPNG形式に変換してください。\n      invalid_dimensions: |\n        <b>エラー！</b>\n        ステッカーのサイズが無効です。ステッカーは512x512ピクセルでなければなりません。\n      invalid_animated: |\n        <b>エラー！</b>\n        アニメーションステッカーファイルが正しいTGS形式ではありません。\n      invalid_video: |\n        <b>エラー！</b>\n        ビデオファイルが正しいWEBM形式ではありません。\n      file_type:\n        static: |\n          <b>エラー!</b>\n          このファイルタイプはサポートされていません\n          この写真または静的ステッカーを静的パックに追加できます\n\n          <i>作成 (/新規) または選択してください(/パック) 別のパック</i>\n        video: |\n          <b>エラー!</b>\n          このファイルの種類はサポートされていません\n          このビデオ ファイルをビデオ パックに追加できます\n\n          <i>作成 (/新規) または (/パック) 別のパック</i>\n        animated: |\n          <b>エラー!</b>\n          このファイルタイプはサポートされていません\n          このアニメーションファイルをベクターパックに追加できます\n\n          <i>別のパックを作成 (/new) するか、選択 (/packs) します</i>\n        unknown: |\n          <b>エラー！</b>\n          このファイルタイプはサポートされていません。\n\n          <i>作成（/new）するか、別のパック</i> を選択してください。\n      wait_load: |\n        <b>待ってください！</b>\n\n        ボットはまだ前のファイルを処理中です...\n        処理の優先順位を上げたり、キューに複数のステッカーを追加できるように、ボットの開発を支援することができます (／donate)。\n      timeout: |\n        <b>現在、ボットに大きな負荷がかかっています</b>\n        そのため、ビデオ変換はブーストが有効になっているパックでのみ利用可能です\n\n        詳細については、/donateをフォローしてください\n      convert: |\n        <b>エラー!</b>\n        残念ながら、ボットはビデオを変換できませんでした。\n\n        おそらくあなたのビデオはボットが理解できない形式で保存されています。 mp4形式であることを確認してください。\n        ボットの内部エラーである可能性もあります。このビデオをもう一度送信してみてください。\n      too_big: |\n        <b>エラー！</b>.\n\n        ファイルが大きすぎて処理できません。送信する前に品質と期間を減らしてください。\n      sticker_not_found: |\n        <b>エラー!</b>\n\n        このステッカーは見つかりませんでした。正しいパックにあることを確認するか、もう一度追加してみてください。\nnews:\n  join: |\n    ℹ️ <a href=\"${link}\">チャンネル</a> に参加して、ボットに関する最新ニュースを入手してください。\n\n    <i>チャンネルに登録して、ボットに関する最新ニュース、アップデートや新機能を入手してください。</i>\n  join_btn: '📢 チャンネルに参加'\n  not_joined: '🙅 チャンネルを購読していません'\n  continue: '✅ 続行'\nuserAbout:\n  help: |\n    <b>🧑‍🎨</b>\n\n    このメニューを使用すると、ユーザーとそのステッカー パックに関する情報を見つけることができます\n\n    ユーザーに関する情報を取得するには、 ボタンを使用してください彼のメッセージを以下または転送してください\n  result: |\n    <b>🧑‍🎨 ユーザー情報</b>\n    <b>🆔 ユーザーID:</b> <code>${userId}</code>\n    <b>🎨 このユーザーのパック:</b>\n    ${packs}\n  no_packs: |\n    <i>このオーナーのステッカーに関する情報はありません</i>\n  forward_hidden: |\n    ユーザーはメッセージを転送する機能を隠しました。ステッカーパックを表示するには、下のボタンを使用してください。\n  select_user: '🧑‍🎨 ユーザーを選択'\nscenes:\n  new_pack:\n    pack_type: |\n      <b><u>パックタイプ</u></b>\n    regular: '😊 ステッカー'\n    custom_emoji: '🌟 絵文字 (プレミアム)'\n    static: '🌟 静止画'\n    animated: '✨ ベクター'\n    video: '📹 ビデオ'\n    pack_format: |\n      <b><u>パックの種類を選択</u></b>\n\n      <b>共通</b> - 静的 (移動しない)、ラスター、ファイル形式 - PNG 追加前 (ボットが処理中)、追加後 - WEBP。\n      通常パックの例 - t.me/addstickers/Animals\n\n      <b>ビデオ</b> - アニメーションビデオパック。ビデオ、gif、写真を追加できます。\n      サンプル ビデオ パック - t.me/addstickers/TheMascot\n\n      <b>アニメーション</b> - アニメーション、ベクター (ファイル内のオブジェクトの正確な説明が含まれているため、ファイル形式 - TGS、Lottie 形式のバリエーション。\n      アニメーション パックの例 - t.me/addstickers/IsabelleShizue\n\n      <i>アニメーションおよびビデオ ステッカー セットには、最大 50 個のステッカーを含めることができます。静的ステッカー セットには、最大 120 枚のステッカーを含めることができます。</i>\n    pack_title: |\n      <b>新しいステッカーパックの名前を入力してください：<i>ボタンでランダムな名前を選択できます。</i></b>\n    pack_name: |\n      <b>新しいステッカーパックの短縮リンクを入力してください:</b>\n\n      <i>たとえば、このパックは「動物」を短縮リンクとして使用します: https://t.me/addstickers/<u>動物</u></i>\n      <i>ボタンでランダムに短縮リンクを選択できます。</i>\n    ok: |\n      パック <a href=\"${link}\">${title}</a> 正常に作成されました!\n\n      <b>パックのリンク:</b> <pre>${link}</pre>\n\n      ファイル、写真、ビデオ、またはステッカーを送信して、セットに追加します\n    error:\n      title_long: 名前は${max}文字を超えることはできません。\n      name_long: アドレスは${max}文字を超えることはできません。\n      telegram:\n        name_invalid: そのアドレスは使用できません。\n        name_occupied: このアドレスはすでに使用されています。\n        upload_failed: |\n          <b>エラー！</b>\n          BotはTelegramにステッカーをアップロードできません。\n\n          後でもう一度お試しください。\n  copy:\n    enter: |\n      コピーできますが、その前に新しいパックを作りましょう\n    progress: |\n      <a href=\"${originalLink}\">${originalTitle}</a> から <a href=\"${link}\">${title}</a>\n\n      進行状況: ${current}/${total}\n    done: |\n      <a href=\"${originalLink}\">${originalTitle}</a> から <a href=\"${link}\">${title}</a> へのパックのコピーが正常に完了しました。\n    pay: |\n      <b>パック変換</b>\n\n      パッケージの1つのタイプから別のタイプへの変換には1クレジットが必要です。\n\n      <b>現在の残高:</b> ${balance}クレジット\n\n      クレジットを購入する: /donate\n    pay_btn: '✅ 確認'\n    error:\n      premium: |\n        <b>エラー！</b>\n        この機能は寄付メンバーのみが利用可能です。\n\n        /donate コマンドを送信してこれを行うことができます。\n  original:\n    enter: |\n      このボットを介して追加されたステッカーを送信してください。元のステッカーを表示します。\n    error:\n      not_found: |\n        <b>エラー！</b>\n        元のステッカーが見つかりませんでした。\n  delete:\n    enter: |\n      このボットを通して追加されたステッカーを送信すると、パックから削除します。\n    confirm: |\n      このステッカーを削除してもよろしいですか？\n    error:\n      not_found: |\n        <b>エラー!</b>\n        ステッカーが見つかりません。\n  rename:\n    enter_name: |\n      <b>新しいタイトルを <a href=\"${link}\">${title}</a>:</b>\n    success: |\n      <b>タイトルが正常に変更されました!</b>\n\n      新しいタイトル: <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      ❕ サフィックス \"<code>${titleSuffix}</code>\" を削除するには、パックをブーストする必要があります。 詳細はメニューでご確認ください: \\/donate\n  packAbout:\n    enter: |\n      <b>ステッカーやカスタム絵文字を送ってそれに関する情報を調べてみましょう:</b>\n    not_found: |\n      ステッカーが見つかりませんでした\n    result: |\n      <b>📦 パック:</b> <a href=\"${link}\">${name}</a>\n      🆔 <code>${setId}</code> <i>(所有者のパックごとにインクリメントされる一意の番号)</i>\n\n      🧑‍🎨 オーナーID: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 このオーナーからの他のパック:</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>この所有者の他のステッカーに関する情報はありません</i>\n  boost:\n    sure: |\n      <b><a href=\"${link}\">${title}</a> を本当にブーストしますか?</b>\n\n      ブーストすると、処理の優先順位が上がり、キューに複数のステッカーを追加できるようになります。\n      詳細については、メニューから /donate を参照してください。\n\n      <b>価格:</b> 1クレジット\n      <b>現在の残高:</b> ${balance}クレジット\n    btn:\n      yes: はい、ブースト！\n      no: いいえ、キャンセル\n    canceled: |\n      ブーストはキャンセルされました\n    success: |\n      ブーストが正常に完了しました！\n\n      ${title} がブーストされました\n    error:\n      not_enough_credits: |\n        このパックをブーストするためのクレジットが不足しています。\n\n        バランスをトップアップするには、 /donate コマンドを送信してください。\n      already_boosted: |\n        このパックは既にブーストされています。\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>公開したいパックからステッカーを送ってください</b>\n\n        <i>あなたが所有するどのパックでも公開できます、他の場所で作成されたものであっても</i>\n      owner_proof: |\n        <b>このパックの所有権を確認するには、いくつかの簡単な手順に従う必要があります:</b>\n        1. @Stickers ボットを開きます\n        2. <code>を送信します/packstats</code> コマンド\n        3. 必要なパックを見つけて選択します\n        4. 受信したメッセージをボットに転送します\n      publish_new_access_denied: |\n        <b>エラー!</b>\n        このパックはあなたのものではありません。\n\n        自分のパックのみを公開できます\n      banned: |\n        <b>エラー！</b>\n        この機能の使用は禁止されています。\n        管理者に問い合わせてください。\n      enter: |\n        「<a href=\"${link}\">${title}</a>」パックをボットのパブリック ディレクトリ\n        に公開しようとしています。このパックは、名前、タグ、またはフィルタによってボットのユーザーであれば誰でも見つけることができます。\n        これにより、より多くの人があなたのパックをインストールするようになります\n        多くの人が興味を持ちそうな高品質のパックのみを送るようにしてください\n\n        <b>ルールパックの公開について:</b>\n        • 狭い範囲の人々を対象とした個人パックを公開しないでください。たとえば、友達の顔やメッセージの引用など\n        • EU 法やその他の現地法に違反するステッカーの圧力を投稿しないでください\n\n        追加情報を送信する必要があります。カタログに掲載されました\n      continue_button: 続ける\n      enter_description: |\n        <b>他の人が見つけられるようにパックを簡単に説明します</b>\n\n        <i>ハッシュタグを使用して [#] を分類することもできます</i>\n        <i>例: #アニメ #ミーム #動物 #かわいい #kpop #面白い #猫 #ゲーム </i>\n      select_language: |\n        <b>パックの言語を選択:</b>\n        <i>複数の言語</i>\n      button_all_languages: 全ての言語\n      button_confirm_language: 確認\n      set_safe: |\n        <b>あなたのパックはユーザーにとって安全ですか？</b>\n        <i>つまり、エロティカやその他の衝撃的なコンテンツ</i> が含まれていません。\n      button_safe:\n        safe: はい、安全です\n        not_safe: いいえ、安全ではありません\n      no_tags: 指定されていません\n      confirm: |\n        <b>パックの公開を確認します \"<a href=\"${link}\">${title}</a>\"</b>\n\n        <b>説明:</b> <i>${description}</i>\n\n        <b>タグ:</b> ${tags}\n        <b>言語:</b> ${languages}\n      button_confirm: '✅ 公開確認'\n      success: |\n        おめでとうございます。あなたのパックは他のユーザーが見つけられるボットの一般的なディレクトリに公開されました！\n\n        /packsコマンドでパックを選択することで、パック検索情報を編集できます。\n\n\n\n        <i>公式ボットの @Stickers</i> でパックの統計を確認できることをお知らせします。\n    unpublish:\n      success: |\n        パックはボットカタログから正常に非公開になりました。\n  delete_pack:\n    enter: |\n      <a href=\"${link}\">${title}</a>パックを削除してもよろしいですか?\n      完全に削除され、復元することはできません。\n\n      ステッカーを 1 つだけ削除したい場合は、/delete コマンドを使用します。\n\n       <code>${confirm}</code> を送信して、このパックを本当に削除することを確認してください。\n    confirm: はい、私は完全に確かです。\n    success: |\n      <b>パックを削除しました！</b>\n    error:\n      - <b>エラー!</b>\n      - Opps、何か問題が発生しました。\n  frame:\n    no_video: |\n      <b>エラー!</b>\n      ビデオパックにのみフレームを追加できます。\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>フレームタイプを選択:</b>\n      フレームはステッカー周りの透明な背景です\n\n      <code>lite</code> — 角が少し切り取られます\n      <code>medium</code> — 角がもっと切り取られます\n      <code>rounded</code> — 角が丸くなります\n      <code>square</code> — フレームの矩形の形状が変わらず、そのままです\n      <code>circle</code> — フレームが円形になります\n\n      <i>将来的には、/frame コマンドを使用してフレームタイプを設定できます</i>\n    types:\n      lite: '1. Lite'\n      medium: '2. 中'\n      rounded: '3. 丸みを帯びた'\n      square: '4. スクエア'\n      circle: '5. 円形'\n    selected: |\n      <b>選択したフレームタイプ:</b> ${type}\n  photoClear:\n    enter: |\n      背景を削除したい <u>写真</u> を送信してください。背景なしのファイル\n\n      <i>写真に最適です。絵やイラストなどではさらにうまくいきません</i>\n    enter_anime: |\n      背景を削除したい<u>写真</u>を送り、背景なしのファイルを返送します\n\n      <i>アニメ画像に最適です</i>\n    choose_model: |\n      <b>モデルを選択:</b>\n    web_app: WebApp - 人と写真用\n    model:\n      ordinary: Common — 人と一緒に写真を撮るには\n      general: 全般 — 任意の写真用\n      anime: Anime — アニメ写真用\n      birefnet_general: 全般 — 任意の写真用\n    add_to_set_btn: '🌟 セットに追加'\n    error: |\n      <b>エラー！</b>\n      オペ、何か問題が発生しました。\n  leave: |\n    アクションがキャンセルされました。\n  btn:\n    cancel: '❌ キャンセル'\nerror:\n  telegram: |\n    <b>Telegramでエラーが発生しました！</b>\n    <code>${error}</code>\n  answerCbQuery:\n    telegram: |\n      Telegramでエラーが発生しました：\n      ${error}\n  banned: |\n    <b>エラー！</b>\n    この機能の使用は禁止されています。\n\n    <i>これが間違いだと思う場合は、管理者に問い合わせてください: @ly_oBot</i>\n  unknown: |\n    <b>不明なエラーが発生しました。もう一度お試しください。</b>\n\n    問題が解決しない場合は、@Ly_oBot に連絡してください。\n    どのボットのことを話しているのかをすぐに書き、問題を詳細に説明してください。\n"
  },
  {
    "path": "locales/kk.yaml",
    "content": "---\nlanguage_name: '🇰🇿 Қазақша'\nname: fStik — Стикерлер мен эмодзи\ndescription:\n  long: |\n    Сурет, бейне және GIF-тен түрлендірусіз стикерлер мен эмодзи жасаңыз!\n\n    Мүмкіндіктер:\n    • Пакеттерді оңай басқару\n    • Бейне стикерлер және теңшелетін эмодзи\n    • Түпнұсқа файлдарды жүктеп алу\n    • Стикер/бейне/GIF-ті суретке түрлендіру\n    • Стикерлер каталогы\n\n    Стикер іздеу: play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    Сурет, бейне, GIF-тен стикер мен эмодзи жасаңыз. Каталог және іздеу. 🇺🇦\nratelimit: Асықпа!\ncmd:\n  start:\n    enter: |\n      🧙 Сәлем, ${name}! Еможи мен стикер сиқыршысымын!\n      Жіберген сурет, бейне, гифіңді тез-ақ стик қып берем\n\n      Не істей алатыным туралы көбірек білу үшін /help пәрменін жібер\n\n      💬 Көмек керек болса @fStikCommunity қолдау шатымызға қосыл (ағылшынша ғана)\n    group: |\n      🧙 Сәлем, ${groupTitle}! Еможи мен стикер сиқыршысымын!\n\n      Стикерді топ жинағына қосу үшін сурет, бейне, гиф я стикерге жауап ретінде /ss пәрменін жазып жіберіңіз.\n    catalog: |\n      <b>😻Каталогымыздан жаңа стикер жинақтар таба аласың</b>\n\n      • Төмендегі түймені басып, талғамыңа сай үлкен стикер жинақ каталогына ашып ал\n      • Кілт сөз арқылы ізде я дайын тұрған қойындыны ақтар\n      • Жинаққа баға беруді ұмытпа, сонда оның рейтиңдағы орны өзгереді\n    commands:\n      ss: '🌟 Стикерді сақтау'\n      start: '📜 Бастапқы мәзір'\n      help: '📖 Көмек'\n      packs: '📁 Жинақтарды басқару'\n      new: '🌝 Стикер жинағын жасау'\n      catalog: '📖 Каталог'\n      publish: '📤 Жинақты жариялау'\n      delete: '❌ Стикерді жою'\n      original: '🔍 Стикердің түпнұсқасын табу'\n      restore: '🔀 Жинақты қайтару'\n      copy: '📋 Жинақты көшіру'\n      emoji: '📝 Еможи суффискін өзгерту'\n      round: '🎥 Дөңгелек пішінді бейне'\n      clear: '🖼️ Сурет аясын алып таста'\n      about: '📦 Жинақ туралы ақпар'\n      user_about: '🧑‍🎨 Аптыр туралы ақпар'\n      lang: '🌐 Тіл ауыстыру'\n      report: '🚨 Шағыну'\n      donate: '☕️ Әзірлеушіні қолдау'\n      add_to_group: '👥 Топқа қосу'\n      privacy: '🔒 Құпиялық саясаты'\n    btn:\n      new: '📥 Жаңасын құру'\n      catalog: '💖 Каталог ашу'\n      catalog_mini: '💖 Каталог'\n      catalog_browser: '🌐 Браузерде аш'\n      catalog_browser_mini: '🌐 Браузерде'\n      catalog_app: '📱 Android қолданба жүктеп алу'\n      catalog_app_mini: '📱 Android қолданба'\n  inline:\n    switch_pm: '📁 Жинақ таңдау'\n  restore: |\n    <b>🗃 Жинақты қайтару</b>\n\n    Жинақты қайтарып алу үшін, сол жинақтың сілтемесін жіберуің керек\n  copy: |\n    <b>🗄 Жинақты көшіру</b>\n\n    Басқа жинақты жаңаға көшіру үшін оның сілтемесін жіберсең болды\n  report: |\n    <b>🚨 Хабарлама</b>\n\n    Заңды бұзатын я Telegram қызмет көрсету шартына қайшы келетін жинақты көріп қалсаң, сілтемесін @StickersReportBot ботқа жібер\n\n    <i>Бот қай жинақта қандай стикер барын басқара алмайды да, әрі сол стикерлер үшін жауапты да емес</i>\n  packs:\n    info: |\n      <b>📁 Жинақтар</b>\n    types:\n      regular: Стикер\n      custom_emoji: Еможи\n      inline: Инлайн\n    empty: |\n      <b>Әзірге бір де бір жинағың жоқ.</b>\n      /new пәрмені арқылы жаңасын жасап ал\n    inline_title: Орнатылған жинақ\n    select_group_pack_info: |\n      <b>📁 Жинақты таңдау</b>\n\n      Жинақты топта қолдану үшін әкімші оны төмендегі батырма арқылы таңдап алуы керек\n    select_group_pack: Жинақты таңдау\n  emoji:\n    info: |\n      Осы жинақтың әдепкі еможиін өзгерту үшін <code>/emoji</code> пәрменін қойып, артынан бос орны бар еможи жазып жіберіңіз\n\n      Мысалы: <code>/emoji 🌟</code>\n    done: Еможи сәтті өзгертілді.\n    error: Еможи өзгермей қалды!\n  round_video:\n    enabled: |\n      Бейнелер енді дөңгелек пішінге ие болады\n    disabled: |\n      Бейнелердің енді дөңгелек пішіні болмайды\n  paysupport: |\n    <b>👨‍💻 Төлемді қолдау</b>\n\n    Боттың жұмысына қатысты кез келген мәселе бойынша, оның ішінде төлемдер мен қайырымдылықтар бойынша, әзірлеушімен тікелей байланысуға болады\n\n    <b>Байланыс:</b>\n    🧑‍💻 Әзірлеуші: @ly_oBot\ndonate:\n  menu: |\n    <b>☕️ Бот дамуын қолдау:</b>\n    Ботты дамытуды қолдау арқылы сіз Кредиттер аласыз\n\n    <b>Теңгерім:</b> <code>${balance}</code> Кредиттер\n    1 Кредитпен, сіз бір пакетті арттыра аласыз.\n\n    <b>Арттыру келесі артықшылықтарды береді:</b>\n    ➖ Пакет атында \"<code>${titleSuffix}</code>\" жоқ <i>(сілтемеде емес)</i>\n    ➖ Тақырып 64 таңбаға дейін (35 орнына)\n    ➖ Бейнелер 35 секундқа дейін\n    ➖ Басымды түрлендіру кезегі\n    ➖ Бір мезгілде бірнеше стикер\n    ➖ Жарнамасыз\n\n    <b>Сатып алғыңыз келетін кредиттер мөлшерін таңдаңыз:</b>\n  btn:\n    donate: '☕️ Садақа беріңіз'\n  topup: |\n    <b>Сатып алғыңыз келетін кредиттер мөлшерін енгізіңіз:</b>\n  invalid_amount: |\n    <b>Жарамсыз сома</b>\n\n    Минималды сома — 1 Кредит\n  paymenu: |\n    Сіз <b>${amount} Кредит</b> сатып алғыңыз келеді <b>${price}$</b> үшін\n\n    ⚠️ Кредиттер әкімші арқылы қолмен беріледі.\n    Күту уақыты 5 минуттан 1 сағатқа дейін\n\n    <u>Төлем әдісін таңдаңыз:</u>\n  description: |\n    Кредиттерді сатып алу арқылы сіз боттың дамуын қолдай аласыз және қосымша мүмкіндіктерді пайдалану құқығына ие боласыз\n  update: |\n    <b>🔄 Балансты жаңарту</b>\n\n    Баланс: <code>${balance}</code> Кредиттер (қосылған <code>${amount}</code> Кредиттер)\n  error:\n    already_donated: |\n      Бұл төлем үшін сіз бұрыннан Кредиттер алғансыз\n    error: |\n      <b>Қате!</b>\n      Төлемді өңдеу кезінде қате орын алды\n    canceled: |\n      Төлем тоқтатылды\ncoedit:\n  info: |\n    <b>👥 Кемесінде басқару</b>\n\n    <a href=\"${link}\">${title}</a> арқылы басқаруды жәнеүші мекен-жайы: <code>${colink}</code>\n\n    <b>Қалай пайдаланатын:</b>\n    1. Пакетке қатысытуыңыз келетін адамға сілтемелі беріңіз\n    2. Сілтемеге басқаннан кейін “бастау” басып, олар барлы арқылы редакторларға қосылады\n    3. Редактордың пакетте жасалған таңбаларды қосу, жою және өңдеуге мүмкіндік беріледі\n\n    <b>Редакторлар:</b>\n    ${editors}\n\n    <i>Редакторларды алау үшін, сізге сілтемені қайта жүктеу керек</i>\n  no_editors: |\n    Әзірге редакторлар жоқ\n  btn:\n    send: '📤 Сілтеме жіберіңіз'\n    reset: '🔁 Сілтемені қалпына келтіру'\n  share: |\n    Сілтемені орындаңыз және буманы бірге өңдеу үшін \"бастау\" түймесін басыңыз \"${title}\"\n  reset: |\n    <b>🔁 Сілтеме қалпына келтірілді</b>\n\n    Бірлескен өңдеуге арналған жаңа сілтеме <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: Пакет табылмады\n      not_owner: Бұл сіздің пакетіңіз емес\n      hidden: Бума сәтті жасырылды\n      restored: Бума сәтті қалпына келтірілді\n    set_pack: |\n      🌟 Таңдалған <a href=\"${link}\">${title}</a> бума\n\n      <b>❔ Қалай қосамыз?</b>\n      Пакетке қосу үшін фотосуретті, бейнені немесе стикерді жіберіңіз\n    set_inline_pack: |\n      Таңдалған <u>${title}</u> пакеті\n\n      Оны қолдану үшін, барлық сәулеттерде жазыңыз <code>@${botUsername} </code>және бозға\n      Олайда басқара алатын батырманы басу арқылы пайдалануға болады\n    boost:\n      info: |\n        \\n⚡ <b><a href=\"https://t.me/${botUsername}?start=boost\">Күштіру</a></b>: ${boostStatus}\n      status:\n        on: Қосылған\n        off: Өшірілген\n    hidden: <a href=\"${link}\">${title}</a> бумасы тізімнен жасырылған.\n    restored: <a href=\"${link}\">${title}</a> бумасы тізіміңізге қалпына келтірілді.\n    btn:\n      hide: '❌ Жинақты жасыру'\n      delete: '🗑 Жинақты жою'\n      restore: '✅ Қайтару'\n      use_pack: '📦 Жинақты қолдану'\n      boost: '⚡ Буст'\n      frame: '🖼 Фрейм'\n      rename: '✏️ Атын өзгерту'\n      search_gif: '🔎 GIF іздеу'\n      coedit: '👥 Бірлескен өңдеу'\n      catalog_add: '🗂 Каталогқа қосу'\n      catalog_edit: '📝 Каталогта өңдеу'\n      catalog_delete: '🗑 Каталогтан жою'\n      catalog_share: '🔗️️ Бөлісіңіз'\n      catalog_open: '📂 Каталогта ашыңыз'\n    error:\n      not_found: |\n        Қате!\n        Стикер табылмады.\n      invalid_png: |\n        Қате!\n        Файл жарамды PNG суреті емес. Оны PNG форматына түрлендіріп, қайтадан жіберіңіз.\n      invalid_dimensions: |\n        Қате!\n        Стикердің өлшемдері жарамсыз. Стикерлер 512x512 пиксель болуы керек.\n      invalid_animated: |\n        Қате!\n        Анимациялық стикер файлы дұрыс TGS форматында емес.\n      invalid_video: |\n        Қате!\n        Бейне файлы дұрыс WEBM форматында емес.\n      restore: |\n        Қате!\n        Буманы қалпына келтіру мүмкін емес.\n      copy: |\n        Қате!\n        Пакетті табу мүмкін емес.\n    select_group:\n      success: |\n        Жинақ <a href=\"${link}\">${title}</a> топ үшін сәтті таңдалды.\n      access_rights:\n        add: Топтық жинаққа кім стикерлер қоса алады?\n        delete: Топтық жинақтан стикерлерді кім өшіре алады?\n        rights:\n          all: Барлығы\n          admins: Тек әкімшілер\n      error: |\n        Қате!\n        Жинақ табылған жоқ.\n  sticker:\n    answerCbQuery:\n      delete: Стикер бумадан сәтті жойылды.\n      restored: Стикер ағымдағы бумаға сәтті сақталды.\n    delete: Стикер бумадан сәтті жойылды.\n    restored: Стикер ағымдағы бумаға сәтті сақталды.\n    btn:\n      delete: '🗑 Жою'\n      copy: '🌟 Көшіру'\n      restore: '✅ Қайтару'\n    error:\n      not_found: |\n        ҚАТЕ!\n        Стикер табылмады.\n      invalid_png: |\n        <b>Қате!</b>\n        Бұл файл жарамды PNG кескіні емес. Жібермес бұрын оны PNG форматына түрлендіріңіз.\n      invalid_dimensions: |\n        <b>Қате!</b>\n        Стикер өлшемдері жарамсыз. Стикерлер 512x512 пиксель болуы керек.\n      invalid_animated: |\n        <b>Қате!</b>\n        Анимирленген стикер файлы дұрыс TGS форматында емес.\n      invalid_video: |\n        <b>Қате!</b>\n        Бейне файлы дұрыс WEBM форматында емес.\n  group_settings:\n    success: |\n      Топ параметрлері сәтті жаңартылды.\nsticker:\n  add:\n    ok: |\n      <b>Сәтті түсірілді:</b>\n      <a href=\"${link}\">${title}</a>\n\n      Бұл стикердiң барлығынан кейін, бұл набор барлық пайдаланушылар үшін жаңартылады.\n\n      <i>Егер оларды қосу керек болса, стикермен байланысты бір-немесе бірнеше эмоджи жіберу</i>\n    ok_inline: |\n      <b>Бумаға сәтті қосылды:</b>\n      <u>${title}</u>\n    send_emoji: Керемет, енді сәйкес келетін эмодзиді жіберіңіз\n    converting_process: |\n      <b>Күте тұрыңыз...</b>\n      Файлыңыз түрлендіру кезегінде тұр. Аяқтауды күтіңіз. Бұл біраз уақыт алуы мүмкін.\n\n      Барысы: ${progress} / ${total}\n\n      <i>Ботқа қолдау көрсеткен пайдаланушылар кезекте басымдыққа ие болады (толығырақ: /қайырымдылық)</i>\n    catalog_offer: |\n      <b>😲 Уау, керемет пакет жасадың!</b>\n\n      Боттың басқа пайдаланушылары да көре алуы үшін жалпыға ортақ стикерлер каталогына <a href=\"${link}\">${title}</a> қосқыңыз келе ме?\n      <i>Бұл көп уақытты алмайды</i>\n    quote: |\n      Бұл хабарламадан дәйексөз жасау үшін @QuotlyBot пайдаланыңыз\n    error:\n      reply: |\n        <b>Қате!</b>\n        Стикерге жауап беріңізші.\n      no_selected_pack: |\n        <b>Сіз буманы таңдамадыңыз</b>\n\n        Буманы жасаңыз (/жаңа) немесе (/бумалар) таңдаңыз\n      no_selected_group_pack: |\n        <b>Сіз топтық жинақты таңдамадыңыз</b>\n\n        Пожалуйста, жинақты таңдау үшін /packs командасын қолданыңыз\n      no_rights: |\n        <b>Қате!</b>\n        Сізде осы жинаққа стикер қосу құқығы жоқ.\n      stickers_too_much: |\n        Бұл пакетте стикерлердің максималды саны бар.\n\n        /new пәрмені арқылы жаңа бума жасауға болады.\n      have_already: |\n        <b>Бұл стикер пакетте бұрыннан бар</b>\n\n        Эмодзиді өзгерткіңіз келсе, оны келесі хабарламаға жіберіңіз.\n      stickerset_invalid: |\n        <b>Қате!</b>\n        Бот ағымдағы таңдалған бумаға қол жеткізе алмайды.\n\n        Басқа буманы (/жаңа) жасаңыз немесе (/бумаларды) таңдаңыз.\n      invalid_png: |\n        <b>Қате!</b>\n        Бұл файл жарамды PNG кескіні емес. Жібермес бұрын оны PNG форматына түрлендіріңіз.\n      invalid_dimensions: |\n        <b>Қате!</b>\n        Стикер өлшемдері жарамсыз. Стикерлер 512x512 пиксель болуы керек.\n      invalid_animated: |\n        <b>Қате!</b>\n        Анимирленген стикер файлы дұрыс TGS форматында емес.\n      invalid_video: |\n        <b>Қате!</b>\n        Бейне файлы дұрыс WEBM форматында емес.\n      file_type:\n        static: |\n          <b>Қате!</b>\n          Бұл файл түріне қолдау көрсетілмейді\n          Бұл фотосуретті немесе статикалық стикерді статикалық бумаға қосуға болады\n\n          <i>Жасау (/жаңа) немесе таңдау (/пакеттер) басқа пакет</i>\n        video: |\n          <b>Қате!</b>\n          Бұл файл түріне қолдау көрсетілмейді\n          Бұл бейне файлдарын бейне бумасына қосуға болады\n\n          <i>Жасау (/жаңа) немесе таңдау (/ пакеттер) басқа пакет</i>\n        animated: |\n          <b>Қате!</b>\n          Бұл файл түріне қолдау көрсетілмейді\n          Бұл анимациялық файлдарды векторлық бумаға қосуға болады\n\n          <i>Жасау (/жаңа) немесе (/) таңдаңыз. пакеттер) басқа пакет</i>\n        unknown: |\n          <b>Қате!</b>\n          Бұл файл түріне қолдау көрсетілмейді\n\n          <i>Жасау (/жаңа) немесе басқа буманы (/бумаларды) таңдаңыз</i>\n      wait_load: |\n        <b>Күтіңіз!</b>\n\n        Бот алдыңғы файлды әлі өңдеп жатыр...\n        Өңдеу басымдығын арттыру және кезекке бірден көп стикер қосу мүмкіндігін алу үшін ботты қолдауыңызға болады (\\/donate).\n      timeout: |\n        <b>Қазіргі уақытта бот үлкен жүктемені бастан кешіруде</b>\n        Сондықтан, бейне түрлендіру тек белсенді күшейткіші бар бумалар үшін қол жетімді\n\n        Қосымша мәліметтер алу үшін / садақа беру\n      convert: |\n        <b>Қате!</b>\n        Өкінішке орай, бот бейнеңізді түрлендіре алмады.\n\n        Бейнеңіз ботқа түсініксіз форматта сақталған болуы мүмкін. Оның mp4 пішімінде екеніне көз жеткізіңіз.\n        Бұл боттың ішкі қатесі де болуы мүмкін, бұл бейнені қайта жіберіп көріңіз.\n      too_big: |\n        <b>Қате!</b>.\n\n        Файл өңдеу үшін тым үлкен. Жіберер алдында сапасы мен ұзақтығын азайтыңыз.\n      sticker_not_found: |\n        <b>Қате!</b>\n\n        Бұл стикер табылмады. Оның дұрыс пакте екеніне көз жеткізіңіз немесе қайта қосып көріңіз.\nnews:\n  join: |\n    ℹ️ <a href=\"${link}\">Бот туралы соңғы жаңалықтарды алу үшін</a> арнамызға қосылыңыз.\n\n    <i>Бот туралы соңғы жаңалықтарды, сондай-ақ жаңартулар мен жаңа мүмкіндіктерді алу үшін арнаға жазылыңыз.</i>\n  join_btn: '📢 Каналға қосылыңыз'\n  not_joined: '🙅 Сіз арнаға жазылмағансыз'\n  continue: '✅ Жалғастыру'\nuserAbout:\n  help: |\n    <b>🧑‍🎨 Пайдаланушы туралы</b>\n\n    Бұл мәзірді пайдалану арқылы сіз пайдаланушы және оның стикерлер пакеттері туралы ақпаратты біле аласыз\n\n    Пайдаланушы туралы ақпарат алу үшін түймені пайдаланыңыз. төменде немесе оның хабарламасын жіберіңіз\n  result: |\n    <b>🧑‍🎨 Пайдаланушы ақпараты</b>\n    <b>🆔 Пайдаланушы ИДсы:</b> <code>${userId}</code>\n    <b>🎨 Осы пайдаланушының пакеттері:</b>\n    ${packs}\n  no_packs: |\n    <i>Бізде бұл иесінің стикерлері туралы ақпарат жоқ</i>\n  forward_hidden: |\n    Пайдаланушы хабарламаларды қайта жіберу мүмкіндігін жасырды. Оның стикерлер пакеттерін көру үшін төмендегі түймені пайдаланыңыз.\n  select_user: '🧑‍🎨 Пайдаланушыны таңдаңыз'\nscenes:\n  new_pack:\n    pack_type: |\n      <b><u>Пакет түрін таңдаңыз</u></b>\n    regular: '😊 Стикер'\n    custom_emoji: '🌟 Эмодзи (премиум)'\n    static: '🌟 Статикалық'\n    animated: '✨ Вектор'\n    video: '📹 Видео'\n    pack_format: |\n      <b><u>Бума түрін таңдаңыз</u></b>\n\n      <b>Жалпы</b> - статикалық (жылжытпаңыз), растр, файл пішім - PNG қосу алдында (бот өңделуде), қосқаннан кейін - WEBP.\n      Кәдімгі буманың мысалы - t.me/addstickers/Animals\n\n      <b>Бейне</b> - анимациялық бейне бумасы. Кез келген бейне, gif және фотосуретті қосуға болады.\n      Үлгі бейне бумасы - t.me/addstickers/TheMascot\n\n      <b>Анимациялық</b> - анимациялық, векторлық (оларда файл ішіндегі нысандардың нақты сипаттамасы бар, қажет олар кез келген масштабта анық көрсетіледі), файл пішімі - TGS, Lottie пішімінің вариациясы.\n      Анимациялық буманың мысалы - t.me/addstickers/IsabelleShizue\n\n      <i>Анимациялық және бейне стикерлер жиынтықтарында 50-ге дейін стикер болуы мүмкін. Статикалық жапсырмалар жиынтығында 120-ға дейін жапсырма болуы мүмкін.</i>\n    pack_title: |\n      <b>Жаңа стикерлер бумасының атын енгізіңіз:</b>\n      <i>Түймеде кездейсоқ атауды таңдауға болады.</i>\n    pack_name: |\n      <b>Жаңа стикерлер жинағы үшін қысқа сілтемені енгізіңіз:</b>\n\n      <i>Мысалы, бұл бумада қысқа сілтеме ретінде \"Жануарлар\" пайдаланылады: https://t.me/ addstickers/<u>Жануарлар</u></i>\n      <i>Түймеде кездейсоқ қысқа сілтемені таңдауға болады.</i>\n    ok: |\n      <a href=\"${link}\">${title}</a> бумасы сәтті жасалды!\n\n      <b>Пакет сілтемесі:</b> <pre>${link}</pre>\n\n      Мен файлды, фотосуретті, бейнені немесе стикерді жіберіңіз. оны жинағыңызға қосыңыз\n    error:\n      title_long: Атау ${max} таңбаларынан үлкен болмауы керек.\n      name_long: Мекенжай ${max} таңбаларынан үлкен болмауы керек.\n      telegram:\n        name_invalid: Бұл мекенжайды пайдалану мүмкін емес.\n        name_occupied: Бұл мекенжай әлдеқашан алынған.\n        upload_failed: |\n          <b>Қате!</b>\n          Бот стикерлерді Telegram-ға жүктей алмайды.\n\n          Кейінірек қайталап көріңіз.\n  copy:\n    enter: |\n      Мен оны көшіре аламын, бірақ оған дейін жаңа бума жасайық\n    progress: |\n      <a href=\"${originalLink}\">${originalTitle}</a> көшірмесінен <a href=\"${link}\">${title}</a> пакетіне көшіру\n\n      Толықтару: ${current}/${total}\n    done: |\n      <a href=\"${originalLink}\">${originalTitle}</a> бастап <a href=\"${link}\">${title}</a> дейін буманы көшіру сәтті аяқталды.\n    pay: |\n      <b>Пакетті өзгерту</b>\n\n      Бір түрден екінші түрге ауыстыру 1 кредит тұрады\n\n      <b>Ағымдағы баланс:</b> ${balance} Кредит\n\n      Кредит сатып алу: /donate\n    pay_btn: '✅ Растау'\n    error:\n      premium: |\n        <b>Қате!</b>\n        Бұл мүмкіндік тек мүшелерді қайырымдылыққа беру үшін қолжетімді.\n\n        Мұны /donate пәрменін жіберу арқылы жасауға болады.\n  original:\n    enter: |\n      Осы бот арқылы қосылған стикерді жіберіңіз, мен сізге оның түпнұсқа стикерін көрсетемін.\n    error:\n      not_found: |\n        <b>Қате!</b>\n        Түпнұсқа стикерді таба алмадым.\n  delete:\n    enter: |\n      Осы бот арқылы қосылған стикерді жіберіңіз, мен оны пакеттен өшіремін.\n    confirm: |\n      Осы стикерді шынымен жойғыңыз келе ме?\n    error:\n      not_found: |\n        <b>Қате!</b>\n        Мен стикерді таба алмадым.\n  rename:\n    enter_name: |\n      <b> <a href=\"${link}\">${title}</a>үшін жаңа тақырып енгізіңіз:</b>\n    success: |\n      <b>Тақырып сәтті өзгертілді!</b>\n\n      Жаңа тақырып: <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      ❕ \"<code>${titleSuffix}</code>\" суффиксін алып тастау үшін пакетті арттыру қажет. Толығырақ мәзір арқылы келесіден қараңыз: \\/donate\n  packAbout:\n    enter: |\n      <b>Ол туралы ақпаратты іздеу үшін маған стикер немесе реттелетін эмодзи жіберіңіз:</b>\n    not_found: |\n      Мен стикерді таба алмадым\n    result: |\n      <b>📦 Пакет:</b> <a href=\"${link}\">${name}</a>\n      🆔 <code>${setId}</code> <i>(Сайландырған №, санатылған қавылы пакетке уақытша қосылмайды)</i>\n\n      🧑‍🎨 Іесі ID: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 Бұл іесінің басқа пакеттері:</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>Бізде бұл иесінің басқа стикерлері туралы ақпарат жоқ</i>\n  boost:\n    sure: |\n      <b><a href=\"${link}\">${title}</a> күшейткіңіз келетініне сенімдісіз бе?</b>\n\n      Күшейту өңдеу басымдығын арттырады және кезекке бірден көп стикер қосу мүмкіндігін береді\n      Көп ақпарат алу үшін мәзірге кіріңіз: /donate\n\n      <b>Бағасы:</b> 1 Кредит\n      <b>Ағымдағы баланс:</b> ${balance} Кредит\n    btn:\n      yes: Иә, күшейтіңіз!\n      no: Жоқ, бас тарту\n    canceled: |\n      Көтеру тоқтатылды\n    success: |\n      Көтермелеу сәтті аяқталды!\n\n      ${title} енді күшейтілді\n    error:\n      not_enough_credits: |\n        Бұл пакетті күшейту үшін жеткілікті Кредиттеріңіз жоқ.\n\n        Балансыңызды толтыру үшін /donate командасын жіберіңіз.\n      already_boosted: |\n        Бұл бума әлдеқашан күшейтілген.\n  catalog:\n    publish:\n      publish_new: |\n        <b>Пакеттен штрихті жариялау керек</b>\n\n        <i>Сіздің табыстығыңызға қатыспалайтын барлық пакеттерді жариялай аласыз, сондықтан олар әр түрлі жасалып тұр</i>\n      owner_proof: |\n        <b>Бұл бумаға меншік құқығын растау үшін бірнеше қарапайым қадамдарды орындау қажет:</b>\n        1. @Stickers ботын ашыңыз\n        2. <code>жіберу /packstats</code> пәрмені\n        3. Қажетті буманы тауып таңдаңыз\n        4. Алынған хабарламаны ботқа жіберіңіз\n      publish_new_access_denied: |\n        <b>Қате!</b>\n        Бұл пакет сіздікі емес.\n\n        Сіз тек өз бумаңызды жариялай аласыз\n      banned: |\n        <b>Қате!</b>\n        Сізге бұл мүмкіндікті пайдалануға тыйым салынды.\n        Әкімшіге хабарласыңыз.\n      enter: |\n        Сіз \"<a href=\"${link}\">${title}</a>\" бумасын біздің боттың жалпы каталогында жариялағалы жатырсыз\n        Оны боттың кез келген пайдаланушысы аты, тегтері немесе сүзгі арқылы таба алады.\n        Осыған байланысты пакетіңізді көбірек адамдар орнатады\n        Адамдардың көп бөлігін қызықтыруы мүмкін тек жоғары сапалы бумаларды жіберуге тырысыңыз\n\n        <b>Ережелер бумаларды жариялау үшін:</b>\n        • Адамдардың тар шеңберіне арналған жеке бумаларды жарияламаңыз. Мысалы, достарыңыздың бет-әлпеттері немесе хабарламаларыңыздан алынған дәйексөздер сияқты\n        • ЕО заңдарын немесе басқа жергілікті заңдарды бұзатын жапсырма қысымдарын жарияламаңыз\n\n        Ол үшін қосымша ақпарат жіберуіңіз керек. каталогында жарияланған\n      continue_button: Жалғастыру\n      enter_description: |\n        <b>Басқалар оны таба алатындай пакетіңізді қысқаша сипаттаңыз</b>\n\n        <i>Сондай-ақ [#]</i>\n        санаттау үшін хэштегтерді пайдалануға болады.<i>Мысалы: #аниме #мем #жануарлар #сүйкімді #кпоп #күлкілі #мысық #ойын </i>\n      select_language: |\n        <b>Буманың қай тілдерге арналғанын таңдаңыз:</b>\n        <i>Бірнеше тілді таңдауға болады</i>\n      button_all_languages: Барлық тілдер\n      button_confirm_language: Растау\n      set_safe: |\n        <b>Сіздің пакетіңіз пайдаланушылар үшін қауіпсіз бе?</b>\n        <i>Яғни, оның құрамында эротика және басқа да таң қалдыратын мазмұн жоқ</i>\n      button_safe:\n        safe: Иә, қауіпсіз\n        not_safe: Жоқ, бұл қауіпсіз емес\n      no_tags: нақтыланбады\n      confirm: |\n        Пакеттің \"<a href=\"${link}\">${title}</a>\" жариялануын растау</b>\n\n        Aнықтама:</b> <i>${description}</i>\n\n        Тегі:</b> ${tags}\n        Сөйлеулер:</b> ${languages}\n      button_confirm: '✅ Жариялануды растау'\n      success: |\n        Құттықтаймыз, сіздің бума басқа пайдаланушылар таба алатын біздің боттың жалпы каталогында жарияланды!\n\n        Буманы /packs пәрменімен таңдау арқылы буманы іздеу туралы ақпаратты өңдеуге болады.\n\n        <i>Сіздің пакетіңіздің статистикасын @Stickers ресми боты арқылы көруге болатынын еске саламыз</i>\n    unpublish:\n      success: |\n        Бума бот каталогынан сәтті шығарылды.\n  delete_pack:\n    enter: |\n      <a href=\"${link}\">${title}</a>бумасын жойғыңыз келетініне сенімдісіз бе?\n      Ол біржола жойылады және оны қалпына келтіру мүмкін емес.\n\n      Тек бір стикерді жойғыңыз келсе, /delete пәрменін пайдаланыңыз.\n\n      Осы буманы шынымен жойғыңыз келетінін растау үшін <code>${confirm}</code> жіберіңіз.\n    confirm: Иә, мен толық сенімдімін.\n    success: |\n      <b>Бума сәтті жойылды!</b>\n    error:\n      - <b>Қате!</b>\n      - Ой, бірдеңе дұрыс болмады.\n  frame:\n    no_video: |\n      <b>Қате!</b>\n      Бейне бумаларына кадрларды ғана қосуға болады.\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>Кадр түрін таңдаңыз:</b>\n      Кадр - арға дарға стикердің астын қорғататын ақпарат\n\n      <code>lite</code> — бет қабатталады\n      <code>ом</code> — беттен көбірек айырылады\n      <code>испилен</code> — бет углери омрумде жаралады\n      <code>шарша</code> — шарша формасында кадр, ол орынды жерге, сондықтан ол нөсер өзгермейді\n      <code>дөңгелек</code> — дөңгелектің түрі\n\n      <i>Бейтеу кейін, кадр түрін орнату үшін /frame командасын толықтыры аласыз</i>\n    types:\n      lite: '1. Лайт'\n      medium: '2. Орта'\n      rounded: '3. Дөңгеленген'\n      square: '4. Шаршы'\n      circle: '5. Шеңбер'\n    selected: |\n      <b>Таңдалған кадр түрі:</b> ${type}\n  photoClear:\n    enter: |\n      Фонды алып тастағыңыз келетін <u>фотосуретін</u> жіберіңіз, мен файлды фондықсыз жіберемін\n\n      <i>Фотосуреттермен жақсы жұмыс істейді. Сызбалармен, иллюстрациялармен және т.б. нашар жұмыс істейді.</i>\n    enter_anime: |\n      Фонды ала алған суреттен фонды алып тастау керек болатын <u>сурет</u> жіберіңіз, мен фонсыз файлды жіберемін\n\n      <i>Ол ақыраттырма жасайтын жақты суреттермен жұмыс істейді</i>\n    choose_model: |\n      <b>Үлгіні таңдаңыз:</b>\n    web_app: WebApp - адамдармен фотосуреттерге арналған\n    model:\n      ordinary: Жалпы — адамдармен фотосуреттер үшін\n      general: Жалпы — кез келген фотосуреттер үшін\n      anime: Аниме — аниме суреттері үшін\n      birefnet_general: BirefNet - кез келген фотосурет үшін\n    add_to_set_btn: '🌟 Жиынтыққа қосу'\n    error: |\n      <b>Қате!</b>\n      Ой, бірдеңе дұрыс болмады.\n  leave: |\n    Әрекеттен бас тартылды.\n  btn:\n    cancel: '❌ Бас тарту'\nerror:\n  telegram: |\n    <b>Telegram қатені қайтарды!</b>\n    <code>${error}</code>\n  answerCbQuery:\n    telegram: |\n      Telegram қатені қайтарды:\n      ${error}\n  banned: |\n    <b>Қате!</b>\n    Сізге бұл мүмкіндікті пайдалануға тыйым салынды.\n\n    <i>Егер сіз мұны қате деп ойласаңыз, әкімшіге хабарласыңыз: @ly_oBot</i>\n  unknown: |\n    <b>Белгісіз қате орын алды, әрекетті қайталаңыз.</b>\n\n    Егер мәселе шешілмесе, @Ly_oBot мекенжайына жазыңыз.\n    Қай бот туралы айтып жатқаныңызды дереу жазып, мәселені бір хабарламада егжей-тегжейлі сипаттаңыз.\n"
  },
  {
    "path": "locales/pt.yaml",
    "content": "---\nlanguage_name: '🇧🇷 Português'\nname: fStik — Figurinhas e Emoji\ndescription:\n  long: |\n    Crie stickers e emojis de fotos, vídeos e GIFs sem conversão manual. Tudo é processado automaticamente.\n\n    Recursos:\n    • Gerenciamento de pacotes\n    • Stickers de vídeo e emoji personalizados\n    • Baixar arquivos originais\n    • Converter para imagem\n    • Catálogo de stickers\n\n    Pesquise adesivos: play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    Crie stickers e emojis de fotos, vídeos, GIFs. Catálogo e busca de stickers. 🇺🇦\nratelimit: Não tão frequente!\ncmd:\n  start:\n    enter: |\n      🧙 Olá, ${name}, eu sou o assistente de pacote de emojis e adesivos.\n      Eu posso transformar suas fotos, vídeos, e GIFs em stickers legais com apenas alguns cliques\n\n      Envie o comando /help para saber mais sobre o que posso fazer\n\n      💬 Precisa de ajuda? Junte-se ao nosso chat de suporte em @fStikCommunity (apenas em inglês)\n    group: |\n      🧙 Olá, ${groupTitle}! Eu sou o mago dos emojis e pacotes de figurinhas.\n\n      Para adicionar uma figurinha a um pacote de grupo, use o comando \\/ss em resposta a uma foto, vídeo, gif ou figurinha.\n    catalog: |\n      <b>😻 Você pode encontrar novos pacotes de sticker no nosso catálogo</b>\n\n      • Clique no botão abaixo e tenha acesso a um catálogo enorme de pacotes de stickers para cada gosto\n      • Procure por palavras-chave ou nas abas predefinidas\n      • Não se esqueça de dar nota para promover ou rebaixar um pacote de stickes no ranking\n    commands:\n      ss: '🌟 Salvar sticker'\n      start: '📜 Menu inicial'\n      help: '📖 Ajuda'\n      packs: '📁 Gerenciar pacotes'\n      new: '🌝 Criar pacote de stickers'\n      catalog: '📖 Catálogo'\n      publish: '📤 Publicar pacote'\n      delete: '❌ Excluir figurinha'\n      original: '🔍 Encontre o sticker original'\n      restore: '🔀 Restaurar um pacote'\n      copy: '📋 Copiar um pacote'\n      emoji: '📝 Altere o emoji vinculado'\n      round: '🎥 Video em formato redondo'\n      clear: '🖼️ Remova o fundo da foto'\n      about: '📦 Informações do Pack'\n      user_about: '🧑‍🎨 Informações do criador'\n      lang: '🌐 Mude o idioma'\n      report: '🚨 Reportar pacote'\n      donate: '☕️ Apoie o desenvolvedor'\n      add_to_group: '👥 Adicionar ao grupo'\n      privacy: '🔒 Política de privacidade'\n    btn:\n      new: '📥 Crie novo'\n      catalog: '💖 Abrir catálogo'\n      catalog_mini: '💖 Catálogo'\n      catalog_browser: '🌐 Abrir no navegador'\n      catalog_browser_mini: '🌐 No navegador'\n      catalog_app: '📱 Baixe o aplicativo Android'\n      catalog_app_mini: '📱 Aplicativo Android'\n  inline:\n    switch_pm: '📁 Selecione o pacote'\n  restore: |\n    <b>🗃️ Pack restoration</b>\n\n    Para restaurar um pacote, você precisa me enviar um link para o pacote que você deseja restaurar\n  copy: |\n    <b>🗄️ Copiar pacote</b>\n\n    Para copiar outro pacote para um novo, você só precisa me enviar um link para um pacote de sticker ou emoji\n  report: |\n    <b>🚨 Reportar</b>\n\n    Se você encontrar um pacote de sticker que você acredita que pode infringir a lei ou ir contra os Termos de Serviço do Telegram, por favor, denuncie-a enviando o link para @StickersReportBot\n\n    <i>Lembre-se que o bot não é responsável pelo conteúdo dos pacotes e não tem a capacidade de controlá-lo</i>\n  packs:\n    info: |\n      <b>📁 Pacotes</b>\n    types:\n      regular: Figurinhas\n      custom_emoji: Emojis\n      inline: Em linha\n    empty: |\n      <b>Você ainda não tem nenhum pacote.</b>\n      Para criar um, escreva o comando /new\n    inline_title: Pacote em linha\n    select_group_pack_info: |\n      <b>📁 Selecione o pacote</b>\n\n      Para usar o pacote no grupo, os administradores devem selecioná-lo usando o botão abaixo\n    select_group_pack: Selecione o pacote\n  emoji:\n    info: |\n      Para alterar o emoji padrão do pacote atual, envie <code>/emoji</code> seguido do emoji separado por um espaço\n\n      Por exemplo: <code>/emoji 🌟</code>\n    done: Emoji alterado com sucesso.\n    error: Houve um erro ao alterar o emoji!\n  round_video:\n    enabled: |\n      Vídeos agora terão formato arredondado\n    disabled: |\n      Vídeos não terão mais formato arredondado\n  paysupport: |\n    <b>👨‍💻 Suporte de Pagamento</b>\n\n    Para todas as questões relacionadas ao funcionamento do bot, incluindo pagamentos e doações, você pode entrar em contato diretamente com o desenvolvedor\n\n    <b>Contatos:</b>\n    🧑‍💻 Desenvolvedor: @ly_oBot\ndonate:\n  menu: |\n    <b>☕️ Suporte ao desenvolvimento do bot</b>\n    Ao apoiar o desenvolvimento do bot, você receberá Créditos\n\n    <b>Saldo:</b> <code>${balance}</code> Créditos\n    Com 1 Crédito, você tem a oportunidade de dar um boost num pacote.\n\n    <b>O boost oferece os seguintes benefícios:</b>\n    ➖ Sem \"<code>${titleSuffix}</code>\" no nome do pacote <i>(não no link)</i>\n    ➖ Título até 64 caracteres (em vez de 35)\n    ➖ Vídeos até 35 segundos\n    ➖ Prioridade na fila de conversão\n    ➖ Vários adesivos de uma vez\n    ➖ Sem anúncios\n\n    <b>Selecione a quantidade de Créditos que você deseja comprar:</b>\n  btn:\n    donate: '☕ Doar'\n  topup: |\n    <b>Insira a quantidade de Créditos que você deseja comprar:</b>\n  invalid_amount: |\n    <b>Quantidade inválida</b>\n\n    A quantidade mínima é 1 Crédito\n  paymenu: |\n    Você quer comprar <b>${amount} Créditos</b> por <b>${price}$</b>\n\n    ⚠️ Créditos são emitidos manualmente pelo administrador.\n    O tempo de espera varia de 5 minutos a 1 hora\n\n    <u>Selecione o método de pagamento:</u>\n  description: |\n    Comprando Créditos, você apoia o desenvolvimento do bot e obtém a oportunidade de usar recursos adicionais\n  update: |\n    <b>🔄 Atualização de saldo</b>\n\n    Saldo: <code>${balance}</code> Créditos (adicionado <code>${amount}</code> Créditos)\n  error:\n    already_donated: |\n      Você já recebeu Créditos para este pagamento\n    error: |\n      <b>Error!</b>\n      Ocorreu um erro ao processar o pagamento\n    canceled: |\n      Pagamento cancelado\ncoedit:\n  info: |\n    <b>👥 Coedição</b>\n\n    Link para coedição <a href=\"${link}\">${title}</a>: <code>${colink}</code>\n\n    <b>Como usar:</b>\n    1. Envie o link para a pessoa que deseja dar acesso ao pack\n    2. Após clicar no link, eles precisam clicar em \"iniciar\" e serão adicionados aos editores\n    3. O editor pode adicionar, excluir e editar adesivos no pacote\n\n    <b>Editores:</b>\n    ${editors}\n\n    <i>Para remover editores, você precisa redefinir o link</i>\n  no_editors: |\n    Nenhum editor ainda\n  btn:\n    send: '📤 Enviar link'\n    reset: '🔁 Redefinir link'\n  share: |\n    Siga o link e pressione \"iniciar\" para co-editar o pacote \"${title}\"\n  reset: |\n    <b>🔁 Reinicialização do link com sucesso</b>\n\n    Novo link para coedição <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: Pacote não encontrado\n      not_owner: Este não é o seu pacote\n      hidden: Pacote oculto com sucesso\n      restored: Pacote restaurado com sucesso\n    set_pack: |\n      🌟 Selected <a href=\"${link}\">${title}</a> pack\n\n      <b>❔ Acrescentar?</b>\n      Enviar foto, vídeo ou adesivo para adicionar ao pacote\n    set_inline_pack: |\n      Selecionar <u>${title}</u> pacote\n\n      Para usá-lo, escrever em qualquer bate-papo <code>@${botUsername} </code>e espaço\n      Você também pode usá-lo pressionando o botão abaixo\n    boost:\n      info: |\n        \\n⚡ <b><a href=\"https://t.me/${botUsername}?start=boost\">Impulsionar</a></b>: ${boostStatus}\n      status:\n        on: Ativado\n        off: Desativado\n    hidden: Pacote <a href=\"${link}\">${title}</a> oculto da sua lista.\n    restored: Pacote <a href=\"${link}\">${title}</a> restaurado em sua lista.\n    btn:\n      hide: '❌ Ocultar pacote'\n      delete: '🗑 Excluir pacote'\n      restore: '✅ Restaurar'\n      use_pack: '📦 Pacote de uso'\n      boost: '⚡ Impulsionar'\n      frame: '🖼 Moldura'\n      rename: '✏️ Renomear'\n      search_gif: '🔎 Pesquise GIF'\n      coedit: '👥 Coedição'\n      catalog_add: '🗂 Adicionar ao catálogo'\n      catalog_edit: '📝 Editar no catálogo'\n      catalog_delete: '🗑 Excluir do catálogo'\n      catalog_share: '🔗 Compartilhar'\n      catalog_open: '📂 Abrir no catálogo'\n    error:\n      not_found: |\n        Erro!\n        Não foi possível encontrar um adesivo.\n      invalid_png: |\n        Erro!\n        O arquivo não é uma imagem PNG válida. Por favor, converta para o formato PNG antes de enviar.\n      invalid_dimensions: |\n        Erro!\n        As dimensões do adesivo são inválidas. Os adesivos devem ser de 512x512 pixels.\n      invalid_animated: |\n        Erro!\n        O arquivo de adesivo animado não está no formato TGS correto.\n      invalid_video: |\n        Erro!\n        O arquivo de vídeo não está no formato WEBM correto.\n      restore: |\n        Erro!\n        Não é possível restaurar o pacote.\n      copy: |\n        Erro!\n        Não foi possível encontrar o pacote.\n    select_group:\n      success: |\n        Pacote <a href=\"${link}\">${title}</a> selecionado com sucesso para o grupo.\n      access_rights:\n        add: Quem pode adicionar stickers ao pacote do grupo?\n        delete: Quem pode excluir stickers do pacote do grupo?\n        rights:\n          all: Todos\n          admins: Somente administradores\n      error: |\n        Erro!\n        Conjunto não encontrado.\n  sticker:\n    answerCbQuery:\n      delete: O adesivo foi removido com sucesso da embalagem.\n      restored: O adesivo foi salvo com sucesso no pacote atual.\n    delete: O adesivo foi removido com sucesso da embalagem.\n    restored: O adesivo foi salvo com sucesso no pacote atual.\n    btn:\n      delete: '🗑 Excluir'\n      copy: '🌟 Copiar'\n      restore: '✅ Restaurar'\n    error:\n      not_found: |\n        ERRO!\n        Não foi possível encontrar um adesivo.\n      invalid_png: |\n        <b>Erro!</b>\n        O arquivo não é uma imagem PNG válida. Por favor, converta para o formato PNG antes de enviar.\n      invalid_dimensions: |\n        <b>Erro!</b>\n        As dimensões do sticker são inválidas. Stickers devem ter 512x512 pixels.\n      invalid_animated: |\n        <b>Erro!</b>\n        O arquivo de sticker animado não está no formato TGS correto.\n      invalid_video: |\n        <b>Erro!</b>\n        O arquivo de vídeo não está no formato WEBM correto.\n  group_settings:\n    success: |\n      Configurações do grupo atualizadas com sucesso.\nsticker:\n  add:\n    ok: |\n      <b>Adicionado com sucesso ao pacote:</b>\n      <a href=\"${link}\">${title}</a>\n\n      Dentro de uma hora, este pacote será atualizado para todos os usuários.\n\n      <i>Envie um ou mais emojis que combinem com o sticker, se desejar adicioná-los</i>\n    ok_inline: |\n      <b>Adicionado ao pacote com sucesso:</b>\n      <u>${title}</u>\n    send_emoji: Ótimo, agora envie os emojis que correspondam aos\n    converting_process: |\n      <b>Aguarde...</b>\n      Seu arquivo está na fila para conversão. Espere a conclusão. Isso pode levar algum tempo.\n\n      Progresso: ${progress} / ${total}\n\n      <i>Os usuários que apoiam o bot têm prioridade na fila (para saber mais: /donate)</i>\n    catalog_offer: |\n      <b>😲 Nossa, você fez um ótimo pacote de sticker!</b>\n\n      Gostaria de adicionar o <a href=\"${link}\">${title}</a> ao catálogo público de stickers para que outros usuários do bot também possam vê-lo?\n      <i>Não leva muito tempo</i>\n    quote: |\n      Use o @QuotlyBot para criar uma citação desta mensagem\n    error:\n      reply: |\n        <b>Erro!</b>\n        Por favor, responda ao adesivo.\n      no_selected_pack: |\n        <b>Você não selecionou um pacote</b>\n\n        Por favor, crie (/new) ou escolha (/packs) pacote\n      no_selected_group_pack: |\n        <b>Você não selecionou um pacote de grupo</b>\n\n        Por favor, selecione um pacote usando o comando /packs\n      no_rights: |\n        <b>Erro!</b>\n        Você não tem permissão para adicionar stickers a este pacote.\n      stickers_too_much: |\n        Este pacote possui o número máximo de adesivos.\n\n        Você pode criar um novo pacote usando o comando /new.\n      have_already: |\n        <b>Este sticker já está no pacote</b>\n\n        Se você quiser mudar o emoji, envie-o pela mensagem a seguir.\n      stickerset_invalid: |\n        <b>erro!</b>\n        Bot não pode acessar o pacote escolhido atualmente.\n\n        Por favor, crie (/new) ou escolha (/packs) outro pacote.\n      invalid_png: |\n        <b>Erro!</b>\n        O arquivo não é uma imagem PNG válida. Por favor, converta para o formato PNG antes de enviar.\n      invalid_dimensions: |\n        <b>Erro!</b>\n        As dimensões do sticker são inválidas. Stickers devem ter 512x512 pixels.\n      invalid_animated: |\n        <b>Erro!</b>\n        O arquivo de sticker animado não está no formato TGS correto.\n      invalid_video: |\n        <b>Erro!</b>\n        O arquivo de vídeo não está no formato WEBM correto.\n      file_type:\n        static: |\n          <b>Erro!</b>\n          Este tipo de arquivo não é suportado\n          Você pode adicionar este adesivo estático ou foto ao pacote estático\n\n          <i>Criar (/new) ou escolher (/packs) outro pacote</i>\n        video: |\n          <b>Erro!</b>\n          Este tipo de arquivo não é suportado\n          Você pode adicionar este arquivo de vídeo ao pacote de vídeo\n\n          <i>Criar (/new) ou escolher (/packs) outro pacote</i>\n        animated: |\n          <b>Erro!</b>\n          Este tipo de arquivo não é suportado\n          Você pode adicionar este arquivo animado ao vetor do pacote\n\n          <i>Criar (/new) ou escolher (/packs) outro pacote</i>\n        unknown: |\n          <b>Erro!</b>\n          Este tipo de arquivo não é suportado\n\n          <i>Criar (/new) ou escolher (/packs) outro pacote</i>\n      wait_load: |\n        <b>Aguarde!</b>\n\n        O bot ainda está processando o arquivo anterior...\n        Você pode apoiar o desenvolvimento do bot (\\/donate) para aumentar a prioridade de processamento e permitir adicionar mais de um sticker à fila.\n      timeout: |\n        <b>No momento, o bot está passando por uma carga enorme</b>\n        Portanto, a conversão de vídeo está disponível apenas para pacotes com boost ativo\n\n        Para mais detalhes, siga o / doar\n      convert: |\n        <b>Erro!</b>\n        Infelizmente, o bot não conseguiu converter o seu vídeo.\n\n        Talvez o seu vídeo está salvo num formato incompreensível para o bot. Certifique-se que o vídeo está no formato MP4.\n        Também pode ter sido um erro interno no bot, tente enviar o vídeo novamente.\n      too_big: |\n        <b>Erro!</b>\n\n        O arquivo é muito grande para ser processado. Por favor, reduza a qualidade e durante antes de enviar.\n      sticker_not_found: |\n        <b>Erro!</b>\n\n        Este adesivo não pôde ser encontrado. Por favor, certifique-se de que está no pacote correto ou tente adicioná-lo novamente.\nnews:\n  join: |\n    i <a href=\"${link}\">Entre no nosso canal</a> para receber as últimas notícias sobre o bot.\n\n    <i>Inscreva-se no canal para obter as últimas notícias sobre o bot, bem como atualizações e novos recursos.</i>\n  join_btn: '📢 Entre no canal'\n  not_joined: '🙅 Você não está inscrito no canal'\n  continue: '✅ Continuar'\nuserAbout:\n  help: |\n    <b>🧑‍🎨 Usuário sobre</b>\n\n    Usando este menu você pode descobrir informações sobre o usuário e seu pacote de sticker\n\n    Para obter informações sobre o usuário, use o botão abaixo ou encaminhe sua mensagem\n  result: |\n    <b>🧑‍🎨 Informações do usuário</b>\n    <b>🆔 ID do usuário:</b> <code>${userId}</code>\n    <b>Pacotes deste usuário:</b>\n    ${packs}\n  no_packs: |\n    <i>Não temos informações sobre adesivos deste proprietário</i>\n  forward_hidden: |\n    O usuário ocultou a habilidade de encaminhar mensagens. Use o botão abaixo para ver seus pacotes de stickers.\n  select_user: '🧑‍🎨 Selecionar usuário'\nscenes:\n  new_pack:\n    pack_type: |\n      <b><u>Escolher tipo de pacote</u></b>\n    regular: '😊 Adesivo'\n    custom_emoji: '🌟 Emoji (premium)'\n    static: '🌟 Estático'\n    animated: '✨ Vetor'\n    video: '📹 Vídeo'\n    pack_format: |\n      <b><u>Escolha o tipo de pacote</u></b>\n\n      <b>Comum</b> - estático (não mover), raster, arquivo formato - antes de adicionar PNG (o bot está processando), após adicionar - WEBP.\n      Um exemplo de pacote normal - t.me/addstickers/Animals\n\n      <b>Vídeo</b> - pacote de vídeo de animação. Você pode adicionar qualquer vídeo, gif e foto.\n      Exemplo de pacote de vídeos - t.me/addstickers/TheMascot\n\n      <b>Animado</b> - animado, vetorial (possuem uma descrição exata dos objetos dentro do arquivo, devido para o qual são exibidos claramente em qualquer escala), formato de arquivo - TGS, uma variação do formato Lottie.\n      Um exemplo de pacote animado - t.me/addstickers/IsabelleShizue\n\n      <i>Conjuntos de adesivos animados e de vídeo podem ter até 50 adesivos. Os conjuntos de adesivos estáticos podem ter até 120 adesivos.</i>\n    pack_title: |\n      <b>Digite o nome do novo pacote de sticker:</b>\n      <i>Você pode escolher um nome aleatório no botão.</i>\n    pack_name: |\n      <b>Insira um link curto para o novo pacote de adesivos:</b>\n\n      <i>Por exemplo, este pacote usa 'Animais' como link curto: https://t.me/ addstickers/<u>Animais</u></i>\n      <i>Você pode escolher um link curto aleatório no botão.</i>\n    ok: |\n      Pacote <a href=\"${link}\">${title}</a> criado com sucesso! Link\n\n      <b>do pacote:</b> <pre>${link}</pre>\n\n      Envie um arquivo, foto, vídeo ou sticker para que eu o adicione ao seu conjunto\n    error:\n      title_long: O nome não pode ser maior que ${max} símbolos.\n      name_long: O endereço não pode ser maior que ${max} símbolos.\n      telegram:\n        name_invalid: Esse endereço não pode ser usado.\n        name_occupied: Este endereço já foi pego.\n        upload_failed: |\n          <b>erro!</b>\n          Bot não pode enviar stickers para o Telegram.\n\n          Por favor, tente novamente mais tarde.\n  copy:\n    enter: |\n      Eu posso copiá-lo, mas antes disso, vamos criar um novo pacote\n    progress: |\n      Copiando pacote de <a href=\"${originalLink}\">${originalTitle}</a> para <a href=\"${link}\">${title}</a>\n\n      Progresso: ${current}/${total}\n    done: |\n      Cópia de pacotes de <a href=\"${originalLink}\">${originalTitle}</a> a <a href=\"${link}\">${title}</a> concluída com sucesso.\n    pay: |\n      <b>Conversão de pacote</b>\n\n      Converter um pacote de um tipo para outro custa 1 crédito\n\n      <b>Saldo atual:</b> ${balance} Créditos\n\n      Comprar créditos: /donate\n    pay_btn: '✅ Confirmar'\n    error:\n      premium: |\n        <b>Erro!</b>\n        Este recurso está disponível apenas para membros doadores.\n\n        Você pode fazer isso enviando o comando /donate.\n  original:\n    enter: |\n      Envie o sticker que foi adicionado através deste bot e mostrarei o original.\n    error:\n      not_found: |\n        <b>Erro!</b>\n        Não encontrei o sticker original.\n  delete:\n    enter: |\n      Envie o sticker que foi adicionado através deste bot e vou excluí-lo do pacote.\n    confirm: |\n      Tem certeza que deseja excluir este sticker?\n    error:\n      not_found: |\n        <b>Erro!</b>\n        eu não consegui encontrar o adesivo.\n  rename:\n    enter_name: |\n      <b>Insira um novo título para <a href=\"${link}\">${title}</a>:</b>\n    success: |\n      <b>Título alterado com sucesso!</b>\n\n      Novo título: <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      ❕ Para remover o sufixo \"<code>${titleSuffix}</code>\", você precisa dar um boost no pacote. Mais detalhes no menu visitando: \\/donate\n  packAbout:\n    enter: |\n      <b>Envie-me um sticker ou emoji personalizado para procurar informações sobre ele:</b>\n    not_found: |\n      Eu não consegui encontrar o sticker\n    result: |\n      <b>📦 Pack:</b> <a href=\"${link}\">${name}</a>\n      🆔 <code>${setId}</code> <i>(número único para pacotes de dono, incrementado por pacote)</i>\n\n      :artista: ID do Proprietário: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 Outros pacotes deste proprietário:</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>Não temos informações sobre outros adesivos deste proprietário</i>\n  boost:\n    sure: |\n      <b>Tem certeza de que deseja impulsionar <a href=\"${link}\">${title}</a>?</b>\n\n      Impulsionar aumentará a prioridade de processamento e a capacidade de adicionar mais de um adesivo à fila\n      Você pode encontrar mais informações detalhadas sobre os impulsos no menu visitando: /donate\n\n      <b>Preço:</b> 1 Crédito\n      <b>Saldo atual:</b> ${balance} Créditos\n    btn:\n      yes: Sim, aumentar!\n      no: Não, cancelar\n    canceled: |\n      Otimização cancelada\n    success: |\n      Aceleração concluída com sucesso!\n\n      ${title} será impulsionada\n    error:\n      not_enough_credits: |\n        Você não tem Créditos suficientes para impulsionar este pacote.\n\n        Você pode recarregar seu saldo enviando o comando /donate.\n      already_boosted: |\n        Este pacote já está aumentado.\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>Envie-me o adesivo do pacote que deseja publicar</b>\n\n        <i>Você pode publicar qualquer pacote que seja de sua propriedade, mesmo se eles foram criados em outro lugar</i>\n      owner_proof: |\n        <b>Para verificar a propriedade deste pacote, você precisa seguir alguns passos simples:</b>\n        1. Abra @Stickers bot\n        2. Enviar o comando <code>/packstats</code>\n        3. Encontre e escolha o pacote necessário\n        4. Encaminhe a mensagem recebida para o bot\n      publish_new_access_denied: |\n        <b>Erro!</b>\n        Este pacote não é seu.\n\n        Você só pode publicar seus próprios pacotes\n      banned: |\n        <b>Erro!</b>\n        Você está banido de usar essa funcionalidade.\n        Por favor, contate o administrador.\n      enter: |\n        Você está prestes a publicar o pacote \"<a href=\"${link}\">${title}</a>\" na pasta pública do nosso bot\n        Ele pode ser encontrado por qualquer usuário do bot pelo nome, tags ou filtro\n        Por causa disso, mais pessoas instalarão seu pacote\n        Tente enviar apenas pacotes de alta qualidade que podem ser de interesse para um grande número de pessoas\n\n        <b>Regras para publicação de pacotes:</b>\n        • Não publique seus pacotes pessoais destinados a um pequeno círculo de pessoas. Por exemplo, tais como os rostos dos seus amigos ou citações de suas mensagens\n        • Não publique pressões de adesivos que violem as leis da UE ou outras leis locais\n\n        Você precisará enviar informações adicionais para que elas sejam publicadas no catálogo\n      continue_button: Continuar\n      enter_description: |\n        <b>Descreva sucintamente o seu pacote para que outros possam encontrá-lo</b>\n\n        <i>Você também pode usar hashtags para categorizar [#]</i>\n        <i>Por exemplo: #anime #meme #animals #cute #kpop #funny #cat #game </i>\n      select_language: |\n        <b>Escolha quais idiomas o seu pacote será:</b>\n        <i>Você pode selecionar vários idiomas</i>\n      button_all_languages: Todas os idiomas\n      button_confirm_language: Confirmar\n      set_safe: |\n        <b>Seu pacote é seguro para usuários?</b>\n        <i>Ou seja, ele não contém erotica e outro conteúdo chocante</i>\n      button_safe:\n        safe: Sim, é seguro\n        not_safe: Não, não é seguro\n      no_tags: não foi especificado\n      confirm: |\n        <b>Confirmar a publicação do pacote \"<a href=\"${link}\">${title}</a>\"</b>\n\n        <b>Descrição:</b> <i>${description}</i>\n\n        <b>Tags:</b> ${tags}\n        <b>Linguagens:</b> ${languages}\n      button_confirm: '✅ Confirmar publicação'\n      success: |\n        Parabéns, seu pacote foi publicado no diretório geral do nosso bot onde outros usuários podem encontrá-lo!\n\n        Você pode editar as informações de pesquisa do pacote selecionando o pacote com o comando /packs.\n\n        <i>Lembramos que as estatísticas do seu pack podem ser visualizadas através do bot oficial @Stickers</i>\n    unpublish:\n      success: |\n        O pacote foi despublicado com sucesso do catálogo de bots.\n  delete_pack:\n    enter: |\n      Tem certeza de que deseja excluir o pacote <a href=\"${link}\">${title}</a>?\n      Ele será excluído permanentemente e não poderá ser recuperado.\n\n      Se você deseja excluir apenas um adesivo, use o comando /delete.\n\n      Envie <code>${confirm}</code> para confirmar que você realmente deseja excluir este pacote.\n    confirm: Sim, tenho a certeza absoluta.\n    success: |\n      <b>Pacote excluído com sucesso!</b>\n    error:\n      - <b>Erro!</b>\n      - Ops, algo deu errado.\n  frame:\n    no_video: |\n      <b>Erro!</b>\n      Você só pode adicionar quadros aos pacotes de vídeo.\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>Escolher tipo de quadro:</b>\n      Frame é um fundo transparente em torno do adesivo\n\n      <code>lite</code> — os cantos serão cortados um pouco\n      <code>médio</code> — os cantos serão recortados mais\n      <code>arredondados</code> — os cantos serão arredondados\n      <code>quadrados</code> — o formato retangular do quadro, isto é, ele não será alterado de qualquer forma\n      <code>círculo</code> — o quadro estará na forma de um círculo\n\n      <i>no futuro, você pode usar o comando /frame para definir o tipo de frame</i>\n    types:\n      lite: '1. Lite'\n      medium: '2. Médio'\n      rounded: '3. Arredondado'\n      square: '4. Quadrado'\n      circle: '5. Círculo'\n    selected: |\n      <b>tipo de frame selecionado:</b> ${type}\n  photoClear:\n    enter: |\n      Envie uma <u>foto</u> da qual deseja remover o fundo e eu enviarei o arquivo sem o fundo\n\n      <i>Funciona melhor com fotos. Funciona pior com desenhos, ilustrações, etc.</i>\n    enter_anime: |\n      Envie uma <u>fotografia</u> da qual deseja remover o fundo e enviarei o arquivo sem o fundo\n\n      <i>Funciona melhor com imagens de anime</i>\n    choose_model: |\n      <b>Escolha o modelo:</b>\n    web_app: WebApp - para fotos com pessoas\n    model:\n      ordinary: Comum — para fotos com pessoas\n      general: Geral — para quaisquer fotos\n      anime: Anime - para fotos de animes\n      birefnet_general: BirefNet - para quaisquer fotos\n    add_to_set_btn: '🌟 Adicionar ao conjunto'\n    error: |\n      <b>Erro!</b>\n      Opps, algo deu errado.\n  leave: |\n    Ação cancelada.\n  btn:\n    cancel: '❌ Cancelar'\nerror:\n  telegram: |\n    <b>O Telegram retornou um erro!</b>\n    <code>${error}</code>\n  answerCbQuery:\n    telegram: |\n      O Telegram retornou um erro:\n      ${error}\n  banned: |\n    <b>Erro!</b>\n    Você está banido de usar essa funcionalidade.\n\n    <i>Se você acha que isso é um engano, por favor contate o administrador: @ly_oBot</i>\n  unknown: |\n    <b>Ocorreu um erro desconhecido, por favor, tente novamente.</b>\n\n    Se o problema persistir, escreva para @Ly_oBot.\n    Por favor, escreva imediatamente sobre qual bot você está falando e descreva o problema em detalhes em uma mensagem.\n"
  },
  {
    "path": "locales/ru.yaml",
    "content": "---\nlanguage_name: '🇷🇺 Русский'\nname: fStik — Стикеры и эмодзи\ndescription:\n  long: |\n    Создавай стикеры и эмодзи из фото, видео и GIF. Без ручной конвертации — бот сделает всё сам. Проще, чем @Stickers.\n\n    Возможности:\n    • Управление паками\n    • Видео-стикеры и кастомные эмодзи\n    • Скачивание оригиналов\n    • Конвертация стикера/видео/GIF в картинку\n    • Каталог стикеров\n\n    Поиск стикеров: play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    Создавай стикеры и эмодзи из фото, видео, GIF. Каталог и поиск стикеров. 🇺🇦\nratelimit: Не так часто!\ncmd:\n  start:\n    enter: |\n      Привет, ${name}!\n      Создаю стикеры и эмодзи из фото, видео и GIF.\n\n      💬 @fStikCommunityRU\n    group: |\n      Привет, ${groupTitle}! Я создаю стикеры и эмодзи.\n\n      Чтобы добавить стикер в групповой пак, используй /ss в ответ на фото, видео, гифку или стикер.\n    catalog: |\n      <b>🔍 Каталог стикеров</b>\n\n      Ищи паки по ключевым словам или смотри популярные.\n      Оценивай чтобы помочь другим найти крутые паки.\n    search_catalog: |\n      <b>🌐 Каталог паков</b>\n\n      Смотри паки других или публикуй свои:\n    commands:\n      ss: '🌟 Сохранить стикер'\n      start: '📜 Меню'\n      help: '❓ Помощь'\n      packs: '📁 Мои паки'\n      new: '➕ Новый пак'\n      search_catalog: '🌐 Каталог'\n      catalog: '🌐 Каталог'\n      publish: '📤 Опубликовать'\n      delete: '🗑 Удалить стикер'\n      original: '🔎 Откуда стикер'\n      restore: '♻️ Восстановить пак'\n      copy: '📋 Копировать пак'\n      emoji: '😀 Изменить эмодзи'\n      round: '⭕ Круглое видео'\n      clear: '✂️ Удалить фон'\n      info: '🔎 Чей стикер'\n      user_about: '👤 Об авторе'\n      lang: '🌐 Язык'\n      report: '🚨 Жалоба'\n      donate: '⭐ Поддержать'\n      add_to_group: '👥 Добавить в группу'\n      privacy: '🔒 Приватность'\n      guide: '❓ Помощь'\n    btn:\n      new: '➕ Новый пак'\n      catalog: '🔍 Каталог'\n      catalog_mini: '🔍 Каталог'\n      catalog_browser: '🌐 Открыть в браузере'\n      catalog_browser_mini: '🌐 В браузере'\n      catalog_app: '📱 Скачать Android приложение'\n      catalog_app_mini: '📱 Android приложение'\n  guide:\n    menu: |\n      <b>📖 Как пользоваться</b>\n\n      Выбери тему:\n    create: |\n      <b>🎨 Создание стикеров</b>\n\n      1. Отправь /new чтобы создать пак\n      2. Выбери тип: обычный, видео или эмодзи\n      3. Отправляй фото, видео или GIF\n      4. Готово! Бот сам всё конвертирует\n\n      <b>Полезные команды:</b>\n      • /clear — удалить фон с фото\n      • /round — сделать кружок из видео\n      • /frame — форма видео-стикера:\n        └ lite, medium, rounded, square, circle\n\n      <b>💡 Совет:</b> Отправляй PNG как <b>Файл</b> (📎) чтобы сохранить прозрачность. Режим «Фото» сжимает и убирает её.\n    manage: |\n      <b>📁 Управление паками</b>\n\n      <b>Команды:</b>\n      • /packs — список твоих паков\n      • /delete — удалить стикер\n      • /copy — скопировать пак\n      • /restore — вернуть скрытые паки\n      • /original — найти оригинал стикера\n      • /about — инфо о паке и авторе\n\n      <b>Изменить эмодзи:</b>\n      Отправь стикер → отправь новый эмодзи.\n      Или отправь эмодзи сразу после добавления.\n\n      <b>Эмодзи по умолчанию:</b>\n      /emoji 🔥 — все новые стикеры получат этот.\n\n      <b>В группах:</b>\n      /ss (в ответ на медиа) — быстрое добавление в групповой пак.\n\n      <b>Inline паки (Для поиска):</b>\n      Отдельный тип паков для быстрого доступа через @имя_бота в любом чате.\n      • Не настоящий Telegram пак — хранится только в боте\n      • Может содержать стикеры, фото, GIF, видео\n      • Создай в /packs → вкладка «Для поиска»\n      • Также есть режим поиска GIF (через Tenor)\n\n      <b>Совместное редактирование (Co-edit):</b>\n      Нажми на пак → Co-edit → получи ссылку.\n      Любой с ссылкой может добавлять/удалять стикеры.\n      Сбрось ссылку чтобы убрать всех редакторов.\n\n      <b>Опции пака:</b>\n      Нажми на пак в /packs → переименовать, буст, совместное редактирование, удалить.\n    catalog: |\n      <b>🔍 Поиск и публикация</b>\n\n      • /catalog — смотри популярные паки\n      • /publish — поделись своим паком публично\n      • Оценивай паки ⭐ чтобы помочь другим найти крутые\n\n      Хорошие паки получают больше просмотров. Твой может стать вирусным 🔥\n    boost: |\n      <b>⚡ Буст и дополнительные возможности</b>\n\n      <b>Преимущества буста:</b>\n      • Без \"${titleSuffix}\" в названии пака\n      • Приоритетная обработка видео\n      • Более длинные видео (до 30 сек)\n      • Добавляй несколько стикеров сразу\n\n      <b>Скрытая фича:</b>\n      /new fill — создать адаптивные эмодзи (меняют цвет под текст).\n\n      Получить буст → /donate\n    problems: |\n      <b>❓ FAQ</b>\n\n      <b>⚫ Фон стал чёрным?</b>\n      Телеграм убирает прозрачность, если отправить как «Фото».\n      → Отправляй PNG как <b>Файл</b> (📎 скрепка).\n\n      <b>🔄 Изменения не видны?</b>\n      Телеграм кеширует стикеры. Подожди ~1 час или перезапусти приложение.\n\n      <b>📹 Видео не анимируется?</b>\n      Скорее всего странный формат. Попробуй конвертировать в MP4 или отправить как Файл вместо Видео.\n\n      <b>🗑 Как удалить стикер?</b>\n      Отправь /delete, потом нажми на стикер.\n\n      <b>🤏 Стикер слишком маленький?</b>\n      Обрежь картинку до квадрата (1:1) перед отправкой.\n\n      <b>🔗 Как убрать \"_by_fStikBot\" из ссылки?</b>\n      Никак. Телеграм требует этот суффикс чтобы определить, каким ботом создан пак. Это правило платформы, не наше.\n\n      <b>💎 Нужен Premium?</b>\n      • Создавать паки: бесплатно\n      • Использовать стикеры: бесплатно\n      • Отправлять кастомные эмодзи: только Premium (правило Telegram)\n\n      <b>Ещё вопросы?</b> @fStikCommunity\n    btn:\n      create: '🎨 Создание стикеров'\n      manage: '📁 Управление паками'\n      catalog: '🔍 Поиск и публикация'\n      boost: '⚡ Буст и возможности'\n      problems: '❓ Проблемы?'\n      back: '← Назад'\n  inline:\n    switch_pm: '📁 Выбрать пак'\n  lang:\n    choose: |\n      🌐 Выбери язык\n\n      Помоги с переводом: https://crwd.in/fStikBot\n  restore: |\n    <b>♻️ Восстановление пака</b>\n\n    Отправь ссылку на пак, который хочешь восстановить.\n  copy: |\n    <b>📋 Копирование пака</b>\n\n    Отправь ссылку на пак, который хочешь скопировать.\n  report: |\n    <b>🚨 Жалоба</b>\n\n    Нашёл пак, нарушающий правила? Отправь ссылку на @StickersReportBot\n  packs:\n    info: |\n      <b>📁 Паки</b>\n    types:\n      regular: Стикеры\n      custom_emoji: Эмодзи\n      inline: Для поиска\n    empty: |\n      Паков пока нет. Создай первый → /new\n    inline_title: Пак для поиска\n    select_group_pack_info: |\n      <b>📁 Выбор пака</b>\n\n      Чтобы использовать пак в группе, администраторам нужно выбрать его кнопкой ниже\n    select_group_pack: Выбрать пак\n  emoji:\n    info: |\n      Чтобы изменить стандартные эмодзи для текущего пака, отправь <code>/emoji</code> с эмодзи через пробел\n\n      Например — <code>/emoji 🌟</code>\n    done: Эмодзи успешно изменены.\n    error: Произошла ошибка при изменении эмодзи!\n  round_video:\n    enabled: |\n      Теперь видео будут иметь округлую форму\n    disabled: |\n      Видео больше не будут иметь округлую форму\n  paysupport: |\n    <b>👨‍💻 Поддержка платежей</b>\n\n    По всем вопросам, связанным с работой бота, включая платежи и донаты, ты можешь связаться напрямую с разработчиком\n\n    <b>Контакты:</b>\n    🧑‍💻 Разработчик: @ly_oBot\ndonate:\n  menu: |\n    <b>⭐ Поддержка бота</b>\n\n    <b>Баланс:</b> ${balance} кредитов\n    1 кредит = буст одного пака\n\n    <b>Буст даёт:</b>\n    • Без \"<code>${titleSuffix}</code>\" в названии\n    • Название до 64 символов (вместо 35)\n    • Видео до 35 секунд\n    • Приоритетная очередь конвертации\n    • Несколько стикеров одновременно\n    • Без рекламы\n\n    <b>Сколько кредитов купить?</b>\n  invoice_title: '${amount} Кредитов'\n  btn:\n    donate: '☕️ Донат'\n  topup: |\n    <b>Введи количество Кредитов, которое хочешь купить:</b>\n  invalid_amount: |\n    <b>Неверное количество</b>\n\n    Минимальное количество — 1 Кредит\n  paymenu: |\n    Ты хочешь купить <b>${amount} Кредитов</b> за <b>${price}$</b>\n\n    ⚠️ Кредиты выдаются вручную администратором.\n    Время ожидания — от 5 минут до 1 часа\n\n    <u>Выбери способ оплаты:</u>\n  description: |\n    Покупая Кредиты, ты поддерживаешь развитие бота и получаешь возможность использовать дополнительные функции\n  update: |\n    <b>🔄 Обновление баланса</b>\n\n    Баланс: <code>${balance}</code> Кредитов (добавлено <code>${amount}</code> Кредитов)\n  error:\n    already_donated: |\n      Ты уже получил Кредиты за этот платёж\n    already_paid: Платёж уже выполнен\n    not_found: Платёж не найден\n    user_not_found: Пользователь не найден\n    error: |\n      <b>Ошибка!</b>\n      Произошла ошибка при обработке платежа\n    canceled: |\n      Платёж отменён\ncoedit:\n  info: |\n    <b>👥 Совместное редактирование</b>\n\n    Ссылка: <code>${colink}</code>\n\n    Поделись — другие смогут добавлять/удалять стикеры в <a href=\"${link}\">${title}</a>.\n\n    <b>Редакторы:</b> ${editors}\n\n    <i>Сбрось ссылку чтобы убрать всех</i>\n  no_editors: |\n    нет\n  btn:\n    send: '📤 Отправить ссылку'\n    reset: '🔁 Сбросить ссылку'\n  share: |\n    Перейди по ссылке и нажми «Старт» для совместного редактирования пака «${title}»\n  reset: |\n    <b>🔁 Ссылка успешно сброшена</b>\n\n    Новая ссылка для совместного редактирования <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: Пак не найден\n      not_owner: Не твой пак\n      hidden: Пак скрыт\n      restored: Пак восстановлен\n    set_pack: |\n      ✅ Выбран <a href=\"${link}\">${title}</a>\n\n      Отправь фото, видео или стикер чтобы добавить.\n    set_inline_pack: |\n      ✅ Выбран <u>${title}</u>\n\n      Используй: <code>@${botUsername} </code>в любом чате.\n    boost:\n      info: |\n        \\n⚡ <b><a href=\"https://t.me/${botUsername}?start=boost\">Улучшение</a></b>: ${boostStatus}\n      status:\n        on: Включён\n        off: Отключён\n    hidden: Пак <a href=\"${link}\">${title}</a> скрыт из твоего списка.\n    restored: Пак <a href=\"${link}\">${title}</a> восстановлен в твой список.\n    btn:\n      hide: '❌ Скрыть пак'\n      delete: '🗑 Удалить пак'\n      restore: '✅ Восстановить'\n      use_pack: '📦 Использовать пак'\n      boost: '⚡ Улучшить'\n      frame: '🖼 Рамка'\n      rename: '✏️ Переименовать'\n      search_gif: '🔎 Поиск GIF'\n      coedit: '👥 Совместное редактирование'\n      catalog_add: '🗂 Добавить в каталог'\n      catalog_edit: '📝 Редактировать в каталоге'\n      catalog_delete: '🗑 Удалить из каталога'\n      catalog_share: '🔗 Поделиться'\n      catalog_open: '📂 Открыть в каталоге'\n    error:\n      not_found: |\n        Ошибка!\n        Не удалось найти стикер.\n      invalid_png: |\n        Ошибка!\n        Файл не является корректным PNG-изображением. Преобразуй его в формат PNG перед отправкой.\n      invalid_dimensions: |\n        Ошибка!\n        Размеры стикера некорректны. Стикеры должны быть 512x512 пикселей.\n      invalid_animated: |\n        Ошибка!\n        Файл анимированного стикера не в правильном формате TGS.\n      invalid_video: |\n        Ошибка!\n        Видеофайл не в правильном формате WEBM.\n      restore: |\n        Ошибка!\n        Невозможно восстановить пак.\n      copy: |\n        Ошибка!\n        Невозможно найти пак.\n    select_group:\n      success: |\n        Пак <a href=\"${link}\">${title}</a> успешно выбран для группы.\n      access_rights:\n        add: Кто может добавлять стикеры в групповой пак?\n        delete: Кто может удалять стикеры из группового пака?\n        rights:\n          all: Все\n          admins: Только администраторы\n      error: |\n        Ошибка!\n        Пак не найден.\n  sticker:\n    answerCbQuery:\n      delete: Стикер успешно удалён из пака.\n      restored: Стикер успешно сохранён в текущий пак.\n    delete: Стикер успешно удалён из пака.\n    restored: Стикер успешно сохранён в текущий пак.\n    btn:\n      delete: '🗑 Удалить'\n      copy: '🌟 Копировать'\n      restore: '✅ Восстановить'\n    error:\n      not_found: |\n        Ошибка!\n        Не удалось найти стикер.\n      invalid_png: |\n        <b>Ошибка!</b>\n        Файл не является корректным PNG-изображением. Преобразуй его в формат PNG перед отправкой.\n      invalid_dimensions: |\n        <b>Ошибка!</b>\n        Размеры стикера некорректны. Стикеры должны быть 512x512 пикселей.\n      invalid_animated: |\n        <b>Ошибка!</b>\n        Файл анимированного стикера не в правильном формате TGS.\n      invalid_video: |\n        <b>Ошибка!</b>\n        Видеофайл не в правильном формате WEBM.\n  group_settings:\n    success: |\n      Настройки группы успешно обновлены.\nsticker:\n  add:\n    ok: |\n      ✅ Добавлено в <a href=\"${link}\">${title}</a>\n\n      <i>Можешь отправить эмодзи для этого стикера</i>\n    ok_inline: |\n      ✅ Добавлено в <u>${title}</u>\n    send_emoji: Отправь эмодзи для этого стикера\n    converting_process: |\n      ⏳ Конвертация: ${progress}/${total}\n\n      <i>Буст = приоритет → /donate</i>\n    catalog_offer: |\n      Хочешь поделиться паком <a href=\"${link}\">${title}</a>?\n      Добавь в каталог — другие смогут его найти.\n    quote: |\n      Используй @QuotlyBot для создания цитаты из этого сообщения\n    error:\n      reply: |\n        <b>Ошибка!</b>\n        Отвечай на стикер.\n      no_selected_pack: |\n        <b>Ты не выбрал пак</b>\n\n        Создай (/new) или выбери (/packs) пак\n      no_selected_group_pack: |\n        <b>Ты не выбрал групповой пак</b>\n\n        Выбери пак командой /packs\n      no_rights: |\n        <b>Ошибка!</b>\n        У тебя нет прав добавлять стикеры в этот пак.\n      stickers_too_much: |\n        В этом паке максимальное количество стикеров.\n\n        Можешь создать новый пак командой /new.\n      have_already: |\n        <b>Этот стикер уже есть в паке</b>\n\n        Если хочешь изменить эмодзи, отправь их в следующем сообщении.\n      stickerset_invalid: |\n        <b>Ошибка!</b>\n        Бот не может получить доступ к выбранному стикерпаку.\n\n        Создай (/new) или выбери (/packs) другой стикерпак.\n      invalid_png: |\n        <b>Ошибка!</b>\n        Файл не является корректным PNG-изображением. Преобразуй его в формат PNG перед отправкой.\n      invalid_dimensions: |\n        <b>Ошибка!</b>\n        Размеры стикера некорректны. Стикеры должны быть 512x512 пикселей.\n      invalid_animated: |\n        <b>Ошибка!</b>\n        Файл анимированного стикера не в правильном формате TGS.\n      invalid_video: |\n        <b>Ошибка!</b>\n        Видеофайл не в правильном формате WEBM.\n      file_type:\n        static: |\n          <b>Ошибка!</b>\n          Этот тип файла не поддерживается\n          Ты можешь добавить это фото или статический стикер в статический стикерпак\n\n          <i>Создай (/new) или выбери (/packs) другой стикерпак</i>\n        video: |\n          <b>Ошибка!</b>\n          Этот тип файла не поддерживается\n          Ты можешь добавить этот видеофайл в видеопак\n\n          <i>Создай (/new) или выбери (/packs) другой стикерпак</i>\n        animated: |\n          <b>Ошибка!</b>\n          Этот тип файла не поддерживается\n          Ты можешь добавить этот анимированный файл в анимированный стикерпак\n\n          <i>Создай (/new) или выбери (/packs) другой стикерпак</i>\n        unknown: |\n          <b>Ошибка!</b>\n          Этот тип файла не поддерживается\n\n          <i>Создай (/new) или выбери (/packs) другой стикерпак</i>\n      wait_load: |\n        ⏳ Ещё обрабатываю предыдущий файл...\n      timeout: |\n        ⚠️ Сейчас большая нагрузка. Попробуй снова через несколько минут.\n      convert: |\n        Не удалось конвертировать видео.\n\n        Попробуй MP4 формат или отправь снова.\n      too_big: |\n        <b>Ошибка!</b>\n\n        Файл слишком большой для обработки. Уменьши качество и продолжительность файла перед отправкой.\n      sticker_not_found: |\n        <b>Ошибка!</b>\n\n        Этот стикер не удалось найти. Убедись, что он в правильном паке, или попробуй добавить его снова.\n      invalid_image: |\n        <b>Ошибка!</b>\n\n        Не удалось обработать это изображение. Попробуй отправить другой файл или формат.\nnews:\n  join: |\n    📢 <a href=\"${link}\">Подпишись на канал</a> — новости, обновления, новые функции.\n  join_btn: '📢 Подписаться'\n  not_joined: '🙅 Ты не подписан на канал'\n  continue: '✅ Продолжить'\nuserAbout:\n  help: |\n    <b>🧑‍🎨 О пользователе</b>\n\n    С помощью этого меню ты можешь узнать информацию о пользователе и его стикерпаках\n\n    Чтобы получить информацию о пользователе, воспользуйся кнопкой ниже или перешли его сообщение\n  result: |\n    <b>🧑‍🎨 Информация о пользователе</b>\n    <b>🆔 ID пользователя:</b> <code>${userId}</code>\n    <b>🎨 Паки от этого пользователя:</b>\n    ${packs}\n  no_packs: |\n    <i>У нас нет информации о паках этого владельца</i>\n  forward_hidden: |\n    Пользователь скрыл возможность пересылать сообщения. Воспользуйся кнопкой ниже, чтобы просмотреть его стикерпаки.\n  select_user: '🧑‍🎨 Выбрать пользователя'\nscenes:\n  new_pack:\n    pack_type: |\n      <b>Тип пака:</b>\n    regular: '🖼 Стикеры'\n    custom_emoji: '✨ Эмодзи'\n    pack_title: |\n      <b>Название пака:</b>\n    pack_name: |\n      <b>Короткая ссылка:</b>\n\n      Пример: t.me/addstickers/<u>MoiStikery</u>\n    ok: |\n      ✅ Пак создан: <a href=\"${link}\">${title}</a>\n\n      Отправь фото, видео или стикер чтобы добавить.\n    error:\n      title_long: Название не должно быть длиннее ${max} символов.\n      name_long: Адрес не должен быть длиннее ${max} символов.\n      telegram:\n        name_invalid: Такой адрес использовать не получится.\n        name_occupied: Этот адрес уже занят. Попробуй другой.\n        upload_failed: |\n          <b>Ошибка!</b>\n          Бот не может загрузить стикеры в Telegram.\n\n          Попробуй позже.\n  copy:\n    enter: |\n      Сначала создадим новый пак для копии.\n    progress: |\n      ⏳ Копирую: ${current}/${total}\n    done: |\n      ✅ Скопировано в <a href=\"${link}\">${title}</a>\n    done_partial: |\n      ⚠️ Скопировано в <a href=\"${link}\">${title}</a>\n\n      ${success} стикеров скопировано, ${failed} не удалось скопировать.\n    done_pending: |\n      ✅ Скопировано в <a href=\"${link}\">${title}</a>\n\n      ${success} стикеров скопировано, ${pending} видео ещё обрабатываются.\n    done_partial_pending: |\n      ⚠️ Скопировано в <a href=\"${link}\">${title}</a>\n\n      ${success} стикеров скопировано, ${failed} не удалось, ${pending} видео ещё обрабатываются.\n    pay: |\n      <b>Конвертация пака</b>\n\n      Конвертация пака из одного типа в другой стоит 1 Кредит\n\n      <b>Текущий баланс:</b> ${balance} Кредитов\n\n      Купить Кредиты: /donate\n    pay_btn: '✅ Подтвердить'\n    error:\n      all_failed: |\n        ❌ Не удалось скопировать ни одного стикера из <a href=\"${originalLink}\">${originalTitle}</a>.\n\n        Пак не создан.\n      premium: |\n        <b>Ошибка!</b>\n        К сожалению, эта функция доступна только для тех, кто поддержал бота.\n\n        Ты можешь это сделать, отправив команду /donate.\n  original:\n    enter: |\n      <b>🔎 Откуда стикер</b>\n\n      Отправь стикер — покажу из какого пака он скопирован.\n      Если оригинал не найден — получишь файл (PNG/WEBM).\n    error:\n      not_found: |\n        Оригинал не найден. Вот файл стикера:\n  delete:\n    enter: |\n      <b>🗑 Удаление стикера</b>\n\n      Отправь стикер, который хочешь удалить из пака.\n    confirm: |\n      Удалить этот стикер из пака?\n    error:\n      not_found: |\n        Стикер не найден в базе. Возможно, он создан не через этого бота.\n  rename:\n    enter_name: |\n      Новое название для <a href=\"${link}\">${title}</a>:\n    success: |\n      ✅ Переименовано: <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      Буст уберёт \"${titleSuffix}\" → /donate\n  packAbout:\n    enter: |\n      <b>🔎 Чей стикер</b>\n\n      Отправь стикер — покажу пак, автора и другие его паки.\n      Или перешли сообщение — покажу паки этого человека.\n    not_found: |\n      Стикер не найден.\n    btn:\n      download: '📎 Скачать файл'\n      show_all_packs: '📦 Все паки (${count})'\n    result: |\n      <b>📦 Пак:</b> <a href=\"${link}\">${name}</a>\n      🔢 Стикеров: <code>${stickerCount}</code> | 🏷 #<code>${setId}</code> | ${dcId}\n\n      🧑‍🎨 ID владельца: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 Другие паки от этого владельца:</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>У нас нет информации о других паках этого владельца</i>\n    unknown_owner: '<i>Неизвестно</i>'\n    hidden: '<i>[скрыто]</i>'\n  boost:\n    sure: |\n      Бустнуть <a href=\"${link}\">${title}</a>?\n\n      <b>Цена:</b> 1 кредит\n      <b>Баланс:</b> ${balance}\n    btn:\n      yes: '⚡ Бустнуть'\n      no: Отмена\n    canceled: |\n      Отменено\n    success: |\n      ⚡ ${title} бустнут!\n    error:\n      not_enough_credits: |\n        Недостаточно кредитов. /donate\n      already_boosted: |\n        Уже бустнут.\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>Отправь стикер из пака, который хочешь опубликовать</b>\n\n        <i>Можешь опубликовать любой свой пак, даже созданный в другом месте</i>\n      owner_proof: |\n        <b>Чтобы подтвердить владение этим паком, выполни несколько простых шагов:</b>\n        1. Открой бота @Stickers\n        2. Отправь команду <code>/packstats</code>\n        3. Найди и выбери нужный пак\n        4. Перешли полученное сообщение мне\n      publish_new_access_denied: |\n        <b>Ошибка!</b>\n        Это не твой пак.\n\n        Можно публиковать только свои паки\n      banned: |\n        <b>Ошибка!</b>\n        Тебе запрещено использовать эту функцию.\n        Свяжись с администратором.\n      enter: |\n        Опубликовать <a href=\"${link}\">${title}</a> в каталоге?\n\n        Другие пользователи смогут найти твой пак по названию или тегам.\n\n        <b>Правила:</b>\n        • Только качественные паки для широкой аудитории\n        • Без личных фото или приватного контента\n        • Без нарушения законов\n      continue_button: Продолжить\n      enter_description: |\n        <b>Кратко опиши свой пак, чтобы другие могли его найти</b>\n\n        <i>Можешь использовать хэштеги для категоризации [#]</i>\n        <i>Например: #аниме #мемы #животные #cute #kpop #funny #кошки #game</i>\n      select_language: |\n        <b>Выбери языки, для которых предназначен твой пак:</b>\n        <i>Можно выбрать несколько</i>\n      button_all_languages: Любой язык\n      button_confirm_language: Подтвердить\n      set_safe: |\n        <b>Твой пак безопасен для пользователей?</b>\n        <i>То есть не содержит эротики и другого шокирующего контента</i>\n      button_safe:\n        safe: Да, безопасен\n        not_safe: Нет, небезопасен\n      no_tags: не указаны\n      confirm: |\n        <b>Подтвердить публикацию стикерпака «<a href=\"${link}\">${title}</a>»</b>\n\n        <b>Описание:</b> <i>${description}</i>\n\n        <b>Теги:</b> ${tags}\n        <b>Языки:</b> ${languages}\n      button_confirm: '✅ Подтвердить публикацию'\n      success: |\n        Поздравляю! Твой пак опубликован в каталоге, теперь его смогут найти другие пользователи!\n\n        Редактировать информацию о паке можно через /packs.\n\n        <i>Статистику своего пака можно посмотреть через официального бота @Stickers</i>\n    unpublish:\n      success: |\n        Пак успешно удалён из каталога.\n  delete_pack:\n    enter: |\n      ⚠️ Удалить <a href=\"${link}\">${title}</a> навсегда?\n\n      Отправь <code>${confirm}</code> для подтверждения.\n    confirm: Да, удалить\n    success: |\n      🗑 Пак удалён\n    error:\n      - Что-то пошло не так.\n  frame:\n    no_video: |\n      <b>Ошибка!</b>\n      Рамку можно установить только для видеопаков.\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>Выбери тип рамки:</b>\n      Рамка — прозрачный фон вокруг стикера\n\n      <code>лёгкая</code> — углы слегка закруглены\n      <code>средняя</code> — углы умеренно закруглены\n      <code>закруглённая</code> — углы сильно закруглены\n      <code>квадратная</code> — прямоугольная форма без закругления\n      <code>круглая</code> — стикер в форме круга\n\n      <i>Изменить рамку можно командой /frame</i>\n    types:\n      lite: '1. Лёгкая'\n      medium: '2. Средняя'\n      rounded: '3. Закруглённая'\n      square: '4. Квадратная'\n      circle: '5. Круглая'\n    selected: |\n      <b>Выбран тип рамки:</b> ${type}\n  photoClear:\n    enter: |\n      <b>✂️ Удаление фона</b>\n\n      Отправь фото — получишь PNG без фона.\n      <i>Лучше работает с фото людей.</i>\n    enter_anime: |\n      <b>✂️ Удаление фона</b>\n\n      Отправь фото — получишь PNG без фона.\n      <i>Лучше работает с аниме.</i>\n    choose_model: |\n      Модель:\n    web_app: WebApp — для фото с людьми\n    model:\n      ordinary: Обычная — для фото с людьми\n      general: Общая — для любых фото\n      anime: Аниме — для аниме-картинок\n      birefnet_general: BirefNet — для любых фото\n    add_to_set_btn: '🌟 Добавить в пак'\n    error: |\n      Что-то пошло не так. Попробуй снова.\n    error_timeout: |\n      ⏱ Обработка заняла слишком много времени. Попробуй позже или с фото поменьше.\n    error_queue_disabled: |\n      Эта функция временно недоступна. Попробуй позже.\n  videoRound:\n    enter: |\n      <b>⭕ Видео в кружок</b>\n\n      Отправь видео — сделаю из него кружок.\n    not_video: |\n      Отправь видео, GIF, видеостикер или анимированное изображение.\n    error: |\n      Не удалось конвертировать. Попробуй другое видео.\n    forbidden: |\n      Не могу отправить тебе кружок. Проверь настройки приватности (голосовые сообщения должны быть разрешены).\n    processing: |\n      ⏳ Конвертирую: ${position}/${total}\n\n      <i>Буст = приоритет → /donate</i>\n    file_too_big: |\n      Файл слишком большой (макс. 20 МБ). Отправь видео поменьше.\n  search:\n    enter: |\n      <b>🔍 Поиск стикеров</b>\n\n      Введи ключевые слова — найду паки в каталоге.\n  leave: |\n    Отменено\n  btn:\n    cancel: '← Отмена'\nerror:\n  telegram: |\n    Ошибка Telegram: <code>${error}</code>\n  answerCbQuery:\n    telegram: |\n      Ошибка: ${error}\n  file_too_big: |\n    Файл слишком большой (макс. 20 МБ).\n  download: |\n    Не удалось загрузить файл. Попробуй ещё раз.\n  banned: |\n    🚫 Доступ запрещён. Вопросы → @ly_oBot\n  access_denied: Доступ запрещён\n  unknown: |\n    Что-то пошло не так. Попробуй снова.\n\n    Не помогает? Пиши @Ly_oBot\n  rate_limit: |\n    ⏳ Слишком много запросов. Подожди немного и попробуй снова.\n  rate_limit_seconds: |\n    ⏳ Слишком много запросов. Подожди ${seconds} секунд.\n"
  },
  {
    "path": "locales/tr.yaml",
    "content": "---\nlanguage_name: '🇹🇷 Türkçe'\nname: fStik — Çıkartmalar ve Emoji\ndescription:\n  long: |\n    Fotoğraf, video ve GIF'lerden manuel dönüştürme olmadan çıkartma ve emoji oluşturun. Her şey otomatik işlenir.\n\n    Özellikler:\n    • Paket yönetimi\n    • Video çıkartma ve özel emoji\n    • Orijinal dosyaları indir\n    • Resme dönüştür\n    • Çıkartma kataloğu\n\n    Çıkartmaları aramak için: play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    Fotoğraf, video, GIF'lerden sticker ve emoji oluşturun. Katalog ve arama. 🇺🇦\nratelimit: Pek sık değil!\ncmd:\n  start:\n    enter: |\n      🧙 Merhaba, ${name}! Ben emoji ve çıkartma paketi sihirbazıyım.\n      Fotoğraflarınızı, videolarınızı ve GIF'lerinizi sadece birkaç tıklamayla harika çıkartmalara dönüştürebilirim\n\n      Neler yapabileceğim hakkında daha fazla bilgi edinmek için /help komutunu gönderin\n\n      💬 Yardıma mı ihtiyacınız var? @fStikCommunity adresindeki destek sohbetimize katılın (yalnızca İngilizce)\n    group: |\n      🧙 Merhaba, ${groupTitle}! Ben emoji ve çıkartma paketi sihirbazıyım.\n\n      Bir çıkartmayı grup paketine eklemek için, bir fotoğraf, video, gif veya çıkartmaya yanıt olarak /ss komutunu kullanın.\n    catalog: |\n      <b>😻 Yeni çıkartma paketlerini kataloğumuzda bulabilirsiniz</b>\n\n      • Aşağıdaki butona tıklayın ve her zevke uygun çıkartma paketlerinden oluşan geniş bir kataloğa erişin\n      • Anahtar kelimelere göre veya hazırlanmış sekmelerde arama yapın\n      • Tanıtmak için puan vermeyi unutmayın veya çıkartma paketini sıralamada düşürün\n    commands:\n      ss: '🌟 Çıkartmayı kaydet'\n      start: '📜 Başlangıç menüsü'\n      help: '📖 Yardım'\n      packs: '📁 Paketleri yönet'\n      new: '🌝 Çıkartma paketi oluştur'\n      catalog: '📖 Katalog'\n      publish: '📤 Paketi yayınla'\n      delete: '❌ Çıkartmayı sil'\n      original: '🔍 Orijinal çıkartmayı bul'\n      restore: '🔀 Bir paketi geri yükle'\n      copy: '📋 Bir paketi kopyala'\n      emoji: '📝 Emoji soneki değiştir'\n      round: '🎥 Yuvarlak video'\n      clear: '🖼️ Fotoğraftan arka planı kaldır'\n      about: '📦 Paket bilgisi'\n      user_about: '🧑‍🎨 Yaratıcı bilgisi'\n      lang: '🌐 Dil değiştir'\n      report: '🚨 Paketi şikayet et'\n      donate: '☕️ Geliştiriciyi destekle'\n      add_to_group: '👥 Gruba Ekle'\n      privacy: '🔒 Gizlilik politikası'\n    btn:\n      new: '📥 Yeni oluştur'\n      catalog: '💖 Kataloğu aç'\n      catalog_mini: '💖 Katalog'\n      catalog_browser: '🌐 Tarayıcıda aç'\n      catalog_browser_mini: '🌐 Tarayıcıda'\n      catalog_app: '📱 Android uygulamasını indirin'\n      catalog_app_mini: '📱 Android uygulaması'\n  inline:\n    switch_pm: '📁 Paket seç'\n  restore: |\n    <b>🗃 Paket restorasyonu</b>\n\n    Bir paketi geri yüklemek için bana geri yüklemek istediğiniz paketin bağlantısını göndermeniz gerekiyor\n  copy: |\n    <b>🗄 Paketi kopyala</b>\n\n    Başka bir paketi yenisine kopyalamak için bana bir çıkartma veya emoji paketinin bağlantısını göndermeniz yeterli\n  report: |\n    <b>🚨 Rapor</b>\n\n    Yasaları ihlal edebileceğini veya Telegram'ın Hizmet Koşullarına aykırı olabileceğini düşündüğünüz bir çıkartma paketiyle karşılaşırsanız, lütfen bağlantısını @StickersReportBot adresine göndererek bize bildirin\n\n    <i>Botun paketlerin içeriğinden sorumlu olmadığını ve onu kontrol etme yeteneğinin olmadığını unutmayın</i>\n  packs:\n    info: |\n      <b>📁 Paketler</b>\n    types:\n      regular: Çıkartmalar\n      custom_emoji: Emojiler\n      inline: Inline\n    empty: |\n      <b>Henüz bir çıkartma paketin yok</b>\n      Oluşturmak için /new komutunu yaz\n    inline_title: Satır içi paket\n    select_group_pack_info: |\n      <b>📁 Paket seçin</b>\n\n      Paketin grupta kullanılması için yöneticilerin aşağıdaki düğmeyi kullanarak paketi seçmeleri gerekmektedir.\n    select_group_pack: Paket seç\n  emoji:\n    info: |\n      Geçerli çıkartma paketi için varsayılan emojiyi değiştirmek için <code>/emoji</code> ve ardından boşlukla ayrılmış bir emoji yazın.\n      Örneğin - <code>/emoji 🌟</code>\n    done: Emoji başarıyla güncellendi.\n    error: Emoji değiştirilirken bir hata oluştu!\n  round_video:\n    enabled: |\n      Videolar artık yuvarlak bir şekle sahip olacak\n    disabled: |\n      Videolar artık yuvarlak bir şekle sahip olmayacak\n  paysupport: |\n    <b>👨‍💻 Ödeme Desteği</b>\n\n    Botun çalışmasıyla ilgili tüm konularda, ödemeler ve bağışlar dahil, doğrudan geliştiriciye ulaşabilirsiniz\n\n    <b>İletişim:</b>\n    🧑‍💻 Geliştirici: @ly_oBot\ndonate:\n  menu: |\n    <b>☕️ Bot geliştirme desteği</b>\n    Botun geliştirilmesini destekleyerek Krediler kazanacaksınız\n\n    <b>Bakiye:</b> <code>${balance}</code> Krediler\n    1 Kredi ile bir paketi yükseltme fırsatınız var.\n\n    <b>Yükseltme şu avantajları sağlar:</b>\n    ➖ Paket adında \"<code>${titleSuffix}</code>\" olmaz <i>(bağlantıda değil)</i>\n    ➖ Başlık 64 karaktere kadar (35 yerine)\n    ➖ 35 saniyeye kadar videolar\n    ➖ Dönüştürme kuyruğunda öncelik\n    ➖ Aynı anda birden fazla çıkartma\n    ➖ Reklam yok\n\n    <b>Satın almak istediğiniz Kredi miktarını seçin:</b>\n  btn:\n    donate: '☕️ Bağış yap'\n  topup: |\n    <b>Satın almak istediğiniz Kredi miktarını girin:</b>\n  invalid_amount: |\n    <b>Geçersiz tutar</b>\n\n    Minimum tutar 1 kredidir\n  paymenu: |\n    <b>${amount} Kredi</b> satın almak istiyorsunuz için <b>${price}$</b>\n\n    ⚠️ Krediler yönetici tarafından manuel olarak verilir.\n    Bekleme süresi 5 dakikadan 1 saate kadar değişmektedir\n\n    <u>Ödeme yöntemi seçin:</u>\n  description: |\n    Kredi satın alarak, botun geliştirilmesini destekler ve ek özellikleri kullanma fırsatını elde edersiniz\n  update: |\n    <b>🔄 Bakiye güncellemesi</b>\n\n    Bakiye: <code>${balance}</code> Kredi (eklenen <code>${amount}</code> Kredi)\n  error:\n    already_donated: |\n      Bu ödeme için zaten Kredi aldınız\n    error: |\n      <b>Hata!</b>\n      Ödemeyi işlerken bir hata oluştu\n    canceled: |\n      Ödeme iptal edildi\ncoedit:\n  info: |\n    <b>👥 Ortak düzenleme</b>\n\n    Ortak düzenleme için bağlantı <a href=\"${link}\">${title}</a>: <code>${colink}</code>\n\n    <b>Nasıl kullanılır:</b>\n    1. Pakete erişim vermek istediğiniz kişiye bağlantıyı gönderin\n    2. Bağlantıya tıkladıktan sonra, \"başlat\" düğmesine basmaları gerekmekte ve editörlere ekleneceklerdir\n    3. Editör, pakette çıkartma ekleyebilir, silebilir ve düzeltebilir\n\n    <b>Editörler:</b>\n    ${editors}\n\n    <i>Editörleri silmek için, bağlantıyı sıfırlamanız gerekmektedir</i>\n  no_editors: |\n    Henüz editör yok\n  btn:\n    send: '📤 Bağlantıyı gönder'\n    reset: '🔁 Bağlantıyı sıfırla'\n  share: |\n    Bağlantıyı takip edin ve \"${title}\" paketini birlikte düzenlemek için \"başlat\" tuşuna basın\n  reset: |\n    <b>🔁 Bağlantı sıfırlama başarılı</b>\n\n    Ortak düzenleme için yeni bağlantı <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: Paket bulunamadı\n      not_owner: Bu sizin paketiniz değil\n      hidden: Paket başarıyla gizlendi\n      restored: Paket başarıyla geri yüklendi\n    set_pack: |\n      🌟 Seçilen <a href=\"${link}\">${title}</a> paket\n\n      <b>❔ Nasıl eklenir?</b>\n      Pakete eklemek için fotoğraf, video veya çıkartma gönderin\n    set_inline_pack: |\n      Seçilen <u>${title}</u> paket\n\n      Kullanmak için herhangi bir sohbete <code>@${botUsername}</code> yazın ve boşluk bırakın\n      Ayrıca, aşağıdaki düğmeye basarak da kullanabilirsiniz\n    boost:\n      info: |\n        \\n⚡ <b><a href=\"https://t.me/${botUsername}?start=boost\">Güçlendirme</a></b>: ${boostStatus}\n      status:\n        on: Etkin\n        off: Devre dışı\n    hidden: Paket <a href=\"${link}\">${title}</a> listenizden gizlendi.\n    restored: Paket <a href=\"${link}\">${title}</a> listenize geri yüklendi.\n    btn:\n      hide: '❌ Paketi gizle'\n      delete: '🗑 Paketi sil'\n      restore: '✅ Geri yükle'\n      use_pack: '📦 Paketi kullan'\n      boost: '⚡ Güçlendir'\n      frame: '🖼 Çerçeve'\n      rename: '✏️ Yeniden adlandır'\n      search_gif: '🔎 GIF ara'\n      coedit: '👥 Ortak düzenleme'\n      catalog_add: '🗂 Kataloğa ekle'\n      catalog_edit: '📝 Katalogda düzenle'\n      catalog_delete: '🗑 Katalogdan sil'\n      catalog_share: '🔗️️ Paylaş'\n      catalog_open: '📂 Katalogda aç'\n    error:\n      not_found: |\n        Hata!\n        Çıkartmayı bulamıyorum.\n      invalid_png: |\n        Hata!\n        Dosya geçerli bir PNG resmi değil. Lütfen göndermeden önce bunu PNG formatına dönüştürün.\n      invalid_dimensions: |\n        Hata!\n        Çıkartmanın boyutları geçersiz. Çıkartmalar 512x512 piksel olmalıdır.\n      invalid_animated: |\n        Hata!\n        Animasyonlu çıkartma dosyası doğru TGS formatında değil.\n      invalid_video: |\n        Hata!\n        Video dosyası doğru WEBM formatında değil.\n      restore: |\n        Hata!\n        Paket geri yüklenemiyor.\n      copy: |\n        Hata!\n        Paket bulunamıyor.\n    select_group:\n      success: |\n        Paket <a href=\"${link}\">${title}</a> başarıyla grup için seçildi.\n      access_rights:\n        add: Kimler grup paketine çıkartma ekleyebilir?\n        delete: Kimler grup paketinden çıkartma silebilir?\n        rights:\n          all: Herkes\n          admins: Sadece yöneticiler\n      error: |\n        Hata!\n        Set bulunamadı.\n  sticker:\n    answerCbQuery:\n      delete: Çıkartma paketten başarıyla çıkarıldı.\n      restored: Çıkartma başarıyla mevcut pakete kaydedildi.\n    delete: Çıkartma paketten başarıyla çıkarıldı.\n    restored: Çıkartma başarıyla mevcut pakete kaydedildi.\n    btn:\n      delete: '🗑 Sil'\n      copy: '🌟 Kopyala'\n      restore: '✅ Geri yükle'\n    error:\n      not_found: |\n        Hata!\n        Çıkartmayı bulamıyorum.\n      invalid_png: |\n        <b>Hata!</b>\n        Dosya geçerli bir PNG resmi değil. Lütfen göndermeden önce PNG formatına dönüştürün.\n      invalid_dimensions: |\n        <b>Hata!</b>\n        Çıkartma boyutları geçersiz. Çıkartmalar 512x512 piksel olmalıdır.\n      invalid_animated: |\n        <b>Hata!</b>\n        Animasyonlu çıkartma dosyası doğru TGS formatında değil.\n      invalid_video: |\n        <b>Hata!</b>\n        Video dosyası doğru WEBM formatında değil.\n  group_settings:\n    success: |\n      Grup ayarları başarıyla güncellendi.\nsticker:\n  add:\n    ok: |\n      <b>Pakete başarıyla eklendi:</b>\n      <a href=\"${link}\">${title}</a>\n\n      Bir saat içinde, bu paket tüm kullanıcılar için güncellenecek.\n\n      <i>Etikete eşleşen bir veya daha fazla emoji gönderin, eklemek istiyorsanız</i>\n    ok_inline: |\n      <b>Pakete başarıyla eklendi:</b>\n      <u>${title}</u>\n    send_emoji: Harika, şimdi çıkartmayla eşleşen emojiyi gönder\n    converting_process: |\n      <b>Bekle...</b>\n      Dosyanız dönüştürülmek üzere sırada. Tamamlanmasını bekleyin. Bu biraz zaman alabilir.\n\n      İlerleme: ${progress} / ${total}\n\n      <i>Botu destekleyen kullanıcılar kuyrukta öncelik kazanır (devamı: /donate)</i>\n    catalog_offer: |\n      <b>😲 Vay, harika bir paket yapmışsın!</b>\n\n      Botun diğer kullanıcılarının da görebilmesi için <a href=\"${link}\">${title}</a> genel çıkartma kataloğuna eklemek ister misiniz?\n      <i>Çok fazla zaman almaz</i>\n    quote: |\n      Bu mesajdan bir alıntı oluşturmak için @QuotlyBot kullanın\n    error:\n      reply: |\n        <b>Hata!</b>\n        Lütfen çıkartmaya yanıt verin.\n      no_selected_pack: |\n        <b>Paket seçmediniz</b>\n\n        Lütfen (/yeni) paket oluşturun veya (/packs) seçin\n      no_selected_group_pack: |\n        <b>Grup paketi seçmediniz</b>\n\n        Lütfen, /packs komutu ile bir paket seçin\n      no_rights: |\n        <b>Hata!</b>\n        Bu pakete çıkartma ekleme hakkınız yok.\n      stickers_too_much: |\n        Bu pakette maksimum sayıda çıkartma bulunur.\n\n        /new komutunu kullanarak yeni bir paket oluşturabilirsiniz.\n      have_already: |\n        <b>Bu çıkartma zaten pakette</b>\n\n        Emojiyi değiştirmek istiyorsanız aşağıdaki mesajla gönderin.\n      stickerset_invalid: |\n        <b>Hata!</b>\n        Bot mevcut seçtiğiniz pakete erişemiyor.\n\n        Lütfen başka bir paket oluşturun (/yeni) veya seçin (/packs).\n      invalid_png: |\n        <b>Hata!</b>\n        Dosya geçerli bir PNG resmi değil. Lütfen göndermeden önce PNG formatına dönüştürün.\n      invalid_dimensions: |\n        <b>Hata!</b>\n        Çıkartma boyutları geçersiz. Çıkartmalar 512x512 piksel olmalıdır.\n      invalid_animated: |\n        <b>Hata!</b>\n        Animasyonlu çıkartma dosyası doğru TGS formatında değil.\n      invalid_video: |\n        <b>Hata!</b>\n        Video dosyası doğru WEBM formatında değil.\n      file_type:\n        static: |\n          <b>Hata!</b>\n          Bu dosya türü desteklenmiyor\n          Bu fotoğrafı veya statik etiketi statik pakete ekleyebilirsiniz\n\n          <i>Oluştur (/yeni) veya seçin (/packs) başka bir paket</i>\n        video: |\n          <b>Hata!</b>\n          Bu dosya türü desteklenmiyor\n          Bu video dosyalarını video paketine ekleyebilirsiniz\n\n          <i>Oluştur (/yeni) veya (/ paket) başka bir paket</i>\n        animated: |\n          <b>Hata!</b>\n          Bu dosya türü desteklenmiyor\n          Bu animasyonlu dosyaları vektör paketine ekleyebilirsiniz\n\n          <i>Oluştur (/yeni) veya (/ paket) başka bir paket</i>\n        unknown: |\n          <b>Hata!</b>\n          Bu dosya türü desteklenmiyor\n\n          <i>Başka bir paket oluşturun (/yeni) veya seçin (/packs)</i>\n      wait_load: |\n        <b>Bekle!</b>\n\n        Bot hala bir önceki dosyayı işliyor...\n        İşleme önceliğini artırmak ve kuyruğa birden fazla çıkartma ekleyebilmek için bot geliştirmeyi (/donate) destekleyebilirsiniz.\n      timeout: |\n        <b>Şu anda bot çok büyük bir yük yaşıyor</b>\n        Bu nedenle, video dönüştürme yalnızca aktif güçlendirmeli paketler için kullanılabilir\n\n        Daha fazla ayrıntı için /donate komutuna bakın\n      convert: |\n        <b>Hata!</b>\n        Maalesef bot videonuzu dönüştüremedi.\n\n        Belki de videonuz bot için anlaşılmaz bir biçimde kaydedilmiştir. mp4 formatında olduğundan emin olun.\n        Botun dahili bir hatası da olabilir, bu videoyu tekrar göndermeyi deneyin.\n      too_big: |\n        <b>Hata!</b>\n\n        Dosya işlenemeyecek kadar büyük. Lütfen göndermeden önce kaliteyi ve süreyi azaltın.\n      sticker_not_found: |\n        <b>Hata!</b>\n\n        Bu çıkartma bulunamadı. Lütfen doğru pakette olduğundan emin olun veya tekrar eklemeyi deneyin.\nnews:\n  join: |\n    ℹ️ <a href=\"${link}\">Botla ilgili en son haberleri almak için</a> kanalımıza katılın.\n\n    <i>Botla ilgili en son haberlerin yanı sıra güncellemeler ve yeni özellikler almak için kanala abone olun.</i>\n  join_btn: '📢Kanala katılın'\n  not_joined: '🙅 Kanala abone değilsiniz'\n  continue: '✅ Devam'\nuserAbout:\n  help: |\n    <b>🧑‍🎨 Kullanıcı hakkında</b>\n\n    Bu menüyü kullanarak kullanıcı ve çıkartma paketleri hakkında bilgi bulabilirsiniz\n\n    Kullanıcı hakkında bilgi almak için butonu kullanın aşağıda veya mesajını iletin\n  result: |\n    <b>🧑‍🎨 Kullanıcı bilgisi</b>\n    <b>🆔 Kullanıcı IDsi:</b> <code>${userId}</code>\n    <b>🎨 Bu kullanıcının paketleri:</b>\n    ${packs}\n  no_packs: |\n    <i>Bu sahibinin çıkartmaları hakkında hiçbir bilgimiz yok</i>\n  forward_hidden: |\n    Kullanıcı mesajları iletme yeteneğini gizledi. Çıkartma paketlerini görüntülemek için aşağıdaki düğmeyi kullanın.\n  select_user: '🧑‍🎨 Kullanıcı seç'\nscenes:\n  new_pack:\n    pack_type: |\n      <b><u>Paket türünü seçin</u></b>\n    regular: '😊 Çıkartmalar'\n    custom_emoji: '🌟 Emoji (premium)'\n    static: '🌟 Statik'\n    animated: '✨ vektör'\n    video: '📹 Video'\n    pack_format: |\n      <b><u>Çıkartma paketi türünü seçin</u></b>\n\n      <b>Ortak</b> - statik (hareket etmeyin), raster, dosya formatı - PNG eklemeden önce (bot işliyor), ekledikten sonra - WEBP.\n      Normal çıkartma paketi örneği - t.me/addstickers/Animals\n\n      <b>Video</b> - animasyon video paketi. Herhangi bir video, gif ve fotoğraf ekleyebilirsiniz.\n      Örnek video çıkartma paketi - t.me/addstickers/TheMascot\n\n      <b>Animasyonlu</b> - animasyonlu, vektör (dosyanın içindeki nesnelerin tam bir açıklaması vardır, bu nedenle her ölçekte net bir şekilde görüntülenirler), dosya formatı - TGS, Lottie biçiminin bir varyasyonu.\n      Animasyonlu çıkartma paketi örneği - t.me/addstickers/IsabelleShizue\n\n      <i>Animasyonlu ve video çıkartma setlerinde en fazla 50 çıkartma bulunabilir. Statik çıkartma setlerinde en fazla 120 çıkartma bulunabilir.</i>\n    pack_title: |\n      <b>Çıkartma paketine yeni bir isim gir:</b>\n    pack_name: |\n      <b>Yeni çıkartma paketi için kısa bir bağlantı girin:</b>\n\n      <i>Örneğin, bu paket kısa bağlantı olarak 'Hayvanlar'ı kullanır: https://t.me/ addstickers/<u>Hayvanlar</u></i>\n      <i>Düğmeden rastgele kısa bağlantıyı seçebilirsiniz.</i>\n    ok: |\n      Etiket paketi <a href=\"${link}\">${title}</a> başarıyla oluşturuldu!\n\n      <b>Çıkartma paketi linki:</b> <pre>${link}</pre>\n\n      Bir dosya, fotoğraf, video veya çıkartma gönderin ki setinize ekleyeyim\n    error:\n      title_long: İsim ${max}  değerinden büyük olamaz.\n      name_long: Kısa link ${max} değerinden büyük olamaz.\n      telegram:\n        name_invalid: Bu adres kullanılamaz.\n        name_occupied: Bu adres zaten alınmış.\n        upload_failed: |\n          <b>Hata!</b>\n          Bot, Telegram'a çıkartma yükleyemez.\n\n          Lütfen daha sonra tekrar deneyin.\n  copy:\n    enter: |\n      Kopyalayabilirim ama ondan önce yeni bir paket oluşturalım\n    progress: |\n      Bu çıkartma paketini <a href=\"${originalLink}\">${originalTitle}</a> bu çıkartma paketine <a href=\"${link}\">${title}</a> kopyalama\n\n      Durum: ${current}/${total}\n    done: |\n      Bu çıkartma paketini <a href=\"${originalLink}\">${originalTitle}</a> bu çıkartma paketine <a href=\"${link}\">${title}</a> kopyalama başarılı sonuçlandı.\n    pay: |\n      <b>Paket dönüştürme</b>\n\n      Bir türden diğerine dönüştürmek 1 krediye mal olur\n\n      <b>Mevcut bakiye:</b> ${balance} Kredi\n\n      Kredi satın al: /donate\n    pay_btn: '✅ Onayla'\n    error:\n      premium: |\n        <b>Hata!</b>\n        Ne yazık ki, bu özellik yalnızca botu destekleyen kişiler tarafından kullanılabilir.\n\n        Bunu yapmak için /donate komutunu kullanabilirsin.\n  original:\n    enter: |\n      Bu botla eklenen çıkartmayı gönderin, size orijinal çıkartmasını göstereceğim.\n    error:\n      not_found: |\n        <b>Hata!</b>\n        Orijinal çıkartmayı bulamıyorum.\n  delete:\n    enter: |\n      Bu bot aracılığıyla eklenen çıkartmayı gönderin, çıkartma paketinden silerim.\n    confirm: |\n      Bu çıkartmayı silmek istediğinizden emin misiniz?\n    error:\n      not_found: |\n        <b>Hata!</b>\n        Çıkartmayı bulamadım.\n  rename:\n    enter_name: |\n      <b> <a href=\"${link}\">${title}</a>:</b>için yeni başlık girin\n    success: |\n      <b>Başlık başarıyla değiştirildi!</b>\n\n      Yeni unvan: <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      ❕ \"<code>${titleSuffix} </code>\" ekini kaldırmak için, paketi yükseltmeniz gerekir. /bağış komutunu ziyaret ederek menüden daha fazla bilgi edinebilirsiniz\n  packAbout:\n    enter: |\n      <b>Bu konuda bilgi aramak için bana bir çıkartma veya özel emoji gönderin:</b>\n    not_found: |\n      stickerı bulamadım\n    result: |\n      <b>📦 Paket:</b> <a href=\"${link}\">${name}</a>\n      🆔 <code>${setId}</code> <i>(Sahibin paketlerine benzersiz numara, her paket başına arttırılır)</i>\n\n      🧑‍🎨 Sahip ID: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 Bu sahibin diğer paketleri:</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>Bu sahibin diğer çıkartmaları hakkında hiçbir bilgimiz yok</i>\n  boost:\n    sure: |\n      <b><a href=\"${link}\">${title}</a> artırmak istediğinizden emin misiniz?</b>\n\n      Artırma, işleme önceliğini ve kuyruğa birden fazla çıkartma ekleme yeteneğini artırır\n      Bağış yapmak hakkında daha fazla bilgiyi menüde /donate komutuyla bulabilirsiniz\n\n      <b>Fiyat:</b> 1 Kredi\n      <b>Mevcut bakiye:</b> ${balance} Kredi\n    btn:\n      yes: Evet, artırın!\n      no: Hayır, iptal et\n    canceled: |\n      Yükseltme iptal edildi\n    success: |\n      Yükseltme başarıyla tamamlandı!\n\n      ${title} artık güçlendirildi\n    error:\n      not_enough_credits: |\n        Bu paketi artırmak için yeterli Krediniz yok.\n\n        Bakiye yüklemek için /donate komutunu gönderin.\n      already_boosted: |\n        Bu paket zaten güçlendirilmiş.\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>Yayınlamak istediğiniz paketten çıkartmayı gönderin</b>\n\n        <i>Başka bir yerde oluşturulmuş olsalar bile, sizinle ilgili olan herhangi bir paketi yayınlayabilirsiniz</i>\n      owner_proof: |\n        <b>Bu paketin sahipliğini doğrulamak için birkaç basit adımı uygulamanız gerekir:</b>\n        1. @Stickers botunu açın\n        2. <code>gönder /packstats</code> komut\n        3. Gerekli paketi bulun ve seçin\n        4. Alınan mesajı bota iletin\n      publish_new_access_denied: |\n        <b>Hata!</b>\n        Bu paket sizin değil.\n\n        Yalnızca kendi paketlerinizi yayınlayabilirsiniz\n      banned: |\n        <b>Hata!</b>\n        Bu özelliği kullanmanız yasaklandı.\n        Lütfen yönetici ile iletişime geçin.\n      enter: |\n        \"<a href=\"${link}\">${title}</a>\" paketini botumuzun genel dizininde yayınlamak üzeresiniz\n        Botun herhangi bir kullanıcısı tarafından isme, etiketlere veya filtreye göre bulunabilir\n        Bu nedenle paketinizi daha fazla kişi kuracaktır\n        Yalnızca çok sayıda kişinin ilgisini çekebilecek yüksek kaliteli paketler göndermeye çalışın\n\n        <b>Kurallar paket yayınlamak için:</b>\n        • Dar bir insan grubuna yönelik kişisel paketlerinizi yayınlamayın. Örneğin, arkadaşlarınızın yüzleri veya mesajlarınızdan alıntılar gibi\n        • AB yasalarını veya diğer yerel yasaları ihlal eden çıkartma baskıları paylaşmayın\n\n        Bunun olması için ek bilgi göndermeniz gerekecektir. katalogda yayınlandı\n      continue_button: Devam\n      enter_description: |\n        <b>Başkalarının bulabilmesi için paketinizi kısaca tanımlayın</b>\n\n        <i>Kategorize etmek için hashtag'leri de kullanabilirsiniz [#]</i>\n        <i>Örneğin: #anime #meme #animals #sevimli #kpop #komik #kedi #oyun </i>\n      select_language: |\n        <b>Paketinizin hangi dillere yönelik olduğunu seçin:</b>\n        <i>Birden fazla dil seçebilirsiniz</i>\n      button_all_languages: Tüm diller\n      button_confirm_language: Onayla\n      set_safe: |\n        <b>Paketiniz kullanıcılar için güvenli mi?</b>\n        <i>Yani erotik ve diğer şok edici içerikleri barındırmaz</i>\n      button_safe:\n        safe: Evet, güvenli\n        not_safe: Hayır, güvenli değil\n      no_tags: belirtilmemiş\n      confirm: |\n        <b>\"<a href=\"${link}\">${title}</a>\" paketin yayınını onaylayın</b>\n\n        <b>Açıklama:</b> <i>${description}</i>\n\n        <b>Etiketler:</b> ${tags}\n        <b>Diller:</b> ${languages}\n      button_confirm: '✅ Yayını onayla'\n      success: |\n        Tebrikler, paketiniz diğer kullanıcıların bulabileceği botumuzun genel dizininde yayınlandı!\n\n        /packs komutu ile paketi seçerek paket arama bilgilerini düzenleyebilirsiniz.\n\n        <i>Paketinizin istatistiklerinin resmi @Stickers botu aracılığıyla görüntülenebileceğini hatırlatırız</i>\n    unpublish:\n      success: |\n        Paket bot kataloğundan başarıyla kaldırıldı.\n  delete_pack:\n    enter: |\n      <a href=\"${link}\">${title}</a>paketini silmek istediğinizden emin misiniz?\n      Kalıcı olarak silinecek ve kurtarılamaz.\n\n      Yalnızca bir çıkartmayı silmek istiyorsanız, /delete komutunu kullanın.\n\n      Bu paketi gerçekten silmek istediğinizi onaylamak için <code>${confirm}</code> gönderin.\n    confirm: Evet, kesinlikle eminim.\n    success: |\n      <b>Paket başarıyla silindi!</b>\n    error:\n      - <b>Hata!</b>\n      - Hay aksi, bir şeyler ters gitti.\n  frame:\n    no_video: |\n      <b>Hata!</b>\n      Video paketlerine yalnızca çerçeve ekleyebilirsiniz.\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>Çerçeve türünü seçin:</b>\n      Çerçeve, çıkartmanın etrafındaki şeffaf arka plan\n\n      <code>lite</code> — köşeler biraz kesilecek\n      <code>medium</code> — köşeler daha fazla kesilecek\n      <code>rounded</code> — köşeler yuvarlatılacak\n      <code>square</code> — düz kenarlı çerçeve şekli, yani herhangi bir şekilde değiştirilmeyecek\n      <code>circle</code> — çerçeve daire şeklinde olacak\n\n      <i>Gelecekte, türü belirlemek için /çerçeve komutunu kullanabilirsiniz</i>\n    types:\n      lite: '1. Hafif'\n      medium: '2. Orta'\n      rounded: '3. Yuvarlak'\n      square: '4. Kare'\n      circle: '5. Daire'\n    selected: |\n      <b>Seçilen çerçeve tipi:</b> ${type}\n  photoClear:\n    enter: |\n      Arka planı kaldırmak istediğiniz bir <u>fotoğraf</u> gönderin, ben de arka planı olmayan dosyayı göndereceğim\n\n      <i>Fotoğraflar ile en iyi sonucu verir. Çizimler, illüstrasyonlar vb. ile daha kötü çalışır.</i>\n    enter_anime: |\n      Arka planı kaldırmak istediğiniz fotoğrafı gönderin ve arka planı olmayan dosyayı ben size göndereyim\n\n      <i>En iyi sonucu anime resimleriyle alırsınız</i>\n    choose_model: |\n      <b>Modeli seçin:</b>\n    web_app: WebApp - insanlarla fotoğraflar için\n    model:\n      ordinary: Ortak — insanlarla fotoğraflar için\n      general: Genel — tüm fotoğraflar için\n      anime: Anime — anime resimleri için\n      birefnet_general: BirefNet — tüm fotoğraflar için\n    add_to_set_btn: '🌟 Sete ekle'\n    error: |\n      <b>Hata!</b>\n      Maalesef bir şeyler ters gitti.\n  leave: |\n    Eylem iptal edildi!\n  btn:\n    cancel: '❌ İptal'\nerror:\n  telegram: |\n    <b>Telegram hatası!</b>\n    <code>${error}</code>\n  answerCbQuery:\n    telegram: |\n      Telegram hatası:\n      ${error}\n  banned: |\n    <b>Hata!</b>\n    Bu özelliği kullanmanız yasaklandı.\n\n    <i>Eğer bunun bir hata olduğunu düşünüyorsanız, lütfen yönetici ile iletişime geçin: @ly_oBot</i>\n  unknown: |\n    <b>Bilinmeyen bir hata oluştu, lütfen tekrar deneyin.</b>\n\n    Sorun devam ederse @Ly_oBot'a yazın.\n    Lütfen hangi bottan bahsettiğinizi hemen yazın ve sorunu tek mesajda ayrıntılı olarak açıklayın.\n"
  },
  {
    "path": "locales/uk.yaml",
    "content": "---\nlanguage_name: '🇺🇦 Українська'\nname: fStik — Стікери та емодзі\ndescription:\n  long: |\n    Створюй стікери та емодзі з фото, відео і GIF. Без ручної конвертації — бот зробить все сам. Простіше, ніж @Stickers.\n\n    Можливості:\n    • Керування паками\n    • Відео-стікери та кастомні емодзі\n    • Завантаження оригіналів\n    • Конвертація стікера/відео/GIF у картинку\n    • Каталог стікерів\n\n    Пошук стікерів: play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    Створюй стікери та емодзі з фото, відео, GIF. Каталог і пошук стікерів. 🇺🇦\nratelimit: Занадто часто!\ncmd:\n  start:\n    enter: |\n      Привіт, ${name}!\n      Створюю стікери та емодзі з фото, відео і GIF.\n\n      💬 @StickLyUA\n    group: |\n      Привіт, ${groupTitle}! Я створюю стікери та емодзі.\n\n      Щоб додати стікер до групового паку, використай /ss у відповідь на фото, відео, GIF чи стікер.\n    catalog: |\n      <b>🔍 Каталог стікерів</b>\n\n      Шукай паки за ключовими словами або переглядай популярні.\n      Оцінюй щоб допомогти іншим знайти круті паки.\n    search_catalog: |\n      <b>🌐 Каталог паків</b>\n\n      Переглядай паки інших або публікуй свої:\n    commands:\n      ss: '🌟 Зберегти стікер'\n      start: '📜 Меню'\n      help: '❓ Допомога'\n      packs: '📁 Мої паки'\n      new: '➕ Новий пак'\n      search_catalog: '🌐 Каталог'\n      catalog: '🌐 Каталог'\n      publish: '📤 Опублікувати'\n      delete: '🗑 Видалити стікер'\n      original: '🔎 Звідки стікер'\n      restore: '♻️ Відновити пак'\n      copy: '📋 Копіювати пак'\n      emoji: '😀 Змінити емодзі'\n      round: '⭕ Кругле відео'\n      clear: '✂️ Видалити фон'\n      info: '🔎 Чий стікер'\n      user_about: '👤 Про автора'\n      lang: '🌐 Мова'\n      report: '🚨 Скарга'\n      donate: '⭐ Підтримати'\n      add_to_group: '👥 Додати в групу'\n      privacy: '🔒 Приватність'\n      guide: '❓ Допомога'\n    btn:\n      new: '➕ Новий пак'\n      catalog: '🔍 Каталог'\n      catalog_mini: '🔍 Каталог'\n      catalog_browser: 'Відкрити в браузері'\n      catalog_browser_mini: '🌐 У браузері'\n      catalog_app: 'Завантажити для Android'\n      catalog_app_mini: '📱 Додаток для Android'\n  guide:\n    web: |\n      <b>📖 Як користуватись fStikBot</b>\n\n      Повні гайди, поради та FAQ на нашому сайті.\n    menu: |\n      <b>❓ Допомога</b>\n\n      Обери тему:\n    create: |\n      <b>🎨 Створення стікерів</b>\n\n      1. Надішли /new щоб створити пак\n      2. Обери тип: звичайний, відео або емодзі\n      3. Надсилай фото, відео чи GIF\n      4. Готово! Бот сам все конвертує\n\n      <b>Корисні команди:</b>\n      • /clear — видалити фон з фото\n      • /round — зробити кружок з відео\n      • /frame — форма відео-стікера:\n        └ lite, medium, rounded, square, circle\n\n      <b>💡 Порада:</b> Надсилай PNG як <b>Файл</b> (📎) щоб зберегти прозорість. Режим «Фото» стискає і прибирає її.\n    manage: |\n      <b>📁 Керування паками</b>\n\n      <b>Команди:</b>\n      • /packs — список твоїх паків\n      • /delete — видалити стікер\n      • /copy — скопіювати пак\n      • /restore — повернути приховані паки\n      • /original — знайти оригінал стікера\n      • /about — інфо про пак та автора\n\n      <b>Змінити емодзі:</b>\n      Надішли стікер → надішли новий емодзі.\n      Або надішли емодзі одразу після додавання.\n\n      <b>Емодзі за замовчуванням:</b>\n      /emoji 🔥 — всі нові стікери матимуть цей.\n\n      <b>В групах:</b>\n      /ss (у відповідь на медіа) — швидке додавання до групового паку.\n\n      <b>Інлайн-паки:</b>\n      Окремий тип паків для швидкого доступу через @ім'я_бота в будь-якому чаті.\n      • Не справжній Telegram пак — зберігається тільки в боті\n      • Може містити стікери, фото, GIF, відео\n      • Створи в /packs → вкладка «Інлайн»\n      • Також є режим пошуку GIF (через Tenor)\n\n      <b>Спільне редагування (Co-edit):</b>\n      Натисни на пак → Co-edit → отримай посилання.\n      Будь-хто з посиланням може додавати/видаляти стікери.\n      Скинь посилання щоб прибрати всіх редакторів.\n\n      <b>Опції паку:</b>\n      Натисни на пак в /packs → перейменувати, буст, спільне редагування, видалити.\n    catalog: |\n      <b>🔍 Каталог</b>\n\n      • /catalog — популярні паки\n      • /publish — опублікувати свій пак\n      • Оцінюй паки щоб просувати круті\n    boost: |\n      <b>⚡ Буст</b>\n\n      • Без \"${titleSuffix}\" у назві\n      • Пріоритетна обробка відео\n      • Відео до 30 сек\n      • Кілька стікерів в чергу\n\n      <b>Секрет:</b> /new fill — адаптивні емодзі (змінюють колір під текст)\n\n      Отримати → /donate\n    problems: |\n      <b>❓ FAQ</b>\n\n      <b>⚫ Фон став чорним?</b>\n      Телеграм прибирає прозорість, якщо надіслати як «Фото».\n      → Надсилай PNG як <b>Файл</b> (📎 скріпка).\n\n      <b>🔄 Зміни не видно?</b>\n      Телеграм кешує стікери. Зачекай ~1 годину або перезапусти додаток.\n\n      <b>📹 Відео не анімується?</b>\n      Скоріш за все дивний формат. Спробуй конвертувати в MP4 або надіслати як Файл замість Відео.\n\n      <b>🗑 Як видалити стікер?</b>\n      Надішли /delete, потім натисни на стікер.\n\n      <b>🤏 Стікер замалий?</b>\n      Обріж картинку до квадрата (1:1) перед надсиланням.\n\n      <b>🔗 Як прибрати \"_by_fStikBot\" з посилання?</b>\n      Ніяк. Телеграм вимагає цей суфікс щоб визначити, яким ботом створено пак. Це правило платформи, не наше.\n\n      <b>💎 Потрібен Premium?</b>\n      • Створювати паки: безкоштовно\n      • Використовувати стікери: безкоштовно\n      • Надсилати кастомні емодзі: тільки Premium (правило Telegram)\n\n      <b>Ще питання?</b> @fStikCommunity\n    btn:\n      open: '📖 Відкрити гайд'\n      create: '🎨 Створення стікерів'\n      manage: '📁 Керування паками'\n      catalog: '🔍 Пошук і публікація'\n      boost: '⚡ Буст і можливості'\n      problems: '❓ Проблеми?'\n      back: '← Назад'\n  inline:\n    switch_pm: '📁 Вибрати пак'\n  lang:\n    choose: |\n      🌐 Обери мову\n\n      Допоможи з перекладом: https://crwd.in/fStikBot\n  restore: |\n    <b>♻️ Відновлення паку</b>\n\n    Надішли посилання на пак, який хочеш відновити.\n  copy: |\n    <b>📋 Копіювання паку</b>\n\n    Надішли посилання на пак, який хочеш скопіювати.\n  report: |\n    <b>🚨 Скарга</b>\n\n    Знайшов пак що порушує правила? Надішли посилання на @StickersReportBot\n  packs:\n    info: |\n      <b>📁 Мої паки</b>\n    types:\n      regular: Стікери\n      custom_emoji: Емодзі\n      inline: Інлайн\n    empty: |\n      Паків поки немає. Створи перший → /new\n    inline_title: Інлайн-пак\n    select_group_pack_info: |\n      <b>📁 Вибір паку</b>\n\n      Адміни можуть вибрати пак для групи кнопкою нижче.\n    select_group_pack: Вибрати пак\n  emoji:\n    info: |\n      Щоб змінити стандартні емодзі для поточного паку, надішли <code>/emoji</code> з емодзі через пробіл\n\n      Наприклад — <code>/emoji 🌟</code>\n    no_pack_selected: |\n      Спочатку обери пак через /packs — потім /emoji 🌟\n    done: Емодзі успішно змінено.\n    error: Сталася помилка під час зміни емодзі!\n  round_video:\n    enabled: |\n      Відтепер відео матимуть заокруглену форму\n    disabled: |\n      Відео більше не матимуть заокругленої форми\n  paysupport: |\n    <b>💳 Питання по оплаті</b>\n\n    Пиши розробнику: @ly_oBot\n  mosaic:\n    enter: |\n      🧩 Мозаїка для <b>${packTitle}</b>\n\n      Надішли фото — поріжу його на сітку емодзі.\n    no_pack: |\n      Спочатку потрібен пак кастомних емодзі.\n      Створи через /new і обери «Емодзі».\n    choose_grid: |\n      📐 Обери розмір сітки:\n    btn:\n      recommended: \"✅ ${rows}×${cols}\"\n      option: \"${rows}×${cols} · ${total}шт\"\n      custom: \"✏️ Свій розмір\"\n      cancel: \"← Скасувати\"\n      exit: \"🚪 Вийти з мозаїки\"\n      undo: \"🗑 Видалити цю мозаїку\"\n    custom_prompt: |\n      Введи розмір сітки (напр. 3x4):\n    custom_invalid: |\n      Невірний формат. Приклад: 3x4. Рядки 1-10, стовпці 1-10, всього до 50.\n    no_space: |\n      Недостатньо місця в паку. Вільно ${freeSlots} слотів, потрібно ${total}.\n      Обери меншу сітку або створи новий пак через /new.\n    blurry_warning: |\n      ⚠️ Зображення замале — при цьому розмірі сітки результат може бути розмитий.\n    uploading: \"⏳ Завантаження ${current}/${total}...\"\n    done: |\n      ✅ Мозаїка ${rows}×${cols} додана в пак!\n    done_link: \"📦 Використати пак\"\n    undo_done: |\n      🗑 Мозаїку видалено (${count} емодзі видалено з пака).\n    undo_failed: |\n      ❌ Не вдалося видалити деякі емодзі. Спробуй вручну.\n    wait_photo: |\n      Надішли ще фото або натисни «Вийти».\n    reject_animated: |\n      Анімовані та відео-стікери поки не підтримую. Надішли статичний стікер, фото або PNG/JPEG/WebP файлом.\n    reject_document: |\n      Підтримую тільки зображення (JPEG/PNG/WebP). Надішли файл у цьому форматі.\n    reject_media: |\n      Анімації та відео поки не підтримую. Надішли статичний стікер, фото або PNG/JPEG/WebP файлом.\ndonate:\n  menu: |\n    <b>⭐ Підтримка бота</b>\n\n    <b>Баланс:</b> ${balance} кредитів\n    1 кредит = буст одного паку\n\n    <b>Буст дає:</b>\n    • Без \"<code>${titleSuffix}</code>\" у назві\n    • Назва до 64 символів (замість 35)\n    • Відео до 35 секунд\n    • Пріоритетна черга конвертації\n    • Кілька стікерів одночасно\n    • Без реклами\n\n    <b>Скільки кредитів купити?</b>\n  invoice_title: '${amount} Кредитів'\n  btn:\n    donate: '☕ Донат'\n  topup: |\n    <b>Введи кількість Кредитів, яку хочеш придбати:</b>\n  invalid_amount: |\n    <b>Невірна кількість</b>\n\n    Мінімальна кількість — 1 Кредит\n  paymenu: |\n    Ти хочеш купити <b>${amount} Кредитів</b> за <b>${price}$</b>\n\n    ⚠️ Кредити видаються вручну адміністратором.\n    Час очікування — від 5 хвилин до 1 години\n\n    <u>Вибери метод оплати:</u>\n  description: |\n    Купуючи Кредити, ти підтримуєш розвиток бота і отримуєш можливість використовувати додаткові функції\n  update: |\n    <b>🔄 Оновлення балансу</b>\n\n    Баланс: <code>${balance}</code> Кредитів (додано <code>${amount}</code> Кредитів)\n  error:\n    already_donated: |\n      Ти вже отримав Кредити за цей платіж\n    already_paid: Платіж вже здійснено\n    not_found: Платіж не знайдено\n    user_not_found: Користувача не знайдено\n    error: |\n      Сталася помилка під час обробки платежу\n    canceled: |\n      Платіж скасовано\ncoedit:\n  info: |\n    <b>👥 Спільне редагування</b>\n\n    Посилання: <code>${colink}</code>\n\n    Поділись — інші зможуть додавати і видаляти стікери з <a href=\"${link}\">${title}</a>.\n\n    <b>Редактори:</b> ${editors}\n\n    <i>Скинь посилання щоб видалити всіх</i>\n  no_editors: |\n    немає\n  btn:\n    send: '📤 Надіслати посилання'\n    reset: '🔁 Скинути посилання'\n  share: |\n    Перейди за посиланням і натисни «Старт» для спільного редагування паку «${title}»\n  reset: |\n    <b>🔁 Посилання успішно скинуто</b>\n\n    Нове посилання для спільного редагування <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: Пак не знайдено\n      not_owner: Це не твій пак\n      hidden: Пак успішно приховано\n      restored: Пак успішно відновлено\n    set_pack: |\n      ✅ Вибрано <a href=\"${link}\">${title}</a>\n\n      Надішли фото, відео або стікер щоб додати.\n    set_inline_pack: |\n      ✅ Вибрано <u>${title}</u>\n\n      Використовуй: <code>@${botUsername} </code>в будь-якому чаті.\n    boost:\n      info: |\n        \\n⚡ <b><a href=\"https://t.me/${botUsername}?start=boost\">Покращення</a></b>: ${boostStatus}\n      status:\n        on: Активне\n        off: Неактивне\n    hidden: Пак <a href=\"${link}\">${title}</a> приховано з твого списку.\n    restored: Пак <a href=\"${link}\">${title}</a> відновлено до твого списку.\n    btn:\n      hide: '👁 Приховати пак'\n      delete: '🗑 Видалити пак'\n      restore: '✅ Відновити'\n      use_pack: '📦 Використати пак'\n      boost: '⚡ Покращити'\n      frame: '🖼 Рамка'\n      rename: '✏️ Перейменувати'\n      search_gif: '🔎 Пошук GIF'\n      coedit: '👥 Спільне редагування'\n      catalog_add: '🗂 Додати до каталогу'\n      catalog_edit: '📝 Редагувати в каталозі'\n      catalog_delete: '🗑 Видалити з каталогу'\n      catalog_share: '🔗️️ Поділитися'\n      catalog_open: '📂 Відкрити в каталозі'\n      mosaic: '🧩 Мозаїка'\n    error:\n      not_found: |\n        Не вдалося знайти стікер.\n      invalid_png: |\n        Файл не є коректним PNG-зображенням. Перетвори його у формат PNG перед надсиланням.\n      invalid_dimensions: |\n        Розміри стікера некоректні. Стікери повинні бути 512x512 пікселів.\n      invalid_animated: |\n        Файл анімованого стікера не у правильному форматі TGS.\n      invalid_video: |\n        Відеофайл не у правильному форматі WEBM.\n      restore: |\n        Неможливо відновити пак.\n      copy: |\n        Неможливо знайти пак.\n    select_group:\n      success: |\n        Пак <a href=\"${link}\">${title}</a> успішно обрано для групи.\n      access_rights:\n        add: Хто може додавати стікери до групового паку?\n        delete: Хто може видаляти стікери з групового паку?\n        rights:\n          all: Всі\n          admins: Тільки адміністратори\n      error: |\n        Пак не знайдено.\n  sticker:\n    answerCbQuery:\n      delete: Стікер успішно видалено з паку.\n      restored: Стікер успішно збережено до поточного паку.\n    delete: Стікер успішно видалено з паку.\n    restored: Стікер успішно збережено до поточного паку.\n    btn:\n      delete: '🗑 Видалити'\n      copy: '🌟 Копіювати'\n      restore: '✅ Відновити'\n    error:\n      not_found: |\n        Не вдалося знайти стікер.\n      invalid_png: |\n        Файл не є коректним PNG-зображенням. Перетвори його у формат PNG перед надсиланням.\n      invalid_dimensions: |\n        Розміри стікера некоректні. Стікери повинні бути 512x512 пікселів.\n      invalid_animated: |\n        Файл анімованого стікера не у правильному форматі TGS.\n      invalid_video: |\n        Відеофайл не у правильному форматі WEBM.\n  group_settings:\n    success: |\n      Налаштування групи успішно оновлено.\nsticker:\n  add:\n    ok: |\n      ✅ Додано до <a href=\"${link}\">${title}</a>\n\n      <i>Можеш надіслати емодзі для цього стікера</i>\n    ok_inline: |\n      ✅ Додано до <u>${title}</u>\n    send_emoji: Надішли емодзі для цього стікера\n    converting_process: |\n      ⏳ Конвертація: ${progress}/${total}\n\n      <i>Буст = пріоритет → /donate</i>\n    catalog_offer: |\n      Хочеш поділитись паком <a href=\"${link}\">${title}</a>?\n      Додай до каталогу — інші зможуть його знайти.\n    quote: |\n      Використай @QuotlyBot для створення цитати з цього повідомлення\n    error:\n      reply: |\n        Відповідай на стікер.\n      no_selected_pack: |\n        <b>Ти не вибрав пак</b>\n\n        Створи (/new) або вибери (/packs) пак\n      no_selected_group_pack: |\n        <b>Ти не вибрав груповий пак</b>\n\n        Вибери пак командою /packs\n      no_rights: |\n        У тебе немає прав додавати стікери до цього паку.\n      stickers_too_much: |\n        У цьому паку максимальна кількість стікерів.\n\n        Можеш створити новий пак командою /new.\n      have_already: |\n        <b>Цей стікер вже є в паку</b>\n\n        Якщо хочеш змінити емодзі, надішли його в наступному повідомленні.\n      stickerset_invalid: |\n        Бот не може отримати доступ до обраного стікерпаку.\n\n        Створи (/new) або вибери (/packs) інший стікерпак.\n      invalid_png: |\n        Файл не є коректним PNG-зображенням. Перетвори його у формат PNG перед надсиланням.\n      invalid_dimensions: |\n        Розміри стікера некоректні. Стікери повинні бути 512x512 пікселів.\n      invalid_animated: |\n        Файл анімованого стікера не у правильному форматі TGS.\n      invalid_video: |\n        Відеофайл не у правильному форматі WEBM.\n      file_type:\n        static: |\n          Цей тип файлу не підтримується\n          Ти можеш додати це фото або статичний стікер до статичного стікерпаку\n\n          <i>Створи (/new) або вибери (/packs) інший стікерпак</i>\n        video: |\n          Цей тип файлу не підтримується\n          Ти можеш додати цей відеофайл до відеопаку\n\n          <i>Створи (/new) або вибери (/packs) інший стікерпак</i>\n        animated: |\n          Цей тип файлу не підтримується\n          Ти можеш додати ці анімовані файли до векторного стікерпаку\n\n          <i>Створи (/new) або вибери (/packs) інший стікерпак</i>\n        unknown: |\n          Цей тип файлу не підтримується\n\n          <i>Створи (/new) або вибери (/packs) інший стікерпак</i>\n      wait_load: |\n        ⏳ Ще обробляю попередній файл...\n      timeout: |\n        ⚠️ Зараз велике навантаження. Спробуй ще раз через кілька хвилин.\n      convert: |\n        Не вдалося конвертувати відео.\n\n        Спробуй MP4 формат або надішли ще раз.\n      too_big: |\n\n        Файл занадто великий для обробки. Зменш якість і тривалість файлу перед надсиланням.\n      sticker_not_found: |\n\n        Цей стікер не вдалося знайти. Переконайся, що він у правильному паку, або спробуй додати його знову.\n      invalid_image: |\n\n        Не вдалося обробити це зображення. Спробуй надіслати інший файл або формат.\nnews:\n  join: |\n    📢 <a href=\"${link}\">Підпишись на канал</a> — новини, оновлення, нові функції.\n  join_btn: '📢 Підписатись'\n  not_joined: '🙅 Ти не підписаний на канал'\n  continue: '✅ Продовжити'\nuserAbout:\n  help: |\n    <b>🧑‍🎨 Про користувача</b>\n\n    За допомогою цього меню ти можеш дізнатися інформацію про користувача та його стікерпаки\n\n    Щоб отримати інформацію про користувача, скористайся кнопкою нижче або перешли його повідомлення\n  result: |\n    <b>🧑‍🎨 Інформація про користувача</b>\n    <b>🆔 ID користувача:</b> <code>${userId}</code>\n    <b>🎨 Паки від цього користувача:</b>\n    ${packs}\n  no_packs: |\n    <i>У нас немає інформації про паки цього власника</i>\n  forward_hidden: |\n    Користувач приховав можливість пересилати повідомлення. Скористайся кнопкою нижче, щоб переглянути його стікерпаки.\n  select_user: '🧑‍🎨 Обрати користувача'\nscenes:\n  new_pack:\n    pack_type: |\n      <b>Який пак створюємо?</b>\n\n      <b>🖼 Стікери</b> — класичні, як смайли в чаті.\n      <b>✨ Емодзі</b> — кастомні емодзі всередині тексту. Надсилати їх можуть лише користувачі з Telegram Premium.\n    regular: '🖼 Стікери'\n    custom_emoji: '✨ Емодзі'\n    pack_title: |\n      <b>Назва паку</b>\n\n      Її бачать всі. Або напиши свою, або вибери з підказок нижче.\n    pack_name: |\n      <b>Адреса паку</b>\n\n      Тільки англ. літери, цифри та <code>_</code>. Наприклад: t.me/addstickers/<u>MoiStikery</u>\n    ok: |\n      ✅ Пак створено: <a href=\"${link}\">${title}</a>\n\n      Надішли фото, відео або стікер щоб додати.\n    error:\n      title_long: Назва не має бути довшою за ${max} символів.\n      name_long: Адреса не має бути довшою за ${max} символів.\n      telegram:\n        name_invalid: Таку адресу використовувати не вийде.\n        name_occupied: Ця адреса вже зайнята. Спробуй іншу.\n        upload_failed: |\n          Бот не може завантажити стікери в Telegram.\n\n          Спробуй пізніше.\n  copy:\n    enter: |\n      Спочатку створимо новий пак для копії.\n    progress: |\n      ⏳ Копіюю: ${current}/${total}\n    done: |\n      ✅ Скопійовано в <a href=\"${link}\">${title}</a>\n    done_partial: |\n      ⚠️ Скопійовано в <a href=\"${link}\">${title}</a>\n\n      ${success} стікерів скопійовано, ${failed} не вдалося скопіювати.\n    done_pending: |\n      ✅ Скопійовано в <a href=\"${link}\">${title}</a>\n\n      ${success} стікерів скопійовано, ${pending} відео ще обробляються.\n    done_partial_pending: |\n      ⚠️ Скопійовано в <a href=\"${link}\">${title}</a>\n\n      ${success} стікерів скопійовано, ${failed} не вдалося, ${pending} відео ще обробляються.\n    pay: |\n      <b>Конвертація паку</b>\n\n      Конвертація паку з одного типу в інший коштує 1 Кредит\n\n      <b>Поточний баланс:</b> ${balance} Кредитів\n\n      Купити Кредити: /donate\n    pay_btn: '✅ Підтвердити'\n    error:\n      all_failed: |\n        ❌ Не вдалося скопіювати жодного стікера з <a href=\"${originalLink}\">${originalTitle}</a>.\n\n        Пак не створено.\n      premium: |\n        На жаль, ця функція доступна тільки для тих, хто підтримав бота.\n\n        Ти можеш це зробити, надіславши команду /donate.\n  original:\n    enter: |\n      <b>🔎 Звідки стікер</b>\n\n      Надішли стікер — покажу з якого паку він скопійований.\n      Якщо оригінал не знайдено — отримаєш файл (PNG/WEBM).\n    source_found: |\n      🔎 Скопійовано з: <a href=\"${link}\">${title}</a>\n    error:\n      not_found: |\n        Оригінал не знайдено. Ось файл стікера:\n  delete:\n    enter: |\n      <b>🗑 Видалення стікера</b>\n\n      Надішли стікер, який хочеш видалити з паку.\n    confirm: |\n      Видалити цей стікер з паку?\n    error:\n      not_found: |\n        Стікер не знайдено в базі. Можливо, він створений не через цього бота.\n  rename:\n    enter_name: |\n      Нова назва для <a href=\"${link}\">${title}</a>:\n    success: |\n      ✅ Перейменовано: <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      Буст прибере \"${titleSuffix}\" → /donate\n  packAbout:\n    enter: |\n      <b>🔎 Чий стікер</b>\n\n      Надішли стікер — покажу пак, автора та інші його паки.\n      Або перешли повідомлення — покажу паки цієї людини.\n    not_found: |\n      Стікер не знайдено.\n    btn:\n      download: '📎 Скачати файл'\n      show_all_packs: '📦 Всі паки (${count})'\n    result: |\n      <b>📦 Пак:</b> <a href=\"${link}\">${name}</a>\n      🔢 Стікерів: <code>${stickerCount}</code> | 🏷 #<code>${setId}</code> | ${dcId}\n\n      🧑‍🎨 ID власника: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 Інші паки від цього власника:</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>У нас немає інформації про інші паки цього власника</i>\n    unknown_owner: '<i>Невідомо</i>'\n    hidden: '<i>[приховано]</i>'\n  boost:\n    sure: |\n      Бустнути <a href=\"${link}\">${title}</a>?\n\n      <b>Ціна:</b> 1 кредит\n      <b>Баланс:</b> ${balance}\n    btn:\n      yes: '⚡ Бустнути'\n      no: Скасувати\n    canceled: |\n      Скасовано\n    success: |\n      ⚡ <a href=\"${link}\">${title}</a> — буст активовано!\n\n      <b>Розблоковано:</b>\n      • Назва без суфіксу «${titleSuffix}» — після перейменування\n      • До 64 символів у назві (замість 35)\n      • Відео до 35 секунд\n      • Пріоритет у конвертації\n      • Кілька стікерів одночасно\n\n      <i>Щоб прибрати суфікс — натисни «✏️ Перейменувати» в меню паку.</i>\n    error:\n      not_enough_credits: |\n        Недостатньо кредитів. /donate\n      already_boosted: |\n        Вже бустнуто.\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>Надішли стікер з паку, який хочеш опублікувати</b>\n\n        <i>Можеш опублікувати будь-який свій пак, навіть створений в іншому місці</i>\n      owner_proof: |\n        <b>Щоб підтвердити володіння цим паком, виконай кілька простих кроків:</b>\n        1. Відкрий бота @Stickers\n        2. Надішли команду <code>/packstats</code>\n        3. Знайди та вибери потрібний пак\n        4. Перешли отримане повідомлення мені\n      publish_new_access_denied: |\n        Це не твій пак.\n\n        Можна публікувати лише власні паки\n      banned: |\n        Тобі заборонено використовувати цю функцію.\n        Звернися до адміністратора.\n      enter: |\n        Опублікувати <a href=\"${link}\">${title}</a> в каталозі?\n\n        Інші користувачі зможуть знайти твій пак за назвою або тегами.\n\n        <b>Правила:</b>\n        • Тільки якісні паки для широкої аудиторії\n        • Без особистих фото чи приватного контенту\n        • Без порушення законів\n      continue_button: Продовжити\n      enter_description: |\n        <b>Коротко опиши свій пак, щоб інші могли його знайти</b>\n\n        <i>Можеш використовувати хештеги для категоризації [#]</i>\n        <i>Наприклад: #anime #meme #animals #cute #kpop #funny #cat #game</i>\n      select_language: |\n        <b>Вибери мови, для яких призначений твій пак:</b>\n        <i>Можна вибрати кілька</i>\n      button_all_languages: Будь-які мови\n      button_confirm_language: Підтвердити\n      set_safe: |\n        <b>Твій пак безпечний для користувачів?</b>\n        <i>Тобто не містить еротики та іншого шокуючого контенту</i>\n      button_safe:\n        safe: Так, безпечний\n        not_safe: Ні, небезпечний\n      no_tags: не вказані\n      confirm: |\n        <b>Підтвердити публікацію стікерпаку «<a href=\"${link}\">${title}</a>»</b>\n\n        <b>Опис:</b> <i>${description}</i>\n\n        <b>Теги:</b> ${tags}\n        <b>Мови:</b> ${languages}\n      button_confirm: '✅ Підтвердити публікацію'\n      success: |\n        Вітаю! Твій пак опубліковано в каталозі, тепер його зможуть знайти інші користувачі!\n\n        Редагувати інформацію про пак можна через /packs.\n\n        <i>Статистику свого паку можна переглянути через офіційного бота @Stickers</i>\n    unpublish:\n      success: |\n        Пак успішно видалено з каталогу.\n  delete_pack:\n    enter: |\n      ⚠️ Видалити <a href=\"${link}\">${title}</a> назавжди?\n\n      Надішли <code>${confirm}</code> для підтвердження.\n    confirm: Так, видалити\n    success: |\n      🗑 Пак видалено\n    error:\n      - Щось пішло не так.\n  frame:\n    no_video: |\n      Рамку можна додати лише до відеопаків.\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>Вибери форму стікера:</b>\n\n      <code>легка</code> — майже без заокруглення\n      <code>середня</code> — трохи заокруглені кути\n      <code>заокруглена</code> — сильно заокруглені кути\n      <code>квадратна</code> — рівні кути без заокруглення\n      <code>кругла</code> — стікер у формі кола\n\n      <i>Змінити форму можна командою /frame</i>\n    types:\n      lite: '1. Легка'\n      medium: '2. Середня'\n      rounded: '3. Заокруглена'\n      square: '4. Квадратна'\n      circle: '5. Кругла'\n    selected: |\n      <b>Вибрано тип рамки:</b> ${type}\n  photoClear:\n    enter: |\n      <b>✂️ Видалення фону</b>\n\n      Надішли фото — отримаєш PNG без фону.\n      <i>Краще працює з фото людей.</i>\n    enter_anime: |\n      <b>✂️ Видалення фону</b>\n\n      Надішли фото — отримаєш PNG без фону.\n      <i>Краще працює з аніме.</i>\n    choose_model: |\n      Модель:\n    web_app: WebApp — для фото з людьми\n    model:\n      ordinary: Звичайна — для фото з людьми\n      general: Загальна — для будь-яких фото\n      anime: Аніме — для аніме-зображень\n      birefnet_general: BirefNet — для будь-яких фото\n    add_to_set_btn: '🌟 Додати до паку'\n    error: |\n      Ой, щось пішло не так. Спробуй ще раз.\n    error_timeout: |\n      ⏱ Обробка зайняла забагато часу. Спробуй пізніше або з меншим фото.\n    error_queue_disabled: |\n      Ця функція тимчасово недоступна. Спробуй пізніше.\n  videoRound:\n    enter: |\n      <b>⭕ Відео в кружок</b>\n\n      Надішли відео — зроблю з нього кружок.\n    not_video: |\n      Надішли відео, GIF, відеостікер або анімоване зображення.\n    error: |\n      Не вдалося конвертувати. Спробуй інше відео.\n    forbidden: |\n      Не можу надіслати тобі кружок. Перевір налаштування приватності (голосові повідомлення мають бути дозволені).\n    processing: |\n      ⏳ Конвертую: ${position}/${total}\n\n      <i>Буст = пріоритет → /donate</i>\n    file_too_big: |\n      Файл занадто великий (макс. 20 МБ). Надішли менше відео.\n  search:\n    enter: |\n      <b>🔍 Пошук стікерів</b>\n\n      Введи ключові слова — знайду паки в каталозі.\n  leave: |\n    Скасовано\n  btn:\n    cancel: '← Скасувати'\nerror:\n  telegram: |\n    Помилка Telegram: <code>${error}</code>\n  telegram_reasons:\n    sticker_not_in_set: |\n      Стікера вже немає в паку — можливо, його видалили раніше.\n    pack_invalid: |\n      Пак стікерів недоступний. Можливо, його видалили в Telegram.\n    pack_full: |\n      Пак переповнений (досягнуто ліміт Telegram). Видали кілька стікерів і спробуй знову.\n    not_pack_owner: |\n      Цей пак належить іншому користувачу — змінювати його не можна.\n    pack_name_taken: |\n      Така адреса паку вже зайнята. Обери іншу назву.\n    pack_name_invalid: |\n      Адреса паку має невірний формат. Дозволені латинські літери, цифри й нижні підкреслення.\n    invalid_sticker_format: |\n      Файл не підходить як стікер: невірний формат або розмір.\n    invalid_emoji: |\n      Емодзі некоректне. Надішли стандартне Unicode-емодзі.\n    rate_limited: |\n      Telegram просить зачекати — спробуй ще раз за кілька секунд.\n    cannot_reach_user: |\n      Не можу написати користувачу: бот заблокований або чат недоступний.\n  answerCbQuery:\n    telegram: |\n      Помилка: ${error}\n  file_too_big: |\n    Файл занадто великий (макс. 20 МБ).\n  download: |\n    Не вдалося завантажити файл. Спробуй ще раз.\n  banned: |\n    🚫 Доступ заборонено. Питання → @ly_oBot\n  access_denied: Доступ заборонено\n  unknown: |\n    Щось пішло не так. Спробуй ще раз.\n\n    Не допомагає? Пиши @Ly_oBot\n  rate_limit: |\n    ⏳ Забагато запитів. Зачекай трохи і спробуй ще раз.\n  rate_limit_seconds: |\n    ⏳ Забагато запитів. Зачекай ${seconds} секунд.\n"
  },
  {
    "path": "locales/uz.yaml",
    "content": "---\nlanguage_name: '🇺🇿 O''zbek'\nname: fStik — Stikerlar va Emoji\ndescription:\n  long: |\n    Foto, video va GIF-lardan konvertatsiya qilmasdan stikerlar va emoji yarating!\n\n    Imkoniyatlar:\n    • Oson paket boshqaruvi\n    • Video stikerlar va maxsus emoji\n    • Asl fayllarni yuklab olish\n    • Stiker/video/GIF-ni rasmga aylantirish\n    • Stikerlar katalogi\n\n    Stikerlarni qidirish: play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    Foto, video, GIF'lardan stiker va emoji yarating. Katalog va qidiruv. 🇺🇦\nratelimit: Bunchalik tez-tez emas!\ncmd:\n  start:\n    enter: |\n      🧙 Salom, ${name}! Men emoji va stikerlar to‘plami ustasiman.\n      Men bir necha marta bosish orqali rasmlaringiz, videolaringiz va GIF-fayllaringizni ajoyib stikerlarga aylantira olaman\n\n      Nima qila olishim haqida ko'proq bilish uchun /help buyrug'ini yuboring\n\n      💬 Yordam kerakmi? @fStikCommunity sahifasidagi qo'llab-quvvatlash chatimizga qo'shiling (faqat ingliz tilida)\n    group: |\n      🧙 Salom, ${groupTitle}! Men emoji va stiker paketi sehrgariman.\n\n      Guruh paketiga stiker qo'shish uchun, rasim, video, gif yoki stikerni javobida /ss buyrug'idan foydalaning.\n    catalog: |\n      <b>😻 Siz bizning katalogimizda yangi stikerlar to‘plamini topishingiz mumkin</b>\n\n      • Quyidagi tugmani bosing va har qanday didga mos stikerlar to‘plamining ulkan katalogiga kirish huquqiga ega bo‘ling\n      • Kalit so‘zlar yoki tayyorlangan varaqlar bo‘yicha qidiring\n      • Tashviqot uchun baho berishni unutmang. yoki stikerlar to'plamini reytingda pasaytiring\n    commands:\n      ss: '🌟 Stikerni saqlang'\n      start: '📜 Start menu'\n      help: '📖 Ma''lumotnoma'\n      packs: '📁 Paketlarni boshqaring'\n      new: '🌝 Stikerlar toʻplami yarating'\n      catalog: '📖 Katalog'\n      publish: '📤 Paketni nashr qilish'\n      delete: '❌ Stikerni oʻchirish'\n      original: '🔍 Find original sticker'\n      restore: '🔀 Paketni tiklang'\n      copy: '📋 Paketdan nusxa oling'\n      emoji: '📝 Change emoji suffix'\n      round: '🎥 Dumaloq shakldagi video'\n      clear: '🖼️ Suratdan fonni olib tashlang'\n      about: '📦 Paket haqida ma''lumot'\n      user_about: '🧑‍🎨 Ijodkor haqida maʼlumot'\n      lang: '🌐 Change language'\n      report: '🚨 Hisobot to''plami'\n      donate: '☕️ Support the developer'\n      add_to_group: '👥 Guruhga qo''shish'\n      privacy: '🔒 Maxfiylik siyosati'\n    btn:\n      new: '📥 Create new'\n      catalog: '💖 Katalogni ochish'\n      catalog_mini: '💖 Katalog'\n      catalog_browser: '🌐 Brauzerda oching'\n      catalog_browser_mini: '🌐 Brauzerda'\n      catalog_app: '📱 Android ilovasini yuklab oling'\n      catalog_app_mini: '📱 Android ilovasi'\n  inline:\n    switch_pm: '📁 To''plamni tanlang'\n  restore: |\n    <b>🗃 Paketni tiklash</b>\n\n    Paketni tiklash uchun siz tiklamoqchi bo'lgan paketga havolani yuborishingiz kerak.\n  copy: |\n    <b>🗄 Toʻplamni nusxalash</b>\n\n    Boshqa paketni yangisiga nusxalash uchun menga stiker yoki kulgichlar toʻplamiga havola yuborish kifoya.\n  report: |\n    <b>🚨 Xabar berish</b>\n\n    Agar qonunni buzishi yoki Telegramning xizmat koʻrsatish shartlariga zid kelishi mumkin boʻlgan stikerlar toʻplamiga duch kelsangiz, iltimos, uning havolasini @ manziliga yuborib, bizga xabar bering. StickersReportBot\n\n    <i>Esda tutingki, bot paketlar mazmuni uchun javobgar emas va uni boshqarish imkoniga ega emas</i>\n  packs:\n    info: |\n      <b>📁 Paketlar</b>\n    types:\n      regular: Stikerlar\n      custom_emoji: Emojilar\n      inline: Inline\n    empty: |\n      <b>Sizda hali hech qanday paket yo'q.</b>\n      Yaratish uchun /new buyrug'ini yozing\n    inline_title: Inline paketi\n    select_group_pack_info: |\n      <b>📁 Paketni tanlash</b>\n\n      Guruhda paketni ishlatish uchun, administratorlar pastdagi tugmani bosib uni tanlashlari kerak\n    select_group_pack: Paketni tanlash\n  emoji:\n    info: |\n      Жорий гуруҳ учун эмоджиларни қайта йўналтириш учун <code>/emoji</code> жўнатинг, кейинги эмоджиларни бўш майдон билан ажратинг\n\n      Масалан - <code>/emoji 🌟</code>\n    done: Emoji muvaffaqiyatli oʻzgartirildi.\n    error: There was an error changing emoji!\n  round_video:\n    enabled: |\n      Videolar endi yumaloq shaklga ega bo'ladi\n    disabled: |\n      Videolar endi yumaloq shaklga ega bo‘lmaydi\n  paysupport: |\n    <b>👨‍💻 To'lov yordami</b>\n\n    Bot faoliyati bilan bog'liq barcha masalalar, jumladan to'lovlar va xayr-ehsonlar yuzasidan to'g'ridan-to'g'ri ishlab chiquvchiga murojaat qilishingiz mumkin\n\n    <b>Aloqalar:</b>\n    🧑‍💻 Ishlab chiquvchi: @ly_oBot\ndonate:\n  menu: |\n    <b>☕️ Bot ishlab chiqishni qo'llab-quvvatlash</b>\\nBotning rivojlanishini qo'llab-quvvatlab, siz Kreditlar olasiz\\n\\n<b>Balans:</b> <code>${balance}</code> Kreditlar\\n1 Kredit bilan, siz bir quti boost qilish imkoniyatiga egasiz.\\n\\n<b>Boost quyidagi foydalarni beradi:</b>\\n➖ Paket nomida \"<code>${titleSuffix}</code>\" yo'q <i>(havolada emas)</i>\\n➖ Sarlavha 64 belgigacha (35 o'rniga)\\n➖ Videolar 35 soniyagacha\\n➖ Ustuvor konvertatsiya navbati\\n➖ Bir vaqtning o'zida bir nechta stikerlar\\n➖ Reklamasiz\\n\\n<b>Xarid qilish uchun Kreditlar miqdorini tanlang:</b>\n  btn:\n    donate: '☕️ Xayriya qiling'\n  topup: |\n    <b>Xarid qilish uchun Kreditlar miqdorini kirit:</b>\n  invalid_amount: |\n    <b>Noto'g'ri miqdor</b>\n\n    Minimal miqdor 1 Kredit\n  paymenu: |\n    Siz <b>${amount} Kredit</b> ni <b>${price}$</b> ga sotib olmoqchisiz\n\n    ⚠️ Kreditlar administrator tomonidan qo'lda beriladi.\n    Kutish vaqti 5 daqiqadan 1 soatgacha\n\n    <u>To'lov usulini tanlang:</u>\n  description: |\n    Kreditlarni sotib olib, siz botni rivojlantirishga yordam berasiz va qo'shimcha xususiyatlardan foydalanish imkoniyatiga ega bo'lasiz\n  update: |\n    <b>🔄 Balans yangilanishi</b>\n\n    Balans: <code>${balance}</code> Kreditlar (qo'shildi <code>${amount}</code> Kreditlar)\n  error:\n    already_donated: |\n      Siz bu to'lov uchun allaqachon Kreditlarni oldingiz\n    error: |\n      <b>Xato!</b>\n      To'lovni qayta ishlashda xato yuz berdi\n    canceled: |\n      To'lov bekor qilindi\ncoedit:\n  info: |\n    <b>👥 Ҳамкорлик</b>\n\n    Ҳамкорлик учун ҳавола <a href=\"${link}\">${title}</a>: <code>${colink}</code>\n\n    <b>Қўллаш усули:</b>\n    1. Ўтказиш керак бўлган кишига ҳаволани юборинг\n    2. Ҳаволаға босқан киши “бошлаш” тугмасини босиб, тахрирчилар қаторига қўшилади\n    3. Тахрирчи тахрир қила, ўчиради ва стикерларни қўшишни бажаришга имконият беради\n\n    <b>Тахрирчилар:</b>\n    ${editors}\n\n    <i>Тахрирчиларни олиб ташлаш учун, ҳаволани кайта олиш керак</i>\n  no_editors: |\n    Hozircha muharrirlar yo'q\n  btn:\n    send: '📤 Havolani yuboring'\n    reset: '🔁 Havolani tiklash'\n  share: |\n    Havolani kuzatib boring va \"${title}\" to'plamini birgalikda tahrirlash uchun \"start\" tugmasini bosing.\n  reset: |\n    <b>🔁 Havolani tiklash muvaffaqiyatli amalga oshirildi</b>\n\n    Birgalikda tahrirlash uchun yangi havola <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: Paket topilmadi\n      not_owner: Bu sizning to'plamingiz emas\n      hidden: Paket muvaffaqiyatli yashirildi\n      restored: Paket muvaffaqiyatli tiklandi\n    set_pack: |\n      🌟 Tanlangan <a href=\"${link}\">${title}</a> toʻplami\n\n      <b>❔ Qanday qilib qoʻshiladi?</b>\n      Paketga qo'shish uchun fotosurat, video yoki stiker yuboring\n    set_inline_pack: |\n      Танланган <u>${title}</u> гуруҳ\n\n      Уни кўрсатиш учун, ичидагиларда <code>@${botUsername}</code> ни босинг ва куч пасткини босинг\n    boost:\n      info: |\n        \\n⚡ <b><a href=\"https:\\/\\/t.me\\/${botUsername}?start=boost\">Boost</a></b>: ${boostStatus}\n      status:\n        on: Yoqilgan\n        off: O'chirilgan\n    hidden: Toʻplam <a href=\"${link}\">${title}</a> roʻyxatingizdan yashirilgan.\n    restored: <a href=\"${link}\">${title}</a> toʻplami roʻyxatingizga tiklandi.\n    btn:\n      hide: '❌ Paketni yashirish'\n      delete: '🗑 Paketni o''chirish'\n      restore: '✅ Qayta tiklash'\n      use_pack: '📦 Paketdan foydalaning'\n      boost: '⚡ Kuchaytiring'\n      frame: '🖼 Ramka'\n      rename: '✏️ Nomini oʻzgartirish'\n      search_gif: '🔎 Search GIF'\n      coedit: '👥 Birgalikda tahrirlash'\n      catalog_add: '🗂 Add to catalog'\n      catalog_edit: '📝 Katalogda tahrirlash'\n      catalog_delete: '🗑 Katalogdan oʻchirish'\n      catalog_share: '🔗️️ Ulashish'\n      catalog_open: '📂 Katalogda ochish'\n    error:\n      not_found: |\n        Tizimda xatolik!\n        Stikerni topib boʻlmadi.\n      invalid_png: |\n        Xato!\n        Fayl haqiqiy PNG rasm emas. Iltimos, uni PNG formatiga aylantiring va qayta yuboring.\n      invalid_dimensions: |\n        Xato!\n        Stiker o'lchamlari noto'g'ri. Stikerlar 512x512 piksel bo'lishi kerak.\n      invalid_animated: |\n        Xato!\n        Animatsiyali stikerni fayli toʻgʻri TGS formatida emas.\n      invalid_video: |\n        Xato!\n        Video fayl to'g'ri WEBM formatida emas.\n      restore: |\n        Xato!\n        Paketni tiklab bo'lmadi.\n      copy: |\n        Xato!\n        Paket topilmadi.\n    select_group:\n      success: |\n        Paket <a href=\"${link}\">${title}</a> muvaffaqiyatli guruhga tanlandi.\n      access_rights:\n        add: Kimlar guruh paketiga stiker qo'shishi mumkin?\n        delete: Kimlar guruh paketidan stikerlarni o'chirishi mumkin?\n        rights:\n          all: Hamma\n          admins: Faqat adminlar\n      error: |\n        Xatolik!\n        To'plam topilmadi.\n  sticker:\n    answerCbQuery:\n      delete: Stiker paketdan muvaffaqiyatli olib tashlandi.\n      restored: Stiker joriy paketga muvaffaqiyatli saqlandi.\n    delete: Stiker paketdan muvaffaqiyatli olib tashlandi.\n    restored: Stiker joriy paketga muvaffaqiyatli saqlandi.\n    btn:\n      delete: '🗑O''chirish'\n      copy: '🌟Nusxalash'\n      restore: '✅Qayta tiklash'\n    error:\n      not_found: |\n        Tizimda xatolik!\n        Stikerni topib bo'lmadi.\n      invalid_png: |\n        <b>Xato!</b>\n        Fayl yaroqli PNG tasviri emas. Iltimos, uni PNG formatiga aylantirib yuboring.\n      invalid_dimensions: |\n        <b>Xato!</b>\n        Stiker o'lchamlari yaroqsiz. Stikerlar 512x512 piksel bo'lishi kerak.\n      invalid_animated: |\n        <b>Xato!</b>\n        Animatsiyalangan stiker fayli to'g'ri TGS formatida emas.\n      invalid_video: |\n        <b>Xato!</b>\n        Video fayl to'g'ri WEBM formatida emas.\n  group_settings:\n    success: |\n      Guruh sozlamalari muvaffaqiyatli yangilandi.\nsticker:\n  add:\n    ok: |\n      <b>Uddoqqi paketga muvaffaqiyatli qo'shildi:</b>\\n<a href=\"${link}\">${title}</a>\\n\\nBir soat ichida, bu paket barcha foydalanuvchilar uchun yangilangan bo'ladi.\\n\\n<i>Agardan emoji yoki bir nechta emoji yuboring, agar ularni qo'shmoqchi bo'lsangiz.</i>\n    ok_inline: |\n      <b>Paketga muvaffaqiyatli qo'shildi:</b>\n      <u>${title}</u>\n    send_emoji: Great, now send the emoji that matches the\n    converting_process: |\n      <b>Wait...</b>\n      Your file is in the queue for conversion. Wait for completion. This may take some time.\n\n      Progress: ${progress} \\/ ${total}\n\n      <i>Users who supported the bot get priority in the queue (more: \\/donate)</i>\n    catalog_offer: |\n      <b>😲 Voy, ajoyib toʻplam yasadingiz!</b>\n\n      Botning boshqa foydalanuvchilari ham koʻrishi uchun umumiy stikerlar katalogiga <a href=\"${link}\">${title}</a> qoʻshmoqchimisiz?\n      <i>Bu ko'p vaqt talab qilmaydi</i>\n    quote: |\n      Bu xabardan sitata yaratish uchun @QuotlyBot-dan foydalaning\n    error:\n      reply: |\n        <b>Xato!</b>\n        Iltimos, stikerga javob yozing.\n      no_selected_pack: |\n        <b>Siz toʻplamni tanlamadingiz</b>\n\n        Iltimos, (/yangi) toʻplamni yarating yoki (/paketlar) tanlang\n      no_selected_group_pack: |\n        <b>Siz guruh paketini tanlamadingiz</b>\n\n        Iltimos, /packs buyrug'i orqali paketni tanlang\n      no_rights: |\n        <b>Xatolik!</b>\n        Sizda ushbu paketga stiker qo'shish huquqi yo'q.\n      stickers_too_much: |\n        Ushbu paketda stikerlarning maksimal soni mavjud.\n\n        Siz /new buyrug'i yordamida yangi paket yaratishingiz mumkin.\n      have_already: |\n        <b>Ushbu stiker allaqachon paketda</b>\n\n        Agar kulgichni o'zgartirmoqchi bo'lsangiz, uni quyidagi xabarga yuboring.\n      stickerset_invalid: |\n        <b>Xato!</b>\n        Bot joriy tanlangan paketingizga kira olmaydi.\n\n        Iltimos, yarating (/yangi) yoki boshqa paketni tanlang (/paketlar).\n      invalid_png: |\n        <b>Xato!</b>\n        Fayl yaroqli PNG tasviri emas. Iltimos, uni PNG formatiga aylantirib yuboring.\n      invalid_dimensions: |\n        <b>Xato!</b>\n        Stiker o'lchamlari yaroqsiz. Stikerlar 512x512 piksel bo'lishi kerak.\n      invalid_animated: |\n        <b>Xato!</b>\n        Animatsiyalangan stiker fayli to'g'ri TGS formatida emas.\n      invalid_video: |\n        <b>Xato!</b>\n        Video fayl to'g'ri WEBM formatida emas.\n      file_type:\n        static: |\n          <b>Xato!</b>\n          Bu fayl turi qoʻllab-quvvatlanmaydi\n          Siz ushbu rasm yoki statik stikerni statik toʻplamga qoʻshishingiz mumkin\n\n          <i>Yarating (/yangi) yoki tanlang (/paketlar) boshqa paket</i>\n        video: |\n          <b>Xato!</b>\n          Bu fayl turi qoʻllab-quvvatlanmaydi\n          Siz ushbu video fayllarni video toʻplamga qoʻshishingiz mumkin\n\n          <i>Yaratish (/yangi) yoki (/ ni tanlang) paketlar) boshqa paket</i>\n        animated: |\n          <b>Xato!</b>\n          Bu fayl turi qoʻllab-quvvatlanmaydi\n          Ushbu animatsiyali fayllarni vektor toʻplamiga qoʻshishingiz mumkin\n\n          <i>Yaratish (/yangi) yoki (/) ni tanlang. paketlar) boshqa paket</i>\n        unknown: |\n          <b>Xato!</b>\n          Bu fayl turi qoʻllab-quvvatlanmaydi\n\n          <i>Yarating (/yangi) yoki boshqa paketni tanlang (/paketlar)</i>\n      wait_load: |\n        <b>Kutib turing!</b>\n\n        Bot hali ham avvalgi faylni qayta ishlamoqda...\n        Qayta ishlash ustuvorligini oshirish va navbatga bir nechta stiker qo‘shish imkoniyatini oshirish uchun siz botni ishlab chiqishni (\\/donate) qo‘llab-quvvatlashingiz mumkin.\n      timeout: |\n        <b>Ayni paytda bot juda katta yuklanishni boshdan kechirmoqda</b>\n        Shuning uchun video konvertatsiya faqat faol kuchaytirilgan paketlar uchun mavjud\n\n        Batafsil ma'lumot uchun / ehson qilish\n      convert: |\n        <b>Xato!</b>\n        Afsuski, bot videongizni aylantira olmadi.\n\n        Ehtimol, videongiz bot uchun tushunarsiz formatda saqlangandir. U mp4 formatida ekanligiga ishonch hosil qiling.\n        Bu botning ichki xatosi ham boʻlishi mumkin, bu videoni qayta yuborishga urinib koʻring.\n      too_big: |\n        <b>Xato!</b>.\n\n        Faylni qayta ishlash uchun juda katta. Yuborishdan oldin sifat va muddatni kamaytiring.\n      sticker_not_found: |\n        <b>Xato!</b>\n\n        Bu stiker topilmadi. To'g'ri to'plamda ekanligiga ishonch hosil qiling yoki uni qayta qo'shishga harakat qiling.\nnews:\n  join: |\n    ℹ️ <a href=\"${link}\">Bot haqidagi so'nggi yangiliklardan xabardor bo'lish uchun</a> kanalimizga qo'shiling.\n\n    <i>Bot haqidagi eng so'nggi yangiliklar, shuningdek, yangilanishlar va yangi xususiyatlarni olish uchun kanalga obuna bo'ling.</i>\n  join_btn: '📢 Kanalga qo''shiling'\n  not_joined: '🙅 Siz kanalga obuna bo''lmagansiz'\n  continue: '✅ Davom eting'\nuserAbout:\n  help: |\n    <b>🧑‍🎨 Foydalanuvchi haqida</b>\n\n    Ushbu menyudan foydalanib siz foydalanuvchi va uning stiker paketlari haqida ma'lumot olishingiz mumkin\n\n    Foydalanuvchi haqida ma'lumot olish uchun tugmani bosing pastga yoki uning xabarini yo'naltiring\n  result: |\n    <b>🧑‍🎨 Фойдаланувчи ҳақида</b>\n    <b>🆔 Фойдаланувчи ID:</b> <code>${userId}</code>\n    <b>🎨 Ушбу фойдаланувчининг сақламалари:</b>\n    ${packs}\n  no_packs: |\n    <i>Bizda bu egasining stikerlari haqida hech qanday ma'lumot yo'q</i>\n  forward_hidden: |\n    Foydalanuvchi xabarlarni yo'naltirish imkoniyatini yashirgan. Uning stiker paketlarini ko'rish uchun quyidagi tugmadan foydalaning.\n  select_user: '🧑‍🎨 Foydalanuvchini tanlang'\nscenes:\n  new_pack:\n    pack_type: |\n      <b><u>Paket turini tanlang</u></b>\n    regular: '😊 Stiker'\n    custom_emoji: '🌟 Emoji (premium)'\n    static: '🌟 Static'\n    animated: '✨ Vektor'\n    video: '📹 Video'\n    pack_format: |\n      <b><u>Paket turini tanlang</u></b>\n\n      <b>Umumiy</b> - statik (harakat qilmang), rastr, fayl format - PNG qo'shishdan oldin (bot qayta ishlanmoqda), qo'shgandan keyin - WEBP.\n      Oddiy to'plamga misol - t.me/addstickers/Animals\n\n      <b>Video</b> - animatsion video to'plami. Siz har qanday video, gif va fotosuratni qo'shishingiz mumkin.\n      Namuna video to'plami - t.me/addstickers/TheMascot\n\n      <b>Animatsiyalangan</b> - animatsiyali, vektor (ular fayl ichidagi ob'ektlarning aniq tavsifiga ega bo'lishi kerak. ular har qanday miqyosda aniq ko'rsatiladi), fayl formati - TGS, Lottie formatining o'zgarishi.\n      Animatsion to'plamga misol - t.me/addstickers/IsabelleShizue\n\n      <i>Animatsion va video stikerlar to'plamida 50 tagacha stiker bo'lishi mumkin. Statik stikerlar to‘plamida 120 tagacha stiker bo‘lishi mumkin.</i>\n    pack_title: |\n      <b>Yangi stikerlar to'plami uchun nomni kiriting</b>\n      <i>Shuningdek pastda tasodifiy hosil bo'lgan nomni ham tanlashingiz mumkin</i>\n    pack_name: |\n      <b>Yangi stikerlar toʻplami uchun qisqa havolani kiriting:</b>\n\n      <i>Masalan, bu toʻplamda “Hayvonlar” qisqa havola sifatida ishlatiladi: https://t.me/ addstickers/<u>Hayvonlar</u></i>\n      <i>Tugmadagi tasodifiy qisqa havolani tanlashingiz mumkin.</i>\n    ok: |\n      <a href=\"${link}\">${title}</a> toʻplami muvaffaqiyatli yaratildi!\n\n      <b>Paket havolasi:</b> <pre>${link}</pre>\n\n      Fayl, rasm, video yoki stiker yuboring, shunda men uni to'plamingizga qo'shing\n    error:\n      title_long: Ism ${max} belgidan oshmasligi kerak.\n      name_long: Manzil ${max} belgidan oshmasligi kerak.\n      telegram:\n        name_invalid: Ushbu manzilni ishlatib bo'lmaydi.\n        name_occupied: Ushbu manzil band.\n        upload_failed: |\n          <b>Xato!</b>\n          Bot Telegram’ga stiker yuklay olmaydi.\n\n          Iltimos, keyinroq qayta urinib ko'ring.\n  copy:\n    enter: |\n      Men uni nusxalashim mumkin, lekin undan oldin yangi paket yarataylik\n    progress: |\n      <a href=\"${originalLink}\">${originalTitle}</a> дан <a href=\"${link}\">${title}</a> гуруҳига нусҳа олиш\n\n      Бажарилмоқда: ${current}/${total}\n    done: |\n      <a href=\"${originalLink}\">${originalTitle}</a> dan <a href=\"${link}\">${title}</a> gacha toʻplamdan nusxalash muvaffaqiyatli yakunlandi.\n    pay: |\n      <b>O'ram konvertatsiyasi</b>\n\n      Bir turdagi o'ramni boshqa turga aylantirish 1 kredit turadi\n\n      <b>Joriy balans:</b> ${balance} Kreditlar\n\n      Kredit sotib olish: /donate\n    pay_btn: '✅ Confirm'\n    error:\n      premium: |\n        <b>Tizimda Xatolik! </b>\n        Afsuski, bu funksiya faqat botni qo'llab-quvatlagan foydalanuvchilargagina mavjud.\n\n        Siz buni \\/donate buyrug'ini yuborish orqali qilishingiz mumkin.\n  original:\n    enter: |\n      Ushbu bot orqali qo'shilgan stikerni yuboring va men sizga asl nusxasini ko'rsataman.\n    error:\n      not_found: |\n        <b>Tizimda xatolik!</b>\n        Ushbu stikerning asl nusxasini topolmadim.\n  delete:\n    enter: |\n      Ushbu bot orqali qo'shilgan stikerni yuboring, men uni paketdan o'chirib tashlayman.\n    confirm: |\n      Haqiqatan ham bu stikerni oʻchirib tashlamoqchimisiz?\n    error:\n      not_found: |\n        <b>Xato!</b>\n        Stikerni topa olmadim.\n  rename:\n    enter_name: |\n      <b> <a href=\"${link}\">${title}</a>uchun yangi nom kiriting:</b>\n    success: |\n      <b>Sarlavha muvaffaqiyatli o'zgartirildi!</b>\n\n      Yangi nom: <a href=\"${link}\">${title}</a>\n    boost_notice: |\n      ❕ \"<code>${titleSuffix}</code>\" qo'shimchani olib tashlash uchun, siz qutini boost qilishingiz kerak. Batafsil ma'lumot olish uchun menyuga tashrif buyuring:\\/donate\n  packAbout:\n    enter: |\n      <b>Bu haqda ma'lumot olish uchun menga stiker yoki maxsus kulgich yuboring:</b>\n    not_found: |\n      Stikerni topa olmadim\n    result: |\n      <b>📦 Гуруҳ:</b> <a href=\"${link}\">${name}</a>\n      🆔 <code>${setId}</code> <i>(Сақлама ёкига учун махсус рақам, ўзига хос вақтида кенгайтирилади)</i>\n\n      🧑‍🎨 Сақлама эгаси ID: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 Бошқа сақламалар ушбу эгасидан:</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>Ushbu egasining boshqa stikerlari haqida bizda ma'lumot yo'q</i>\n  boost:\n    sure: |\n      <b>Haqiqatan ham <a href=\"${link}\">${title}</a> ni kuchaytirmoqchimisiz?</b>\n\n      Kuchaytirish qayta ishlashni ustuvor qilish va qayta ishlash navbatiga bir nechta stiker qo'shish imkoniyatini oshiradi\n      Kuchaytirish haqida batafsil ma'lumotni menyuda /donate orqali olishingiz mumkin\n\n      <b>Narx:</b> 1 Kredit\n      <b>Joriy balans:</b> ${balance} Kreditlar\n    btn:\n      yes: Ha, kuchaytiring!\n      no: Yo'q, bekor qiling\n    canceled: |\n      Kuchaytirish bekor qilindi\n    success: |\n      Boost muvaffaqiyatli yakunlandi!\n\n      ${title} endi kuchaytirildi\n    error:\n      not_enough_credits: |\n        Bu o'ram uchun kuchaytirish yetarli Kreditlarga ega emassiz.\n\n        Balansingizni to'ldirish uchun /donate buyrug'ini yuborishingiz mumkin.\n      already_boosted: |\n        Bu paket allaqachon kuchaytirilgan.\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>Бажаришдан гуруҳ стикерини маҳсул кўлдириш</b>\n\n        <i>Сизга тегишли бўлса, бўш майдоний стикерларни ҳам бажариш имконияти бор</i>\n      owner_proof: |\n        <b>Ushbu paketning egaligini tasdiqlash uchun siz bir necha oddiy amallarni bajarishingiz kerak:</b>\n        1. @Stickers botini oching\n        2. <code>yuboring /packstats</code> buyrug'i\n        3. Kerakli paketni toping va tanlang\n        4. Qabul qilingan xabarni botga yo'naltiring\n      publish_new_access_denied: |\n        <b>Xato!</b>\n        Bu paket sizniki emas.\n\n        Siz faqat o'zingizning paketlaringizni nashr qilishingiz mumkin\n      banned: |\n        <b>Xato!</b>\n        Sizga ushbu xususiyatdan foydalanish taqiqlangan.\n        Iltimos, administratorga murojaat qiling.\n      enter: |\n        Siz \"<a href=\"${link}\">${title}</a>\" to'plamini botimizning umumiy katalogida nashr qilmoqchisiz\n        Uni botning istalgan foydalanuvchisi nomi, teglari yoki filtri orqali topishi mumkin.\n        Shu sababli paketingizni ko'proq odamlar o'rnatadilar\n        Ko'p odamlarni qiziqtiradigan faqat yuqori sifatli paketlarni yuborishga harakat qiling\n\n        <b>Qoidalar to'plamlarni nashr qilish uchun:</b>\n        • Tor doiradagi odamlar uchun mo'ljallangan shaxsiy to'plamlaringizni nashr qilmang. Masalan, doʻstlaringizning yuzlari yoki xabarlaringizdan iqtiboslar\n        • Yevropa Ittifoqi qonunlarini yoki boshqa mahalliy qonunlarni buzuvchi stiker bosimlarini joylashtirmang\n\n        Buning uchun qoʻshimcha maʼlumotlarni taqdim etishingiz kerak boʻladi. katalogida chop etilgan\n      continue_button: Davom eting\n      enter_description: |\n        <b>To'plamingizni boshqalar topa olishi uchun qisqacha ta'riflang</b>\n\n        <i>Siz [#]</i>\n        turkumlash uchun heshteglardan ham foydalanishingiz mumkin.<i>Masalan: #anime #mem #hayvonlar #cute #kpop #kulgili #mushuk #o'yin </i>\n      select_language: |\n        <b>Paketingiz qaysi tillar uchun ekanligini tanlang:</b>\n        <i>Siz bir nechta tillarni tanlashingiz mumkin</i>\n      button_all_languages: All languages\n      button_confirm_language: Confirm\n      set_safe: |\n        <b>Sizning paketingiz foydalanuvchilar uchun xavfsizmi?</b>\n        <i>Ya'ni, unda erotik va boshqa hayratlanarli kontent mavjud emas</i>\n      button_safe:\n        safe: Yes, it is safe\n        not_safe: No, it is not safe\n      no_tags: belgilanmagan\n      confirm: |\n        <b>Гуруҳни “<a href=\"${link}\">${title}</a>” чоп этишни тасдиқлаш</b>\n\n        <b>Тавсиф:</b> <i>${description}</i>\n\n        <b>Теглар:</b> ${tags}\n        <b>Тиллар:</b> ${languages}\n      button_confirm: '✅ Confirm publication'\n      success: |\n        Tabriklaymiz, sizning paketingiz boshqa foydalanuvchilar topa oladigan botimizning umumiy katalogida chop etildi!\n\n        Paketni qidirish ma'lumotlarini /packs buyrug'i bilan to'plamni tanlash orqali tahrirlashingiz mumkin.\n\n        <i>Sizga shuni eslatib o'tamizki, paketingiz statistikasini @Stickers rasmiy boti orqali ko'rish mumkin</i>\n    unpublish:\n      success: |\n        Paket bot katalogidan muvaffaqiyatli olib tashlandi.\n  delete_pack:\n    enter: |\n      Haqiqatan ham <a href=\"${link}\">${title}</a>toʻplamini oʻchirib tashlamoqchimisiz?\n      U butunlay oʻchirib tashlanadi va uni qayta tiklab boʻlmaydi.\n\n      Agar siz faqat bitta stikerni oʻchirmoqchi boʻlsangiz, /delete buyrugʻidan foydalaning.\n\n      Ushbu paketni haqiqatan ham oʻchirib tashlamoqchi ekanligingizni tasdiqlash uchun <code>${confirm}</code> yuboring.\n    confirm: Ha, men mutlaqo aminman.\n    success: |\n      <b>Toʻplam muvaffaqiyatli oʻchirildi!</b>\n    error:\n      - <b>Xato!</b>\n      - Opps, nimadir xato ketdi.\n  frame:\n    no_video: |\n      <b>Xato!</b>\n      Siz faqat video paketlarga ramkalar qo'shishingiz mumkin.\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>Рамкани танлаш:</b>\n      Рамка - стикернинг о̄рақда майдонлари бўлими\n\n      <code>lite</code> — кўрсатмалар бир нечта кенгайтган бўлиди\n      <code>medium</code> — кўрсатмалар кенгайтирилган\n      <code>rounded</code> — кўрсатмалар кенгайишидан ўзигача кенгайтирилган\n      <code>square</code> — рамка дайриИк ко̄рсатқич, бундан ташқари у онга ўзгартирилмайди\n      <code>circle</code> — рамка ай югурт формаида\n\n      <i>Келаида, келаида рамкани тузатиш учун команда ишлаб чикарасиз</i>\n    types:\n      lite: '1. Lite'\n      medium: '2. O''rta'\n      rounded: '3. Dumaloq'\n      square: '4. Kvadrat'\n      circle: '5. Doira'\n    selected: |\n      <b>Tanlangan ramka turi:</b> ${type}\n  photoClear:\n    enter: |\n      Orqa fonni olib tashlamoqchi bo'lgan <u>rasmini</u> yuboring va men faylni fonsiz yuboraman\n\n      <i>Fotosuratlar bilan yaxshi ishlaydi. Chizmalar, rasmlar va boshqalar bilan yomonroq ishlaydi.</i>\n    enter_anime: |\n      Тусанча фонини ўчириш қисматидан уч учрамаликадан рақмий олинган фотони юборинг ва мәқул ҳолатда олишим\n    choose_model: |\n      <b>Modelni tanlang:</b>\n    web_app: WebApp - odamlar bilan suratga olish uchun\n    model:\n      ordinary: Umumiy — odamlar bilan suratga olish uchun\n      general: Umumiy - har qanday fotosuratlar uchun\n      anime: Anime — anime rasmlari uchun\n      birefnet_general: Umumiy - har qanday fotosuratlar uchun\n    add_to_set_btn: '🌟 Toʻplamga qoʻshish'\n    error: |\n      <b>Xato!</b>\n      Op, nimadir xato ketdi.\n  leave: |\n    Harakat bekor qilindi.\n  btn:\n    cancel: '❌ Bekor qilish'\nerror:\n  telegram: |\n    <b>Telegram xatolikni qaytardi!</b>\n    <code>${error}</code>\n  answerCbQuery:\n    telegram: |\n      Telegram xatolikni qaytardi:\n      ${error}\n  banned: |\n    <b>Xato!</b>\n    Sizga ushbu xususiyatdan foydalanish taqiqlangan.\n\n    <i>Agar bu xato deb o'ylasangiz, administratorga murojaat qiling: @ly_oBot</i>\n  unknown: |\n    <b>An unknown error has occurred, please try again.</b>\n\n    If the problem persists, then write to @Ly_oBot.\n    Please write immediately about which bot you are talking about and describe the problem in detail in one message.\n"
  },
  {
    "path": "locales/zh.yaml",
    "content": "---\nlanguage_name: '🇨🇳 简体中文'\nname: fStik — 贴纸和表情\ndescription:\n  long: |\n    从照片、视频和GIF创建贴纸和表情，无需手动转换 - 机器人处理一切！\n\n    功能：\n    • 包管理\n    • 视频贴纸和自定义表情\n    • 下载原始文件\n    • 转换为图片\n    • 贴纸目录\n\n    搜索贴纸: play.google.com/store/apps/details?id=app.fstik 🇺🇦\n  short: |\n    从照片、视频、GIF创建贴纸和表情。贴纸目录和搜索。🇺🇦\nratelimit: 操作太频繁了！\ncmd:\n  start:\n    enter: |\n      🧙 你好，${name}！我是表情符号和贴纸包向导。\n      我可以将照片、视频和 GIF 快速转换成炫酷的贴纸。\n\n      发送 /help 命令获取更多信息\n\n      💬 支持聊天：@fStikCommunity （仅限英语）\n    group: |\n      🧙 你好，${groupTitle}！我是表情包和贴纸包的向导。\n\n      要将贴纸添加到群组包中，请在回复照片、视频、GIF 或贴纸时使用 /ss 命令。\n    catalog: |\n      <b>😻 您可以在我们的目录中找到新的贴纸包</b>\n\n      • 单击下面的按钮并访问适合各种口味的大量贴纸包目录\n      • 按关键字或在准备好的标签中搜索\n      • 不要忘记评价以进行促销或降低贴纸包的排名\n    commands:\n      ss: '🌟 保存贴纸'\n      start: '📜 开始菜单'\n      help: '📖 帮助'\n      packs: '📁 管理包'\n      new: '🌝 创建贴纸包'\n      catalog: '📖 目录'\n      publish: '📤 出版包'\n      delete: '❌ 删除贴纸'\n      original: '🔍 查找原始贴纸'\n      restore: '🔀 恢复一个包'\n      copy: '📋 恢复一个包'\n      emoji: '📝 更改表情符号后缀'\n      round: '🎥 圆形视频'\n      clear: '🖼️ 删除照片背景'\n      about: '📦 包装信息'\n      user_about: '🧑‍🎨 创作者信息'\n      lang: '🌐 更改语言'\n      report: '🚨 报告包'\n      donate: '☕️ 支持开发人员'\n      add_to_group: '👥 添加到小组'\n      privacy: '🔒 隐私政策'\n    btn:\n      new: '📥 创建新的'\n      catalog: '💖 打开目录'\n      catalog_mini: '💖 目录'\n      catalog_browser: '🌐 在浏览器中打开'\n      catalog_browser_mini: '🌐 浏览器中'\n      catalog_app: '📱 下载安卓应用程序'\n      catalog_app_mini: '📱 安卓应用程序'\n  inline:\n    switch_pm: '📁 选择包'\n  restore: |\n    <b>🗃 包装修复</b>\n\n    如果您之前通过该机器人创建了一个软件包，但在软件包列表中找不到它，那么您可以将它恢复回来。\n\n    <b>为此，您需要执行几项操作：</b>\n    1.打开 @Stickers 机器人\n    2.发送 <code>/packstats</code> 命令\n    3.查找并选择所需的软件包\n    4.将收到的信息转发给机器人\n    5.如果恢复成功，机器人会通知相关信息\n    <b>🗄 复制包</b>\n\n    要将另一个软件包复制到新的软件包中，您只需向我发送一个贴纸或表情包的链接即可\n  copy: |\n    <b>🗄 复制包</b>\n\n    要将另一个软件包复制到新的包中，您只需向我发送一个贴纸或表情包的链接即可\n  report: |\n    <b>🚨 报告</b>\n\n    如果您发现您认为可能违法或违反 Telegram 服务条款的贴纸包，请将其链接发送至 @StickersReportBot 报告给我们。\n\n    <i>请记住，机器人不对包的内容负责，也无法控制包的内容</i>\n  packs:\n    info: |\n      <b>📁 管理包</b>\n    types:\n      regular: 贴纸\n      custom_emoji: 表情符号\n      inline: 内联\n    empty: |\n      <b>您还没有包.</b>\n      要创建，请编写命令 /new\n    inline_title: 内联包\n    select_group_pack_info: |\n      <b>📁 选择包</b>\n\n      要在群组中使用此包，管理员必须使用下方按钮进行选择\n    select_group_pack: 选择包\n  emoji:\n    info: |\n      要更改当前包的默认表情符号，请发送 <code>/emoji</code> 并用空格分隔表情符号。\n\n      例如 - <code>/emoji 🌟</code>\n    done: 表情已成功更改。\n    error: 更改表情时发生错误！\n  round_video:\n    enabled: |\n      视频现在将呈现圆形\n    disabled: |\n      视频将不再是圆形\n  paysupport: |\n    <b>👨‍💻 支付支持</b>\n\n    关于机器人操作的所有问题，包括支付和捐赠，您可以直接联系开发者\n\n    <b>联系方式:</b>\n    🧑‍💻 开发者: @ly_oBot\ndonate:\n  menu: |\n    <b>☕️ 机器人开发支持</b>\n    通过支持机器人的开发，您将获得信用点数\n\n    <b>余额：</b> <code>${balance}</code> 信用点数\n    使用 1 个信用点，您有机会提升一个包。\n\n    <b>升级可带来以下好处：</b>\n    ➖ 包名中没有 \"<code>${titleSuffix}</code>\" <i>（不在链接中）</i>\n    ➖ 标题最多64个字符（而不是35个）\n    ➖ 视频最长可达35秒\n    ➖ 优先转换队列\n    ➖ 一次添加多个贴纸\n    ➖ 没有广告\n\n    <b>请选择你想购买的信用点数：</b>\n  btn:\n    donate: '☕️ 捐赠'\n  topup: |\n    <b>请输入你想购买的积分数量：</b>\n  invalid_amount: |\n    <b>无效金额</b>\n\n    最低金额是 1 个信用点\n  paymenu: |\n    您要购买 <b>${amount} 个信用点</b>，价格为 <b>${price}$</b>\n\n    ⚠️ 信用点由管理员手动发放。\n    等待时间从 5 分钟到 1 小时不等\n\n    <u>请选择支付方式:</u>\n  description: |\n    购买信用点支持机器人的开发，并获得使用附加功能的机会\n  update: |\n    <b>🔄 余额更新</b>\n\n    余额: <code>${balance}</code> 信用点(添加了 <code>${amount}</code> 信用点)\n  error:\n    already_donated: |\n      您已经为此支付获得了信用点\n    error: |\n      <b>错误!</b>\\n处理支付时出错\n    canceled: |\n      支付已取消\ncoedit:\n  info: |\n    <b>👥 联合编辑 </b>\n\n    共同编辑链接 <a href=\"${link}\">${title}</a>: <code>${colink}</code>\n\n    <b>如何使用</b>\n    1.将链接发送给您想让其访问软件包的人\n    2.点击链接后，他们需要按 \"开始\"，然后就会被添加到编辑器中\n    3.编辑器可以添加、删除和编辑软件包中的贴纸\n\n    <b>Editors:</b>\n    ${editors}\n\n    <i>要删除编辑器，您需要重新设置链接</i>\n  no_editors: |\n    尚无编辑\n  btn:\n    send: '📤 发送链接'\n    reset: '🔁 重置链接'\n  share: |\n    点击链接并按下 \"开始\"，共同编辑\"${title}\"软件包\n  reset: |\n    <b>🔁 链路重置成功</b>\n\n    共同编辑的新链接 <a href=\"${link}\">${title}</a>:\n    <code>${colink}</code>\ncallback:\n  pack:\n    answerCbQuer:\n      not_found: 未找到包\n      not_owner: 这不是你的贴纸包\n      hidden: 包成功隐藏\n      restored: 成功恢复包\n    set_pack: |\n      🌟 已选 <a href=\"${link}\">${title}</a> 打包\n\n      <b>❔ 如何添加？</b>\n      发送照片、视频或贴纸以添加到包中\n    set_inline_pack: |\n      选择了<u>${title}</u>包\n\n      要使用它，请在任何聊天内容中写入 <code> @${botUsername} </code> 和空格。\n      您也可以按下面的按钮使用它\n    boost:\n      info: |\n        \\n⚡ <b><a href=\"https://t.me/${botUsername}?start=boost\">加速</a></b>: ${boostStatus}\n      status:\n        on: 已启用\n        off: 未启用\n    hidden: 将 <a href=\"${link}\">${title} </a> 从列表中隐藏。\n    restored: 将 <a href=\"${link}\">${title}</a> 还原到您的列表。\n    btn:\n      hide: '❌ 隐藏包'\n      delete: '🗑删除包'\n      restore: '✅ 恢复'\n      use_pack: '📦 使用包'\n      boost: '⚡ 提升'\n      frame: '🖼 边框'\n      rename: '✏️重命名'\n      search_gif: '🔎 搜索 GIF'\n      coedit: '👥 共同编辑'\n      catalog_add: '🗂 添加到目录'\n      catalog_edit: '📝 在目录中编辑'\n      catalog_delete: '🗑 从目录中删除'\n      catalog_share: '🔗️️ 分享'\n      catalog_open: '📂 在目录中打开'\n    error:\n      not_found: |\n        错误！\n        找不到标签。\n      invalid_png: |\n        错误！\n        文件不是有效的PNG图像。请在发送前将其转换为PNG格式。\n      invalid_dimensions: |\n        错误！\n        贴纸尺寸无效。贴纸必须为 <b>512</b>x<b>512</b> 像素。\n      invalid_animated: |\n        错误！\n        动画标签文件的格式不正确，应为TGS格式。\n      invalid_video: |\n        错误！\n        视频文件的格式不正确，应为WEBM格式。\n      restore: |\n        错误！\n        无法还原包。\n      copy: |\n        错误！\n        找不到包。\n    select_group:\n      success: |\n        包<a href=\"${link}\">${title}</a>已成功为群组选择。\n      access_rights:\n        add: 谁可以添加贴纸到群组包？\n        delete: 谁可以从群组包中删除贴纸？\n        rights:\n          all: 所有人\n          admins: 只有管理员\n      error: |\n        错误！\n        未找到集合。\n  sticker:\n    answerCbQuery:\n      delete: 贴纸已成功从包中取出。\n      restored: 贴纸已成功保存到当前包。\n    delete: 贴纸已成功从包中取出。\n    restored: 贴纸已成功保存到当前包。\n    btn:\n      delete: '🗑 删除'\n      copy: '🌟 复制'\n      restore: '✅ 恢复'\n    error:\n      not_found: |\n        错误！\n        找不到标签。\n      invalid_png: |\n        <b>错误！</b>\n        该文件不是有效的PNG图像。请将其转换为PNG格式后再发送。\n      invalid_dimensions: |\n        <b>错误！</b>\n        贴纸尺寸无效。贴纸必须是512x512像素。\n      invalid_animated: |\n        <b>错误！</b>\n        动画贴纸文件格式不正确，应该为TGS格式。\n      invalid_video: |\n        <b>错误！</b>\n        视频文件格式不正确，应为WEBM格式。\n  group_settings:\n    success: |\n      群组设置已成功更新。\nsticker:\n  add:\n    ok: |\n      <b>成功添加到包中：</b>\n      <a href=\"${link}\">${title}</a>\n\n      1小时内，此包将更新给所有用户。\n\n      <i>发送一个或多个与贴纸匹配的emoji，如果您想要添加它们</i>\n    ok_inline: |\n      <b>成功添加到包装中：</b>\n      <u>${title}</u>\n    send_emoji: 太好了，现在发送与之匹配的表情\n    converting_process: |\n      <b>等待...</b>\n      你的文件正在等待转换。等待完成。这可能需要一些时间。\n\n      进度: ${progress} / ${total}\n\n      <i>支持机器人的用户在队列中获得优先级 (更多: /donate)</i>\n    catalog_offer: |\n      <b>😲 哇，你做了一个很棒的包！</b>\n\n      您是否希望将 <a href=\"${link}\">${title}</a> 添加到公共贴纸目录中，以便机器人的其他用户也能看到？\n      <i>无需花费大量时间</i>\n    quote: |\n      使用 @QuotlyBot 从此消息创建引用\n    error:\n      reply: |\n        <b>错误！</b>\n        请回复贴纸。\n      no_selected_pack: |\n        <b>您没有选择包</b>\n\n        请创建 (/new) 或选择 (/packs) 包\n      no_selected_group_pack: |\n        <b>您没有选择群组包</b>\n\n        请使用 /packs 命令选择一个包\n      no_rights: |\n        <b>错误！</b>\n        您没有权限添加贴纸到此包。\n      stickers_too_much: |\n        该包中的贴纸数量最多。\n\n        您可以使用 /new 命令创建新包\n      have_already: |\n        <b>该贴纸已在包中</b>\n\n        如果您想更改表情符号，请在以下信息中发送。\n      stickerset_invalid: |\n        <b>错误！</b>\n        Bot 无法访问您当前选择的包。\n\n        请创建 (/new) 或选择 (/packs) 另一个包。\n      invalid_png: |\n        <b>错误！</b>\n        该文件不是有效的PNG图像。请将其转换为PNG格式后再发送。\n      invalid_dimensions: |\n        <b>错误！</b>\n        贴纸尺寸无效。贴纸必须是512x512像素。\n      invalid_animated: |\n        <b>错误！</b>\n        动画贴纸文件格式不正确，应该为TGS格式。\n      invalid_video: |\n        <b>错误！</b>\n        视频文件格式不正确，应为WEBM格式。\n      file_type:\n        static: |\n          <b>错误！</b>\n          不支持该文件类型\n          您可以将这张照片或静态贴纸添加到静态包中\n\n          <i>创建 (/new) 或选择 (/packs) 另一个包</i>\n        video: |\n          <b>错误！</b>\n          不支持该文件类型\n          您可以将这些视频文件添加到视频包中\n\n          <i>创建 (/new) 或选择 (/packs) 另一个包</i>\n        animated: |\n          <b>错误！</b>\n          不支持该文件类型\n          您可以将该动画文件添加到矢量包中\n\n          <i>创建 (/new) 或选择 (/packs) 另一个包</i>\n        unknown: |\n          <b>错误！</b>\n          不支持该文件类型\n\n          <i>创建 (/new) 或选择 (/packs) 另一个包</i>\n      wait_load: |\n        <b>等等！</b>\n\n        Bot 仍在处理之前的文件...\n        您可以支持 bot 开发 (/donate) 以提高处理的优先级和向队列添加多个贴纸的能力。\n      timeout: |\n        <b>目前，机器人正在经历巨大的负载</b>\n        因此，视频转换只适用于激活了提升功能的包\n\n        如需了解更多详情，请访问 /donate\n      convert: |\n        <b>错误！</b>\n        不幸的是，机器人无法转换您的视频。\n\n        也许您的视频是以机器人无法理解的格式保存的。确保它是 mp4 格式。\n        也可能是bot内部错误，重新发送这个视频试试。\n      too_big: |\n        <b>错误！</b>.\n\n        文件太大，无法处理。请在发送前降低质量和持续时间。\n      sticker_not_found: |\n        <b>错误！</b>\n\n        未找到此贴纸。请确保它在正确的包中或尝试重新添加。\nnews:\n  join: |\n    ℹ️ <a href=\"${link}\">加入我们的频道</a> 获取有关机器人的最新消息。\n\n    <i>订阅频道，获取有关机器人的最新消息以及更新和新功能。</i>\n  join_btn: '📢 加入频道'\n  not_joined: '🙅 您尚未订阅该频道'\n  continue: '✅ 继续'\nuserAbout:\n  help: |\n    <b>🧑‍🎨 关于用户</b>\n\n    通过该菜单，您可以找到有关用户及其贴纸包的信息\n\n    要获取用户信息，请使用下面的按钮或转发他的信息\n  result: |\n    <b>🧑‍🎨 用户信息</b>\n    <b>🆔 用户 ID:</b> <code>${userId}</code>\n    <b>🎨 该用户提供的包：</b>\n    ${packs}\n  no_packs: |\n    <i>我们没有关于该业主贴纸的信息</i>\n  forward_hidden: |\n    该用户隐藏了转发消息的功能。使用下面的按钮查看他的贴纸包。\n  select_user: '🧑‍🎨 选择用户'\nscenes:\n  new_pack:\n    pack_type: |\n      <b><u>选择包装类型</u></b>\n    regular: '😊 贴纸'\n    custom_emoji: '🌟 表情符号（高级）'\n    static: '🌟 静态'\n    animated: '✨ 矢量'\n    video: '📹 视频'\n    pack_format: |\n      <b><u>选择包类型</u></b>\n\n      <b>常见问题</b> - 静态（不移动），光栅，文件格式 - 添加前为 PNG（机器人正在处理），添加后为 WEBP。\n      常规包示例 - t.me/addstickers/Animals\n\n      <b>视频</b> - 动画视频包。您可以添加任何视频、gif 和照片。\n      视频示例包 - t.me/addstickers/TheMascot\n\n      <b>Animated</b> - 动画，矢量（它们对文件中的对象有精确的描述，因此在任何比例下都能清晰显示），文件格式 - TGS，Lottie 格式的一种变体。\n      动画包示例 - t.me/addstickers/IsabelleShizue\n\n      <i>动画和视频贴纸套装最多可有 50 个贴纸。静态贴纸套装最多可有 120 个贴纸。\n    pack_title: |\n      <b>为新贴纸输入名称：</b>\n      <i>您也可以在下面选择一个随机生成的名称。</i>\n    pack_name: |\n      <b>输入新贴纸包的短链接：</b>\n\n      <i>例如，此包使用“动物”作为短链接：https://t.me/addstickers/<u>动物</u></i>\n      <i>您可以在按钮上选择随机短链接。</i>\n    ok: |\n      包 <a href=\"${link}\">${title}</a> 创建成功！\n\n      <b>软件包链接：</b> <pre>${link}</pre>\n\n      发送文件、照片、视频或贴纸，以便我将其添加到您的套装中\n    error:\n      title_long: 名称字符数不得超过 ${max} 字符\n      name_long: 地址长度不得超过 ${max} 字符。\n      telegram:\n        name_invalid: 您不能使用这样的地址。\n        name_occupied: 该地址已被占用。\n        upload_failed: |\n          <b>错误！</b>\n          机器人无法将贴纸上传到 Telegram。\n\n          请稍后再试。\n  copy:\n    enter: |\n      我可以复制它，但在此之前，让我们创建一个新的包\n    progress: |\n      将包从 <a href=\"${originalLink}\">${originalTitle}</a> 复制到 <a href=\"${link}\">${title}</a>\n\n      进度： ${current}/${total}\n    done: |\n      成功完成从 <a href=\"${originalLink}\">${originalTitle}</a> 到 <a href=\"${link}\">${title}</a> 的打包复制。\n    pay: |\n      <b>包转换</b>\n\n      将包从一种类型转换为另一种类型需要 1 个信用点\n\n      <b>当前余额:</b> ${balance} 个信用点\n\n      购买信用点: /donate\n    pay_btn: '✅ 确认'\n    error:\n      premium: |\n        <b>错误！</b>\n        此功能仅用于捐赠成员。\n\n        您可以通过发送/捐赠命令来做到这一点。\n  original:\n    enter: |\n      发送一个通过该机器人添加的贴纸，我会给你看原件。\n    error:\n      not_found: |\n        <b>错误！</b>\n        我没有找到这张贴纸的原件。\n  delete:\n    enter: |\n      发送通过此机器人添加的贴纸，我将把它从包中删除。\n    confirm: |\n      您确定要删除这个贴纸吗？\n    error:\n      not_found: |\n        <b>错误！</b>\n        我找不到贴纸。\n  rename:\n    enter_name: |\n      <b>输入新标题r <a href=\"${link}\">${title}</a>:</b>\n    success: |\n      <b>标题已成功更改！</b>\n\n      新标题<a href=\"${link}\">${title}</a>\n    boost_notice: |\n      ❕ 要删除后缀 \"<code>${titleSuffix}</code>\", 您需要提升包。更多详情请访问：\\/donate\n  packAbout:\n    enter: |\n      <b>给我发送一个贴纸或自定义表情符号，以查找相关信息：</b>\n    not_found: |\n      我找不到贴纸\n    result: |\n      <b>📦 包：</b> <a href=\"${link}\">${name}</a>\n      🆔 <code>${setId}</code> <i>(拥有者包的唯一编号，每包递增)）</i>\n\n      🧑‍🎨 用户: <code>${ownerId}</code>\n      ${mention}\n\n      <b>🎨 该用户的其他包</b>\n      ${otherPacks}\n    no_other_packs: |\n      <i>我们没有该用户其他贴纸的信息</i>\n  boost:\n    sure: |\n      <b>您确定要提升 <a href=\"${link}\">${title}</a> 吗？</b>\n\n      提升将增加处理优先级并且能够将多个贴纸添加到队列中\n      您可以通过访问菜单中的 /donate 获取更多有关提升的详细信息\n\n      <b>价格:</b> 1 个信用点\n      <b>当前余额:</b> ${balance} 个信用点\n    btn:\n      yes: 是，提升！\n      no: 否，取消\n    canceled: |\n      提升已取消\n    success: |\n      提升成功完成！\n\n      ${title} 现在得到提升\n    error:\n      not_enough_credits: |\n        您的信用点不足以提升此包。\n\n        您可以通过发送 /donate 命令来充值余额。\n      already_boosted: |\n        这个包已经提升.\n  catalog:\n    publish:\n      publish_new: |\n        👌 <b>将您要发布的包中的贴纸发给我</b>\n\n        <i>您可以发布任何属于您的包，即使它们是在其他地方创建的</i>\n      owner_proof: |\n        <b>要验证此数据包的所有权，您需要遵循几个简单的步骤：</b>\n        1.打开 @Stickers 机器人\n        2.发送 <code>/packstats</code> 命令\n        3.查找并选择所需的软件包\n        4.将收到的信息转发给机器人\n      publish_new_access_denied: |\n        <b>错误！</b>\n        这个包不是你的。\n\n        您只能发布自己的包\n      banned: |\n        <b>错误！</b>\n        您已被禁止使用此功能。\n        请联系管理员。\n      enter: |\n        您即将在我们机器人的公共目录中发布\"<a href=\"${link}\">${title}</a>\"包\n        机器人的任何用户都可以通过名称、标签或过滤器找到它\n        因此，会有更多人安装您的软件包\n        尽量只发送高质量的软件包，这样会有更多人感兴趣\n\n        <b>发布软件包的规则：</b>\n        - 不要发布面向小范围人群的个人软件包。例如，您朋友的面孔或您信息中的引语。\n        - 不要发布违反欧盟法律或其他地方法律的贴纸压力。\n\n        您需要提交其他信息才能在目录中发布\n      continue_button: 继续\n      enter_description: |\n        <b>简要描述您的背包，以便他人找到它</b>\n\n        <i>您还可以使用标签对 [#]</i> 进行分类\n        <i>例如#anime #meme #animals #cute #kop #funny #cat #game </i>\n      select_language: |\n        <b>选择您的软件包适用的语言：</b>\n        <i>您可以选择多种语言</i>\n      button_all_languages: 所有语言\n      button_confirm_language: 确认\n      set_safe: |\n        <b>您的软件包对用户安全吗？\n        <i>即不包含色情和其他令人震惊的内容</i>\n      button_safe:\n        safe: 是的，它是没有的\n        not_safe: 不，它有的\n      no_tags: 不确定\n      confirm: |\n        <b>确认出版\"<a href=\"${link}\">${title}</a>\"</b> 包\n\n        <b>描述：</b> <i>${description}</i>\n\n        <b>标签：</b> ${tags}\n        <b>语言：</b> ${languages}\n      button_confirm: '✅ 确认发布'\n      success: |\n        恭喜您，您的软件包已发布在我们机器人的总目录中，其他用户可以在其中找到！\n\n        使用_/packs_命令选择数据包，即可编辑数据包搜索信息。\n\n        <i>我们提醒您，可以通过官方机器人 @Stickers 查看包的统计数据</i>\n    unpublish:\n      success: |\n        该数据包已成功从机器人目录中取消发布。\n  delete_pack:\n    enter: |\n      您确定要删除数据包 <a href=\"${link}\">${title}</a> 吗？\n      它将被永久删除，无法恢复。\n\n      如果只想删除一个贴纸，请使用 /delete 命令。\n\n      发送 <code>${confirm}</code> 以确认您真的要删除此贴包。\n    confirm: 是的，我完全肯定。\n    success: |\n      <b>成功删除软件包！</b>\n    error:\n      - <b>错误！</b>\n      - 哎呀，出错了。\n  frame:\n    no_video: |\n      <b>错误！</b>\n      您只能为视频包添加帧。\n    select_type: |\n      <a href=\"${example}\">&#8203;</a><b>选择边框类型：</b>\n      边框是贴纸周围的透明背景\n\n      <code>lite</code> - 边角将稍作修剪\n      <code>medium</code> - 将更多地剪切边角\n      <code>rounded</code> - 边角将被磨圆\n      <code>square</code> - 边框的矩形形状，即不会以任何方式改变。\n      <code>circle</code> - 边框将呈圆形\n\n      <i>今后，您可以使用 /frame 命令来设置边框类型</i>\n    types:\n      lite: '1. 精简版'\n      medium: '2. 中等'\n      rounded: '3. 圆形'\n      square: '4. 方形'\n      circle: '5. 圆圈'\n    selected: |\n      <b>所选边框类型：</b> ${type}\n  photoClear:\n    enter: |\n      发送一张您想去掉背景的照片<u></u>，我将发送没有背景的文件。\n\n      <i>对照片效果最佳。对图画、插图等效果较差</i>。\n    enter_anime: |\n      发送一张您想去掉背景的照片<u></u>，我将发送没有背景的文件。\n\n      <i>它对动漫图片最有效</i>\n    choose_model: |\n      <b>选择型号：</b>\n    web_app: WebApp - 用于与人合影\n    model:\n      ordinary: 普通 - 用于与人合影\n      general: 一般 - 任何照片\n      anime: 动漫 - 用于动漫图片\n      birefnet_general: 一般 - 任何照片\n    add_to_set_btn: '🌟 添加到设置'\n    error: |\n      <b>错误！</b>\n      哎呀，出错了。\n  leave: |\n    行动取消。\n  btn:\n    cancel: '❌ 取消'\nerror:\n  telegram: |\n    <b>电报返回错误！</b>\n    <code>${error}</code>\n  answerCbQuery:\n    telegram: |\n      Telegram 返回错误信息：\n      ${error}\n  banned: |\n    <b>错误！</b>\n    您被禁止使用此功能。\n\n    <i>如果您认为这是一个错误，请联系管理员：@ly_oBot</i>\n  unknown: |\n    <b>出现未知错误，请再试一次。</b>\n\n    如果问题仍然存在，请写到 @Ly_oBot。\n    请立即写出你谈论的机器人并在一个消息中详细描述问题。\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"fstikbot\",\n  \"version\": \"1.27.2\",\n  \"description\": \"\",\n  \"main\": \"index.js\",\n  \"scripts\": {\n    \"start\": \"node index.js\",\n    \"lint\": \"node_modules/.bin/eslint --ext js .\",\n    \"lint:fix\": \"node_modules/.bin/eslint --fix --ext js .\",\n    \"banners:build\": \"node banners/build.js\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/LyoSU/fStikBot.git\"\n  },\n  \"author\": \"LyoSU\",\n  \"license\": \"SEE LICENSE IN LICENSE\",\n  \"bugs\": {\n    \"url\": \"https://github.com/LyoSU/fStikBot/issues\"\n  },\n  \"homepage\": \"https://github.com/LyoSU/fStikBot#readme\",\n  \"dependencies\": {\n    \"@pm2/io\": \"^6.1.0\",\n    \"bull\": \"^4.7.0\",\n    \"dotenv\": \"^16.4.7\",\n    \"emoji-regex\": \"^10.2.1\",\n    \"error-stack-parser\": \"^2.0.6\",\n    \"got\": \"^11.8.6\",\n    \"ioredis\": \"^4.28.5\",\n    \"limax\": \"^4.1.0\",\n    \"moment\": \"^2.30.1\",\n    \"mongoose\": \"^5.13.22\",\n    \"node-cron\": \"^3.0.2\",\n    \"openai\": \"^4.65.0\",\n    \"sharp\": \"^0.32.6\",\n    \"stegcloak\": \"^0.0.1\",\n    \"sticker-pack-names\": \"^1.0.0\",\n    \"telegraf\": \"^3.40.0\",\n    \"telegraf-i18n\": \"^6.5.0\",\n    \"telegraf-ratelimit\": \"^2.0.0\",\n    \"telegram\": \"^2.15.15\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^8.57.0\",\n    \"eslint-config-standard\": \"^17.1.0\",\n    \"eslint-plugin-import\": \"^2.29.1\",\n    \"eslint-plugin-n\": \"^16.6.2\",\n    \"eslint-plugin-promise\": \"^6.1.1\",\n    \"puppeteer\": \"^24.41.0\"\n  }\n}\n"
  },
  {
    "path": "privacy.html",
    "content": "<b>🔒 @fStikBot Privacy Policy</b>\n\nHello! I care about your privacy. Here's a quick overview of my privacy policy:\n\n• 👤 I only collect data necessary for the bot's operation\n• 🌐 Created sticker packs are public within Telegram\n• 🔐 Your data is protected, but remember the limitations of an individual project\n• ✅ You have the right to access, correct, and delete your data\n• 🔞 The bot is not intended for users under 18\n\n<b>You can read the full privacy policy</b> here:\nhttps://telegra.ph/fStikBot-privacy-08-15\n\nBy using @fStikBot, you agree to this policy.\n\nIf you have any questions or concerns about privacy, please contact me via @Ly_OBot.\n\nThank you for trusting @fStikBot! 🙏\n"
  },
  {
    "path": "scenes/admin-pack-bulk-delete.js",
    "content": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst { escapeHTML } = require('../utils')\n\nconst adminPackBulkDelete = new Scene('adminPackBulkDelete')\n\nadminPackBulkDelete.enter(async (ctx) => {\n  const welcomeText = `\nBulk Delete Sticker Packs\n\nThis tool allows you to delete multiple sticker packs and custom emoji sets based on the links provided in your message.\n\n⚠️ Warning: This action is irreversible. Use with caution.\n\nTo proceed, please send me a message containing links to the sticker packs you want to delete.\nThe links can be visible or hidden in the message entities.\nOr click \"Cancel\" to go back.\n  `\n\n  const replyMarkup = Markup.inlineKeyboard([\n    [Markup.callbackButton('❌ Cancel', 'admin:pack:bulk_delete:cancel')]\n  ])\n\n  await ctx.replyWithHTML(welcomeText, { reply_markup: replyMarkup })\n})\n\nadminPackBulkDelete.on('message', async (ctx) => {\n  const message = ctx.message\n  const entities = message.entities || message.caption_entities || []\n  const text = message.text || message.caption || ''\n\n  const links = new Set()\n\n  // Extract links from visible text\n  const visibleLinks = text.match(/https?:\\/\\/t\\.me\\/addstickers\\/\\w+/g) || []\n  visibleLinks.forEach(link => links.add(link))\n\n  // Extract links from entities\n  entities.forEach(entity => {\n    if (entity.type === 'text_link') {\n      if (entity.url.startsWith('https://t.me/addstickers/')) {\n        links.add(entity.url)\n      }\n    } else if (entity.type === 'url') {\n      const url = text.slice(entity.offset, entity.offset + entity.length)\n      if (url.startsWith('https://t.me/addstickers/')) {\n        links.add(url)\n      }\n    }\n  })\n\n  if (links.size === 0) {\n    return ctx.replyWithHTML('❌ No valid sticker pack links found in your message. Please try again with valid links.')\n  }\n\n  const stickerSetNames = Array.from(links).map(link => link.split('/').pop())\n\n  const confirmText = `\nFound ${stickerSetNames.length} sticker pack(s) in your message:\n\n${stickerSetNames.map(name => `• ${escapeHTML(name)}`).join('\\n')}\n\nAre you sure you want to delete all these packs?\n\n⚠️ This action cannot be undone!\n  `\n\n  const replyMarkup = Markup.inlineKeyboard([\n    [\n      Markup.callbackButton('✅ Yes, delete all', 'admin:pack:bulk_delete:confirm'),\n      Markup.callbackButton('❌ No, cancel', 'admin:pack:bulk_delete:cancel')\n    ]\n  ])\n\n  ctx.session.stickerSetsToDelete = stickerSetNames\n\n  await ctx.replyWithHTML(confirmText, { reply_markup: replyMarkup })\n})\n\nadminPackBulkDelete.action('admin:pack:bulk_delete:confirm', async (ctx) => {\n  const stickerSetNames = ctx.session.stickerSetsToDelete\n\n  if (!stickerSetNames || stickerSetNames.length === 0) {\n    return ctx.answerCbQuery('❌ No sticker sets to delete. Operation cancelled.', true)\n  }\n\n  let deletedCount = 0\n  let errorCount = 0\n\n  for (const setName of stickerSetNames) {\n    try {\n      const stickerSet = await ctx.telegram.getStickerSet(setName)\n      for (const sticker of stickerSet.stickers) {\n        await ctx.telegram.deleteStickerFromSet(sticker.file_id).catch(() => {})\n      }\n      deletedCount++\n    } catch (error) {\n      console.error(`Error deleting sticker set ${setName}:`, error)\n      errorCount++\n    }\n  }\n\n  const resultText = `\nOperation completed:\n✅ Successfully deleted: ${deletedCount} pack(s)\n❌ Failed to delete: ${errorCount} pack(s)\n\nTotal packs processed: ${stickerSetNames.length}\n  `\n\n  await ctx.answerCbQuery()\n  await ctx.replyWithHTML(resultText)\n  delete ctx.session.stickerSetsToDelete\n  return ctx.scene.leave()\n})\n\nadminPackBulkDelete.action('admin:pack:bulk_delete:cancel', async (ctx) => {\n  await ctx.answerCbQuery('Operation cancelled')\n  delete ctx.session.stickerSetsToDelete\n  return ctx.scene.leave()\n})\n\nadminPackBulkDelete.on('callback_query', async (ctx) => {\n  await ctx.answerCbQuery('Unknown action')\n})\n\nmodule.exports = adminPackBulkDelete\n"
  },
  {
    "path": "scenes/admin-pack.js",
    "content": "const Markup = require('telegraf/markup')\nconst Scene = require('telegraf/scenes/base')\nconst { escapeHTML } = require('../utils')\n\nconst adminPackFind = new Scene('adminPackFind')\n\nadminPackFind.enter(async (ctx) => {\n  const welcomeText = `\n<b>Welcome to the Admin Sticker Pack Management!</b>\n\nTo manage a sticker pack or custom emoji set, please send me:\n• A sticker from the pack\n• A custom emoji from the set\n• The pack's share URL (e.g., https://t.me/addstickers/packname or https://t.me/addemoji/setname)\n• Or simply the pack/set name\n\nI'll help you view, edit, or remove the pack/set.\n  `\n\n  const replyMarkup = Markup.inlineKeyboard([\n    [Markup.callbackButton('🏠 Back to Admin Menu', 'admin:menu')]\n  ])\n\n  await ctx.replyWithHTML(welcomeText, {\n    reply_markup: replyMarkup\n  }).catch(() => {})\n})\n\nadminPackFind.on(['sticker', 'text', 'custom_emoji'], async (ctx) => {\n  const { sticker, text, custom_emoji } = ctx.message\n  let packName\n\n  if (sticker) {\n    packName = sticker.set_name\n  } else if (custom_emoji) {\n    packName = custom_emoji.set_name\n  } else if (text) {\n    const urlMatch = text.match(/(?:addstickers|addemoji)\\/(.+)/)\n    if (urlMatch) {\n      packName = urlMatch[1]\n    } else {\n      packName = text.trim()\n    }\n  }\n\n  if (!packName) {\n    return ctx.replyWithHTML('❌ Invalid input. Please send a sticker, custom emoji, pack URL, or pack name.')\n  }\n\n  let stickerSet\n  try {\n    stickerSet = await ctx.telegram.getStickerSet(packName)\n  } catch (firstErr) {\n    // Pack not found as a sticker set — try emoji set lookup before giving up.\n    try {\n      const customEmojiStickers = await ctx.telegram.getCustomEmojiStickers([packName.split('_')[0]])\n      if (customEmojiStickers?.length > 0) {\n        stickerSet = {\n          name: packName,\n          title: 'Custom Emoji Set',\n          is_emoji: true,\n          stickers: customEmojiStickers\n        }\n      }\n    } catch (secondErr) {\n      console.error('admin-pack: emoji set lookup failed:', secondErr.message)\n    }\n  }\n\n  if (!stickerSet) {\n    return ctx.replyWithHTML(`❌ Pack/emoji set <code>${escapeHTML(packName)}</code> not found. Check the name and try again.`)\n  }\n\n  if (packName.split('_').pop() !== ctx.options.username) {\n    return ctx.replyWithHTML('⚠️ This pack/set is not managed by this bot. You can only manage packs/sets created with this bot.')\n  }\n\n  let info\n  try {\n    info = await ctx.db.StickerSet.findOne({ name: packName })\n  } catch (dbErr) {\n    console.error('admin-pack: DB lookup failed:', dbErr.message)\n    return ctx.replyWithHTML('❌ Database error while fetching pack info. Try again later.')\n  }\n\n  ctx.session.admin = { editPack: stickerSet, info }\n  await ctx.scene.enter('adminPackEdit')\n})\n\nconst adminPackEdit = new Scene('adminPackEdit')\n\nadminPackEdit.enter(async (ctx) => {\n  const { editPack, info } = ctx.session.admin\n\n  if (!editPack) {\n    return ctx.scene.enter('adminPackFind')\n  }\n\n  const packOwner = await ctx.db.User.findById(info?.owner)\n  const resultText = `\n<b>${editPack.is_emoji ? 'Custom Emoji Set' : 'Sticker Pack'} Details:</b>\n\n📦 Name: <code>${escapeHTML(editPack.name)}</code>\n🏷 Title: ${escapeHTML(editPack.title)}\n👤 Owner: <a href=\"tg://user?id=${packOwner?.telegram_id}\">${escapeHTML(packOwner?.first_name)}</a>\n🖼 ${editPack.is_emoji ? 'Emojis' : 'Stickers'}: ${editPack.stickers.length}\n\nWhat would you like to do with this ${editPack.is_emoji ? 'set' : 'pack'}?\n  `\n\n  const replyMarkup = Markup.inlineKeyboard([\n    [\n      Markup.callbackButton('🔄 Change Owner', 'admin:pack:edit:change_owner'),\n      Markup.callbackButton('🗑 Remove', 'admin:pack:edit:remove')\n    ],\n    [Markup.callbackButton('🔙 Back to Search', 'admin:pack:find')]\n  ])\n\n  await ctx.replyWithHTML(resultText, { reply_markup: replyMarkup }).catch(() => {})\n})\n\nadminPackEdit.action('admin:pack:edit:change_owner', async (ctx) => {\n  await ctx.answerCbQuery()\n  await ctx.replyWithHTML('👤 To change the owner, please send me the Telegram ID of the new owner.')\n  ctx.scene.state.awaitingNewOwner = true\n})\n\nadminPackEdit.on('text', async (ctx) => {\n  if (ctx.scene.state.awaitingNewOwner) {\n    const newOwnerId = ctx.message.text.trim()\n    const newOwner = await ctx.db.User.findOne({ telegram_id: newOwnerId })\n\n    if (!newOwner) {\n      return ctx.replyWithHTML('❌ User not found. Please check the ID and try again.')\n    }\n\n    const { info } = ctx.session.admin\n    info.owner = newOwner._id\n    await info.save()\n\n    await ctx.replyWithHTML(`✅ ${info.is_emoji ? 'Set' : 'Pack'} owner has been changed to <a href=\"tg://user?id=${newOwner.telegram_id}\">${escapeHTML(newOwner.first_name)}</a>`)\n    ctx.scene.state.awaitingNewOwner = false\n    return ctx.scene.reenter()\n  }\n})\n\nadminPackEdit.action('admin:pack:edit:remove', async (ctx) => {\n  const { editPack } = ctx.session.admin\n\n  const confirmText = `\n⚠️ <b>Warning: ${editPack.is_emoji ? 'Custom Emoji Set' : 'Sticker Pack'} Removal</b>\n\nYou are about to remove the ${editPack.is_emoji ? 'set' : 'pack'} \"${escapeHTML(editPack.title)}\".\nThis action cannot be undone.\n\nAre you sure you want to proceed?\n  `\n\n  const replyMarkup = Markup.inlineKeyboard([\n    [\n      Markup.callbackButton('✅ Yes, remove', 'admin:pack:edit:remove:confirm'),\n      Markup.callbackButton('❌ No, cancel', 'admin:pack:edit:remove:cancel')\n    ]\n  ])\n\n  await ctx.editMessageText(confirmText, {\n    parse_mode: 'HTML',\n    reply_markup: replyMarkup\n  }).catch(() => {})\n})\n\nadminPackEdit.action('admin:pack:edit:remove:confirm', async (ctx) => {\n  const { editPack } = ctx.session.admin\n\n  try {\n    const stickerSet = await ctx.telegram.getStickerSet(editPack.name)\n\n    for (const sticker of stickerSet.stickers) {\n      await ctx.telegram.deleteStickerFromSet(sticker.file_id).catch(() => {})\n      await ctx.db.Sticker.deleteOne({ fileUniqueId: sticker.file_unique_id })\n    }\n\n    await ctx.answerCbQuery(`✅ ${editPack.is_emoji ? 'Custom emoji set' : 'Sticker pack'} has been successfully removed`, true)\n    await ctx.replyWithHTML(`✅ The ${editPack.is_emoji ? 'custom emoji set' : 'sticker pack'} \"${escapeHTML(editPack.title)}\" has been removed.`)\n    return ctx.scene.enter('adminPackFind')\n  } catch (error) {\n    console.error('Error removing sticker pack or custom emoji set:', error)\n    await ctx.answerCbQuery('❌ There was an error removing the pack/set', true).catch(() => {})\n    await ctx.replyWithHTML('❌ An error occurred while removing the pack/set. Please try again later.')\n  }\n})\n\nadminPackEdit.action('admin:pack:edit:remove:cancel', async (ctx) => {\n  await ctx.answerCbQuery('Operation cancelled')\n  return ctx.scene.reenter()\n})\n\nadminPackEdit.action('admin:pack:find', async (ctx) => {\n  await ctx.answerCbQuery()\n  return ctx.scene.enter('adminPackFind')\n})\n\nmodule.exports = [\n  adminPackFind,\n  adminPackEdit\n]\n"
  },
  {
    "path": "scenes/donate.js",
    "content": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst mongoose = require('mongoose')\nconst { replyOrEditBanner } = require('../banners')\n\n// Regional pricing tiers\nconst PRICING_TIERS = {\n  // Tier 1 - Premium regions (×1.3)\n  tier1: ['en', 'de', 'fr', 'ja'],\n  // Tier 2 - Standard regions (×1.0)\n  tier2: ['es', 'pt', 'tr', 'ar', 'zh'],\n  // Tier 3 - Economy regions (×0.6)\n  tier3: ['ru', 'uk', 'uz', 'kk', 'id', 'be', 'hy', 'az']\n}\n\nconst TIER_MULTIPLIERS = {\n  tier1: 1.3,\n  tier2: 1.0,\n  tier3: 0.6\n}\n\n// Base star prices with volume discounts\nconst CREDIT_PACKAGES = {\n  1: { stars: 25, discount: 0 }, // $0.33 base\n  3: { stars: 60, discount: 0.20 }, // $0.78 (20% off)\n  5: { stars: 100, discount: 0.20 }, // $1.30 (20% off)\n  10: { stars: 175, discount: 0.30 }, // $2.28 (30% off)\n  25: { stars: 375, discount: 0.40 } // $4.88 (40% off)\n}\n\nconst getPricingTier = (locale) => {\n  if (PRICING_TIERS.tier1.includes(locale)) return 'tier1'\n  if (PRICING_TIERS.tier3.includes(locale)) return 'tier3'\n  return 'tier2' // default\n}\n\nconst calculateStarPrice = (credits, locale) => {\n  const tier = getPricingTier(locale)\n  const multiplier = TIER_MULTIPLIERS[tier]\n\n  // Check for predefined packages first\n  if (CREDIT_PACKAGES[credits]) {\n    return Math.round(CREDIT_PACKAGES[credits].stars * multiplier)\n  }\n\n  // For custom amounts: base 25 stars per credit\n  const basePrice = credits * 25\n  return Math.round(basePrice * multiplier)\n}\n\n// Create invoice link for a specific credit amount\nconst createInvoiceForAmount = async (ctx, amount, starPrice) => {\n  const payment = new ctx.db.Payment({\n    _id: new mongoose.Types.ObjectId(),\n    user: ctx.session.userInfo._id,\n    amount,\n    price: starPrice,\n    currency: 'XTR',\n    paymentSystem: 'telegram',\n    status: 'pending'\n  })\n\n  await payment.save()\n\n  const invoiceLink = await ctx.telegram.callApi('createInvoiceLink', {\n    title: ctx.i18n.t('donate.invoice_title', { amount }),\n    description: ctx.i18n.t('donate.description', { amount }),\n    payload: payment._id.toString(),\n    provider_token: '',\n    currency: 'XTR',\n    prices: JSON.stringify([{ label: 'Credits', amount: starPrice }])\n  })\n\n  return invoiceLink\n}\n\nconst donateScene = new Scene('donate')\n\ndonateScene.enter(async (ctx) => {\n  const locale = ctx.i18n.locale()\n  const packages = [1, 3, 5, 10, 25]\n  const discounts = { 1: '', 3: '', 5: ' (-20%)', 10: ' (-30%)', 25: ' (-40%)' }\n\n  // Calculate prices for each package\n  const prices = {}\n  for (const amount of packages) {\n    prices[amount] = calculateStarPrice(amount, locale)\n  }\n\n  // Create invoice links for all packages in parallel\n  const invoiceLinks = {}\n  await Promise.all(\n    packages.map(async (amount) => {\n      invoiceLinks[amount] = await createInvoiceForAmount(ctx, amount, prices[amount])\n    })\n  )\n\n  // Build buttons with direct payment links\n  const buttons = packages.map((amount) => {\n    const label = amount === 1 ? '1 Credit' : `${amount} Credits`\n    return [Markup.urlButton(`${label} — ${prices[amount]} ⭐${discounts[amount]}`, invoiceLinks[amount])]\n  })\n\n  await replyOrEditBanner(ctx, 'donate', ctx.i18n.t('donate.menu', {\n    titleSuffix: ` :: @${ctx.options.username}`,\n    balance: ctx.session.userInfo.balance\n  }), {\n    reply_markup: Markup.inlineKeyboard(buttons)\n  })\n\n  return ctx.scene.leave()\n})\n\nmodule.exports = donateScene\nmodule.exports.calculateStarPrice = calculateStarPrice\nmodule.exports.getPricingTier = getPricingTier\nmodule.exports.CREDIT_PACKAGES = CREDIT_PACKAGES\nmodule.exports.PRICING_TIERS = PRICING_TIERS\n"
  },
  {
    "path": "scenes/index.js",
    "content": "const Stage = require('telegraf/stage')\nconst I18n = require('telegraf-i18n')\nconst {\n  handleStart\n} = require('../handlers')\n\nconst { match } = I18n\n\nconst messaging = require('./messaging')\nconst sceneNewPack = require('./pack-new')\nconst originalSticker = require('./sticker-original')\nconst deleteSticker = require('./sticker-delete')\nconst packEdit = require('./admin-pack')\nconst adminPackBulkDelete = require('./admin-pack-bulk-delete')\nconst searchStickerSet = require('./pack-search')\nconst photoClear = require('./photo-clear')\nconst videoRound = require('./video-round')\nconst packCatalog = require('./pack-catalog')\nconst packFrame = require('./pack-frame')\nconst packRename = require('./pack-rename')\nconst packDelete = require('./pack-delete')\nconst packAbout = require('./pack-about')\nconst donate = require('./donate')\nconst mosaic = require('./mosaic')\n\nconst stage = new Stage([].concat(\n  sceneNewPack,\n  originalSticker,\n  deleteSticker,\n  messaging,\n  packEdit,\n  adminPackBulkDelete,\n  searchStickerSet,\n  photoClear,\n  videoRound,\n  packCatalog,\n  packFrame,\n  packRename,\n  packDelete,\n  packAbout,\n  donate,\n  mosaic\n))\n\nstage.use((ctx, next) => {\n  if (!ctx.session.scene) ctx.session.scene = {}\n  return next()\n})\n\nstage.hears(([\n  '/cancel',\n  match('scenes.btn.cancel')\n]), async (ctx) => {\n  ctx.session.scene = null\n\n  await ctx.reply(ctx.i18n.t('scenes.leave'), {\n    reply_markup: {\n      remove_keyboard: true\n    },\n    reply_to_message_id: ctx.message.message_id,\n    allow_sending_without_reply: true\n  })\n  await ctx.scene.leave()\n\n  return handleStart(ctx)\n})\n\nstage.hears(([\n  '/start',\n  '/help',\n  '/packs',\n  '/new',\n  '/emoji',\n  '/lang',\n  '/donate',\n  '/publish',\n  '/delete',\n  '/frame',\n  '/catalog',\n  '/mosaic',\n  '/round',\n  '/clear',\n  '/copy',\n  '/restore',\n  '/original',\n  '/about',\n  '/report',\n  '/privacy',\n  '/paysupport'\n]), async (ctx, next) => {\n  await ctx.scene.leave()\n  ctx.session.scene = null\n  await next()\n})\nstage.middleware()\n\nmodule.exports = stage\n"
  },
  {
    "path": "scenes/messaging.js",
    "content": "const mongoose = require('mongoose')\nconst Markup = require('telegraf/markup')\nconst Scene = require('telegraf/scenes/base')\nconst replicators = require('telegraf/core/replicators')\nconst moment = require('moment')\n\nconst redis = require('../utils/redis')\n\n// Keep in sync with utils/messaging.js — same constant, same Redis keys.\nconst MESSAGING_TTL_SECONDS = 7 * 24 * 60 * 60\n\nconst adminMessagingName = new Scene('adminMessagingName')\n\nadminMessagingName.enter(async (ctx) => {\n  const resultText = 'Enter a name for your messaging campaign'\n\n  const replyMarkup = Markup.inlineKeyboard([\n    [\n      Markup.callbackButton('Messaging', 'admin:messaging'),\n      Markup.callbackButton('Admin', 'admin:back')\n    ]\n  ])\n\n  await ctx.editMessageText(resultText, {\n    parse_mode: 'HTML',\n    reply_markup: replyMarkup\n  }).catch(() => {})\n})\n\nadminMessagingName.on('text', async (ctx) => {\n  if (!ctx.session.scene) ctx.session.scene = {}\n  if (ctx.session.scene.edit) {\n    const messaging = await ctx.db.Messaging.findById(ctx.session.scene.edit)\n\n    messaging.name = ctx.message.text\n    await messaging.save()\n\n    const resultText = 'Name changed successfully'\n\n    const replyMarkup = Markup.inlineKeyboard([\n      [\n        Markup.callbackButton('Messaging', 'admin:messaging'),\n        Markup.callbackButton('Admin', 'admin:back')\n      ]\n    ])\n\n    await ctx.replyWithHTML(resultText, {\n      parse_mode: 'HTML',\n      reply_markup: replyMarkup\n    }).catch(() => {})\n  } else {\n    ctx.session.scene.name = ctx.message.text\n    await ctx.scene.enter('adminMessagingMessageData')\n  }\n})\n\nconst parseUrlButton = (text) => {\n  const inlineKeyboard = []\n\n  if (text) {\n    text.split('\\n').forEach((line) => {\n      const linelButton = []\n\n      line.split('|').forEach((row) => {\n        const data = row.split(' - ')\n        if (data[0] && data[1]) {\n          const name = data[0].trim()\n          const url = data[1].trim()\n\n          linelButton.push(Markup.urlButton(name, url))\n        }\n      })\n\n      inlineKeyboard.push(linelButton)\n    })\n  }\n\n  return inlineKeyboard\n}\n\nconst adminMessagingMessageData = new Scene('adminMessagingMessageData')\n\nadminMessagingMessageData.enter(async (ctx) => {\n  if (!ctx.session.scene) ctx.session.scene = {}\n  if (ctx.session.scene.message) {\n    const urlButton = parseUrlButton(ctx.session.scene.keyboard)\n\n    let inlineKeyboard = []\n\n    inlineKeyboard = inlineKeyboard.concat(urlButton)\n\n    inlineKeyboard = inlineKeyboard.concat([\n      [\n        Markup.callbackButton('Add URL button', 'admin:messaging:add_url')\n      ],\n      [\n        Markup.callbackButton('Continue', 'admin:messaging:continue')\n      ],\n      [\n        Markup.callbackButton('Messaging', 'admin:messaging'),\n        Markup.callbackButton('Admin', 'admin:back')\n      ]\n    ])\n\n    const replyMarkup = Markup.inlineKeyboard(inlineKeyboard)\n\n    const method = replicators.copyMethods[ctx.session.scene.message.type]\n    const opts = Object.assign({}, ctx.session.scene.message.data, {\n      chat_id: ctx.chat.id,\n      disable_web_page_preview: true,\n      reply_markup: replyMarkup\n    })\n\n    await ctx.telegram.callApi(method, opts).catch(console.error)\n  } else {\n    const resultText = 'Please send the message you want to send to users'\n\n    const replyMarkup = Markup.inlineKeyboard([\n      [\n        Markup.callbackButton('Messaging', 'admin:messaging'),\n        Markup.callbackButton('Admin', 'admin:back')\n      ]\n    ])\n\n    await ctx.replyWithHTML(resultText, {\n      reply_markup: replyMarkup\n    })\n  }\n})\n\nadminMessagingMessageData.action(/admin:messaging:add_url/, async (ctx) => ctx.scene.enter('adminMessagingMessageUrl'))\n\nadminMessagingMessageData.on('message', async (ctx) => {\n  if (!ctx.session.scene) ctx.session.scene = {}\n  const message = ctx.message\n  const messageType = Object.keys(replicators.copyMethods).find((type) => message[type])\n  const messageData = replicators[messageType](message)\n\n  ctx.session.scene.message = { type: messageType, data: messageData }\n\n  ctx.scene.enter('adminMessagingMessageData')\n})\n\nconst adminMessagingMessageUrl = new Scene('adminMessagingMessageUrl')\n\nadminMessagingMessageUrl.enter(async (ctx) => {\n  if (!ctx.session.scene) ctx.session.scene = {}\n  const resultText = `Send URL buttons in format: Button name - URL\nYou can add multiple buttons in one line with | separator\nYou can add multiple lines for different rows\n\n${ctx.session.scene.keyboard ? 'Current buttons:\\n' + ctx.session.scene.keyboard : ''}`\n\n  const replyMarkup = Markup.inlineKeyboard([\n    [\n      Markup.callbackButton('Messaging', 'admin:messaging'),\n      Markup.callbackButton('Admin', 'admin:back')\n    ]\n  ])\n\n  await ctx.replyWithHTML(resultText, {\n    reply_markup: replyMarkup\n  })\n})\n\nadminMessagingMessageUrl.on('text', async (ctx) => {\n  if (!ctx.session.scene) ctx.session.scene = {}\n  ctx.session.scene.keyboard = ctx.message.text\n  ctx.scene.enter('adminMessagingMessageData')\n})\n\nadminMessagingMessageData.action(/admin:messaging:continue/, async (ctx) => {\n  if (!ctx.session.scene) return ctx.scene.leave()\n  if (ctx.session.scene.edit) ctx.scene.enter('adminMessagingMessageEdit')\n  else ctx.scene.enter('adminMessagingMessageDate')\n})\n\nconst adminMessagingSelectDate = new Scene('adminMessagingMessageDate')\n\nadminMessagingSelectDate.enter(async (ctx) => {\n  const resultText = 'Enter the date when the message should be sent in format DD.MM HH:mm'\n\n  const replyMarkup = Markup.inlineKeyboard([\n    [\n      Markup.callbackButton('Messaging', 'admin:messaging'),\n      Markup.callbackButton('Admin', 'admin:back')\n    ]\n  ])\n\n  await ctx.replyWithHTML(resultText, {\n    reply_markup: replyMarkup\n  })\n})\n\nadminMessagingSelectDate.on('text', async (ctx) => {\n  if (!ctx.session.scene) ctx.session.scene = {}\n  const date = moment(ctx.message.text, 'DD.MM HH:mm')\n\n  let resultText = ''\n  let inlineKeyboard = []\n\n  if (date.isValid()) {\n    ctx.session.scene.date = date\n\n    resultText = `Selected date: ${date.format('DD.MM HH:mm')}`\n\n    inlineKeyboard = [\n      Markup.callbackButton('Continue', 'admin:messaging:continue')\n    ]\n  } else {\n    resultText = 'Invalid date format. Please use DD.MM HH:mm'\n  }\n\n  const replyMarkup = Markup.inlineKeyboard([\n    inlineKeyboard,\n    [\n      Markup.callbackButton('Messaging', 'admin:messaging'),\n      Markup.callbackButton('Admin', 'admin:back')\n    ]\n  ])\n\n  await ctx.replyWithHTML(resultText, {\n    reply_markup: replyMarkup\n  }).catch(() => {})\n})\n\nadminMessagingSelectDate.action(/admin:messaging:continue/, async (ctx) => ctx.scene.enter('adminMessagingSelectGroup'))\n\nconst adminMessagingSelectGroup = new Scene('adminMessagingSelectGroup')\n\nadminMessagingSelectGroup.enter(async (ctx) => {\n  const resultText = 'Select the group of users to send the message to'\n\n  const replyMarkup = Markup.inlineKeyboard([\n    [Markup.callbackButton('All users', 'admin:messaging:group:all')],\n    [Markup.callbackButton('Russian-speaking users', 'admin:messaging:group:ru')],\n    [Markup.callbackButton('Ukrainian-speaking users', 'admin:messaging:group:uk')],\n    [Markup.callbackButton('English-speaking users', 'admin:messaging:group:en')],\n    [Markup.callbackButton('🌐 Other languages', 'admin:messaging:group:other')],\n    [Markup.callbackButton('🇬🇧 Active EN users with packs', 'admin:messaging:group:en_active')],\n    [Markup.callbackButton('🌐 Active users with other languages', 'admin:messaging:group:other_active')],\n    [\n      Markup.callbackButton('Messaging', 'admin:messaging'),\n      Markup.callbackButton('Admin', 'admin:back')\n    ]\n  ])\n\n  await ctx.editMessageText(resultText, {\n    parse_mode: 'HTML',\n    reply_markup: replyMarkup\n  }).catch(() => {})\n})\n\nadminMessagingSelectGroup.action(/admin:messaging:group:(.*)/, async (ctx) => {\n  if (!ctx.session.scene) ctx.session.scene = {}\n  ctx.session.scene.type = ctx.match[1]\n\n  ctx.scene.enter('adminMessagingConfirmation')\n})\n\nconst adminMessagingConfirmation = new Scene('adminMessagingConfirmation')\n\nadminMessagingConfirmation.enter(async (ctx) => {\n  if (!ctx.session.scene?.type) return ctx.scene.leave()\n  let findUsers = {}\n  const monthAgo = moment().subtract(1, 'month')\n  const threeMonthsAgo = moment().subtract(3, 'months')\n\n  if (ctx.session.scene.type === 'all') {\n    findUsers = await ctx.db.User.countDocuments({\n      blocked: { $ne: true },\n      locale: { $ne: 'ru' }\n    })\n  } else if (ctx.session.scene.type === 'ru') {\n    findUsers = await ctx.db.User.countDocuments({\n      blocked: { $ne: true },\n      premium: { $ne: true },\n      locale: 'ru'\n      // updatedAt: { $gte: moment().subtract(1, 'months') }\n    })\n  } else if (ctx.session.scene.type === 'uk') {\n    findUsers = await ctx.db.User.countDocuments({\n      blocked: { $ne: true },\n      locale: 'uk'\n    })\n  } else if (ctx.session.scene.type === 'en') {\n    findUsers = await ctx.db.User.countDocuments({\n      blocked: { $ne: true },\n      locale: 'en'\n    })\n  } else if (ctx.session.scene.type === 'en_active') {\n    // Start from stickersets - find owners with >=2 packs, then filter by user conditions\n    const pipeline = [\n      { $group: { _id: '$owner', packCount: { $sum: 1 } } },\n      { $match: { packCount: { $gte: 2 } } },\n      {\n        $lookup: {\n          from: 'users',\n          localField: '_id',\n          foreignField: '_id',\n          as: 'user'\n        }\n      },\n      { $unwind: '$user' },\n      {\n        $match: {\n          'user.blocked': { $ne: true },\n          'user.banned': { $ne: true },\n          'user.locale': 'en',\n          'user.updatedAt': { $gte: monthAgo.toDate() },\n          'user.createdAt': { $lte: threeMonthsAgo.toDate() }\n        }\n      },\n      { $count: 'totalUsers' }\n    ]\n\n    const result = await ctx.db.StickerSet.aggregate(pipeline).allowDiskUse(true)\n    findUsers = result.length > 0 ? result[0].totalUsers : 0\n  } else if (ctx.session.scene.type === 'other') {\n    findUsers = await ctx.db.User.countDocuments({\n      blocked: { $ne: true },\n      banned: { $ne: true },\n      locale: { $nin: ['en', 'ru', 'uk'] }\n    })\n  } else if (ctx.session.scene.type === 'other_active') {\n    // Start from stickersets - find owners with >=2 packs, then filter by user conditions\n    const pipeline = [\n      { $group: { _id: '$owner', packCount: { $sum: 1 } } },\n      { $match: { packCount: { $gte: 2 } } },\n      {\n        $lookup: {\n          from: 'users',\n          localField: '_id',\n          foreignField: '_id',\n          as: 'user'\n        }\n      },\n      { $unwind: '$user' },\n      {\n        $match: {\n          'user.blocked': { $ne: true },\n          'user.banned': { $ne: true },\n          'user.locale': { $nin: ['en', 'ru', 'uk'] },\n          'user.updatedAt': { $gte: monthAgo.toDate() },\n          'user.createdAt': { $lte: threeMonthsAgo.toDate() }\n        }\n      },\n      { $count: 'totalUsers' }\n    ]\n\n    const result = await ctx.db.StickerSet.aggregate(pipeline).allowDiskUse(true)\n    findUsers = result.length > 0 ? result[0].totalUsers : 0\n  }\n\n  const resultText = `Good! Found ${findUsers} users`\n\n  const replyMarkup = Markup.inlineKeyboard([\n    [\n      Markup.callbackButton('Back to group selection', 'admin:messaging:select_group'),\n      Markup.callbackButton('Continue', 'admin:messaging:publish')\n    ],\n    [\n      Markup.callbackButton('Messaging', 'admin:messaging'),\n      Markup.callbackButton('Admin', 'admin:back')\n    ]\n  ])\n\n  await ctx.replyWithHTML(resultText, {\n    reply_markup: replyMarkup\n  })\n})\n\nconst adminMessagingMessageEdit = new Scene('adminMessagingMessageEdit')\n\nadminMessagingMessageEdit.enter(async (ctx) => {\n  if (!ctx.session.scene?.edit || !ctx.session.scene?.message) return ctx.scene.leave()\n  const messaging = await ctx.db.Messaging.findById(ctx.session.scene.edit)\n\n  if (ctx.session.scene.message.type === messaging.message.type) {\n    messaging.message = ctx.session.scene.message\n    messaging.editStatus = 1\n    await messaging.save()\n\n    if (redis) redis.set(`messaging:${messaging.id}:edit_state`, 0, 'EX', MESSAGING_TTL_SECONDS)\n\n    const resultText = `Editing for messaging \"${messaging.name}\" started`\n\n    const replyMarkup = Markup.inlineKeyboard([\n      [\n        Markup.callbackButton('View status', `admin:messaging:status:${messaging.id}`)\n      ],\n      [\n        Markup.callbackButton('Messaging', 'admin:messaging'),\n        Markup.callbackButton('Admin', 'admin:back')\n      ]\n    ])\n\n    await ctx.replyWithHTML(resultText, {\n      parse_mode: 'HTML',\n      reply_markup: replyMarkup\n    }).catch(() => {})\n\n    ctx.session.scene = null\n    ctx.scene.leave()\n  } else {\n    await ctx.replyWithHTML(`Message type mismatch. Current: ${ctx.session.scene.message.type}, Original: ${messaging.message.type}`)\n    ctx.session.scene.message = messaging.message\n    ctx.scene.enter('adminMessagingMessageData')\n  }\n})\n\nconst adminMessagingPublish = new Scene('adminMessagingPublish')\n\nadminMessagingPublish.enter(async (ctx) => {\n  if (!ctx.session.scene?.message || !ctx.session.scene?.type) return ctx.scene.leave()\n  if (!redis) {\n    await ctx.replyWithHTML('Broadcast disabled: REDIS_HOST not set').catch(() => {})\n    return ctx.scene.leave()\n  }\n  const urlButton = parseUrlButton(ctx.session.scene.keyboard)\n\n  let inlineKeyboard = []\n\n  inlineKeyboard = inlineKeyboard.concat(urlButton)\n\n  ctx.session.scene.message.data.reply_markup = Markup.inlineKeyboard(inlineKeyboard)\n\n  let usersCursor\n  const monthAgo = moment().subtract(1, 'month')\n  const threeMonthsAgo = moment().subtract(3, 'months')\n\n  if (ctx.session.scene.type === 'all') {\n    usersCursor = await ctx.db.User.find({\n      blocked: { $ne: true },\n      locale: { $ne: 'ru' }\n    }).select({ _id: 1, telegram_id: 1 }).cursor()\n  } else if (ctx.session.scene.type === 'ru') {\n    usersCursor = await ctx.db.User.find({\n      blocked: { $ne: true },\n      premium: { $ne: true },\n      locale: 'ru'\n      // updatedAt: { $gte: moment().subtract(1, 'months') }\n    }).select({ _id: 1, telegram_id: 1 }).cursor()\n  } else if (ctx.session.scene.type === 'uk') {\n    usersCursor = await ctx.db.User.find({\n      blocked: { $ne: true },\n      locale: 'uk'\n    }).select({ _id: 1, telegram_id: 1 }).cursor()\n  } else if (ctx.session.scene.type === 'en') {\n    usersCursor = await ctx.db.User.find({\n      blocked: { $ne: true },\n      locale: 'en'\n    }).select({ _id: 1, telegram_id: 1 }).cursor()\n  } else if (ctx.session.scene.type === 'en_active') {\n    // Start from stickersets - more efficient with owner index\n    usersCursor = ctx.db.StickerSet.aggregate([\n      { $group: { _id: '$owner', packCount: { $sum: 1 } } },\n      { $match: { packCount: { $gte: 2 } } },\n      {\n        $lookup: {\n          from: 'users',\n          localField: '_id',\n          foreignField: '_id',\n          as: 'user'\n        }\n      },\n      { $unwind: '$user' },\n      {\n        $match: {\n          'user.blocked': { $ne: true },\n          'user.banned': { $ne: true },\n          'user.locale': 'en',\n          'user.updatedAt': { $gte: monthAgo.toDate() },\n          'user.createdAt': { $lte: threeMonthsAgo.toDate() }\n        }\n      },\n      {\n        $project: {\n          _id: '$user._id',\n          telegram_id: '$user.telegram_id'\n        }\n      }\n    ]).allowDiskUse(true).cursor({ batchSize: 1000 })\n  } else if (ctx.session.scene.type === 'other') {\n    usersCursor = await ctx.db.User.find({\n      blocked: { $ne: true },\n      banned: { $ne: true },\n      locale: { $nin: ['en', 'ru', 'uk'] }\n    }).select({ _id: 1, telegram_id: 1 }).cursor()\n  } else if (ctx.session.scene.type === 'other_active') {\n    // Start from stickersets - more efficient with owner index\n    usersCursor = ctx.db.StickerSet.aggregate([\n      { $group: { _id: '$owner', packCount: { $sum: 1 } } },\n      { $match: { packCount: { $gte: 2 } } },\n      {\n        $lookup: {\n          from: 'users',\n          localField: '_id',\n          foreignField: '_id',\n          as: 'user'\n        }\n      },\n      { $unwind: '$user' },\n      {\n        $match: {\n          'user.blocked': { $ne: true },\n          'user.banned': { $ne: true },\n          'user.locale': { $nin: ['en', 'ru', 'uk'] },\n          'user.updatedAt': { $gte: monthAgo.toDate() },\n          'user.createdAt': { $lte: threeMonthsAgo.toDate() }\n        }\n      },\n      {\n        $project: {\n          _id: '$user._id',\n          telegram_id: '$user.telegram_id'\n        }\n      }\n    ]).allowDiskUse(true).cursor({ batchSize: 1000 })\n  }\n\n  // const users = []\n  const messagingId = mongoose.Types.ObjectId()\n  const key = `messaging:${messagingId}`\n\n  let usersCount = 0\n  const BATCH_SIZE = 1000\n\n  let batch = []\n  for (let user = await usersCursor.next(); user != null; user = await usersCursor.next()) {\n    batch.push(user.telegram_id)\n    usersCount++\n\n    if (batch.length >= BATCH_SIZE) {\n      await redis.rpush(key + ':users', batch)\n      batch = []\n    }\n  }\n  // Push remaining users\n  if (batch.length > 0) {\n    await redis.rpush(key + ':users', batch)\n  }\n\n  // rpush doesn't support inline TTL — apply one now so the list (and any\n  // sibling state keys created later) disappears if the campaign stalls.\n  if (usersCount > 0) {\n    await redis.expire(key + ':users', MESSAGING_TTL_SECONDS)\n  }\n\n  const messaging = new ctx.db.Messaging()\n\n  Object.assign(messaging, {\n    _id: messagingId,\n    creator: ctx.session.user,\n    name: ctx.session.scene.name,\n    message: ctx.session.scene.message,\n    result: {\n      total: usersCount\n    },\n    date: ctx.session.scene.date\n  })\n\n  await messaging.save()\n\n  const resultText = `Message \"${ctx.session.scene.name}\" has been created and scheduled`\n\n  const replyMarkup = Markup.inlineKeyboard([\n    [\n      Markup.callbackButton('View status', `admin:messaging:status:${messagingId}`)\n    ],\n    [\n      Markup.callbackButton('Messaging', 'admin:messaging'),\n      Markup.callbackButton('Admin', 'admin:back')\n    ]\n  ])\n\n  await ctx.editMessageText(resultText, {\n    parse_mode: 'HTML',\n    reply_markup: replyMarkup\n  }).catch(() => {})\n\n  ctx.session.scene = null\n  ctx.scene.leave()\n})\n\nmodule.exports = [\n  adminMessagingName,\n  adminMessagingMessageData,\n  adminMessagingMessageUrl,\n  adminMessagingSelectDate,\n  adminMessagingSelectGroup,\n  adminMessagingConfirmation,\n  adminMessagingMessageEdit,\n  adminMessagingPublish\n]\n"
  },
  {
    "path": "scenes/mosaic.js",
    "content": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst I18n = require('telegraf-i18n')\nconst { getGridSuggestions } = require('../utils/mosaic-grid')\nconst { generatePreview } = require('../utils/mosaic-preview')\nconst { splitImage, checkMinCellSize } = require('../utils/mosaic-split')\nconst { getRateLimitRemaining } = require('../utils/retry-api')\nconst https = require('https')\nconst sharp = require('sharp')\n\nconst { match } = I18n\n\nconst mosaic = new Scene('mosaic')\n\n// Download file from Telegram URL\nconst downloadFile = (fileUrl, timeout = 30000) => new Promise((resolve, reject) => {\n  const data = []\n  let totalSize = 0\n  const MAX_SIZE = 20 * 1024 * 1024\n  const req = https.get(fileUrl, (response) => {\n    if (response.statusCode !== 200) { req.destroy(); reject(new Error(`Download failed: ${response.statusCode}`)); return }\n    response.on('data', (chunk) => {\n      totalSize += chunk.length\n      if (totalSize > MAX_SIZE) { req.destroy(); reject(new Error('File too large')); return }\n      data.push(chunk)\n    })\n    response.on('end', () => resolve(Buffer.concat(data)))\n  })\n  req.on('error', reject)\n  req.setTimeout(timeout, () => { req.destroy(); reject(new Error('Timeout')) })\n})\n\nconst FALLBACK_EMOJI = ['🟥', '🟧', '🟨', '🟩', '🟦', '🟪', '🟫', '⬛', '⬜', '🔲']\n\n// Helper: build inline keyboard for grid selection\nconst buildGridKeyboard = (ctx, suggestions) => {\n  const { recommended, alternatives } = suggestions\n  const buttons = []\n\n  // Row 1: recommended\n  buttons.push([\n    Markup.callbackButton(\n      ctx.i18n.t('cmd.mosaic.btn.recommended', { rows: recommended.rows, cols: recommended.cols }),\n      `mosaic:grid:${recommended.rows}:${recommended.cols}`\n    )\n  ])\n\n  // Row 2: alternatives\n  if (alternatives.length > 0) {\n    buttons.push(alternatives.map(alt =>\n      Markup.callbackButton(\n        ctx.i18n.t('cmd.mosaic.btn.option', { rows: alt.rows, cols: alt.cols, total: alt.total }),\n        `mosaic:grid:${alt.rows}:${alt.cols}`\n      )\n    ))\n  }\n\n  // Row 3: custom + cancel\n  buttons.push([\n    Markup.callbackButton(ctx.i18n.t('cmd.mosaic.btn.custom'), 'mosaic:custom'),\n    Markup.callbackButton(ctx.i18n.t('cmd.mosaic.btn.cancel'), 'mosaic:cancel')\n  ])\n\n  // Row 4: exit\n  buttons.push([\n    Markup.callbackButton(ctx.i18n.t('cmd.mosaic.btn.exit'), 'mosaic:exit')\n  ])\n\n  return Markup.inlineKeyboard(buttons)\n}\n\n// --- Enter handler ---\n\nmosaic.enter(async (ctx) => {\n  if (!ctx.session.scene) ctx.session.scene = {}\n  ctx.session.scene.mosaic = {}\n\n  // Check if user has a custom_emoji pack selected\n  const userInfo = ctx.session.userInfo\n  if (!userInfo || !userInfo.stickerSet) {\n    await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_pack'))\n    return ctx.scene.leave()\n  }\n\n  const stickerSet = await ctx.db.StickerSet.findById(userInfo.stickerSet)\n  if (!stickerSet || stickerSet.packType !== 'custom_emoji') {\n    await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_pack'))\n    return ctx.scene.leave()\n  }\n\n  ctx.session.scene.mosaic.packId = stickerSet.id\n  ctx.session.scene.mosaic.packName = stickerSet.name\n\n  await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.enter', {\n    packTitle: stickerSet.title\n  }), {\n    reply_markup: Markup.keyboard([\n      [{ text: ctx.i18n.t('cmd.mosaic.btn.exit') }]\n    ]).resize()\n  })\n})\n\n// Normalize any accepted message into { fileId, width, height } or { error: <i18n-key> }.\n// For documents, width/height come from the optional thumb — may be null, caller reads from buffer.\nconst IMAGE_DOCUMENT_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp'])\n\nconst getMosaicSource = (message) => {\n  if (message.photo && message.photo.length > 0) {\n    const largest = message.photo[message.photo.length - 1]\n    return { fileId: largest.file_id, width: largest.width, height: largest.height }\n  }\n\n  if (message.sticker) {\n    if (message.sticker.is_animated || message.sticker.is_video) {\n      return { error: 'cmd.mosaic.reject_animated' }\n    }\n    return {\n      fileId: message.sticker.file_id,\n      width: message.sticker.width,\n      height: message.sticker.height\n    }\n  }\n\n  if (message.document) {\n    const mime = message.document.mime_type\n    if (!mime || !IMAGE_DOCUMENT_MIMES.has(mime)) {\n      return { error: 'cmd.mosaic.reject_document' }\n    }\n    return {\n      fileId: message.document.file_id,\n      width: message.document.thumb ? message.document.thumb.width : null,\n      height: message.document.thumb ? message.document.thumb.height : null\n    }\n  }\n\n  // Should not be reachable — handler only binds to photo/document/sticker.\n  return { error: 'cmd.mosaic.reject_media' }\n}\n\n// --- Photo handler ---\n\nmosaic.on(['photo', 'document', 'sticker'], async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n\n  // Block new input while uploading\n  if (ctx.session.scene.mosaic.uploading) {\n    return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.uploading', { current: '...', total: '...' }))\n  }\n\n  const source = getMosaicSource(ctx.message)\n  if (source.error) {\n    return ctx.replyWithHTML(ctx.i18n.t(source.error))\n  }\n\n  // Download + decode + preview. Wrap in try/catch so the user gets a\n  // specific \"download/decode failed\" message instead of the generic\n  // bot.catch \"unknown error\" fallback when a Telegram file times out\n  // or sharp fails to parse the input.\n  let imageBuffer, width, height, previewBuffer, suggestions\n  let stickerSet, freeSlots\n  try {\n    const fileUrl = await ctx.telegram.getFileLink(source.fileId)\n    imageBuffer = await downloadFile(fileUrl.href || fileUrl)\n\n    // Documents don't carry width/height on the message itself — read from buffer.\n    ;({ width, height } = source)\n    if (!width || !height) {\n      const meta = await sharp(imageBuffer).metadata()\n      width = meta.width\n      height = meta.height\n    }\n\n    stickerSet = await ctx.db.StickerSet.findById(ctx.session.scene.mosaic.packId)\n    const currentCount = await ctx.db.Sticker.countDocuments({\n      stickerSet: stickerSet.id,\n      deleted: false\n    })\n    freeSlots = 200 - currentCount\n\n    suggestions = getGridSuggestions(width, height, freeSlots)\n\n    if (suggestions.type === 'no_space') {\n      await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_space', { freeSlots, total: 4 }))\n      return\n    }\n\n    previewBuffer = await generatePreview(imageBuffer, suggestions.recommended.rows, suggestions.recommended.cols)\n  } catch (err) {\n    console.error('[mosaic] preview prep failed:', err.message)\n    const key = /Too large|Timeout|Download/.test(err.message)\n      ? 'sticker.add.error.convert'\n      : 'sticker.add.error.invalid_image'\n    return ctx.replyWithHTML(ctx.i18n.t(key))\n  }\n\n  // Store in scene state\n  ctx.session.scene.mosaic.photoFileId = source.fileId\n  ctx.session.scene.mosaic.photoWidth = width\n  ctx.session.scene.mosaic.photoHeight = height\n  ctx.session.scene.mosaic.freeSlots = freeSlots\n\n  const { recommended } = suggestions\n\n  // Check for blurry warning\n  const isBlurry = !checkMinCellSize(width, height, recommended.rows, recommended.cols)\n  const blurryText = isBlurry ? '\\n' + ctx.i18n.t('cmd.mosaic.blurry_warning') : ''\n\n  const msg = await ctx.replyWithPhoto(\n    { source: previewBuffer },\n    {\n      caption: ctx.i18n.t('cmd.mosaic.choose_grid') + blurryText,\n      parse_mode: 'HTML',\n      reply_markup: buildGridKeyboard(ctx, suggestions)\n    }\n  )\n\n  ctx.session.scene.mosaic.previewMessageId = msg.message_id\n})\n\n// --- Reject animated/video inputs ---\n\nmosaic.on(['animation', 'video', 'video_note'], async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n  if (ctx.session.scene.mosaic.uploading) return\n  await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.reject_media'))\n})\n\n// --- Shared processMosaic function ---\n\nconst processMosaic = async (ctx, rows, cols) => {\n  const state = ctx.session.scene.mosaic\n  const total = rows * cols\n\n  // Lock: prevent concurrent processing\n  if (state.uploading) {\n    return\n  }\n\n  if (!state.photoFileId) {\n    await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.wait_photo'))\n    return\n  }\n\n  // Pre-check: if the user's addStickerToSet is in a 429 cooldown we'd\n  // get synthetic 429 on every single cell. Better to bail here with a\n  // clear \"wait N seconds\" than half-upload and roll back.\n  const cooldown = getRateLimitRemaining('addStickerToSet', ctx.from.id)\n  if (cooldown > 0) {\n    await ctx.replyWithHTML(ctx.i18n.t('error.rate_limit_seconds', { seconds: cooldown }))\n    return\n  }\n\n  state.uploading = true\n\n  try {\n    // Download photo again (not stored in session)\n    const fileUrl = await ctx.telegram.getFileLink(state.photoFileId)\n    const imageBuffer = await downloadFile(fileUrl.href || fileUrl)\n\n    // Send progress message\n    const progressMsg = await ctx.replyWithHTML(\n      ctx.i18n.t('cmd.mosaic.uploading', { current: 0, total })\n    )\n\n    // Split image\n    const cells = await splitImage(imageBuffer, rows, cols)\n\n    // Upload all cells to the pack\n    const stickerSet = await ctx.db.StickerSet.findById(state.packId)\n    const uploadedIds = []\n    const uploadedFileIds = []\n    let uploadedCount = 0\n\n    for (let i = 0; i < cells.length; i++) {\n      const r = Math.floor(i / cols) + 1\n      const c = (i % cols) + 1\n      const fallbackEmoji = FALLBACK_EMOJI[i % FALLBACK_EMOJI.length]\n\n      try {\n        // No outer retry: ctx.telegram.callApi is already wrapped by\n        // utils/retry-api (auto-retries 429 with retry_after ≤ 5s, caches\n        // method+user cooldowns). A second exponential-backoff layer here\n        // just re-tried synthetic 429s 25× and burned ~3min of waits per\n        // partial-failed mosaic.\n        const uploaded = await ctx.telegram.callApi('uploadStickerFile', {\n          user_id: ctx.from.id,\n          sticker_format: 'static',\n          sticker: { source: cells[i] }\n        })\n\n        await ctx.telegram.callApi('addStickerToSet', {\n          user_id: ctx.from.id,\n          name: stickerSet.name,\n          sticker: {\n            sticker: uploaded.file_id,\n            format: 'static',\n            emoji_list: [fallbackEmoji],\n            keywords: ['mosaic', `r${r}c${c}`]\n          }\n        })\n        uploadedCount++\n      } catch (err) {\n        // Upload failed — rollback what succeeded via getStickerSet.\n        if (uploadedCount > 0) {\n          const partialSet = await ctx.telegram.callApi('getStickerSet', { name: stickerSet.name }).catch(() => null)\n          if (partialSet) {\n            const toRollback = partialSet.stickers.slice(-uploadedCount)\n            for (const s of toRollback) {\n              await ctx.telegram.callApi('deleteStickerFromSet', { sticker: s.file_id }).catch(() => {})\n            }\n          }\n        }\n        await ctx.telegram.deleteMessage(ctx.chat.id, progressMsg.message_id).catch(() => {})\n\n        // Pick a message that actually tells the user WHY it failed,\n        // instead of the generic \"undo_failed\" for every kind of error.\n        const description = err?.description || err?.message || ''\n        let replyKey = 'cmd.mosaic.undo_failed'\n        if (err?.code === 429) {\n          const retryAfter = err?.parameters?.retry_after || getRateLimitRemaining('addStickerToSet', ctx.from.id)\n          await ctx.replyWithHTML(ctx.i18n.t('error.rate_limit_seconds', { seconds: retryAfter || 30 }))\n          return\n        } else if (description.includes('STICKERSET_INVALID')) {\n          replyKey = 'sticker.add.error.stickerset_invalid'\n        } else if (description.includes('TOO_MUCH')) {\n          replyKey = 'sticker.add.error.stickers_too_much'\n        }\n        await ctx.replyWithHTML(ctx.i18n.t(replyKey))\n        return\n      }\n\n      // Update progress every 3 uploads\n      if ((i + 1) % 3 === 0 || i === cells.length - 1) {\n        await ctx.telegram.editMessageText(\n          ctx.chat.id, progressMsg.message_id, null,\n          ctx.i18n.t('cmd.mosaic.uploading', { current: i + 1, total })\n        ).catch(() => {})\n        await ctx.telegram.callApi('sendChatAction', {\n          chat_id: ctx.chat.id, action: 'choose_sticker'\n        }).catch(() => {})\n      }\n    }\n\n    // Get all sticker IDs in one API call (instead of N calls during upload)\n    const setInfo = await ctx.telegram.callApi('getStickerSet', { name: stickerSet.name })\n    const addedStickers = setInfo.stickers.slice(-total)\n    // Parallel DB writes — addSticker only creates a new doc per call, no\n    // shared counter, no race. Synchronous pushes to uploadedIds/Ids\n    // preserve mosaic position order regardless of resolution order.\n    await Promise.all(addedStickers.map((sticker) => {\n      uploadedIds.push(sticker.custom_emoji_id)\n      uploadedFileIds.push(sticker.file_id)\n      return ctx.db.Sticker.addSticker(stickerSet.id, '🔲', {\n        file_id: sticker.file_id,\n        file_unique_id: sticker.file_unique_id,\n        stickerType: 'custom_emoji'\n      })\n    }))\n\n    // Delete progress message\n    await ctx.telegram.deleteMessage(ctx.chat.id, progressMsg.message_id).catch(() => {})\n\n    // Build mosaic message with custom_emoji entities\n    const placeholder = '\\u2B1C'\n    let text = ''\n    const entities = []\n\n    for (let r = 0; r < rows; r++) {\n      for (let c = 0; c < cols; c++) {\n        const idx = r * cols + c\n        const offset = text.length\n        text += placeholder\n        entities.push({\n          type: 'custom_emoji',\n          offset,\n          length: placeholder.length,\n          custom_emoji_id: uploadedIds[idx]\n        })\n      }\n      if (r < rows - 1) text += '\\n'\n    }\n\n    // Send mosaic as pure-emoji message (no text!) for correct Telegram rendering\n    await ctx.telegram.callApi('sendMessage', {\n      chat_id: ctx.chat.id,\n      text,\n      entities\n    })\n\n    // Send pack link + undo as separate message\n    const packLink = `${ctx.config.emojiLinkPrefix}${stickerSet.name}`\n    await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.done', { rows, cols }), {\n      reply_markup: Markup.inlineKeyboard([\n        [Markup.urlButton(ctx.i18n.t('cmd.mosaic.done_link'), packLink)],\n        [Markup.callbackButton(ctx.i18n.t('cmd.mosaic.btn.undo'), 'mosaic:undo')]\n      ])\n    })\n\n    // Store uploaded file IDs for undo\n    state.lastMosaicIds = uploadedFileIds\n    state.lastMosaicCount = total\n    state.waitingCustom = false\n\n    // Ready for next photo\n    await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.wait_photo'))\n  } finally {\n    state.uploading = false\n  }\n}\n\n// --- Action handlers ---\n\n// Grid selection callback\nmosaic.action(/^mosaic:grid:(\\d+):(\\d+)$/, async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n\n  const rows = parseInt(ctx.match[1])\n  const cols = parseInt(ctx.match[2])\n  const total = rows * cols\n  const state = ctx.session.scene.mosaic\n\n  if (!state.photoFileId || rows < 1 || rows > 10 || cols < 1 || cols > 10 || total < 2 || total > 50) {\n    return ctx.answerCbQuery(ctx.i18n.t('cmd.mosaic.custom_invalid'), true)\n  }\n\n  if (total > state.freeSlots) {\n    return ctx.answerCbQuery(ctx.i18n.t('cmd.mosaic.no_space', {\n      freeSlots: state.freeSlots, total\n    }), true)\n  }\n\n  await ctx.answerCbQuery()\n  return processMosaic(ctx, rows, cols)\n})\n\n// Custom size: prompt\nmosaic.action('mosaic:custom', async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n  await ctx.answerCbQuery()\n  ctx.session.scene.mosaic.waitingCustom = true\n  await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.custom_prompt'))\n})\n\n// Cancel current photo\nmosaic.action('mosaic:cancel', async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n  await ctx.answerCbQuery()\n  ctx.session.scene.mosaic.photoFileId = null\n  ctx.session.scene.mosaic.waitingCustom = false\n  await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.wait_photo'))\n})\n\n// Undo: remove last mosaic from pack\nmosaic.action('mosaic:undo', async (ctx) => {\n  if (!ctx.session.scene?.mosaic) return ctx.scene.leave()\n\n  const state = ctx.session.scene.mosaic\n  if (!state.lastMosaicIds || state.lastMosaicIds.length === 0) {\n    return ctx.answerCbQuery()\n  }\n\n  await ctx.answerCbQuery()\n\n  let deleted = 0\n  for (const fileId of state.lastMosaicIds) {\n    try {\n      await ctx.telegram.callApi('deleteStickerFromSet', { sticker: fileId })\n      await ctx.db.Sticker.updateOne(\n        { fileId, stickerSet: state.packId },\n        { $set: { deleted: true, deletedAt: new Date() } }\n      )\n      deleted++\n    } catch (e) {\n      // Sticker may already be deleted\n    }\n  }\n\n  state.lastMosaicIds = []\n\n  if (deleted > 0) {\n    await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.undo_done', { count: deleted }))\n  } else {\n    await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.undo_failed'))\n  }\n})\n\n// Exit scene\nmosaic.action('mosaic:exit', async (ctx) => {\n  await ctx.answerCbQuery()\n  delete ctx.session.scene.mosaic\n  await ctx.scene.leave()\n})\n\n// --- Text handler for custom size ---\n\nmosaic.on('text', async (ctx) => {\n  if (!ctx.session.scene?.mosaic?.waitingCustom) return\n\n  const text = ctx.message.text.trim()\n\n  // Flexible parsing: 3x4, 3×4, 3*4, 3:4, 3 на 4, 3 by 4, 3 on 4\n  const match = text.match(/^(\\d+)\\s*[x×*:]\\s*(\\d+)$/i) ||\n                text.match(/^(\\d+)\\s+(?:на|by|on)\\s+(\\d+)$/i)\n\n  if (!match) {\n    return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.custom_invalid'))\n  }\n\n  const rows = parseInt(match[1])\n  const cols = parseInt(match[2])\n  const total = rows * cols\n\n  if (rows < 1 || rows > 10 || cols < 1 || cols > 10 || total < 2 || total > 50) {\n    return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.custom_invalid'))\n  }\n\n  const state = ctx.session.scene.mosaic\n  if (total > state.freeSlots) {\n    return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_space', {\n      freeSlots: state.freeSlots, total\n    }))\n  }\n\n  return processMosaic(ctx, rows, cols)\n})\n\n// --- Exit via keyboard button ---\n\nmosaic.hears(match('cmd.mosaic.btn.exit'), async (ctx) => {\n  delete ctx.session.scene.mosaic\n  await ctx.scene.leave()\n})\n\nmodule.exports = mosaic\n"
  },
  {
    "path": "scenes/pack-about.js",
    "content": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst { sendBanner } = require('../banners')\nconst {\n  escapeHTML,\n  telegramApi,\n  moderatePack,\n  showGramAds\n} = require('../utils')\nconst {\n  db\n} = require('../database')\nconst decodeStickerSetId = require('../utils/decode-sticker-set-id')\n\n// Telegram datacenter regions\nconst DC_REGIONS = {\n  1: '🇺🇸 USA',\n  2: '🇪🇺 Europe',\n  3: '🇺🇸 USA',\n  4: '🇪🇺 Europe',\n  5: '🇸🇬 Asia',\n  7: '🇺🇸 USA'\n}\n\nconst packAbout = new Scene('packAbout')\n\npackAbout.enter(async (ctx) => {\n  await sendBanner(ctx, 'origin', ctx.i18n.t('scenes.packAbout.enter'), {\n    reply_markup: {\n      keyboard: [\n        [{\n          text: ctx.i18n.t('userAbout.select_user'),\n          request_users: {\n            request_id: 1,\n            user_is_bot: false,\n            max_quantity: 1\n          }\n        }],\n        [\n          ctx.i18n.t('scenes.btn.cancel')\n        ]\n      ],\n      resize_keyboard: true\n    }\n  })\n})\n\n// Handle user selection via users_shared\npackAbout.use((ctx, next) => {\n  if (ctx?.message?.users_shared) {\n    const sharedUserId = ctx.message.users_shared.user_ids[0]\n\n    if (!sharedUserId) return next()\n\n    if (ctx.session.userInfo.locale === 'ru' && !ctx.session.userInfo?.stickerSet?.boost) {\n      showGramAds(ctx.chat.id)\n    }\n\n    return ctx.db.StickerSet.find({\n      ownerTelegramId: sharedUserId\n    }).select('_id name public').limit(500).lean().then((findPacks) => {\n      let chunkedPacks = []\n      const chunkSize = 70\n\n      if (findPacks.length > 0) {\n        chunkedPacks = (findPacks.map((pack) => {\n          if (pack.name.toLowerCase().endsWith('fStikBot'.toLowerCase()) && pack.public !== true) {\n            if (\n              ctx.from.id === sharedUserId ||\n              ctx.from.id === ctx.config.mainAdminId ||\n              ctx?.session?.userInfo?.adminRights?.includes('pack')\n            ) {\n              return `<a href=\"https://t.me/addstickers/${escapeHTML(pack.name)}\"><s>${escapeHTML(pack.name)}</s></a>`\n            } else {\n              return '<i>[hidden]</i>'\n            }\n          }\n          return `<a href=\"https://t.me/addstickers/${escapeHTML(pack.name)}\">${escapeHTML(pack.name)}</a>`\n        })).reduce((resultArray, item, index) => {\n          const chunkIndex = Math.floor(index / chunkSize)\n\n          if (!resultArray[chunkIndex]) {\n            resultArray[chunkIndex] = []\n          }\n\n          resultArray[chunkIndex].push(item)\n\n          return resultArray\n        }, [])\n      }\n\n      let packsToReturn\n\n      if (chunkedPacks.length > 0) {\n        packsToReturn = chunkedPacks.shift()\n      }\n\n      // Save data for \"show all packs\" button\n      const totalPacks = findPacks.length\n      if (chunkedPacks.length > 0) {\n        ctx.session.showAllPacksData = {\n          ownerId: sharedUserId,\n          excludeSetId: null\n        }\n      }\n\n      const keyboard = []\n      if (chunkedPacks.length > 0) {\n        keyboard.push([Markup.callbackButton(\n          ctx.i18n.t('scenes.packAbout.btn.show_all_packs', { count: totalPacks }),\n          'show_all_packs'\n        )])\n      }\n\n      return ctx.replyWithHTML(ctx.i18n.t('userAbout.result', {\n        userId: sharedUserId,\n        packs: packsToReturn ? packsToReturn.join(', ') : ctx.i18n.t('userAbout.no_packs')\n      }), {\n        disable_web_page_preview: true,\n        ...(keyboard.length > 0 ? Markup.inlineKeyboard(keyboard).extra() : {})\n      })\n    })\n  }\n  return next()\n})\n\npackAbout.on(['sticker', 'text', 'forward'], async (ctx, next) => {\n  // Handle forwarded message for user info\n  if (ctx.message.forward_from) {\n    const sharedUserId = ctx.message.forward_from.id\n\n    if (ctx.session.userInfo.locale === 'ru' && !ctx.session.userInfo?.stickerSet?.boost) {\n      showGramAds(ctx.chat.id)\n    }\n\n    const findPacks = await ctx.db.StickerSet.find({\n      ownerTelegramId: sharedUserId\n    })\n\n    let chunkedPacks = []\n    const chunkSize = 70\n\n    if (findPacks.length > 0) {\n      chunkedPacks = (findPacks.map((pack) => {\n        if (pack.name.toLowerCase().endsWith('fStikBot'.toLowerCase()) && pack.public !== true) {\n          if (\n            ctx.from.id === sharedUserId ||\n            ctx.from.id === ctx.config.mainAdminId ||\n            ctx?.session?.userInfo?.adminRights?.includes('pack')\n          ) {\n            return `<a href=\"https://t.me/addstickers/${escapeHTML(pack.name)}\"><s>${escapeHTML(pack.name)}</s></a>`\n          } else {\n            return '<i>[hidden]</i>'\n          }\n        }\n        return `<a href=\"https://t.me/addstickers/${escapeHTML(pack.name)}\">${escapeHTML(pack.name)}</a>`\n      })).reduce((resultArray, item, index) => {\n        const chunkIndex = Math.floor(index / chunkSize)\n\n        if (!resultArray[chunkIndex]) {\n          resultArray[chunkIndex] = []\n        }\n\n        resultArray[chunkIndex].push(item)\n\n        return resultArray\n      }, [])\n    }\n\n    let packsToReturn\n\n    if (chunkedPacks.length > 0) {\n      packsToReturn = chunkedPacks.shift()\n    }\n\n    // Save data for \"show all packs\" button\n    const totalPacks = findPacks.length\n    if (chunkedPacks.length > 0) {\n      ctx.session.showAllPacksData = {\n        ownerId: sharedUserId,\n        excludeSetId: null\n      }\n    }\n\n    const keyboard = []\n    if (chunkedPacks.length > 0) {\n      keyboard.push([Markup.callbackButton(\n        ctx.i18n.t('scenes.packAbout.btn.show_all_packs', { count: totalPacks }),\n        'show_all_packs'\n      )])\n    }\n\n    await ctx.replyWithHTML(ctx.i18n.t('userAbout.result', {\n      userId: sharedUserId,\n      packs: packsToReturn ? packsToReturn.join(', ') : ctx.i18n.t('userAbout.no_packs')\n    }), {\n      disable_web_page_preview: true,\n      ...(keyboard.length > 0 ? Markup.inlineKeyboard(keyboard).extra() : {})\n    })\n    return\n  }\n  if (!ctx.message) return\n\n  let sticker\n\n  if (ctx.message.entities && ctx.message.entities[0] && ctx.message.entities[0].type === 'custom_emoji') {\n    const customEmoji = ctx.message.entities.find((e) => e.type === 'custom_emoji')\n\n    if (!customEmoji) return\n\n    const emojiStickers = await ctx.telegram.callApi('getCustomEmojiStickers', {\n      custom_emoji_ids: [customEmoji.custom_emoji_id]\n    })\n\n    if (!emojiStickers) return\n\n    sticker = emojiStickers[0]\n  } else if (ctx.message.sticker) {\n    sticker = ctx.message.sticker\n  } else {\n    return next()\n  }\n\n  if (!sticker) {\n    return ctx.replyWithHTML(ctx.i18n.t('scenes.packAbout.not_found'))\n  }\n\n  if (!sticker.set_name) {\n    return ctx.replyWithHTML(ctx.i18n.t('scenes.packAbout.not_found'))\n  }\n\n  // First check database\n  let stickerSet = await db.StickerSet.findOne({\n    name: sticker.set_name\n  })\n\n  let ownerId = stickerSet?.ownerTelegramId || null\n  let setId = null\n  let dcId = null\n  let stickerCount = null\n\n  // Only use MTProto if we don't have owner info in database\n  if (!ownerId && telegramApi.client) {\n    try {\n      const stickerSetInfo = await telegramApi.client.invoke(new telegramApi.Api.messages.GetStickerSet({\n        stickerset: new telegramApi.Api.InputStickerSetShortName({\n          shortName: sticker.set_name\n        }),\n        hash: 0\n      }))\n\n      if (stickerSetInfo) {\n        const decoded = decodeStickerSetId(stickerSetInfo.set.id.value)\n        ownerId = decoded.ownerId\n        setId = decoded.setId\n        dcId = decoded.dcId\n        stickerCount = stickerSetInfo.set.count\n\n        // Save to database for future requests\n        if (!stickerSet) {\n          stickerSet = await db.StickerSet.create({\n            ownerTelegramId: ownerId,\n            name: sticker.set_name,\n            title: stickerSetInfo.set.title,\n            animated: sticker.is_animated,\n            video: sticker.is_video,\n            packType: sticker.type,\n            thirdParty: true\n          })\n        } else if (!stickerSet.ownerTelegramId) {\n          // Update existing record with owner info\n          stickerSet.ownerTelegramId = ownerId\n          await stickerSet.save()\n        }\n      }\n    } catch (err) {\n      // MTProto API unavailable, continue without owner info\n    }\n  }\n\n  const actualOwnerId = ownerId\n\n  // get all stickerset owners from database (only if we have owner info)\n  const packs = actualOwnerId\n    ? await db.StickerSet.find({\n      ownerTelegramId: actualOwnerId,\n      _id: { $ne: stickerSet?._id || null }\n    })\n    : []\n\n  let chunkedPacks = []\n  const chunkSize = 20 // Reduced to prevent \"message too long\" errors\n\n  if (packs.length > 0) {\n    chunkedPacks = (packs.map((pack) => {\n      if (pack.name.toLowerCase().endsWith('fStikBot'.toLowerCase()) && pack.public !== true) {\n        if (\n          ctx.from.id === actualOwnerId ||\n          ctx.from.id === ctx.config.mainAdminId ||\n          ctx?.session?.userInfo?.adminRights?.includes('pack')\n        ) {\n          return `<a href=\"https://t.me/addstickers/${escapeHTML(pack.name)}\"><s>${escapeHTML(pack.name)}</s></a>`\n        } else {\n          return '<i>[hidden]</i>'\n        }\n      }\n      return `<a href=\"https://t.me/addstickers/${escapeHTML(pack.name)}\">${escapeHTML(pack.name)}</a>`\n    })).reduce((resultArray, item, index) => {\n      const chunkIndex = Math.floor(index / chunkSize)\n\n      if (!resultArray[chunkIndex]) {\n        resultArray[chunkIndex] = []\n      }\n\n      resultArray[chunkIndex].push(item)\n\n      return resultArray\n    }, [])\n  }\n\n  if (ctx.session.userInfo.locale === 'ru' && !ctx.session.userInfo?.stickerSet?.boost) {\n    showGramAds(ctx.chat.id)\n  }\n\n  let ownerChat = null\n  let mention = ctx.i18n.t('scenes.packAbout.unknown_owner')\n\n  if (actualOwnerId) {\n    ownerChat = await ctx.telegram.getChat(actualOwnerId).catch(() => null)\n    mention = (!ownerChat || ownerChat?.has_private_forwards === true) ? undefined : `<a href=\"tg://user?id=${actualOwnerId}\">${escapeHTML(ownerChat?.first_name) || 'unknown'}</a>`\n    if (!mention) mention = `<a href=\"tg://openmessage?user_id=${actualOwnerId}\">[🤖]</a>, <a href=\"https://t.me/@id${actualOwnerId}\">[🍏]</a>`\n  }\n\n  let otherPacks\n\n  if (chunkedPacks.length > 0) {\n    otherPacks = chunkedPacks.shift()\n  }\n\n  // Save sticker for download button\n  ctx.session.lastStickerForDownload = {\n    file_id: sticker.file_id,\n    file_unique_id: sticker.file_unique_id,\n    is_video: sticker.is_video,\n    is_animated: sticker.is_animated\n  }\n\n  // Save data for \"show all packs\" button\n  const totalOtherPacks = packs.length\n  if (chunkedPacks.length > 0) {\n    ctx.session.showAllPacksData = {\n      ownerId: actualOwnerId,\n      excludeSetId: stickerSet?._id || null\n    }\n  }\n\n  // Build keyboard\n  const keyboard = [[Markup.callbackButton(ctx.i18n.t('scenes.packAbout.btn.download'), 'download_original')]]\n  if (chunkedPacks.length > 0) {\n    keyboard.push([Markup.callbackButton(\n      ctx.i18n.t('scenes.packAbout.btn.show_all_packs', { count: totalOtherPacks }),\n      'show_all_packs'\n    )])\n  }\n\n  const otherPacksText = otherPacks\n    ? otherPacks.slice(0, 15).join(', ') + (otherPacks.length > 15 ? '...' : '')\n    : ctx.i18n.t('scenes.packAbout.no_other_packs')\n\n  const dcRegion = dcId ? DC_REGIONS[dcId] || '?' : null\n  const dcDisplay = dcId ? `${dcRegion}` : '?'\n\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.packAbout.result', {\n    link: `https://t.me/addstickers/${sticker.set_name}`,\n    name: escapeHTML(sticker.set_name),\n    ownerId: actualOwnerId ?? '?',\n    mention,\n    setId: setId ?? '?',\n    dcId: dcDisplay,\n    stickerCount: stickerCount ?? '?',\n    otherPacks: otherPacksText\n  }), {\n    disable_web_page_preview: true,\n    ...Markup.inlineKeyboard(keyboard).extra()\n  })\n})\n\nmodule.exports = packAbout\n"
  },
  {
    "path": "scenes/pack-catalog.js",
    "content": "const fs = require('fs')\nconst path = require('path')\nconst Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst I18n = require('telegraf-i18n')\nconst mongoose = require('mongoose')\nconst { db } = require('../database')\nconst { escapeHTML, telegramApi } = require('../utils')\nconst telegram = require('../utils/telegram')\n\nfunction stickerSetIdToOwnerId (u64) {\n  const u32 = u64 >> 32n\n\n  if ((u64 >> 24n & 0xffn) === 0xffn) {\n    return parseInt((u64 >> 32n) + 0x100000000n)\n  }\n  return parseInt(u32)\n}\n\nconst { match } = I18n\nconst i18n = new I18n({\n  directory: path.resolve(__dirname, '../locales'),\n  defaultLanguage: 'ru',\n  defaultLanguageOnMissing: true\n})\n\nconst localesFile = fs.readdirSync('./locales/')\n\nconst createStickerSet = async (packName, userInfo) => {\n  let stickerSet = await db.StickerSet.findOne({\n    name: packName\n  })\n\n  if (!stickerSet) {\n    const stickerSetInfo = await telegram.getStickerSet(packName).catch(() => null)\n\n    if (!stickerSetInfo) {\n      return null\n    }\n\n    stickerSet = new db.StickerSet({\n      _id: mongoose.Types.ObjectId(),\n      owner: userInfo,\n      name: stickerSetInfo.name,\n      title: stickerSetInfo.title,\n      animated: stickerSetInfo.is_animated,\n      video: stickerSetInfo.is_video,\n      create: false,\n      thirdParty: true\n    })\n  }\n\n  if (userInfo.moderator === true) {\n    stickerSet.about.verified = true\n  }\n\n  await stickerSet.save()\n\n  return stickerSet\n}\n\nconst catalogPublishNew = new Scene('catalogPublishNew')\n\ncatalogPublishNew.enter(async (ctx) => {\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.catalog.publish.publish_new'), {\n    reply_markup: Markup.keyboard([\n      [\n        { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n      ]\n    ]).resize()\n  })\n})\n\ncatalogPublishNew.on(['sticker', 'text'], async (ctx) => {\n  if (!ctx.session.scene) ctx.session.scene = {}\n  ctx.session.scene.publish = {}\n\n  let packName\n\n  if (ctx.message.sticker) {\n    packName = ctx.message.sticker.set_name\n  } else if (ctx.message.entities && ctx.message.entities.find(e => e.type === 'custom_emoji')) {\n    const customEmoji = ctx.message.entities.find(e => e.type === 'custom_emoji')\n\n    const emojiStickers = await ctx.telegram.callApi('getCustomEmojiStickers', {\n      custom_emoji_ids: [customEmoji.custom_emoji_id]\n    })\n\n    if (emojiStickers?.[0]?.set_name) {\n      packName = emojiStickers[0].set_name\n    } else {\n      return ctx.scene.reenter()\n    }\n  } else {\n    const messageTextMatch = ctx.message.text.match(/(addstickers)\\/(.*)/)\n\n    if (!messageTextMatch || !messageTextMatch[2]) {\n      return ctx.scene.reenter()\n    }\n\n    packName = messageTextMatch[2]\n  }\n\n  if (!packName) {\n    return ctx.scene.reenter()\n  }\n\n  const getStickerSetInfo = await telegramApi.client.invoke(new telegramApi.Api.messages.GetStickerSet({\n    stickerset: new telegramApi.Api.InputStickerSetShortName({\n      shortName: packName\n    }),\n    hash: 0\n  })).catch(() => {})\n\n  if (!getStickerSetInfo) {\n    return ctx.scene.reenter()\n  }\n\n  // const stickersKeywords = getStickerSetInfo.keywords.map((keyword) => {\n  //   const stickerFind = getStickerSetInfo.documents.find((sticker) => {\n  //     return sticker.id.toString() === keyword.documentId.toString()\n  //   })\n\n  //   if (!stickerFind) {\n  //     return\n  //   }\n\n  //   return {\n  //     sticker: stickerFind,\n  //     keywords: keyword.keyword\n  //   }\n  // })\n\n  const packOwner = stickerSetIdToOwnerId(getStickerSetInfo.set.id.value)\n\n  if (\n    ctx.session.userInfo.moderator !== true &&\n    packOwner !== ctx.from.id\n  ) {\n    ctx.session.scene.publish.packName = packName\n    return ctx.scene.enter('catalogPublishOwnerProof')\n  }\n\n  ctx.session.scene.publish.stickerSet = await createStickerSet(packName, ctx.session.userInfo)\n\n  if (!ctx.session.scene.publish.stickerSet) {\n    return ctx.scene.reenter()\n  }\n\n  if (ctx.session.userInfo.moderator === true) {\n    return ctx.scene.enter('catalogEnterDescription')\n  } else {\n    return ctx.scene.enter('catalogPublish')\n  }\n})\n\nconst catalogPublishOwnerProof = new Scene('catalogPublishOwnerProof')\n\ncatalogPublishOwnerProof.enter(async (ctx) => {\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.catalog.publish.owner_proof'), {\n    reply_markup: Markup.keyboard([\n      [\n        { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n      ]\n    ]).resize()\n  })\n})\n\ncatalogPublishOwnerProof.on('text', async (ctx) => {\n  if (!ctx.session.scene?.publish) return ctx.scene.leave()\n  if (ctx.message.forward_from && ctx.message.forward_from.id === 429000) {\n    if (!ctx.message.entities) {\n      return ctx.scene.reenter()\n    }\n\n    if (\n      !ctx.message.entities[0].url.match(ctx.session.scene.publish.packName)\n    ) {\n      return ctx.scene.reenter()\n    }\n\n    ctx.session.scene.publish.stickerSet = await createStickerSet(ctx.session.scene.publish.packName, ctx.session.userInfo)\n\n    if (!ctx.session.scene.publish.stickerSet) {\n      return ctx.scene.reenter()\n    }\n\n    return ctx.scene.enter('catalogPublish')\n  } else {\n    return ctx.scene.reenter()\n  }\n})\n\nconst catalogPublish = new Scene('catalogPublish')\n\ncatalogPublish.enter(async (ctx) => {\n  if (ctx.session.userInfo.publicBan === true) {\n    await ctx.replyWithHTML(ctx.i18n.t('scenes.catalog.publish.banned'))\n    return ctx.scene.leave()\n  }\n\n  if (!ctx.session.scene) ctx.session.scene = {}\n\n  let stickerSetId\n\n  if (ctx.match && ctx.match[1]) {\n    stickerSetId = ctx.match[1]\n  } else if (ctx.session.scene?.publish?.stickerSet) {\n    stickerSetId = ctx.session.scene.publish.stickerSet._id\n  } else {\n    return ctx.scene.leave()\n  }\n\n  const stickerSet = await ctx.db.StickerSet.findById(stickerSetId)\n\n  const linkPrefix = stickerSet.packType === 'custom_emoji' ? ctx.config.emojiLinkPrefix : ctx.config.stickerLinkPrefix\n\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.catalog.publish.enter', {\n    link: `${linkPrefix}${stickerSet.name}`,\n    title: escapeHTML(stickerSet.title)\n  }), {\n    reply_markup: Markup.keyboard([\n      [\n        { text: ctx.i18n.t('scenes.catalog.publish.continue_button'), style: 'primary' }\n      ],\n      [\n        { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n      ]\n    ]).resize()\n  })\n\n  ctx.session.scene.publish = {\n    stickerSet\n  }\n})\n\ncatalogPublish.hears(match('scenes.catalog.publish.continue_button'), async (ctx) => {\n  return ctx.scene.enter('catalogEnterDescription')\n})\n\nconst catalogEnterDescription = new Scene('catalogEnterDescription')\n\ncatalogEnterDescription.enter(async (ctx) => {\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.catalog.publish.enter_description'), {\n    reply_markup: Markup.keyboard([\n      [\n        { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n      ]\n    ]).resize()\n  })\n})\n\ncatalogEnterDescription.on('text', async (ctx) => {\n  if (!ctx.session.scene?.publish) return ctx.scene.leave()\n  const { entities, text } = ctx.message\n\n  ctx.session.scene.publish.description = text.slice(0, 512)\n\n  if (entities?.length > 0) {\n    const hashtags = []\n    let currentHashtag = ''\n    let currentEntity = null\n\n    // find hashtags in text via entities\n    for (let offset = 0; offset < text.length; offset++) {\n      const entity = entities.find(entity => entity.offset === offset)\n\n      if (entity?.type === 'hashtag') {\n        currentEntity = entity\n      }\n\n      if (currentEntity) {\n        if (text[offset] !== '#') {\n          currentHashtag += text[offset]\n        }\n\n        if (currentEntity.length === currentHashtag.length + 1) {\n          hashtags.push(currentHashtag)\n          currentHashtag = ''\n          currentEntity = null\n        }\n      }\n    }\n\n    // only unique hashtags\n    const uniqueHashtags = [...new Set(hashtags)]\n\n    ctx.session.scene.publish.tags = uniqueHashtags\n  }\n\n  return ctx.scene.enter('catalogSelectLanguage')\n})\n\nconst catalogSelectLanguage = new Scene('catalogSelectLanguage')\n\ncatalogSelectLanguage.enter(async (ctx) => {\n  if (!ctx.session.scene?.publish) return ctx.scene.leave()\n  if (!ctx.session.scene.publish.languages) {\n    ctx.session.scene.publish.languages = []\n  }\n\n  const locales = {}\n\n  localesFile.forEach((fileName) => {\n    const localName = fileName.split('.')[0]\n    if (localName === 'ru' || i18n.t('ru', 'language_name') !== i18n.t(localName, 'language_name')) {\n      locales[localName] = {\n        flag: i18n.t(localName, 'language_name')\n      }\n    }\n  })\n\n  const button = []\n\n  button.push(Markup.callbackButton(ctx.i18n.t('scenes.catalog.publish.button_all_languages'), 'catalog:set_language:all'))\n\n  Object.keys(locales).map((key) => {\n    let name = locales[key].flag\n\n    if (ctx.session.scene.publish.languages.includes(key)) {\n      name = `✅ ${name}`\n    }\n\n    button.push(Markup.callbackButton(name, `catalog:set_language:${key}`))\n  })\n\n  if (ctx.session.scene.publish.languages.length > 0) {\n    button.push(Markup.callbackButton(ctx.i18n.t('scenes.catalog.publish.button_confirm_language'), 'catalog:set_language:confirm'))\n  }\n\n  const resultText = ctx.i18n.t('scenes.catalog.publish.select_language')\n\n  if (ctx.callbackQuery) {\n    await ctx.editMessageText(resultText, {\n      parse_mode: 'HTML',\n      reply_markup: Markup.inlineKeyboard(button, {\n        columns: 1\n      })\n    })\n  } else {\n    await ctx.replyWithHTML(resultText, {\n      reply_markup: Markup.inlineKeyboard(button, {\n        columns: 1\n      })\n    })\n  }\n})\n\ncatalogSelectLanguage.action(/^catalog:set_language:(.*)$/, async (ctx) => {\n  if (!ctx.session.scene?.publish) return ctx.scene.leave()\n  if (ctx.match[1] === 'all') {\n    ctx.session.scene.publish.languages = []\n    return ctx.scene.enter('catalogPublishConfirm')\n  }\n\n  if (ctx.match[1] === 'confirm') {\n    return ctx.scene.enter('catalogPublishConfirm')\n  }\n\n  const language = ctx.match[1]\n\n  if (ctx.session.scene.publish.languages.indexOf(language) === -1) {\n    ctx.session.scene.publish.languages.push(language)\n  } else {\n    ctx.session.scene.publish.languages.splice(ctx.session.scene.publish.languages.indexOf(language), 1)\n  }\n\n  return ctx.scene.reenter()\n})\n\nconst catalogSetSafe = new Scene('catalogSetSafe')\n\ncatalogSetSafe.enter(async (ctx) => {\n  const inlineKeyboard = Markup.inlineKeyboard([\n    [\n      Markup.callbackButton(ctx.i18n.t('scenes.catalog.publish.button_safe.safe'), 'catalog:set_safe:true')\n    ],\n    [\n      Markup.callbackButton(ctx.i18n.t('scenes.catalog.publish.button_safe.not_safe'), 'catalog:set_safe:false')\n    ]\n  ])\n\n  const resultText = ctx.i18n.t('scenes.catalog.publish.set_safe')\n\n  await ctx.replyWithHTML(resultText, {\n    reply_markup: inlineKeyboard\n  })\n})\n\ncatalogSetSafe.action(/^catalog:set_safe:(.*)$/, async (ctx) => {\n  if (!ctx.session.scene?.publish) return ctx.scene.leave()\n  ctx.session.scene.publish.safe = ctx.match[1] === 'true'\n  return ctx.scene.enter('catalogPublishConfirm')\n})\n\nconst catalogPublishConfirm = new Scene('catalogPublishConfirm')\n\ncatalogPublishConfirm.enter(async (ctx) => {\n  if (!ctx.session.scene?.publish) return ctx.scene.leave()\n  const publish = ctx.session.scene.publish\n\n  const languages = []\n\n  publish.languages.forEach((language) => {\n    languages.push(i18n.t(language, 'language_name'))\n  })\n\n  if (languages.length === 0) {\n    languages.push(ctx.i18n.t('scenes.catalog.publish.button_all_languages'))\n  }\n\n  const tags = []\n\n  if (publish.tags && publish.tags.length > 0) {\n    publish.tags.forEach((tag) => {\n      tags.push(`#${tag}`)\n    })\n  }\n\n  if (tags.length <= 0) {\n    tags.push(ctx.i18n.t('scenes.catalog.publish.no_tags'))\n  }\n\n  const resultText = ctx.i18n.t('scenes.catalog.publish.confirm', {\n    link: `${ctx.config.stickerLinkPrefix}${publish.stickerSet.name}`,\n    title: escapeHTML(publish.stickerSet.title),\n    description: escapeHTML(publish.description),\n    tags: tags.join(' '),\n    languages: languages.join(', ')\n  })\n\n  await ctx.replyWithHTML(resultText, {\n    reply_markup: Markup.keyboard([\n      [\n        { text: ctx.i18n.t('scenes.catalog.publish.button_confirm'), style: 'success' }\n      ],\n      [\n        { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n      ]\n    ]).resize()\n  })\n})\n\ncatalogPublishConfirm.hears(match('scenes.catalog.publish.button_confirm'), async (ctx) => {\n  if (!ctx.session.scene?.publish) return ctx.scene.leave()\n  const publish = ctx.session.scene.publish\n\n  publish.stickerSet.about = Object.assign(publish.stickerSet.about, {\n    description: publish.description,\n    tags: publish.tags,\n    languages: publish.languages\n  })\n\n  if (!publish.stickerSet.public) {\n    publish.stickerSet.publishDate = new Date()\n  }\n\n  publish.stickerSet.public = true\n\n  await publish.stickerSet.save()\n\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.catalog.publish.success'))\n\n  ctx.session.scene.publish = null\n\n  ctx.scene.leave()\n\n  await ctx.replyWithHTML('👌', {\n    reply_markup: {\n      remove_keyboard: true\n    }\n  })\n})\n\nconst catalogUnpublish = new Scene('catalogUnpublish')\n\ncatalogUnpublish.action(/^catalog:unpublish:(.*)$/, async (ctx) => {\n  const stickerSetId = ctx.match[1]\n\n  const stickerSet = await ctx.db.StickerSet.findOne({\n    _id: stickerSetId,\n    owner: ctx.session.userInfo._id\n  })\n\n  if (!stickerSet) {\n    return ctx.answerCbQuery(`Sticker set ${stickerSetId} not found`)\n  }\n\n  stickerSet.public = false\n\n  await stickerSet.save()\n\n  await ctx.answerCbQuery(ctx.i18n.t('scenes.catalog.unpublish.success'))\n})\n\nmodule.exports = [\n  catalogPublishNew,\n  catalogPublishOwnerProof,\n  catalogPublish,\n  catalogEnterDescription,\n  catalogSelectLanguage,\n  catalogSetSafe,\n  catalogPublishConfirm,\n  catalogUnpublish\n]\n"
  },
  {
    "path": "scenes/pack-delete.js",
    "content": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst { match } = require('telegraf-i18n')\nconst { escapeHTML } = require('../utils')\nconst { humanizeTelegramError } = require('../utils/telegram-error')\n\nconst packDelete = new Scene('packDelete')\n\npackDelete.enter(async (ctx) => {\n  const stickerSet = await ctx.db.StickerSet.findById(ctx.match[1])\n\n  if (!stickerSet) return ctx.answerCbQuery(ctx.i18n.t('callback.pack.answerCbQuer.not_found'), true)\n\n  if (stickerSet.owner.toString() !== ctx.session.userInfo.id.toString()) {\n    await ctx.answerCbQuery(ctx.i18n.t('error.access_denied'), true)\n    return ctx.scene.leave()\n  }\n\n  await ctx.deleteMessage().catch(() => {})\n\n  ctx.session.scene = {\n    id: 'packDelete',\n    data: {\n      id: stickerSet._id,\n      name: stickerSet.name,\n      title: stickerSet.title\n    }\n  }\n\n  const linkPrefix = stickerSet.packType === 'custom_emoji' ? ctx.config.emojiLinkPrefix : ctx.config.stickerLinkPrefix\n\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.delete_pack.enter', {\n    link: `${linkPrefix}${stickerSet.name}`,\n    title: escapeHTML(stickerSet.title),\n    confirm: ctx.i18n.t('scenes.delete_pack.confirm')\n  }), {\n    reply_markup: Markup.keyboard([\n      [\n        { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n      ]\n    ]).resize()\n  })\n})\n\npackDelete.hears(match('scenes.delete_pack.confirm'), async (ctx) => {\n  if (!ctx.session.scene?.data) return ctx.scene.leave()\n\n  const { id, name, title } = ctx.session.scene.data\n  const successText = title\n    ? `${ctx.i18n.t('scenes.delete_pack.success')}\\n\\n📦 <i>${escapeHTML(title)}</i>`\n    : ctx.i18n.t('scenes.delete_pack.success')\n\n  try {\n    await ctx.telegram.callApi('deleteStickerSet', { name })\n  } catch (error) {\n    const description = error?.description || error?.message || ''\n\n    if (description.includes('STICKERSET_INVALID')) {\n      // Pack already gone in Telegram — clean DB and treat as success.\n      await ctx.db.StickerSet.deleteOne({ _id: id })\n      return ctx.replyWithHTML(successText, {\n        reply_markup: Markup.removeKeyboard()\n      })\n    }\n\n    return ctx.replyWithHTML(humanizeTelegramError(ctx, error), {\n      reply_markup: Markup.keyboard([\n        [\n          { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n        ]\n      ]).resize()\n    })\n  }\n\n  await ctx.db.StickerSet.updateOne({ _id: id }, { deleted: true })\n  await ctx.db.Sticker.updateMany({\n    stickerSet: id\n  }, {\n    $set: { deleted: true, deletedAt: new Date() }\n  })\n\n  await ctx.replyWithHTML(successText, {\n    reply_markup: Markup.removeKeyboard()\n  })\n})\n\nmodule.exports = packDelete\n"
  },
  {
    "path": "scenes/pack-frame.js",
    "content": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst {\n  match\n} = require('telegraf-i18n')\n\nconst packFrame = new Scene('packFrame')\n\npackFrame.enter(async (ctx) => {\n  if (!ctx.session.userInfo.stickerSet) {\n    await ctx.scene.leave()\n    return ctx.replyWithHTML(ctx.i18n.t('scenes.frame.no_sticker_set'), {\n      reply_markup: {\n        remove_keyboard: true\n      }\n    })\n  }\n\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.frame.select_type', {\n    example: 'https://telegra.ph/file/5267f02e571399ba02b84.png'\n  }), {\n    reply_markup: Markup.keyboard([\n      [\n        ctx.i18n.t('scenes.frame.types.lite'),\n        ctx.i18n.t('scenes.frame.types.medium'),\n        ctx.i18n.t('scenes.frame.types.rounded')\n      ],\n      [\n        ctx.i18n.t('scenes.frame.types.square'),\n        ctx.i18n.t('scenes.frame.types.circle')\n      ],\n      [\n        { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n      ]\n    ]).resize()\n  })\n})\n\npackFrame.hears([\n  match('scenes.frame.types.rounded'),\n  match('scenes.frame.types.circle'),\n  match('scenes.frame.types.square'),\n  match('scenes.frame.types.lite'),\n  match('scenes.frame.types.medium')\n], async (ctx) => {\n  if (!ctx.session?.userInfo?.stickerSet) {\n    await ctx.scene.leave()\n    return ctx.replyWithHTML(ctx.i18n.t('scenes.frame.no_sticker_set'), {\n      reply_markup: {\n        remove_keyboard: true\n      }\n    })\n  }\n\n  let type\n\n  switch (ctx.message.text) {\n    case ctx.i18n.t('scenes.frame.types.rounded'):\n      type = 'rounded'\n      break\n    case ctx.i18n.t('scenes.frame.types.circle'):\n      type = 'circle'\n      break\n    case ctx.i18n.t('scenes.frame.types.square'):\n      type = 'square'\n      break\n    case ctx.i18n.t('scenes.frame.types.lite'):\n      type = 'lite'\n      break\n    case ctx.i18n.t('scenes.frame.types.medium'):\n      type = 'medium'\n      break\n  }\n\n  const updateResulet = await ctx.db.StickerSet.updateOne({\n    _id: ctx.session.userInfo.stickerSet._id\n  }, {\n    $set: {\n      frameType: type\n    }\n  })\n\n  if (updateResulet.ok) {\n    await ctx.replyWithHTML(ctx.i18n.t('scenes.frame.selected', {\n      type: ctx.i18n.t(`scenes.frame.types.${type}`)\n    }), {\n      reply_markup: {\n        remove_keyboard: true\n      }\n    })\n  }\n})\n\nmodule.exports = [\n  packFrame\n]\n"
  },
  {
    "path": "scenes/pack-new.js",
    "content": "const got = require('got')\nconst slug = require('limax')\nconst StegCloak = require('stegcloak')\nconst Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst I18n = require('telegraf-i18n')\nconst { generateStrings } = require('sticker-pack-names')\n\nconst { sendBanner } = require('../banners')\nconst {\n  escapeHTML,\n  addSticker,\n  countUncodeChars,\n  substrUnicode\n} = require('../utils')\nconst { humanizeTelegramError } = require('../utils/telegram-error')\nconst log = require('../utils/logger').scope('pack-new')\n\nconst { match } = I18n\n\nconst placeholder = {\n  regular: {\n    video: 'sticker_placeholder.webm'\n  },\n  custom_emoji: {\n    video: 'emoji_placeholder.webm'\n  }\n}\n\nconst stegcloak = new StegCloak(false, false)\n\n// Run `worker(item, index)` over `items` with at most `limit` concurrent tasks.\n// Results are returned in the original order. Errors thrown by the worker\n// are captured as { error } so one failure doesn't abort the pool.\nconst runConcurrent = async (items, limit, worker) => {\n  const results = new Array(items.length)\n  let cursor = 0\n\n  const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {\n    while (true) {\n      const current = cursor++\n      if (current >= items.length) return\n      try {\n        results[current] = await worker(items[current], current)\n      } catch (err) {\n        results[current] = { error: err }\n      }\n    }\n  })\n\n  await Promise.all(runners)\n  return results\n}\n\nconst newPack = new Scene('newPack')\n\nnewPack.enter(async (ctx, next) => {\n  if (!ctx.session.scene) ctx.session.scene = {}\n  const existingNewPack = ctx.session.scene.newPack || {}\n  ctx.session.scene.newPack = existingNewPack\n\n  if (ctx?.message?.text) {\n    const args = ctx.message.text.split(' ')\n\n    if (['fill', 'adaptive'].includes(args[1])) {\n      ctx.session.scene.newPack.fillColor = true\n    }\n  }\n\n  // Якщо це інлайн пак, пропускаємо вибір типу\n  if (ctx.session.scene.newPack.inline) {\n    return ctx.scene.enter('newPackTitle')\n  }\n\n  await sendBanner(ctx, 'new-pack', ctx.i18n.t('scenes.new_pack.pack_type'), {\n    reply_markup: Markup.keyboard([\n      [\n        { text: ctx.i18n.t('scenes.new_pack.regular'), style: 'primary' }\n      ],\n      [\n        { text: ctx.i18n.t('scenes.new_pack.custom_emoji'), style: 'primary' }\n      ],\n      [\n        { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n      ]\n    ]).resize()\n  })\n})\n\nnewPack.on('message', async (ctx) => {\n  if (!ctx.session.scene?.newPack) return ctx.scene.leave()\n  const { text } = ctx.message\n  const { newPack } = ctx.session.scene\n  if (text === ctx.i18n.t('scenes.new_pack.custom_emoji')) {\n    newPack.packType = 'custom_emoji'\n  } else if (text === ctx.i18n.t('scenes.new_pack.regular')) {\n    newPack.packType = 'regular'\n  } else {\n    return ctx.scene.reenter()\n  }\n\n  if (\n    ctx.session.scene?.copyPack &&\n    ctx.session.scene.copyPack.sticker_type !== newPack.packType\n  ) {\n    return ctx.scene.enter('newPackCopyPay')\n  }\n\n  return ctx.scene.enter('newPackTitle')\n})\n\nconst newPackCopyPay = new Scene('newPackCopyPay')\n\nnewPackCopyPay.enter(async (ctx) => {\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.copy.pay', {\n    balance: ctx.session.userInfo.balance\n  }), {\n    reply_markup: Markup.keyboard([\n      [\n        { text: ctx.i18n.t('scenes.copy.pay_btn'), style: 'primary' }\n      ],\n      [\n        { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n      ]\n    ]).resize()\n  })\n})\n\nnewPackCopyPay.hears(match('scenes.copy.pay_btn'), async (ctx) => {\n  if (ctx.session.userInfo.balance < 1) {\n    await ctx.replyWithHTML(ctx.i18n.t('scenes.boost.error.not_enough_credits'), {\n      reply_markup: Markup.removeKeyboard()\n    })\n\n    // Clean up all session state\n    ctx.session.scene = {}\n    return ctx.scene.leave()\n  }\n  return ctx.scene.enter('newPackTitle')\n})\n\nconst newPackTitle = new Scene('newPackTitle')\n\nnewPackTitle.enter(async (ctx) => {\n  if (!ctx.session.scene) return ctx.scene.leave()\n  if (!ctx.session.scene.newPack) {\n    ctx.session.scene.newPack = {}\n  }\n\n  const names = generateStrings({ count: 3 })\n\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.new_pack.pack_title'), {\n    reply_markup: Markup.keyboard([\n      ...names.map((name) => [name]),\n      [\n        { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n      ]\n    ]).resize()\n  })\n})\nnewPackTitle.on('text', async (ctx) => {\n  if (!ctx.session.scene?.newPack) return ctx.scene.leave()\n  const charTitleMax = ctx.config.charTitleMax\n\n  let title = ctx.message.text\n\n  if (countUncodeChars(title) > charTitleMax) {\n    title = substrUnicode(title, 0, charTitleMax)\n  }\n\n  ctx.session.scene.newPack.title = title\n\n  if (ctx.session.scene.newPack.inline) return ctx.scene.enter('newPackConfirm')\n  else return ctx.scene.enter('newPackName')\n})\n\nconst newPackName = new Scene('newPackName')\n\nnewPackName.enter((ctx) => ctx.replyWithHTML(ctx.i18n.t('scenes.new_pack.pack_name'), {\n  reply_to_message_id: ctx.message.message_id,\n  allow_sending_without_reply: true,\n  disable_web_page_preview: true\n}))\n\nnewPackName.on('text', async (ctx) => {\n  // Ensure scene state exists\n  if (!ctx.session.scene?.newPack) {\n    return ctx.scene.enter('newPack')\n  }\n\n  ctx.session.scene.newPack.name = ctx.message.text\n\n  return ctx.scene.enter('newPackConfirm')\n})\n\nconst newPackConfirm = new Scene('newPackConfirm')\n\nnewPackConfirm.enter(async (ctx, next) => {\n  if (!ctx.session.scene?.newPack) return ctx.scene.leave()\n  if (!ctx.session.userInfo) ctx.session.userInfo = await ctx.db.User.getData(ctx.from)\n\n  const copyPack = ctx.session.scene.copyPack\n  const inline = !!ctx.session.scene.newPack.inline\n\n  const nameSuffix = `_by_${ctx.options.username}`\n  const titleSuffix = ` :: @${ctx.options.username}`\n\n  let { name, title, fillColor, packType } = ctx.session.scene.newPack\n\n  // Для inline паку автоматично генеруємо name\n  if (inline) {\n    name = 'inline_' + ctx.from.id\n  } else {\n    name = name.replace(/https/, '')\n    name = name.replace(/t.me\\/addstickers\\//, '')\n    name = slug(name, { separator: '_', maintainCase: true })\n    name = name.replace(/[^0-9a-z_]/gi, '')\n  }\n\n  if (!name) {\n    return ctx.scene.enter('newPackName')\n  }\n\n  const maxNameLength = 64 - nameSuffix.length\n\n  if (name.length >= maxNameLength) {\n    name = name.slice(0, maxNameLength)\n  }\n\n  if (!inline) name += nameSuffix\n  if (!inline) title += titleSuffix\n\n  let alreadyUploadedStickers = 0\n  let createNewStickerSet\n  let hasPlaceholder = false\n  let failedBatchIndices = [] // Track failed stickers from batch for retry\n\n  packType = packType || 'regular'\n\n  if (inline) {\n    createNewStickerSet = true\n  } else {\n    const stickerSetByName = await ctx.db.StickerSet.findOne({ name })\n\n    if (stickerSetByName) {\n      await ctx.replyWithHTML(ctx.i18n.t('scenes.new_pack.error.telegram.name_occupied'), {\n        reply_to_message_id: ctx.message.message_id,\n        allow_sending_without_reply: true\n      })\n      return ctx.scene.enter('newPackName')\n    }\n\n    if (copyPack) {\n      const waitMessage = await ctx.replyWithHTML('⏳', {\n        reply_markup: {\n          remove_keyboard: true\n        }\n      })\n\n      const originalPackType = copyPack.sticker_type\n\n      let uploadedStickers = []\n\n      if (originalPackType === packType) {\n        const stickers = copyPack.stickers.slice(0, 50)\n\n        const batchResults = await Promise.all(stickers.map(async (sticker, originalIndex) => {\n          let stickerFormat\n\n          if (sticker.is_animated) {\n            stickerFormat = 'animated'\n          } else if (sticker.is_video) {\n            stickerFormat = 'video'\n          } else {\n            stickerFormat = 'static'\n          }\n\n          let fileLink\n          try {\n            fileLink = await ctx.telegram.getFileLink(sticker.file_id)\n          } catch (err) {\n            return {\n              error: {\n                telegram: err\n              },\n              originalIndex\n            }\n          }\n\n          const buffer = await got(fileLink, {\n            responseType: 'buffer'\n          }).then((response) => response.body).catch((err) => null)\n\n          if (!buffer) {\n            return {\n              error: {\n                telegram: new Error('Failed to download sticker')\n              },\n              originalIndex\n            }\n          }\n\n          const uploadedSticker = await ctx.telegram.callApi('uploadStickerFile', {\n            user_id: ctx.from.id,\n            sticker_format: stickerFormat,\n            sticker: {\n              source: buffer\n            }\n          }).catch((error) => {\n            return {\n              error: {\n                telegram: error\n              }\n            }\n          })\n\n          if (uploadedSticker.error) {\n            return {\n              error: {\n                telegram: uploadedSticker.error.telegram\n              },\n              originalIndex\n            }\n          }\n\n          return {\n            sticker: uploadedSticker.file_id,\n            format: stickerFormat,\n            emoji_list: sticker.emojis ? sticker.emojis : [sticker.emoji],\n            originalIndex\n          }\n        }))\n\n        // Separate successful and failed stickers\n        failedBatchIndices = batchResults\n          .filter((result) => result.error)\n          .map((result) => result.originalIndex)\n\n        uploadedStickers = batchResults\n          .filter((sticker) => !sticker.error)\n          .sort((a, b) => a.originalIndex - b.originalIndex)\n\n        // if < 90% of stickers uploaded in batch, fail completely\n        if (uploadedStickers.length < stickers.length * 0.90) {\n          await ctx.telegram.deleteMessage(ctx.chat.id, waitMessage.message_id)\n\n          await ctx.replyWithHTML(ctx.i18n.t('scenes.new_pack.error.telegram.upload_failed'), {\n            reply_to_message_id: ctx.message.message_id,\n            allow_sending_without_reply: true\n          })\n\n          // Clean up all session state\n          ctx.session.scene = {}\n          return ctx.scene.leave()\n        }\n\n        // Track how many were successfully uploaded in batch\n        alreadyUploadedStickers = uploadedStickers.length\n      } else {\n        const uploadedSticker = await ctx.telegram.callApi('uploadStickerFile', {\n          user_id: ctx.from.id,\n          sticker_format: 'video',\n          sticker: {\n            source: placeholder[packType].video\n          }\n        })\n\n        uploadedStickers = [\n          {\n            sticker: uploadedSticker.file_id,\n            format: 'video',\n            emoji_list: ['🌟'],\n            placeholder: true\n          }\n        ]\n      }\n\n      // Clean up internal fields before API call (originalIndex, placeholder are internal only)\n      const stickersForApi = uploadedStickers.map(({ sticker, format, emoji_list }) => ({\n        sticker,\n        format,\n        emoji_list\n      }))\n\n      createNewStickerSet = await ctx.telegram.callApi('createNewStickerSet', {\n        user_id: ctx.from.id,\n        name,\n        title,\n        stickers: stickersForApi,\n        sticker_type: packType,\n        needs_repainting: !!fillColor\n      }).catch((error) => {\n        return { error }\n      })\n\n      // Track if we need to delete placeholder after copying is complete\n      hasPlaceholder = uploadedStickers[0]?.placeholder\n\n      await ctx.telegram.deleteMessage(ctx.chat.id, waitMessage.message_id)\n\n      if (createNewStickerSet.error) {\n        // In create-flow, STICKERSET_INVALID actually means \"name not\n        // accepted\" — Telegram quirk where this code surfaces on\n        // create. Keep the context-specific mapping; fall back to the\n        // generic humanizer for everything else.\n        if (createNewStickerSet.error.description === 'STICKERSET_INVALID') {\n          await ctx.replyWithHTML(ctx.i18n.t('scenes.new_pack.error.telegram.name_occupied'), {\n            reply_to_message_id: ctx.message.message_id,\n            allow_sending_without_reply: true\n          })\n          return ctx.scene.enter('newPackName')\n        }\n\n        await ctx.replyWithHTML(humanizeTelegramError(ctx, createNewStickerSet.error), {\n          reply_to_message_id: ctx.message.message_id,\n          allow_sending_without_reply: true\n        })\n        return ctx.scene.enter('newPackName')\n      }\n    } else {\n      const uploadedSticker = await ctx.telegram.callApi('uploadStickerFile', {\n        user_id: ctx.from.id,\n        sticker_format: 'video',\n        sticker: {\n          source: placeholder[packType].video\n        }\n      })\n\n      createNewStickerSet = await ctx.telegram.callApi('createNewStickerSet', {\n        user_id: ctx.from.id,\n        name,\n        title,\n        stickers: [\n          {\n            sticker: uploadedSticker.file_id,\n            format: 'video',\n            emoji_list: ['🌟']\n          }\n        ],\n        sticker_type: packType,\n        needs_repainting: !!fillColor\n      }).catch((error) => {\n        return { error }\n      })\n\n      if (createNewStickerSet.error) {\n        const { error } = createNewStickerSet\n        const description = error?.description || ''\n\n        // Context-specific name validation errors keep their own keys —\n        // they appear at the \"enter pack name\" step and need step-specific\n        // copy. Other Telegram errors flow through the shared humanizer.\n        let messageText\n        if (description === 'Bad Request: invalid sticker set name is specified') {\n          messageText = ctx.i18n.t('scenes.new_pack.error.telegram.name_invalid')\n        } else if (description === 'Bad Request: sticker set name is already occupied') {\n          messageText = ctx.i18n.t('scenes.new_pack.error.telegram.name_occupied')\n        } else {\n          messageText = humanizeTelegramError(ctx, error)\n        }\n\n        await ctx.replyWithHTML(messageText, {\n          reply_to_message_id: ctx.message.message_id,\n          allow_sending_without_reply: true\n        })\n        return ctx.scene.enter('newPackName')\n      }\n    }\n  }\n\n  if (createNewStickerSet) {\n    if (!inline && !ctx?.session?.scene?.copyPack) {\n      // Delayed cleanup of the bootstrap placeholder Telegram requires for\n      // createNewStickerSet. Runs outside any handler timeline, so a\n      // single top-level try/catch is mandatory — an unhandled rejection\n      // here (e.g. getStickerSet 404 if the user nuked the pack first)\n      // would crash the process.\n      setTimeout(async () => {\n        try {\n          const set = await ctx.telegram.getStickerSet(name)\n          const placeholder = set.stickers[0]\n          if (!placeholder) return\n          await ctx.telegram.deleteStickerFromSet(placeholder.file_id)\n        } catch (error) {\n          log.error('placeholder cleanup failed:', error)\n        }\n      }, 1000 * 10)\n    }\n\n    const userStickerSet = await ctx.db.StickerSet.newSet({\n      owner: ctx.session.userInfo.id,\n      ownerTelegramId: ctx.from.id,\n      name,\n      title,\n      inline,\n      packType,\n      boost: !!copyPack,\n      emojiSuffix: '🌟',\n      create: true\n    })\n\n    if (inline) {\n      ctx.session.userInfo.inlineStickerSet = userStickerSet\n      await ctx.replyWithHTML(ctx.i18n.t('callback.pack.set_inline_pack', {\n        title: escapeHTML(userStickerSet.title),\n        botUsername: ctx.options.username\n      }), {\n        reply_to_message_id: ctx.message.message_id,\n        allow_sending_without_reply: true,\n        reply_markup: Markup.inlineKeyboard([\n          Markup.switchToChatButton(ctx.i18n.t('callback.pack.btn.use_pack'), '')\n        ])\n      })\n    } else {\n      let inlineData = ''\n      if (ctx.session.userInfo.inlineType === 'packs') {\n        inlineData = stegcloak.hide('{gif}', '', ' : ')\n      }\n\n      const linkPrefix = userStickerSet.packType === 'custom_emoji' ? ctx.config.emojiLinkPrefix : ctx.config.stickerLinkPrefix\n\n      await ctx.replyWithHTML(ctx.i18n.t('callback.pack.set_pack', {\n        title: escapeHTML(userStickerSet.title),\n        link: `${linkPrefix}${name}`\n      }), {\n        disable_web_page_preview: true,\n        reply_markup: Markup.inlineKeyboard([\n          [\n            Markup.urlButton(ctx.i18n.t('callback.pack.btn.use_pack'), `${linkPrefix}${userStickerSet.name}`)\n          ],\n          [\n            Markup.callbackButton(ctx.i18n.t('callback.pack.btn.boost'), `boost:${userStickerSet.id}`, userStickerSet.boost)\n          ],\n          [\n            Markup.callbackButton(ctx.i18n.t('callback.pack.btn.frame'), 'set_frame')\n          ],\n          [\n            Markup.switchToCurrentChatButton(ctx.i18n.t('callback.pack.btn.search_gif'), inlineData)\n          ],\n          [\n            Markup.callbackButton(ctx.i18n.t('callback.pack.btn.coedit'), `coedit:${userStickerSet.id}`)\n          ]\n        ]),\n        parse_mode: 'HTML'\n      })\n    }\n\n    ctx.session.userInfo.stickerSet = userStickerSet\n\n    // if different pack type, use atomic $inc to prevent race conditions\n    if (copyPack && copyPack.sticker_type !== packType) {\n      await ctx.db.User.updateOne(\n        { _id: ctx.session.userInfo._id },\n        { $inc: { balance: -1 }, $set: { stickerSet: userStickerSet._id } }\n      )\n      ctx.session.userInfo.balance -= 1\n    } else {\n      await ctx.db.User.updateOne(\n        { _id: ctx.session.userInfo._id },\n        { $set: { stickerSet: userStickerSet._id } }\n      )\n    }\n\n    if (!copyPack) {\n      await ctx.replyWithHTML('👌', {\n        reply_markup: {\n          remove_keyboard: true\n        }\n      })\n\n      return ctx.scene.leave()\n    }\n\n    const originalPack = copyPack\n\n    // Calculate how many stickers need to be added via addSticker\n    // For same type: stickers after batch (index >= 50) + failed batch stickers\n    // For different type (placeholder flow): ALL stickers need individual copy\n    const batchAttemptedCount = hasPlaceholder ? 0 : Math.min(50, originalPack.stickers.length)\n    const needsIndividualCopy = hasPlaceholder || originalPack.stickers.length > batchAttemptedCount || failedBatchIndices.length > 0\n\n    // Hoisted so the hasPlaceholder cleanup branch (line ~737) can reference\n    // them safely when runtime skips the needsIndividualCopy block.\n    let successCount = alreadyUploadedStickers\n    let failedCount = 0\n    let pendingCount = 0\n    let processed = 0\n\n    if (needsIndividualCopy) {\n      const message = await ctx.replyWithHTML(ctx.i18n.t('scenes.copy.progress', {\n        originalTitle: escapeHTML(originalPack.title),\n        originalLink: `${ctx.config.stickerLinkPrefix}${originalPack.name}`,\n        title: escapeHTML(title),\n        link: `${ctx.config.stickerLinkPrefix}${name}`,\n        current: alreadyUploadedStickers,\n        total: originalPack.stickers.length\n      }))\n\n      const COPY_CONCURRENCY = 5\n\n      const processCopyItem = async (sticker) => {\n        const result = await addSticker(ctx, sticker, userStickerSet, false)\n\n        if (result?.error) {\n          failedCount++\n        } else if (result?.wait) {\n          // Video stickers queued for async processing - don't count as success yet\n          pendingCount++\n        } else {\n          successCount++\n        }\n        processed++\n\n        if (processed % 10 === 0) {\n          await ctx.telegram.editMessageText(\n            message.chat.id, message.message_id, null,\n            ctx.i18n.t('scenes.copy.progress', {\n              originalTitle: escapeHTML(originalPack.title),\n              originalLink: `${ctx.config.stickerLinkPrefix}${originalPack.name}`,\n              title: escapeHTML(title),\n              link: `${ctx.config.stickerLinkPrefix}${name}`,\n              current: successCount + pendingCount,\n              total: originalPack.stickers.length\n            }),\n            { parse_mode: 'HTML' }\n          ).catch(() => {})\n        }\n      }\n\n      // First, retry failed batch stickers (in parallel, preserving order isn't needed)\n      const retryItems = failedBatchIndices.map((failedIndex) => originalPack.stickers[failedIndex])\n      await runConcurrent(retryItems, COPY_CONCURRENCY, (sticker) => processCopyItem(sticker))\n\n      // Then, continue with stickers after batch (index >= 50)\n      const tailItems = originalPack.stickers.slice(batchAttemptedCount)\n      await runConcurrent(tailItems, COPY_CONCURRENCY, (sticker) => processCopyItem(sticker))\n\n      await ctx.telegram.deleteMessage(message.chat.id, message.message_id)\n\n      // Show result with appropriate message based on outcome\n      if (failedCount > 0 && pendingCount > 0) {\n        await ctx.replyWithHTML(ctx.i18n.t('scenes.copy.done_partial_pending', {\n          originalTitle: escapeHTML(originalPack.title),\n          originalLink: `${ctx.config.stickerLinkPrefix}${originalPack.name}`,\n          title: escapeHTML(title),\n          link: `${ctx.config.stickerLinkPrefix}${name}`,\n          success: successCount,\n          failed: failedCount,\n          pending: pendingCount\n        }),\n        { parse_mode: 'HTML' }\n        )\n      } else if (failedCount > 0) {\n        await ctx.replyWithHTML(ctx.i18n.t('scenes.copy.done_partial', {\n          originalTitle: escapeHTML(originalPack.title),\n          originalLink: `${ctx.config.stickerLinkPrefix}${originalPack.name}`,\n          title: escapeHTML(title),\n          link: `${ctx.config.stickerLinkPrefix}${name}`,\n          success: successCount,\n          failed: failedCount\n        }),\n        { parse_mode: 'HTML' }\n        )\n      } else if (pendingCount > 0) {\n        await ctx.replyWithHTML(ctx.i18n.t('scenes.copy.done_pending', {\n          originalTitle: escapeHTML(originalPack.title),\n          originalLink: `${ctx.config.stickerLinkPrefix}${originalPack.name}`,\n          title: escapeHTML(title),\n          link: `${ctx.config.stickerLinkPrefix}${name}`,\n          success: successCount,\n          pending: pendingCount\n        }),\n        { parse_mode: 'HTML' }\n        )\n      } else {\n        await ctx.replyWithHTML(ctx.i18n.t('scenes.copy.done', {\n          originalTitle: escapeHTML(originalPack.title),\n          originalLink: `${ctx.config.stickerLinkPrefix}${originalPack.name}`,\n          title: escapeHTML(title),\n          link: `${ctx.config.stickerLinkPrefix}${name}`\n        }),\n        { parse_mode: 'HTML' }\n        )\n      }\n    }\n\n    // Delete placeholder sticker after all stickers are copied\n    if (hasPlaceholder) {\n      const getStickerSet = await ctx.telegram.getStickerSet(name).catch(() => null)\n      if (getStickerSet?.stickers?.length > 1) {\n        // Delete placeholder only if there are other stickers\n        const placeholderSticker = getStickerSet.stickers[0]\n        if (placeholderSticker) {\n          await ctx.telegram.deleteStickerFromSet(placeholderSticker.file_id).catch(error => {\n            log.error('failed to delete placeholder sticker:', error)\n          })\n        }\n      } else if (getStickerSet?.stickers?.length === 1 && successCount === 0 && pendingCount === 0) {\n        // All stickers failed - pack only has placeholder\n        // Delete the entire pack since it's useless\n        await ctx.telegram.callApi('deleteStickerSet', { name }).catch(error => {\n          log.error('failed to delete empty sticker set:', error)\n        })\n        // Remove from database\n        await ctx.db.StickerSet.deleteOne({ name }).catch(() => {})\n        // Warn user\n        await ctx.replyWithHTML(ctx.i18n.t('scenes.copy.error.all_failed', {\n          originalTitle: escapeHTML(originalPack.title),\n          originalLink: `${ctx.config.stickerLinkPrefix}${originalPack.name}`\n        }))\n      }\n    }\n\n    // Clean up session state\n    delete ctx.session.scene.copyPack\n\n    await ctx.scene.leave()\n  }\n})\n\nmodule.exports = [\n  newPack,\n  newPackTitle,\n  newPackName,\n  newPackConfirm,\n  newPackCopyPay\n]\n"
  },
  {
    "path": "scenes/pack-rename.js",
    "content": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst {\n  escapeHTML,\n  countUncodeChars,\n  substrUnicode\n} = require('../utils')\n\nconst packRename = new Scene('packRename')\n\npackRename.enter(async (ctx) => {\n  const stickerSet = await ctx.db.StickerSet.findById(ctx.match[2])\n\n  if (stickerSet.owner.toString() !== ctx.session.userInfo.id.toString()) {\n    await ctx.answerCbQuery(ctx.i18n.t('error.access_denied'), true)\n    return ctx.scene.leave()\n  }\n\n  ctx.session.userInfo.stickerSet = stickerSet\n\n  const linkPrefix = stickerSet.packType === 'custom_emoji' ? ctx.config.emojiLinkPrefix : ctx.config.stickerLinkPrefix\n\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.rename.enter_name', {\n    title: escapeHTML(stickerSet.title),\n    link: `${linkPrefix}${stickerSet.name}`\n  }), {\n    reply_markup: Markup.keyboard([\n      [\n        { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n      ]\n    ]).resize()\n  })\n})\n\npackRename.on('text', async (ctx) => {\n  const { stickerSet } = ctx.session.userInfo\n\n  const titleSuffix = stickerSet.boost ? '' : ` :: @${ctx.options.username}`\n  const charTitleMax = stickerSet.boost ? ctx.config.premiumCharTitleMax : ctx.config.charTitleMax\n\n  let newTitle = ctx.message.text\n\n  if (countUncodeChars(newTitle) > charTitleMax) {\n    newTitle = substrUnicode(newTitle, 0, charTitleMax)\n  }\n\n  newTitle += titleSuffix\n\n  let result\n  try {\n    result = await ctx.telegram.callApi('setStickerSetTitle', {\n      name: stickerSet.name,\n      title: newTitle\n    })\n  } catch (error) {\n    if (error.message.includes('STICKERSET_INVALID')) {\n      return ctx.replyWithHTML(ctx.i18n.t('error.stickerset_invalid'), {\n        reply_markup: Markup.removeKeyboard()\n      })\n    }\n    return ctx.replyWithHTML(ctx.i18n.t('error.unknown'), {\n      reply_markup: Markup.removeKeyboard()\n    })\n  }\n\n  if (!result) {\n    return ctx.replyWithHTML(ctx.i18n.t('error.unknown'), {\n      reply_markup: Markup.removeKeyboard()\n    })\n  }\n\n  stickerSet.title = newTitle\n  await stickerSet.save()\n\n  const linkPrefix = stickerSet.packType === 'custom_emoji' ? ctx.config.emojiLinkPrefix : ctx.config.stickerLinkPrefix\n\n  const text = ctx.i18n.t('scenes.rename.success', {\n    title: escapeHTML(stickerSet.title),\n    link: `${linkPrefix}${stickerSet.name}`\n  }) + (titleSuffix\n    ? ('\\n' + ctx.i18n.t('scenes.rename.boost_notice', {\n        titleSuffix: escapeHTML(titleSuffix)\n      }))\n    : '')\n\n  await ctx.replyWithHTML(text, {\n    reply_markup: Markup.removeKeyboard()\n  })\n\n  ctx.scene.leave()\n})\n\nmodule.exports = packRename\n"
  },
  {
    "path": "scenes/pack-search.js",
    "content": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst { escapeHTML } = require('../utils')\n\nconst searchStickerSet = new Scene('searchStickerSet')\n\nsearchStickerSet.enter(async (ctx) => {\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.search.enter'), {\n    reply_markup: Markup.keyboard([\n      [\n        { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n      ]\n    ]).resize()\n  })\n})\n\nsearchStickerSet.on('text', async (ctx) => {\n  const stickerSets = await ctx.db.StickerSet.aggregate([\n    { $search: {\n      index: 'default',\n      compound: {\n        must: [\n          { text: { query: ctx.message.text, path: ['about.description', 'title', 'about.tags'] } }\n        ],\n        filter: [\n          { equals: { value: true, path: 'public' } }\n        ]\n      }\n    }},\n    { $project: { name: 1, title: 1 } },\n    { $limit: 100 }\n  ])\n\n  if (stickerSets && stickerSets.length > 0) {\n    // Batch verify packs with Telegram API (parallel with concurrency limit)\n    const BATCH_SIZE = 10\n    const packList = []\n\n    for (let i = 0; i < stickerSets.length; i += BATCH_SIZE) {\n      const batch = stickerSets.slice(i, i + BATCH_SIZE)\n      const results = await Promise.all(\n        batch.map(pack =>\n          ctx.telegram.getStickerSet(pack.name)\n            .then(info => ({ pack, info }))\n            .catch(() => ({ pack, info: null }))\n        )\n      )\n\n      for (const { pack, info } of results) {\n        if (info && info.stickers && info.stickers.length > 0) {\n          packList.push(`<a href=\"${ctx.config.stickerLinkPrefix}${pack.name}\">${escapeHTML(pack.title)}</a>`)\n        }\n      }\n    }\n\n    if (packList.length > 0) {\n      return ctx.replyWithHTML(packList.join('\\n'))\n    }\n  }\n\n  return ctx.replyWithHTML(ctx.i18n.t('scenes.search.error.not_found'), {\n    reply_to_message_id: ctx.message.message_id,\n    allow_sending_without_reply: true\n  })\n})\n\nmodule.exports = [searchStickerSet]\n"
  },
  {
    "path": "scenes/photo-clear.js",
    "content": "const Scene = require('telegraf/scenes/base')\nconst sharp = require('sharp')\nconst {\n  showGramAds\n} = require('../utils')\nconst { removebgQueue } = require('../utils/queues')\n\nconst photoClearSelect = new Scene('photoClearSelect')\n\nphotoClearSelect.enter(async (ctx) => {\n  if (ctx.callbackQuery) {\n    await ctx.answerCbQuery()\n    await ctx.deleteMessage().catch(() => {})\n  }\n\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.photoClear.choose_model'), {\n    reply_markup: {\n      inline_keyboard: [\n        [\n          {\n            text: ctx.i18n.t('scenes.photoClear.model.ordinary'),\n            callback_data: 'model:ordinary'\n          }\n        ],\n        [\n          {\n            text: ctx.i18n.t('scenes.photoClear.model.general'),\n            callback_data: 'model:general'\n          }\n        ],\n        // [\n        //   {\n        //     text: ctx.i18n.t('scenes.photoClear.model.birefnet_general'),\n        //     callback_data: 'model:birefnet_general'\n        //   }\n        // ],\n        [\n          {\n            text: ctx.i18n.t('scenes.photoClear.model.anime'),\n            callback_data: 'model:anime'\n          }\n        ],\n        [\n          {\n            text: ctx.i18n.t('scenes.photoClear.web_app'),\n            web_app: {\n              url: 'https://bot.lyo.su/remove-background-web/'\n            }\n          }\n        ]\n      ]\n    }\n  })\n})\n\nphotoClearSelect.action(/model:(ordinary|general|birefnet_general|anime)/, async (ctx) => {\n  const [, model] = ctx.match\n\n  ctx.session.clerType = model\n\n  await ctx.scene.enter('photoClear')\n})\n\nconst photoClear = new Scene('photoClear')\n\nphotoClear.enter(async (ctx) => {\n  if (ctx.callbackQuery) {\n    await ctx.answerCbQuery()\n    await ctx.deleteMessage().catch(() => {})\n  }\n\n  await ctx.replyWithHTML(ctx.i18n.t(`scenes.photoClear.${ctx.session.clerType === 'anime' ? 'enter_anime' : 'enter'}`), {\n    reply_markup: {\n      keyboard: [\n        [\n          ctx.i18n.t('scenes.btn.cancel')\n        ]\n      ],\n      resize_keyboard: true\n    }\n  })\n})\n\nphotoClear.on('photo', async (ctx) => {\n  ctx.replyWithChatAction('upload_document').catch(() => {}) // chat action is best-effort UI, fire-and-forget OK\n\n  if (ctx.session.userInfo.locale === 'ru' && !ctx.session.userInfo?.stickerSet?.boost) {\n    showGramAds(ctx.chat.id)\n  }\n\n  const photo = ctx.message.photo[ctx.message.photo.length - 1]\n\n  let fileUrl\n  try {\n    fileUrl = await ctx.telegram.getFileLink(photo.file_id)\n  } catch (err) {\n    return ctx.replyWithHTML(ctx.i18n.t(err.message?.includes('file is too big') ? 'error.file_too_big' : 'error.download'), {\n      reply_to_message_id: ctx.message.message_id,\n      allow_sending_without_reply: true\n    })\n  }\n\n  let model = 'silueta'\n\n  if (ctx.session.clerType === 'anime') {\n    model = 'isnet-anime'\n  } else if (ctx.session.clerType === 'general') {\n    model = 'isnet-general-use'\n  } else if (ctx.session.clerType === 'birefnet_general') {\n    model = 'birefnet-general'\n  } else if (ctx.session.clerType === 'silueta' || ctx.session.clerType === 'ordinary') {\n    model = 'silueta'\n  }\n\n  let priority = 10\n  if (ctx.i18n.locale() === 'ru') priority = 15\n\n  let job\n  try {\n    job = await removebgQueue.add({\n      fileUrl,\n      model\n    }, {\n      priority,\n      attempts: 1,\n      removeOnComplete: true\n    })\n  } catch (err) {\n    // Queue stub (REDIS_HOST unset) rejects with QUEUE_DISABLED; surface\n    // that to the user as \"feature temporarily unavailable\" instead of a\n    // generic error.\n    if (err.code === 'QUEUE_DISABLED') {\n      return ctx.replyWithHTML(ctx.i18n.t('scenes.photoClear.error_queue_disabled'))\n    }\n    console.error('removebg enqueue failed:', err.message)\n    return ctx.replyWithHTML(ctx.i18n.t('scenes.photoClear.error'))\n  }\n\n  // Resolve-with-sentinel timeout instead of reject: Promise.race losers\n  // live on until they settle, and a rejecting loser becomes an unhandled\n  // rejection once the race has a winner. Returning a marker object keeps\n  // the promise harmless and lets the caller branch on it explicitly.\n  const TIMEOUT = Symbol('timeout')\n  let timer\n  const timeoutPromise = new Promise((resolve) => {\n    timer = setTimeout(() => resolve(TIMEOUT), 1000 * 30)\n  })\n\n  // Attach the .catch BEFORE racing — otherwise if job.finished() rejects\n  // after the timeout has already won, the rejection is unhandled.\n  const jobPromise = job.finished().catch(err => ({ error: err.message }))\n  const raceResult = await Promise.race([jobPromise, timeoutPromise])\n  clearTimeout(timer)\n\n  const finish = raceResult === TIMEOUT ? { error: 'Timeout' } : raceResult\n\n  if (finish.content) {\n    const trimBuffer = await sharp(Buffer.from(finish.content, 'base64'))\n      .trim()\n      .webp()\n      .toBuffer()\n\n    await ctx.replyWithDocument({\n      source: trimBuffer,\n      filename: `${model}_${photo.file_unique_id}.webp`\n    }, {\n      reply_to_message_id: ctx.message.message_id,\n      reply_markup: {\n        inline_keyboard: [\n          [\n            {\n              text: ctx.i18n.t('scenes.photoClear.add_to_set_btn'),\n              callback_data: 'add_sticker'\n            }\n          ]\n        ]\n      }\n    })\n  } else {\n    console.error('photoClear job error:', finish.error)\n    const i18nKey = finish.error === 'Timeout'\n      ? 'scenes.photoClear.error_timeout'\n      : 'scenes.photoClear.error'\n    await ctx.replyWithHTML(ctx.i18n.t(i18nKey))\n  }\n})\n\nmodule.exports = [\n  photoClearSelect,\n  photoClear\n]\n"
  },
  {
    "path": "scenes/sticker-delete.js",
    "content": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\n\nconst deleteSticker = new Scene('deleteSticker')\n\ndeleteSticker.enter(async (ctx) => {\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.delete.enter'), {\n    reply_markup: Markup.keyboard([\n      [\n        { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n      ]\n    ]).resize()\n  })\n})\n\ndeleteSticker.on(['sticker', 'message'], async (ctx, next) => {\n  let sticker\n\n  if (ctx.message && ctx.message.entities && ctx.message.entities[0] && ctx.message.entities[0].type === 'custom_emoji') {\n    const customEmoji = ctx.message.entities.find((e) => e.type === 'custom_emoji')\n\n    if (!customEmoji) return next()\n\n    const emojiStickers = await ctx.telegram.callApi('getCustomEmojiStickers', {\n      custom_emoji_ids: [customEmoji.custom_emoji_id]\n    })\n\n    if (!emojiStickers) return next()\n\n    sticker = emojiStickers[0]\n  } else if (ctx.message && ctx.message.sticker) {\n    sticker = ctx.message.sticker\n  } else {\n    return next()\n  }\n\n  if (!sticker) return next()\n\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.delete.confirm'), {\n    reply_to_message_id: ctx.message.message_id,\n    allow_sending_without_reply: true,\n    reply_markup: Markup.inlineKeyboard([\n      [\n        { ...Markup.callbackButton(ctx.i18n.t('callback.sticker.btn.delete'), `delete_sticker:${sticker.file_unique_id}`), style: 'danger' }\n      ]\n    ])\n  })\n})\n\nmodule.exports = deleteSticker\n"
  },
  {
    "path": "scenes/sticker-original.js",
    "content": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst escapeHTML = require('../utils/html-escape')\nconst sendStickerAsDocument = require('../utils/send-sticker-as-document')\n\nconst originalSticker = new Scene('originalSticker')\n\noriginalSticker.enter(async (ctx) => {\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.original.enter'), {\n    reply_markup: Markup.keyboard([\n      [\n        { text: ctx.i18n.t('scenes.btn.cancel'), style: 'danger' }\n      ]\n    ]).resize()\n  })\n})\n\noriginalSticker.on(['sticker', 'text'], async (ctx, next) => {\n  let sticker\n\n  if (ctx.message.text) {\n    if (!ctx.message.entities) return next()\n\n    const customEmoji = ctx.message.entities.find((e) => e.type === 'custom_emoji')\n\n    if (!customEmoji) return next()\n\n    const emojiStickers = await ctx.telegram.callApi('getCustomEmojiStickers', {\n      custom_emoji_ids: [customEmoji.custom_emoji_id]\n    })\n\n    if (!emojiStickers) return next()\n\n    sticker = emojiStickers[0]\n  } else {\n    sticker = ctx.message.sticker\n  }\n\n  const replyExtra = {\n    reply_to_message_id: ctx.message.message_id,\n    allow_sending_without_reply: true\n  }\n\n  // Query supports both new (original) and legacy (file) schema\n  const stickerInfo = await ctx.db.Sticker.findOne({\n    fileUniqueId: sticker.file_unique_id,\n    $or: [\n      { 'original.fileId': { $ne: null } },\n      { 'file.file_id': { $ne: null } }\n    ]\n  })\n\n  if (stickerInfo && stickerInfo.hasOriginal()) {\n    const originalFileId = stickerInfo.getOriginalFileId()\n    const originalFileUniqueId = stickerInfo.getOriginalFileUniqueId()\n\n    // Primary goal of /original: show which pack the sticker was copied\n    // FROM. The copy record has the source's file_unique_id; if fStikBot\n    // has ever indexed the source pack, the source sticker's record will\n    // resolve to a StickerSet with name+title.\n    if (originalFileUniqueId) {\n      const sourceSticker = await ctx.db.Sticker.findOne({\n        fileUniqueId: originalFileUniqueId,\n        stickerSet: { $ne: stickerInfo.stickerSet }\n      }).populate('stickerSet')\n\n      const sourcePack = sourceSticker && sourceSticker.stickerSet\n      if (sourcePack && !sourcePack.deleted && sourcePack.name && sourcePack.title) {\n        const linkPrefix = sourcePack.packType === 'custom_emoji'\n          ? 'https://t.me/addemoji/'\n          : 'https://t.me/addstickers/'\n        await ctx.replyWithHTML(\n          ctx.i18n.t('scenes.original.source_found', {\n            link: `${linkPrefix}${sourcePack.name}`,\n            title: escapeHTML(sourcePack.title)\n          }),\n          replyExtra\n        )\n        return\n      }\n    }\n\n    // Source pack isn't in our DB — give the user the original file instead.\n    // Optimistic: try echoing as a sticker (cheap, preserves animation). On\n    // any failure — expired file_id, DOCUMENT_INVALID, emoji/regular mismatch\n    // — fall through to downloading and re-uploading as a document. NEVER\n    // sendPhoto/sendVideo: Telegram rejects sticker file_ids there.\n    try {\n      await ctx.replyWithSticker(originalFileId, {\n        ...replyExtra,\n        caption: stickerInfo.emojis\n      })\n      return\n    } catch (_) { /* fall through to document fallback */ }\n\n    await sendStickerAsDocument(ctx, originalFileId, originalFileUniqueId, replyExtra)\n    return\n  }\n\n  // No copy record — this is either an untracked sticker or the user sent\n  // the original itself. Give them the file back.\n  await sendStickerAsDocument(ctx, sticker.file_id, sticker.file_unique_id, replyExtra)\n})\n\nmodule.exports = [originalSticker]\n"
  },
  {
    "path": "scenes/video-round.js",
    "content": "const Scene = require('telegraf/scenes/base')\nconst { showGramAds } = require('../utils')\nconst { videoNoteQueue } = require('../utils/queues')\n\nconst videoRound = new Scene('videoRound')\n\nvideoRound.enter(async (ctx) => {\n  if (ctx.callbackQuery) {\n    await ctx.answerCbQuery()\n    await ctx.deleteMessage().catch(() => {})\n  }\n\n  await ctx.replyWithHTML(ctx.i18n.t('scenes.videoRound.enter'), {\n    reply_markup: {\n      keyboard: [\n        [ctx.i18n.t('scenes.btn.cancel')]\n      ],\n      resize_keyboard: true\n    }\n  })\n})\n\nasync function getQueuePosition (jobId) {\n  const waiting = await videoNoteQueue.getWaiting()\n  const index = waiting.findIndex(j => j.id === jobId)\n  return {\n    position: index + 1,\n    total: waiting.length\n  }\n}\n\nasync function processVideo (ctx, fileUrl) {\n  ctx.replyWithChatAction('record_video_note').catch(() => {}) // chat action is best-effort UI\n\n  if (ctx.session.userInfo?.locale === 'ru' && !ctx.session.userInfo?.stickerSet?.boost) {\n    showGramAds(ctx.chat.id)\n  }\n\n  let priority = 10\n  if (ctx.i18n.locale() === 'ru') priority = 15\n\n  const job = await videoNoteQueue.add({\n    fileUrl: typeof fileUrl === 'string' ? fileUrl : fileUrl.href,\n    maxDuration: 60\n  }, {\n    priority,\n    attempts: 1,\n    removeOnComplete: true\n  })\n\n  // Show initial processing message with queue position\n  const { position, total } = await getQueuePosition(job.id)\n  const processingMsg = await ctx.replyWithHTML(ctx.i18n.t('scenes.videoRound.processing', {\n    position,\n    total: total || 1\n  }), {\n    reply_to_message_id: ctx.message.message_id\n  })\n\n  // Update queue position every 2 seconds\n  const updateInterval = setInterval(async () => {\n    const { position: newPos, total: newTotal } = await getQueuePosition(job.id)\n    if (newPos > 0) {\n      await ctx.telegram.editMessageText(\n        ctx.chat.id,\n        processingMsg.message_id,\n        null,\n        ctx.i18n.t('scenes.videoRound.processing', {\n          position: newPos,\n          total: newTotal || 1\n        }),\n        { parse_mode: 'HTML' }\n      ).catch(() => {})\n    }\n  }, 2000)\n\n  const timeoutPromise = new Promise((_, reject) => {\n    setTimeout(() => reject(new Error('Timeout')), 1000 * 120)\n  })\n\n  const result = await Promise.race([job.finished(), timeoutPromise]).catch(() => ({}))\n\n  // Stop updating and delete processing message\n  clearInterval(updateInterval)\n  await ctx.telegram.deleteMessage(ctx.chat.id, processingMsg.message_id).catch(() => {})\n\n  if (result.content) {\n    await ctx.replyWithVideoNote({\n      source: Buffer.from(result.content, 'base64')\n    }, {\n      reply_to_message_id: ctx.message.message_id\n    }).catch(async (err) => {\n      if (err.message?.includes('VOICE_MESSAGES_FORBIDDEN')) {\n        await ctx.replyWithHTML(ctx.i18n.t('scenes.videoRound.forbidden'))\n      } else {\n        await ctx.replyWithHTML(ctx.i18n.t('scenes.videoRound.error'))\n      }\n    })\n  } else {\n    await ctx.replyWithHTML(ctx.i18n.t('scenes.videoRound.error'))\n  }\n}\n\nvideoRound.on(['video', 'video_note', 'animation', 'sticker'], async (ctx) => {\n  // Skip non-video stickers\n  if (ctx.message.sticker && !ctx.message.sticker.is_video) {\n    return ctx.replyWithHTML(ctx.i18n.t('scenes.videoRound.not_video'))\n  }\n\n  const video = ctx.message.video || ctx.message.video_note || ctx.message.animation || ctx.message.sticker\n\n  let fileUrl\n  try {\n    fileUrl = await ctx.telegram.getFileLink(video.file_id)\n  } catch (err) {\n    const key = err.message?.includes('file is too big') ? 'file_too_big' : 'error'\n    return ctx.replyWithHTML(ctx.i18n.t(`scenes.videoRound.${key}`))\n  }\n\n  await processVideo(ctx, fileUrl)\n})\n\nvideoRound.on('document', async (ctx) => {\n  const mime = ctx.message.document.mime_type || ''\n  // Support: video/*, image/gif, image/webp (animated), image/apng\n  const isSupported = mime.startsWith('video/') ||\n                      mime === 'image/gif' ||\n                      mime === 'image/webp' ||\n                      mime === 'image/apng' ||\n                      mime === 'image/png' // APNG often detected as png\n\n  if (!isSupported) {\n    return ctx.replyWithHTML(ctx.i18n.t('scenes.videoRound.not_video'))\n  }\n\n  let fileUrl\n  try {\n    fileUrl = await ctx.telegram.getFileLink(ctx.message.document.file_id)\n  } catch (err) {\n    const key = err.message?.includes('file is too big') ? 'file_too_big' : 'error'\n    return ctx.replyWithHTML(ctx.i18n.t(`scenes.videoRound.${key}`))\n  }\n\n  await processVideo(ctx, fileUrl)\n})\n\nmodule.exports = [videoRound]\n"
  },
  {
    "path": "scripts/README.md",
    "content": "# Maintenance scripts\n\nOne-shot operational scripts. Run from the project root:\n\n```bash\nnode scripts/<name>.js\n```\n\nEach one loads `.env` from the parent directory, so no extra setup is needed.\n\n## `inspect-db.js`\n\nRead-only diagnostic of the `Sticker` and `StickerSet` collections. Dumps\ncounts, index list, a 1000-doc schema-shape sample, collection storage\nstats, and oldest/newest `_id` timestamps.\n\n```bash\nnode scripts/inspect-db.js\n```\n\nDoesn't modify any docs. Useful when sizing ops work.\n\nTo run against a different DB, override inline:\n\n```bash\nMONGODB_URI='mongodb://.../fStikBot?...' node scripts/inspect-db.js\n```\n\n## `top-sets.js`\n\nCron-style helper that lists popular public packs — unrelated to DB\nmaintenance.\n\n## `update-packs.js` / `update-sticker.js`\n\nLegacy one-offs for repairing corrupted records. Kept for reference.\n\n## A note on schema migration\n\nAt 488M Sticker docs (94% in the legacy `info.*` shape) and ~138GB\ncollection size, a bulk rewrite is not viable on a single-node setup — it\nwould take weeks of sustained writes and hammer the live DB. So instead\nof migrating, the codebase treats the legacy shape as a **first-class\nformat**, not tech debt. Every read path already uses `$or` against\nboth the flat `fileId` and nested `info.file_id` fields, each served by\nits own index.\n"
  },
  {
    "path": "scripts/inspect-db.js",
    "content": "// Read-only investigation of the Sticker + StickerSet collections.\n// Produces schema stats for migration planning without touching any docs.\n//\n// Usage: node scripts/inspect-db.js\nrequire('dotenv').config({ path: require('path').join(__dirname, '..', '.env') })\nconst { db } = require('../database')\n\nfunction pct (a, b) {\n  if (!b) return '0%'\n  return ((a / b) * 100).toFixed(1) + '%'\n}\n\nasync function run () {\n  console.log('=== DB inspection ===')\n  console.log('DB host:', db.connection.host || '?')\n  console.log('DB name:', db.connection.name || '?')\n\n  // Fast metadata counts (not an aggregation)\n  const stickerCount = await db.Sticker.estimatedDocumentCount()\n  const packCount = await db.StickerSet.estimatedDocumentCount()\n  console.log(`\\nestimatedDocumentCount:\\n  stickers  = ${stickerCount.toLocaleString()}\\n  packs     = ${packCount.toLocaleString()}`)\n\n  // Indexes\n  const stickerIndexes = await db.Sticker.collection.indexInformation({ full: true })\n  const packIndexes = await db.StickerSet.collection.indexInformation({ full: true })\n  console.log('\\nSticker indexes:')\n  stickerIndexes.forEach((i) => console.log(` - ${i.name}  keys=${JSON.stringify(i.key)}${i.sparse ? ' sparse' : ''}${i.unique ? ' unique' : ''}`))\n  console.log('\\nStickerSet indexes:')\n  packIndexes.forEach((i) => console.log(` - ${i.name}  keys=${JSON.stringify(i.key)}${i.sparse ? ' sparse' : ''}${i.unique ? ' unique' : ''}`))\n\n  // Scan a sample of Sticker docs for schema shape.\n  // For small DBs we just walk all of them; for large we sample.\n  const sampleSize = Math.min(stickerCount, 1000)\n  console.log(`\\nSampling ${sampleSize} Sticker docs for schema shape …`)\n  const t0 = Date.now()\n  let sample\n  if (stickerCount <= 2000) {\n    sample = await db.Sticker.find({}).limit(sampleSize).lean()\n  } else {\n    sample = await db.Sticker.aggregate([{ $sample: { size: sampleSize } }]).allowDiskUse(true).option({ maxTimeMS: 60000 })\n  }\n  console.log(`  sample fetched in ${Date.now() - t0}ms (size=${sample.length})`)\n\n  const stats = {\n    withInfoFileId: 0,\n    withFileFileId: 0,\n    withFlatFileId: 0,\n    withOriginal: 0,\n    missingStickerType: 0,\n    missingFileUniqueId: 0,\n    deleted: 0,\n    legacyOnly: 0,\n    newOnly: 0,\n    both: 0\n  }\n\n  for (const doc of sample) {\n    const hasInfo = !!(doc.info && doc.info.file_id)\n    const hasFile = !!(doc.file && doc.file.file_id)\n    const hasFlat = !!doc.fileId\n    const hasOriginal = !!(doc.original && doc.original.fileId)\n\n    if (hasInfo) stats.withInfoFileId++\n    if (hasFile) stats.withFileFileId++\n    if (hasFlat) stats.withFlatFileId++\n    if (hasOriginal) stats.withOriginal++\n    if (!doc.stickerType) stats.missingStickerType++\n    if (!doc.fileUniqueId) stats.missingFileUniqueId++\n    if (doc.deleted) stats.deleted++\n\n    if (hasInfo && !hasFlat) stats.legacyOnly++\n    else if (hasFlat && !hasInfo) stats.newOnly++\n    else if (hasInfo && hasFlat) stats.both++\n  }\n\n  console.log(`\\nShape distribution (out of ${sample.length} sampled):`)\n  console.log(`  with info.file_id        = ${stats.withInfoFileId}  (${pct(stats.withInfoFileId, sample.length)})`)\n  console.log(`  with file.file_id        = ${stats.withFileFileId}  (${pct(stats.withFileFileId, sample.length)})`)\n  console.log(`  with flat fileId         = ${stats.withFlatFileId}  (${pct(stats.withFlatFileId, sample.length)})`)\n  console.log(`  with original.fileId     = ${stats.withOriginal}  (${pct(stats.withOriginal, sample.length)})`)\n  console.log(`  missing stickerType      = ${stats.missingStickerType}  (${pct(stats.missingStickerType, sample.length)})`)\n  console.log(`  missing fileUniqueId     = ${stats.missingFileUniqueId}  (${pct(stats.missingFileUniqueId, sample.length)})`)\n  console.log(`  deleted flag set         = ${stats.deleted}  (${pct(stats.deleted, sample.length)})`)\n  console.log(`  LEGACY ONLY (info only)  = ${stats.legacyOnly}  (${pct(stats.legacyOnly, sample.length)})`)\n  console.log(`  NEW ONLY (fileId only)   = ${stats.newOnly}  (${pct(stats.newOnly, sample.length)})`)\n  console.log(`  BOTH fields present      = ${stats.both}  (${pct(stats.both, sample.length)})`)\n\n  const legacyEstimate = Math.round(stickerCount * (stats.legacyOnly / sample.length))\n  const missingTypeEstimate = Math.round(stickerCount * (stats.missingStickerType / sample.length))\n  console.log('\\nExtrapolated to full collection:')\n  console.log(`  legacy-only stickers     ≈ ${legacyEstimate.toLocaleString()}`)\n  console.log(`  stickers missing type    ≈ ${missingTypeEstimate.toLocaleString()}`)\n\n  const oldest = await db.Sticker.findOne({}).sort({ _id: 1 }).select('_id').lean()\n  const newest = await db.Sticker.findOne({}).sort({ _id: -1 }).select('_id').lean()\n  if (oldest && newest) {\n    console.log('\\nSticker _id range:')\n    console.log(`  oldest: ${oldest._id} (${oldest._id.getTimestamp().toISOString()})`)\n    console.log(`  newest: ${newest._id} (${newest._id.getTimestamp().toISOString()})`)\n  }\n\n  const packSampleSize = Math.min(packCount, 200)\n  const packSample = packCount <= 500\n    ? await db.StickerSet.find({}).limit(packSampleSize).lean()\n    : await db.StickerSet.aggregate([{ $sample: { size: packSampleSize } }]).allowDiskUse(true).option({ maxTimeMS: 30000 })\n  const packStats = { hide: 0, deleted: 0, create: 0, inline: 0, thirdParty: 0, public: 0 }\n  for (const p of packSample) {\n    if (p.hide) packStats.hide++\n    if (p.deleted) packStats.deleted++\n    if (p.create) packStats.create++\n    if (p.inline) packStats.inline++\n    if (p.thirdParty) packStats.thirdParty++\n    if (p.public) packStats.public++\n  }\n  console.log(`\\nStickerSet flag distribution (of ${packSample.length} sampled):`)\n  Object.entries(packStats).forEach(([k, v]) => console.log(`  ${k.padEnd(12)} = ${v} (${pct(v, packSample.length)})`))\n\n  try {\n    const collStats = await db.Sticker.collection.stats()\n    console.log('\\nSticker collection stats:')\n    console.log(`  storageSize  = ${(collStats.storageSize / 1e9).toFixed(2)} GB`)\n    console.log(`  totalSize    = ${(collStats.totalSize / 1e9).toFixed(2)} GB`)\n    console.log(`  avgObjSize   = ${collStats.avgObjSize} bytes`)\n    console.log(`  indexSize    = ${(collStats.totalIndexSize / 1e9).toFixed(2)} GB`)\n  } catch (err) {\n    console.log('\\n(collection stats unavailable:', err.message, ')')\n  }\n\n  console.log('\\n=== done ===')\n  process.exit(0)\n}\n\nrun().catch((err) => {\n  console.error('inspect failed:', err)\n  process.exit(1)\n})\n"
  },
  {
    "path": "scripts/test-perf-timing.js",
    "content": "// Standalone smoke test for utils/perf-timing.js\n// Run: node scripts/test-perf-timing.js\n//\n// Verifies:\n//   1. perfStage wraps a middleware and records SELF time (not downstream)\n//   2. perfSnapshot returns reasonable p50s matching the known delays\n//   3. The summary log fires exactly once at the 50-update boundary\n//      when PERF_TIMING_INTERVAL=50 and we run 60 cycles\n\nprocess.env.PERF_TIMING = '1'\nprocess.env.PERF_TIMING_INTERVAL = '50'\n\nconst assert = require('assert')\n\n// Require AFTER env is set — module reads env at load time.\nconst { perfStage, perfSnapshot } = require('../utils/perf-timing')\n\nconst sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))\n\nasync function run () {\n  console.log('perf-timing.js smoke test')\n\n  // Capture console.log output so we can count \"[perf]\" lines.\n  const perfLogs = []\n  const origLog = console.log\n  console.log = function (...args) {\n    const line = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')\n    if (line.startsWith('[perf]')) {\n      perfLogs.push(line)\n    } else {\n      origLog.apply(console, args)\n    }\n  }\n\n  // Build a 3-stage chain with known per-stage self-time delays.\n  // stageA waits 5ms BEFORE next, stageB waits 10ms BEFORE next,\n  // stageC is the terminal (tick=true) and waits 20ms with no next call.\n  const stageA = perfStage('stageA', async (ctx, next) => {\n    await sleep(5)\n    await next()\n  })\n  const stageB = perfStage('stageB', async (ctx, next) => {\n    await sleep(10)\n    await next()\n  })\n  const stageC = perfStage('stageC', async (ctx) => {\n    // terminal — simulate handler body\n    await sleep(20)\n    ctx.done = true\n  }, { tick: true })\n\n  // Compose manually: stageA(stageB(stageC))\n  async function runOnce () {\n    const ctx = {}\n    await stageA(ctx, () => stageB(ctx, () => stageC(ctx, async () => {})))\n    return ctx\n  }\n\n  const CYCLES = 60\n  for (let i = 0; i < CYCLES; i++) {\n    // eslint-disable-next-line no-await-in-loop\n    await runOnce()\n  }\n\n  // Restore console.log\n  console.log = origLog\n\n  const snap = perfSnapshot()\n  console.log('snapshot:', JSON.stringify(snap))\n  console.log(`perf log lines captured: ${perfLogs.length}`)\n  perfLogs.forEach(l => console.log('  >', l))\n\n  // Assertions: p50s should be within a tolerance window around the\n  // known delays. setTimeout is not precise — allow +/- 15ms plus some\n  // headroom for slow CI, but require at least the floor.\n  const tol = 15\n  assert.ok(snap.stageA, 'stageA recorded')\n  assert.ok(snap.stageB, 'stageB recorded')\n  assert.ok(snap.stageC, 'stageC recorded')\n\n  assert.ok(snap.stageA.p50 >= 4 && snap.stageA.p50 <= 5 + tol,\n    `stageA p50 expected ~5ms, got ${snap.stageA.p50}`)\n  assert.ok(snap.stageB.p50 >= 9 && snap.stageB.p50 <= 10 + tol,\n    `stageB p50 expected ~10ms, got ${snap.stageB.p50}`)\n  assert.ok(snap.stageC.p50 >= 19 && snap.stageC.p50 <= 20 + tol,\n    `stageC p50 expected ~20ms, got ${snap.stageC.p50}`)\n\n  assert.strictEqual(snap.stageA.total, CYCLES, 'stageA total == CYCLES')\n  assert.strictEqual(snap.stageB.total, CYCLES, 'stageB total == CYCLES')\n  assert.strictEqual(snap.stageC.total, CYCLES, 'stageC total == CYCLES')\n\n  // With INTERVAL=50 and 60 cycles, the summary should fire exactly once\n  // (at cycle 50). Cycle 100 won't happen.\n  assert.strictEqual(perfLogs.length, 1,\n    `expected exactly 1 [perf] log line, got ${perfLogs.length}`)\n  assert.ok(perfLogs[0].includes('stageA='), 'log mentions stageA')\n  assert.ok(perfLogs[0].includes('stageB='), 'log mentions stageB')\n  assert.ok(perfLogs[0].includes('stageC='), 'log mentions stageC')\n  assert.ok(perfLogs[0].includes('(n=50)'), 'log includes (n=50)')\n\n  console.log('\\nsmoke test OK')\n}\n\nrun().catch(err => {\n  console.error(err.stack || err.message)\n  process.exitCode = 1\n})\n"
  },
  {
    "path": "scripts/test-retry-api.js",
    "content": "// Standalone smoke test for utils/retry-api.js\n// Run: node scripts/test-retry-api.js\n//\n// Verifies:\n//   1. Blocked-chat cache short-circuits send methods with synthetic 403\n//   2. Non-send methods bypass the cache\n//   3. clearBlockedChat removes the entry\n//   4. withRetry honors retry_after and adds jitter\n//   5. retryMiddleware clears the cache on incoming ctx.from\n//   6. 403 \"blocked by the user\" response populates the cache\n\nconst assert = require('assert')\nconst Telegram = require('telegraf/telegram')\n\n// Stub the underlying callApi BEFORE retry-api patches the prototype.\n// retry-api wraps whatever callApi exists at require-time, so our stub\n// becomes the \"real\" network layer.\nconst calls = []\nlet stubResponder = () => Promise.resolve({ ok: true })\nTelegram.prototype.callApi = function (method, data) {\n  calls.push({ method, data })\n  return stubResponder(method, data)\n}\n\nconst {\n  clearBlockedChat,\n  retryMiddleware,\n  _blockedCacheSize,\n  _rateLimitCacheSize\n} = require('../utils/retry-api')\n\nconst tg = new Telegram('fake-token')\n\n// Access the cache via the module's internal Map by re-requiring it —\n// we need a way to fully reset between tests. Simplest: export a reset.\n// If we don't want to expand the public surface, iterate the cache via\n// the size helper and clear by calling clearBlockedChat for the chat_ids\n// we touched. For this smoke test we just track them explicitly.\nconst touchedChats = new Set()\nfunction touch (chatId) {\n  touchedChats.add(chatId)\n  return chatId\n}\nfunction resetCache () {\n  for (const id of touchedChats) clearBlockedChat(id)\n  touchedChats.clear()\n}\n\nasync function test (name, fn) {\n  try {\n    calls.length = 0\n    stubResponder = () => Promise.resolve({ ok: true })\n    resetCache()\n    await fn()\n    console.log(`  PASS  ${name}`)\n  } catch (err) {\n    console.error(`  FAIL  ${name}`)\n    console.error(err.stack || err.message)\n    process.exitCode = 1\n  } finally {\n    resetCache()\n  }\n}\n\n;(async () => {\n  console.log('retry-api.js smoke test')\n\n  await test('non-send methods bypass the cache entirely', async () => {\n    stubResponder = () => {\n      const err = new Error('Forbidden: bot was blocked by the user')\n      err.code = 403\n      err.description = 'Forbidden: bot was blocked by the user'\n      return Promise.reject(err)\n    }\n    await assert.rejects(tg.callApi('getMe', {}))\n    assert.strictEqual(calls.length, 1, 'getMe should still hit stub')\n    assert.strictEqual(_blockedCacheSize(), 0, 'non-send 403 should not cache')\n  })\n\n  await test('403 blocked on sendMessage caches the chat_id', async () => {\n    stubResponder = () => {\n      const err = new Error('Forbidden: bot was blocked by the user')\n      err.code = 403\n      err.description = 'Forbidden: bot was blocked by the user'\n      return Promise.reject(err)\n    }\n    await assert.rejects(tg.callApi('sendMessage', { chat_id: touch(111), text: 'x' }))\n    assert.strictEqual(_blockedCacheSize(), 1)\n\n    // Next send to same chat must short-circuit — stub call count stays at 1\n    await assert.rejects(tg.callApi('sendMessage', { chat_id: 111, text: 'y' }))\n    assert.strictEqual(calls.length, 1, 'cached block should skip real call')\n  })\n\n  await test('non-send 403 (createNewStickerSet) also caches the user_id', async () => {\n    stubResponder = () => {\n      const err = new Error('Forbidden: bot was blocked by the user')\n      err.code = 403\n      err.description = 'Forbidden: bot was blocked by the user'\n      return Promise.reject(err)\n    }\n    // Rationale: if ANY call involving this user returns \"user blocked\n    // the bot\", subsequent sendMessage/etc. to the same chat_id will\n    // fail for the same reason. Cache defensively on all 403 blocked.\n    await assert.rejects(tg.callApi('createNewStickerSet', { user_id: touch(222) }))\n    assert.strictEqual(_blockedCacheSize(), 1)\n\n    // Now the scene-style follow-up sendMessage short-circuits\n    await assert.rejects(tg.callApi('sendMessage', { chat_id: 222, text: 'err' }))\n    assert.strictEqual(calls.length, 1, 'follow-up send must not hit network')\n  })\n\n  await test('cascade scenario: send fails, second send short-circuits', async () => {\n    stubResponder = () => {\n      const err = new Error('Forbidden: bot was blocked by the user')\n      err.code = 403\n      err.description = 'Forbidden: bot was blocked by the user'\n      return Promise.reject(err)\n    }\n    await assert.rejects(tg.callApi('sendMessage', { chat_id: touch(333), text: 'err1' }))\n    assert.strictEqual(calls.length, 1)\n    await assert.rejects(tg.callApi('sendMessage', { chat_id: 333, text: 'err2' }))\n    assert.strictEqual(calls.length, 1, 'second send must not hit network')\n  })\n\n  await test('withRetry honors retry_after and completes on success', async () => {\n    let attempts = 0\n    stubResponder = () => {\n      attempts++\n      if (attempts === 1) {\n        const err = new Error('Too Many Requests')\n        err.code = 429\n        err.description = 'Too Many Requests: retry after 1'\n        err.parameters = { retry_after: 1 }\n        return Promise.reject(err)\n      }\n      return Promise.resolve({ ok: true, message_id: 1 })\n    }\n    const start = Date.now()\n    const result = await tg.callApi('sendMessage', { chat_id: 444, text: 'x' })\n    const elapsed = Date.now() - start\n    assert.strictEqual(result.ok, true)\n    assert.strictEqual(attempts, 2, 'should retry once after 429')\n    assert.ok(elapsed >= 1000, `expected >= 1000ms, got ${elapsed}ms (jitter lower bound)`)\n    assert.ok(elapsed < 3000, `expected < 3000ms, got ${elapsed}ms (jitter upper bound)`)\n  })\n\n  await test('429 with retry_after > maxWait throws immediately (uniform fail-fast)', async () => {\n    let attempts = 0\n    stubResponder = () => {\n      attempts++\n      const err = new Error('Too Many Requests')\n      err.code = 429\n      err.description = 'Too Many Requests: retry after 40'\n      err.parameters = { retry_after: 40 }\n      return Promise.reject(err)\n    }\n    const start = Date.now()\n    await assert.rejects(tg.callApi('sendMessage', { chat_id: 888, text: 'x' }))\n    const elapsed = Date.now() - start\n    assert.strictEqual(attempts, 1, 'must NOT retry when retry_after exceeds maxWait')\n    assert.ok(elapsed < 200, `must fail fast — got ${elapsed}ms`)\n  })\n\n  await test('429 with retry_after <= maxWait retries regardless of method', async () => {\n    let attempts = 0\n    stubResponder = () => {\n      attempts++\n      if (attempts < 2) {\n        const err = new Error('Too Many Requests')\n        err.code = 429\n        err.description = 'Too Many Requests: retry after 1'\n        err.parameters = { retry_after: 1 }\n        return Promise.reject(err)\n      }\n      return Promise.resolve({ ok: true })\n    }\n    const result = await tg.callApi('addStickerToSet', { user_id: 999, name: 'pack' })\n    assert.strictEqual(result.ok, true, 'short retry_after should retry and succeed for any method')\n    assert.strictEqual(attempts, 2, 'should retry once then succeed')\n  })\n\n  await test('withRetry honors custom maxWait for direct (non-patched) calls', async () => {\n    // Background workers can pass their own maxWait to withRetry when\n    // they wrap non-Telegram async work. This validates the options\n    // override path — we don't go through tg.callApi here because the\n    // prototype patch applies its own withRetry and we'd nest them.\n    const { withRetry } = require('../utils/retry-api')\n    let attempts = 0\n    const start = Date.now()\n    await assert.rejects(withRetry(async () => {\n      attempts++\n      const err = new Error('Too Many Requests')\n      err.code = 429\n      err.description = 'Too Many Requests: retry after 2'\n      err.parameters = { retry_after: 2 }\n      throw err\n    }, { method: 'addStickerToSet', maxRetries: 1, maxWait: 10 }))\n    const elapsed = Date.now() - start\n    // maxRetries=1 → 2 total attempts; retry_after=2s ≤ maxWait=10 → 1 wait\n    assert.strictEqual(attempts, 2, 'should retry exactly once')\n    assert.ok(elapsed >= 2000, `should wait ~2s between attempts — got ${elapsed}ms`)\n    assert.ok(elapsed < 5000, `but not unreasonably long — got ${elapsed}ms`)\n  })\n\n  await test('retryMiddleware clears cache for incoming user', async () => {\n    stubResponder = () => {\n      const err = new Error('Forbidden: bot was blocked by the user')\n      err.code = 403\n      err.description = 'Forbidden: bot was blocked by the user'\n      return Promise.reject(err)\n    }\n    await assert.rejects(tg.callApi('sendMessage', { chat_id: touch(555), text: 'x' }))\n    assert.strictEqual(_blockedCacheSize(), 1)\n\n    const mw = retryMiddleware()\n    const ctx = { from: { id: 555 }, chat: { id: 555 }, update: {} }\n    await mw(ctx, async () => {})\n    assert.strictEqual(_blockedCacheSize(), 0, 'middleware should clear cache')\n  })\n\n  await test('synthetic 403 carries __cachedBlock flag for catch.js to skip', async () => {\n    stubResponder = () => {\n      const err = new Error('Forbidden: bot was blocked by the user')\n      err.code = 403\n      err.description = 'Forbidden: bot was blocked by the user'\n      return Promise.reject(err)\n    }\n    // First call: real network 403, populates cache, error has no flag\n    let firstErr\n    try { await tg.callApi('sendMessage', { chat_id: touch(777), text: 'x' }) } catch (e) { firstErr = e }\n    assert.strictEqual(firstErr.__cachedBlock, undefined,\n      'real 403 must NOT be marked as cached — catch.js should still log it')\n\n    // Second call: short-circuited, must carry the flag so catch.js\n    // can silently drop it instead of spamming the admin log channel\n    let secondErr\n    try { await tg.callApi('sendMessage', { chat_id: 777, text: 'y' }) } catch (e) { secondErr = e }\n    assert.strictEqual(secondErr.__cachedBlock, true,\n      'cached short-circuit must set __cachedBlock = true')\n    assert.strictEqual(secondErr.code, 403)\n  })\n\n  await test('group 403 (negative chat_id) is NOT cached — permission errors ≠ blocked', async () => {\n    stubResponder = () => {\n      const err = new Error('Forbidden: not enough rights')\n      err.code = 403\n      err.description = 'Forbidden: not enough rights to send text messages'\n      return Promise.reject(err)\n    }\n    await assert.rejects(tg.callApi('sendMessage', { chat_id: -1001234, text: 'x' }))\n    assert.strictEqual(_blockedCacheSize(), 0, 'negative chat_id (group) must not populate cache')\n  })\n\n  await test('429 retry_after on error.response.parameters path also triggers retry', async () => {\n    let attempts = 0\n    stubResponder = () => {\n      attempts++\n      if (attempts === 1) {\n        const err = new Error('Too Many Requests')\n        err.code = 429\n        err.response = { parameters: { retry_after: 1 } }\n        return Promise.reject(err)\n      }\n      return Promise.resolve({ ok: true })\n    }\n    const result = await tg.callApi('sendMessage', { chat_id: 1234, text: 'x' })\n    assert.strictEqual(result.ok, true)\n    assert.strictEqual(attempts, 2, 'should retry using response.parameters.retry_after')\n  })\n\n  await test('429 with retry_after > maxWait caches (method, chat) — siblings short-circuit', async () => {\n    let attempts = 0\n    stubResponder = () => {\n      attempts++\n      const err = new Error('Too Many Requests')\n      err.code = 429\n      err.description = 'Too Many Requests: retry after 7'\n      err.parameters = { retry_after: 7 }\n      return Promise.reject(err)\n    }\n    // First call: real network 429, fails fast, populates rate-limit cache\n    let firstErr\n    try { await tg.callApi('sendChatAction', { chat_id: 1001, action: 'upload_document' }) } catch (e) { firstErr = e }\n    assert.strictEqual(attempts, 1, 'first call must hit the network')\n    assert.strictEqual(firstErr.__cachedRateLimit, undefined, 'real 429 not marked as cached')\n    assert.ok(_rateLimitCacheSize() >= 1, 'cache must populate after fail-fast 429')\n\n    // Second call same (method, chat): short-circuits, zero network\n    let secondErr\n    try { await tg.callApi('sendChatAction', { chat_id: 1001, action: 'upload_document' }) } catch (e) { secondErr = e }\n    assert.strictEqual(attempts, 1, 'cached short-circuit must not hit network')\n    assert.strictEqual(secondErr.__cachedRateLimit, true, 'synthetic error carries flag for catch.js')\n    assert.strictEqual(secondErr.code, 429)\n\n    // Different chat_id for same method: not in cache — must hit network\n    let thirdErr\n    try { await tg.callApi('sendChatAction', { chat_id: 1002, action: 'upload_document' }) } catch (e) { thirdErr = e }\n    assert.strictEqual(attempts, 2, 'different chat must not inherit cooldown')\n    assert.strictEqual(thirdErr.__cachedRateLimit, undefined, 'different chat gets real 429')\n  })\n\n  await test('429 with retry_after > maxWait caches by pack name — siblings on same pack short-circuit', async () => {\n    let attempts = 0\n    stubResponder = () => {\n      attempts++\n      const err = new Error('Too Many Requests')\n      err.code = 429\n      err.description = 'Too Many Requests: retry after 8'\n      err.parameters = { retry_after: 8 }\n      return Promise.reject(err)\n    }\n    // Pack A: real 429, fails fast, populates (method, name) cache.\n    let firstErr\n    try { await tg.callApi('setStickerSetTitle', { name: 'pack_a', title: 'X' }) } catch (e) { firstErr = e }\n    assert.strictEqual(attempts, 1, 'first call must hit the network')\n    assert.strictEqual(firstErr.__cachedRateLimit, undefined, 'real 429 not marked as cached')\n\n    // Same pack name → short-circuited.\n    let secondErr\n    try { await tg.callApi('setStickerSetTitle', { name: 'pack_a', title: 'Y' }) } catch (e) { secondErr = e }\n    assert.strictEqual(attempts, 1, 'cached short-circuit must not hit network for same pack')\n    assert.strictEqual(secondErr.__cachedRateLimit, true, 'synthetic error carries flag')\n\n    // Different pack: per-pack scope is not shared, must hit network.\n    let thirdErr\n    try { await tg.callApi('setStickerSetTitle', { name: 'pack_b', title: 'Z' }) } catch (e) { thirdErr = e }\n    assert.strictEqual(attempts, 2, 'different pack must not inherit cooldown')\n    assert.strictEqual(thirdErr.__cachedRateLimit, undefined, 'different pack gets real 429')\n  })\n\n  await test('429 without chat/user/pack scope does NOT cache (no global method-only lockout)', async () => {\n    // Regression: deleteStickerFromSet payload is { sticker: file_id } — no\n    // chat_id / user_id. A single per-pack 429 with retry_after > maxWait\n    // must NOT cache the method globally; doing so would block every other\n    // user's deleteStickerFromSet for the entire retry_after window.\n    let attempts = 0\n    stubResponder = () => {\n      attempts++\n      const err = new Error('Too Many Requests')\n      err.code = 429\n      err.description = 'Too Many Requests: retry after 30'\n      err.parameters = { retry_after: 30 }\n      return Promise.reject(err)\n    }\n    const sizeBefore = _rateLimitCacheSize()\n    await assert.rejects(tg.callApi('deleteStickerFromSet', { sticker: 'file_a' }))\n    assert.strictEqual(attempts, 1, 'first call must hit the network and fail-fast')\n    assert.strictEqual(_rateLimitCacheSize(), sizeBefore, 'scopeless 429 must not populate cache')\n\n    // Second call (different sticker, same method): must also hit network,\n    // not be short-circuited by a stale method-only cache entry.\n    await assert.rejects(tg.callApi('deleteStickerFromSet', { sticker: 'file_b' }))\n    assert.strictEqual(attempts, 2, 'second call must hit network — no stale method-only lock')\n  })\n\n  await test('429 cache only triggers when retry_after > maxWait (not for retriable 429s)', async () => {\n    // retry_after=2s is ≤ default maxWait=5s → withRetry retries and succeeds.\n    // We must NOT cache a transient 429 that retry already solved, otherwise\n    // every subsequent legit call would synthetically 429.\n    let attempts = 0\n    stubResponder = () => {\n      attempts++\n      if (attempts === 1) {\n        const err = new Error('Too Many Requests')\n        err.code = 429\n        err.parameters = { retry_after: 2 }\n        return Promise.reject(err)\n      }\n      return Promise.resolve({ ok: true })\n    }\n    const sizeBefore = _rateLimitCacheSize()\n    const result = await tg.callApi('sendMessage', { chat_id: 2001, text: 'x' })\n    assert.strictEqual(result.ok, true)\n    assert.strictEqual(_rateLimitCacheSize(), sizeBefore, 'retriable 429 must not populate cache')\n  })\n\n  await test('retryMiddleware ignores kick events', async () => {\n    const mw = retryMiddleware()\n    const ctx = {\n      from: { id: 666 },\n      chat: { id: 666 },\n      update: {\n        my_chat_member: { new_chat_member: { status: 'kicked' } }\n      }\n    }\n    // Pre-cache — a kick should NOT clear (user really did block us)\n    stubResponder = () => {\n      const err = new Error('Forbidden')\n      err.code = 403\n      err.description = 'Forbidden: bot was blocked by the user'\n      return Promise.reject(err)\n    }\n    await assert.rejects(tg.callApi('sendMessage', { chat_id: touch(666), text: 'x' }))\n    const sizeBefore = _blockedCacheSize()\n    await mw(ctx, async () => {})\n    assert.strictEqual(_blockedCacheSize(), sizeBefore, 'kick must not clear cache')\n  })\n\n  if (process.exitCode) {\n    console.error('\\nsmoke test FAILED')\n  } else {\n    console.log('\\nsmoke test OK')\n  }\n})()\n"
  },
  {
    "path": "scripts/top-sets.js",
    "content": "const Telegram = require('telegraf/telegram')\nconst cron = require('node-cron')\nconst { atlasDb } = require('../database')\nconst { escapeHTML } = require('../utils')\n\nconst telegram = new Telegram(process.env.BOT_TOKEN)\nconst config = require('../config')\n\n// Define a function to get the most popular sticker packs in a week\nasync function getPopularStickerPacks () {\n  const timeAgo = new Date().setDate(new Date().getDate() - 30)\n\n  const popularStickerPacks = await atlasDb\n    .StickerSet\n    .find({\n      'about.safe': true,\n      'installations.month': { $gt: 0 },\n      'reaction.total': { $gt: 5 },\n      publishDate: { $gte: timeAgo },\n      stickerChannel: { $exists: false },\n      $and: [\n        { 'about.description': { $ne: null } },\n        { 'about.description': { $ne: '' } }\n      ]\n    })\n    .sort({ 'reaction.total': -1 })\n    .limit(1)\n  return popularStickerPacks\n}\n\n// Define a function to post the most popular sticker packs to a channel\nasync function postPopularStickerPacksToChannel () {\n  const popularStickerPacks = await getPopularStickerPacks()\n  for (const stickerPack of popularStickerPacks) {\n    const stickerSet = await telegram.getStickerSet(stickerPack.name)\n\n    let title = stickerSet.title\n\n    // remove (@username) or :: @username from the title\n    title = title.replace(/\\s\\(@\\w+\\)/, '')\n    title = title.replace(/::\\s@\\w+/, '')\n    title = title.replace(/@/, '')\n\n    let about = `${stickerPack.about.description}`\n\n    // remove Stickers from Stickers.Wiki from the about\n    about = about.replace(/Stickers from Stickers.Wiki/, '').trim()\n\n    const message = `<b>${escapeHTML(title)}</b>\\n${escapeHTML(about)}`\n\n    // get random sticker from the sticker pack\n    const sticker = stickerSet.stickers[Math.floor(Math.random() * stickerSet.stickers.length)]\n\n    const bot = await telegram.getMe()\n\n    const stickerMessageId = await telegram.sendSticker(config.stickerChannelId, sticker.file_id, {\n      reply_markup: {\n        inline_keyboard: [\n          [\n            {\n              text: `👍 ${stickerPack.reaction.total}`,\n              url: `https://t.me/${bot.username}/catalog?startApp=set=${stickerSet.name}&startapp=set=${stickerSet.name}`\n            }\n          ]\n        ]\n      }\n    })\n\n    await telegram.sendMessage(config.stickerChannelId, message, {\n      parse_mode: 'HTML'\n    })\n\n    stickerPack.stickerChannel = {\n      messageId: stickerMessageId.message_id\n    }\n\n    await stickerPack.save()\n  }\n}\n\nif (config.stickerChannelId) {\n  cron.schedule('0 */2 * * *', () => postPopularStickerPacksToChannel()) // every 2 hours\n\n  const updateMessage = async () => {\n    const stickerPacks = await atlasDb.StickerSet.find({ 'stickerChannel.messageId': { $gt: 0 } })\n\n    for (const stickerPack of stickerPacks) {\n      const stickerSet = await telegram.getStickerSet(stickerPack.name)\n      const bot = await telegram.getMe()\n\n      const inlineKeyboard = [\n        {\n          text: `👍 ${stickerPack.reaction.total}`,\n          url: `https://t.me/${bot.username}/catalog?startApp=set=${stickerSet.name}&startapp=set=${stickerSet.name}`\n        }\n      ]\n\n      await telegram.editMessageReplyMarkup(config.stickerChannelId, stickerPack.stickerChannel.messageId, null, {\n        inline_keyboard: [inlineKeyboard]\n      }).catch(() => {})\n\n      await new Promise(resolve => setTimeout(resolve, 3000))\n    }\n\n    updateMessage()\n  }\n\n  updateMessage()\n}\n"
  },
  {
    "path": "scripts/update-packs.js",
    "content": "const { telegramApi } = require('../utils')\nconst Telegram = require('telegraf/telegram')\nconst {\n  db\n} = require('../database')\nconst decodeStickerSetId = require('../utils/decode-sticker-set-id')\n\nconst telegram = new Telegram(process.env.BOT_TOKEN)\n\nlet botInfo = null\n\ntelegram.getMe().then((info) => {\n  botInfo = info\n})\n\nasync function processStickerSets (stickerSets) {\n  const processedStickerSets = []\n\n  const handleError = (stickerSet, error) => {\n    if (error.message.includes('STICKERSET_INVALID')) {\n      console.log(`Sticker set https://t.me/addstickers/${stickerSet.name} is invalid, removing`)\n      return stickerSet.deleteOne()\n    } else {\n      console.error(`Error processing ${stickerSet.name}: ${error.message}`)\n    }\n  }\n\n  const processStickerSet = async (stickerSet) => {\n    try {\n      console.log(`Processing sticker set: ${stickerSet.name}`)\n\n      if (stickerSet.ownerTelegramId) {\n        console.log(`Sticker set ${stickerSet.name} already has ownerTelegramId, skipping further processing`)\n        processedStickerSets.push(stickerSet)\n        return\n      }\n\n      if (stickerSet.owner) {\n        await telegram.getStickerSet(stickerSet.name)\n        const owner = await db.User.findById(stickerSet.owner)\n        if (owner) {\n          stickerSet.ownerTelegramId = owner.telegram_id\n          await stickerSet.save()\n          processedStickerSets.push(stickerSet)\n          console.log(`Updated ownerTelegramId for sticker set: ${stickerSet.name}`)\n          return\n        }\n      }\n\n      const stickerSetInfo = await telegramApi.client.invoke(new telegramApi.Api.messages.GetStickerSet({\n        stickerset: new telegramApi.Api.InputStickerSetShortName({\n          shortName: stickerSet.name\n        }),\n        hash: 0\n      }))\n\n      const ownerTelegramId = decodeStickerSetId(stickerSetInfo.set.id.value).ownerId\n      const owner = await db.User.findOne({ telegram_id: ownerTelegramId })\n\n      if (owner) {\n        stickerSet.owner = owner._id\n      }\n\n      stickerSet.ownerTelegramId = ownerTelegramId\n      await stickerSet.save()\n\n      processedStickerSets.push(stickerSet)\n      console.log(`Successfully processed sticker set: ${stickerSet.name}`)\n    } catch (error) {\n      await handleError(stickerSet, error)\n    }\n  }\n\n  const results = await Promise.allSettled(\n    stickerSets.map(stickerSet => processStickerSet(stickerSet))\n  )\n\n  const successCount = results.filter(result => result.status === 'fulfilled').length\n  const failCount = results.filter(result => result.status === 'rejected').length\n\n  console.log(`Processed ${successCount} sticker sets successfully, ${failCount} failed`)\n\n  return processedStickerSets\n}\n\n(async () => {\n  const batchSize = 50\n\n  const cursor = db.StickerSet.find({\n    ownerTelegramId: { $exists: false }, // not processed yet\n    createdAt: { $lt: new Date(Date.now() - 1000 * 60 * 60 * 24) }, // 24 hours ago\n    inline: { $ne: true } // not inline\n  }).sort({\n    _id: -1\n  }).batchSize(batchSize).cursor()\n\n  let batch = []\n  for await (const stickerSet of cursor) {\n    batch.push(stickerSet)\n\n    if (batch.length === batchSize) {\n      await processStickerSets(batch)\n      batch = []\n    }\n  }\n\n  if (batch.length > 0) {\n    await processStickerSets(batch)\n  }\n})();\n\n(async () => {\n  while (true) {\n    const stickersWithoutParentSet = await db.Sticker.aggregate([\n      {\n        $lookup: {\n          from: 'stickersets',\n          localField: 'stickerSet',\n          foreignField: '_id',\n          as: 'parentSet'\n        }\n      },\n      {\n        $match: {\n          parentSet: {\n            $size: 0\n          }\n        }\n      },\n      {\n        $limit: 1000\n      },\n      {\n        $project: {\n          _id: 1,\n          stickerSet: 1\n        }\n      }\n    ])\n\n    // delete many\n    await db.Sticker.deleteMany({\n      _id: {\n        $in: stickersWithoutParentSet.map(sticker => sticker._id)\n      }\n    })\n      .catch(err => console.error(err))\n      .then(() => console.log(`Deleted ${stickersWithoutParentSet.length} stickers without parent set`))\n    await new Promise(resolve => setTimeout(resolve, 60 * 1000))\n  }\n})()\n"
  },
  {
    "path": "scripts/update-sticker.js",
    "content": "require('dotenv').config({ path: '../.env' })\nconst Telegram = require('telegraf/telegram')\nconst {\n  db\n} = require('../database')\n\nconst telegram = new Telegram(process.env.BOT_TOKEN)\n\n;(async () => {\n  const stickers = db.Sticker.find({\n    // fileUniqueId: { $exists: false },\n  }).cursor()\n\n  for (let sticker = stickers.next(); sticker != null; sticker = stickers.next()) {\n    const stickerInfo = await sticker\n\n    const file = await telegram.getFile(stickerInfo.fileId)\n\n    stickerInfo.fileId = file.file_id\n    stickerInfo.fileUniqueId = file.file_unique_id\n    stickerInfo.save()\n\n    console.log(stickerInfo.fileUniqueId)\n  }\n})()\n"
  },
  {
    "path": "utils/add-sticker-text.js",
    "content": "const path = require('path')\nconst Markup = require('telegraf/markup')\nconst I18n = require('telegraf-i18n')\nconst escapeHTML = require('./html-escape')\nconst { truncateDescription } = require('./telegram-error')\n\n// add-sticker-text feeds telegram.sendMessage (4096-char hard limit).\n// Truncate raw API descriptions well below that so a Telegram dump can't\n// push our final message past the limit and trigger MESSAGE_TOO_LONG.\nconst SEND_MESSAGE_DESCRIPTION_MAX = 1000\n\nconst i18n = new I18n({\n  directory: path.resolve(__dirname, '../locales'),\n  defaultLanguage: 'uk',\n  defaultLanguageOnMissing: true\n})\n\n// Known Telegram API error description substrings → i18n key.\n// Order matters only when substrings overlap — currently disjoint.\n// Add a new known error = add a row. No branching in the rendering code.\nconst TELEGRAM_ERROR_MAP = [\n  ['TOO_MUCH', 'sticker.add.error.stickers_too_much'],\n  ['STICKERSET_INVALID', 'sticker.add.error.stickerset_invalid'],\n  ['file is too big', 'sticker.add.error.too_big'],\n  ['STICKER_VIDEO_BIG', 'sticker.add.error.too_big'],\n  ['STICKER_PNG_NOPNG', 'sticker.add.error.invalid_png'],\n  ['STICKER_PNG_DIMENSIONS', 'sticker.add.error.invalid_dimensions'],\n  ['STICKER_TGS_NOTGS', 'sticker.add.error.invalid_animated'],\n  ['STICKER_VIDEO_NOWEBM', 'sticker.add.error.invalid_video'],\n  ['sticker not found', 'sticker.add.error.sticker_not_found']\n]\n\nmodule.exports = (addStickerResult, lang) => {\n  let messageText = ''\n  let replyMarkup = {}\n\n  if (addStickerResult.ok) {\n    if (addStickerResult.ok.inline) {\n      messageText = i18n.t(lang, 'sticker.add.ok_inline', {\n        title: escapeHTML(addStickerResult.ok.stickerSet.title)\n      })\n\n      replyMarkup = Markup.inlineKeyboard([\n        Markup.switchToChatButton(i18n.t(lang, 'callback.pack.btn.use_pack'), '')\n      ])\n    } else {\n      messageText = i18n.t(lang, 'sticker.add.ok', {\n        title: escapeHTML(addStickerResult.ok.title),\n        link: addStickerResult.ok.link\n      })\n\n      replyMarkup = Markup.inlineKeyboard([\n        Markup.urlButton(i18n.t(lang, 'callback.pack.btn.use_pack'), addStickerResult.ok.link)\n      ])\n    }\n  } else if (addStickerResult.error) {\n    if (addStickerResult.error.type === 'duplicate') {\n      messageText = i18n.t(lang, 'sticker.add.error.have_already')\n\n      if (addStickerResult.error.sticker) {\n        replyMarkup = Markup.inlineKeyboard([\n          { ...Markup.callbackButton(i18n.t(lang, 'callback.sticker.btn.delete'), `delete_sticker:${addStickerResult.error.sticker.fileUniqueId}`), style: 'danger' },\n          { ...Markup.callbackButton(i18n.t(lang, 'callback.sticker.btn.copy'), `restore_sticker:${addStickerResult.error.sticker.fileUniqueId}`), style: 'primary' }\n        ])\n      }\n    } else if (addStickerResult.error.i18nKey) {\n      // Data-driven inline-error path: addSticker short-circuited with a\n      // known i18n key (download failed, too big, queue full, etc.).\n      // Adding a new inline error = add an i18n key, no code change here.\n      messageText = i18n.t(lang, addStickerResult.error.i18nKey)\n    } else if (addStickerResult.error.telegram) {\n      const errDescription = addStickerResult.error.telegram.description || addStickerResult.error.telegram.message || ''\n      if (!errDescription) {\n        throw new Error(JSON.stringify(addStickerResult.error))\n      }\n      const hit = TELEGRAM_ERROR_MAP.find(([needle]) => errDescription.includes(needle))\n      messageText = hit\n        ? i18n.t(lang, hit[1])\n        : i18n.t(lang, 'error.telegram', {\n          error: truncateDescription(errDescription, SEND_MESSAGE_DESCRIPTION_MAX)\n        })\n    } else if (addStickerResult.error === 'ITS_ANIMATED') {\n      messageText = i18n.t(lang, 'sticker.add.error.file_type')\n    } else {\n      messageText = i18n.t(lang, 'error.telegram', {\n        error: truncateDescription(String(addStickerResult.error), SEND_MESSAGE_DESCRIPTION_MAX)\n      })\n    }\n  }\n\n  return {\n    messageText,\n    replyMarkup\n  }\n}\n"
  },
  {
    "path": "utils/add-sticker.js",
    "content": "const path = require('path')\nconst sharp = require('sharp')\nconst I18n = require('telegraf-i18n')\nconst emojiRegex = require('emoji-regex')\nconst { db } = require('../database')\nconst config = require('../config.json')\nconst addStickerText = require('../utils/add-sticker-text')\nconst telegram = require('./telegram')\nconst { convertQueue, removebgQueue } = require('./queues')\nconst downloadFileByUrl = require('./download-file-by-url')\n\n// Track users with video currently processing (userId -> timestamp)\nconst videoProcessing = new Map()\nconst VIDEO_PROCESSING_TTL = 1000 * 60 * 2 // 2 minutes auto-unlock\n\nlet botInfo = null\ntelegram.getMe().then((info) => {\n  botInfo = info\n})\n\nconst i18n = new I18n({\n  directory: path.resolve(__dirname, '../locales'),\n  defaultLanguage: 'uk',\n  defaultLanguageOnMissing: true\n})\n\n// addSticker return contract (single shape — caller always renders via\n// addStickerText, addSticker never calls ctx.reply directly):\n//\n//   { ok: {...} }       — success\n//   { wait: true }      — queued to convertQueue; worker will reply later\n//   { error: {...} }    — any of:\n//     { type: 'duplicate', sticker }   — dup in inline pack (caller renders\n//                                         inline buttons to delete/copy)\n//     { telegram: <err> }              — Telegram API error (caller inspects\n//                                         description for known codes)\n//     { i18nKey: '<key>' }             — simple inline error; caller\n//                                         renders i18n.t(key) verbatim.\n//                                         Data-driven: adding a new inline\n//                                         error = add an i18n key, no code\n//                                         change in addStickerText.\n\n// Update queue position messages when jobs complete (event-driven, not polling)\nasync function updateConvertQueueMessages () {\n  try {\n    const waiting = await convertQueue.getWaiting()\n\n    for (let i = 0; i < waiting.length; i++) {\n      const job = waiting[i]\n      if (job?.data?.input?.convertingMessageId) {\n        const { input } = job.data\n\n        await telegram.editMessageText(input.chatId, input.convertingMessageId, null, i18n.t(input.locale || 'en', 'sticker.add.converting_process', {\n          progress: i + 1,\n          total: waiting.length\n        }), {\n          parse_mode: 'HTML'\n        }).catch(() => {})\n      }\n    }\n  } catch (err) {\n    console.error('updateConvertQueueMessages error:', err.message)\n  }\n}\n\n// Trigger queue position updates only when a slot frees (completion shifts remaining waiting jobs).\n// global:failed/global:active previously duplicated this work and hammered Telegram with edits.\nconvertQueue.on('global:completed', updateConvertQueueMessages)\n\nconvertQueue.on('global:completed', async (jobId, result) => {\n  const { input, metadata, content } = JSON.parse(result)\n\n  videoProcessing.delete(input.userId)\n\n  const stickerExtra = input.stickerExtra\n\n  // Handle case when conversion failed (no metadata/content)\n  if (!metadata || !content) {\n    if (input.convertingMessageId) await telegram.deleteMessage(input.chatId, input.convertingMessageId).catch(() => {})\n\n    if (input?.botId === botInfo?.id) {\n      await telegram.sendMessage(input.chatId, i18n.t(input.locale || 'en', 'sticker.add.error.convert'), {\n        parse_mode: 'HTML'\n      }).catch(() => {})\n    }\n    return\n  }\n\n  stickerExtra.sticker = {\n    source: Buffer.from(content, 'base64')\n  }\n\n  const uploadResult = await uploadSticker(input.userId, input.stickerSet, input.stickerFile, stickerExtra)\n\n  if (input.convertingMessageId) await telegram.deleteMessage(input.chatId, input.convertingMessageId).catch(() => {})\n\n  if (input.showResult && input?.botId === botInfo.id) {\n    const textResult = addStickerText(uploadResult, input.locale || 'en')\n\n    if (textResult.messageText) {\n      await telegram.sendMessage(input.chatId, textResult.messageText, {\n        parse_mode: 'HTML',\n        reply_markup: textResult.replyMarkup\n      })\n    }\n  }\n})\n\nconvertQueue.on('global:failed', async (jobId, errorData) => {\n  const job = await convertQueue.getJob(jobId)\n  if (!job) return\n\n  const { input } = job.data\n\n  if (input?.userId) videoProcessing.delete(input.userId)\n\n  if (input.convertingMessageId) await telegram.deleteMessage(input.chatId, input.convertingMessageId).catch(() => {})\n\n  if (errorData === 'timeout') {\n    await telegram.sendMessage(input.chatId, i18n.t(input.locale || 'en', 'sticker.add.error.timeout'), {\n      parse_mode: 'HTML'\n    })\n  } else {\n    await telegram.sendMessage(config.logChatId, `<b>Convert error</b>\\n\\n<code>${JSON.stringify(errorData)}</code>`, {\n      parse_mode: 'HTML'\n    })\n\n    await telegram.sendMessage(input.chatId, i18n.t(input.locale || 'en', 'sticker.add.error.convert'), {\n      parse_mode: 'HTML'\n    })\n  }\n\n  job.remove()\n})\n\nconst uploadSticker = async (userId, stickerSet, stickerFile, stickerExtra) => {\n  let stickerAdd\n\n  // Validate stickerExtra has required fields\n  if (!stickerExtra || !stickerExtra.sticker) {\n    return {\n      error: {\n        message: 'Invalid sticker data: sticker is undefined'\n      }\n    }\n  }\n\n  const { sticker } = stickerExtra\n\n  if (sticker?.source) {\n    const uploadedSticker = await telegram.callApi('uploadStickerFile', {\n      user_id: userId,\n      sticker_format: stickerExtra.sticker_format,\n      sticker: {\n        source: sticker.source\n      }\n    }).catch((error) => {\n      return {\n        error: {\n          telegram: error\n        }\n      }\n    })\n\n    if (uploadedSticker.error) {\n      return uploadedSticker\n    }\n\n    stickerExtra.sticker = uploadedSticker.file_id\n  }\n\n  // Final validation before API call\n  if (!stickerExtra.sticker) {\n    return {\n      error: {\n        message: 'Sticker file not uploaded properly'\n      }\n    }\n  }\n\n  if (stickerSet.create === false) {\n    stickerAdd = await telegram.callApi('createNewStickerSet', {\n      user_id: userId,\n      name: stickerSet.name,\n      title: stickerSet.title,\n      stickers: [{\n        sticker: stickerExtra.sticker,\n        format: stickerExtra.sticker_format,\n        emoji_list: stickerExtra.emojis\n      }],\n      sticker_type: stickerSet.packType === 'custom_emoji' ? 'custom_emoji' : 'regular'\n    }).catch((error) => {\n      return {\n        error: {\n          telegram: error\n        }\n      }\n    })\n    if (stickerAdd.error) {\n      return stickerAdd\n    }\n    if (stickerAdd) {\n      stickerSet.create = true\n      await stickerSet.save()\n    }\n  } else {\n    stickerAdd = await telegram.callApi('addStickerToSet', {\n      user_id: userId,\n      name: stickerSet.name,\n      sticker: {\n        format: stickerExtra.sticker_format,\n        sticker: stickerExtra.sticker,\n        emoji_list: stickerExtra.emojis\n      }\n    }).catch((error) => {\n      return {\n        error: {\n          telegram: error\n        }\n      }\n    })\n\n    if (stickerAdd.error) {\n      return stickerAdd\n    }\n  }\n\n  if (stickerAdd) {\n    const getStickerSet = await telegram.getStickerSet(stickerSet.name).catch((error) => {\n      return {\n        error: {\n          telegram: error\n        }\n      }\n    })\n    if (getStickerSet.error) {\n      return getStickerSet\n    }\n\n    if (!getStickerSet.stickers || getStickerSet.stickers.length === 0) {\n      return {\n        error: {\n          message: 'Sticker set is empty after adding sticker'\n        }\n      }\n    }\n\n    const stickerInfo = getStickerSet.stickers.slice(-1)[0]\n\n    const sticker = await db.Sticker.addSticker(stickerSet._id, stickerExtra.emojis, stickerInfo, stickerFile)\n\n    const linkPrefix = stickerSet.packType === 'custom_emoji' ? config.emojiLinkPrefix : config.stickerLinkPrefix\n\n    return {\n      ok: {\n        title: stickerSet.title,\n        link: `${linkPrefix}${stickerSet.name}`,\n        stickerInfo,\n        sticker\n      }\n    }\n  }\n}\n\n// Rate limiting for static stickers (userId -> timestamp)\nconst lastStickerTime = new Map()\nconst STICKER_COOLDOWN = 1000 * 30 // 30 seconds\n\n// Periodic cleanup of old entries (every 5 minutes).\n// .unref() so this janitorial timer doesn't keep the process alive on shutdown.\nsetInterval(() => {\n  const now = Date.now()\n  for (const [key, value] of lastStickerTime) {\n    if (now - value > STICKER_COOLDOWN * 2) {\n      lastStickerTime.delete(key)\n    }\n  }\n}, 1000 * 60 * 5).unref()\n\nmodule.exports = async (ctx, inputFile, toStickerSet, showResult = true) => {\n  let stickerFile = inputFile\n\n  // If inputFile is already a sticker from a Telegram set, use it directly\n  // (it's already converted and validated by Telegram)\n  // Only look for original if it's a new file upload (no set_name)\n  if (!stickerFile.set_name) {\n    const originalSticker = await ctx.db.Sticker.findOne({\n      fileUniqueId: stickerFile.file_unique_id\n    })\n\n    // Use original file if available (supports both new and legacy schema)\n    // This preserves the chain: Pack A → Pack B → Pack C all point to original source\n    if (originalSticker && originalSticker.hasOriginal()) {\n      stickerFile = {\n        file_id: originalSticker.getOriginalFileId(),\n        file_unique_id: originalSticker.getOriginalFileUniqueId(),\n        stickerType: originalSticker.getOriginalStickerType() || stickerFile.stickerType,\n        // Preserve these fields for proper sticker type detection\n        set_name: stickerFile.set_name,\n        type: stickerFile.type,\n        is_animated: stickerFile.is_animated,\n        is_video: stickerFile.is_video\n      }\n    }\n  }\n\n  const stickerSet = toStickerSet\n\n  if (stickerSet && stickerSet.inline) {\n    // Validate file_unique_id exists\n    if (!stickerFile?.file_unique_id) {\n      return {\n        error: {\n          message: 'Invalid sticker file: missing file_unique_id'\n        }\n      }\n    }\n\n    // Check for duplicates in inline pack (by fileUniqueId, original.fileUniqueId, or legacy file.file_unique_id)\n    const existingSticker = await ctx.db.Sticker.findOne({\n      stickerSet: stickerSet.id,\n      deleted: false,\n      $or: [\n        { fileUniqueId: stickerFile.file_unique_id },\n        { 'original.fileUniqueId': stickerFile.file_unique_id },\n        { 'file.file_unique_id': stickerFile.file_unique_id }\n      ]\n    })\n\n    if (existingSticker) {\n      return {\n        error: {\n          type: 'duplicate',\n          sticker: existingSticker\n        }\n      }\n    }\n\n    const sticker = await ctx.db.Sticker.addSticker(stickerSet.id, inputFile.emoji, stickerFile, null)\n\n    return {\n      ok: {\n        inline: true,\n        sticker,\n        stickerSet\n      }\n    }\n  }\n\n  const emojis = []\n\n  if (inputFile.emoji) {\n    if (Array.isArray(inputFile.emoji)) {\n      emojis.push(...inputFile.emoji)\n    } else if (typeof inputFile.emoji === 'string') {\n      const emojiList = inputFile.emoji.match(emojiRegex())\n\n      if (emojiList) {\n        emojis.push(...emojiList)\n      }\n    }\n  }\n\n  if (emojis.length === 0) {\n    emojis.push(stickerSet.emojiSuffix)\n  }\n\n  // Unified video detection - check all possible sources\n  const stickerType = stickerFile.stickerType\n  const isVideo =\n    stickerFile.is_video ||\n    stickerType === 'video' ||\n    stickerType === 'video_note' ||\n    inputFile.is_video ||\n    !!(inputFile.mime_type && inputFile.mime_type.match('video')) ||\n    inputFile.mime_type === 'image/gif' ||\n    inputFile.duration > 0\n  const isVideoNote = inputFile.video_note || stickerType === 'video_note'\n\n  if (!ctx.session.userInfo) ctx.session.userInfo = await ctx.db.User.getData(ctx.from)\n\n  const getStickerSetCheck = await ctx.telegram.getStickerSet(stickerSet.name).catch((error) => {\n    return {\n      error: {\n        telegram: error\n      }\n    }\n  })\n  if (getStickerSetCheck.error) {\n    return getStickerSetCheck\n  }\n\n  const stickerExtra = {\n    emojis\n  }\n\n  if (stickerFile.is_animated) {\n    stickerExtra.sticker_format = 'animated'\n  } else if (isVideo || isVideoNote) {\n    stickerExtra.sticker_format = 'video'\n  } else {\n    stickerExtra.sticker_format = 'static'\n  }\n\n  if (stickerFile.is_animated) {\n    const fileUrl = await ctx.telegram.getFileLink(stickerFile).catch((error) => {\n      return {\n        error: {\n          telegram: error\n        }\n      }\n    })\n\n    if (fileUrl.error) {\n      return fileUrl\n    }\n\n    let animatedData\n    try {\n      animatedData = await downloadFileByUrl(fileUrl)\n    } catch (err) {\n      return { error: { i18nKey: 'sticker.add.error.convert' } }\n    }\n\n    stickerExtra.sticker = { source: animatedData }\n    return uploadSticker(ctx.from.id, stickerSet, stickerFile, stickerExtra)\n  }\n\n  // Non-animated stickers (static or video)\n  let fileUrl\n  let fileData\n\n  if (stickerFile.fileUrl) {\n    fileUrl = stickerFile.fileUrl\n  } else {\n    fileUrl = await ctx.telegram.getFileLink(stickerFile).catch((error) => {\n      return {\n        error: {\n          telegram: error\n        }\n      }\n    })\n\n    if (fileUrl.error) {\n      return fileUrl\n    }\n  }\n\n  // Verify sticker_format matches actual file format (fallback check via URL extension)\n  // This catches cases where is_video/is_animated might be incorrectly set\n  const fileUrlStr = fileUrl?.href || fileUrl?.toString() || ''\n  // Extract pathname to handle URLs with query parameters\n  let urlPathname = fileUrlStr\n  try {\n    if (fileUrlStr.startsWith('http')) {\n      urlPathname = new URL(fileUrlStr).pathname\n    }\n  } catch (e) {\n    // Keep original string if URL parsing fails\n  }\n\n  if (urlPathname.endsWith('.webm') && stickerExtra.sticker_format !== 'video') {\n    stickerExtra.sticker_format = 'video'\n  } else if (urlPathname.endsWith('.tgs') && stickerExtra.sticker_format !== 'animated') {\n    stickerExtra.sticker_format = 'animated'\n  } else if ((urlPathname.endsWith('.webp') || urlPathname.endsWith('.png')) && stickerExtra.sticker_format !== 'static') {\n    stickerExtra.sticker_format = 'static'\n  }\n\n  // Handle animated stickers that weren't caught by is_animated check (fallback from URL detection)\n  if (stickerExtra.sticker_format === 'animated' && !stickerFile.is_animated) {\n    let animatedData\n    try {\n      animatedData = await downloadFileByUrl(fileUrl)\n    } catch (err) {\n      return { error: { i18nKey: 'sticker.add.error.convert' } }\n    }\n    stickerExtra.sticker = { source: animatedData }\n    return uploadSticker(ctx.from.id, stickerSet, stickerFile, stickerExtra)\n  }\n\n  // For stickers already in a Telegram set with matching type - use directly\n  if (stickerFile.set_name && stickerFile.type === stickerSet.packType) {\n    // Always download and re-upload to ensure format consistency\n    // Using file_id directly can cause \"wrong file type\" errors when\n    // sticker_format doesn't match the actual file format\n    let stickerData\n    try {\n      stickerData = await downloadFileByUrl(fileUrl)\n    } catch (err) {\n      return { error: { i18nKey: 'sticker.add.error.convert' } }\n    }\n    stickerExtra.sticker = { source: stickerData }\n    return uploadSticker(ctx.from.id, stickerSet, stickerFile, stickerExtra)\n  }\n\n  // Remove background if requested\n  if (inputFile.removeBg) {\n    let priority = 10\n    if (stickerSet?.boost) priority = 5\n    else if (ctx.i18n.locale() === 'ru') priority = 15\n\n    const job = await removebgQueue.add({\n      fileUrl\n    }, {\n      priority,\n      attempts: 1,\n      removeOnComplete: true\n    })\n\n    const { content } = await job.finished()\n\n    const trimBuffer = await sharp(Buffer.from(content, 'base64'))\n      .trim()\n      .toBuffer()\n\n    fileData = trimBuffer\n  }\n\n  // Determine if video processing is needed\n  // Also check sticker_format === 'video' in case URL extension corrected the format\n  const needsVideoProcessing = isVideo || isVideoNote ||\n    stickerExtra.sticker_format === 'video' ||\n    (stickerExtra.sticker_format === 'static' && stickerSet.frameType && stickerSet.frameType !== 'square')\n\n  if (needsVideoProcessing) {\n    // Check if user already has video processing (with auto-unlock after TTL)\n    const lastProcessing = videoProcessing.get(ctx.from.id)\n    if (lastProcessing && (Date.now() - lastProcessing < VIDEO_PROCESSING_TTL) && !stickerSet?.boost) {\n      return { error: { i18nKey: 'sticker.add.error.wait_load' } }\n    }\n\n    // Size check for new files (stickers from sets are already validated)\n    if (!stickerFile.set_name && (inputFile.file_size > 1000 * 1000 * 15 || inputFile.duration > 65)) {\n      return { error: { i18nKey: 'sticker.add.error.too_big' } }\n    }\n\n    // Skip re-encoding if explicitly requested\n    if (inputFile.skip_reencode) {\n      let skipData\n      try {\n        skipData = await downloadFileByUrl(fileUrl)\n      } catch (err) {\n        return { error: { i18nKey: 'sticker.add.error.convert' } }\n      }\n      stickerExtra.sticker = { source: skipData }\n      return uploadSticker(ctx.from.id, stickerSet, stickerFile, stickerExtra)\n    }\n\n    // Convert video through queue\n    if (stickerExtra.sticker_format === 'static') {\n      stickerExtra.sticker_format = 'video'\n    }\n\n    const stickerSetsCount = await ctx.db.StickerSet.countDocuments({\n      owner: ctx.session.userInfo._id,\n      video: true\n    })\n\n    let priority = Math.round(stickerSetsCount / 3)\n    if (ctx.i18n.locale() === 'ru') priority += 40\n    if (stickerSet?.boost) priority = 5\n\n    const maxDuration = stickerSet?.boost ? 35 : 4\n    const total = await convertQueue.getJobCounts()\n\n    if (total.waiting > 200 && priority > 50) {\n      return { error: { i18nKey: 'sticker.add.error.timeout' } }\n    }\n\n    let convertingMessage\n    if (!stickerSet?.boost && total.waiting > 5) {\n      convertingMessage = await ctx.replyWithHTML(ctx.i18n.t('sticker.add.converting_process', {\n        progress: total.waiting + 1,\n        total: total.waiting + 1\n      }))\n    }\n\n    let frameType = isVideoNote ? 'circle' : 'rounded'\n    const forceCrop = inputFile.forceCrop || stickerSet.packType === 'custom_emoji'\n\n    if (frameType === 'rounded') {\n      frameType = stickerSet.frameType || 'square'\n    }\n\n    try {\n      await convertQueue.add({\n        input: {\n          botId: ctx.botInfo.id,\n          userId: ctx.from.id,\n          chatId: ctx.chat.id,\n          locale: ctx.i18n.locale(),\n          showResult,\n          convertingMessageId: convertingMessage ? convertingMessage.message_id : null,\n          stickerExtra,\n          stickerSet,\n          stickerFile\n        },\n        fileUrl,\n        fileData: fileData ? Buffer.from(fileData).toString('base64') : null,\n        timestamp: Date.now(),\n        isEmoji: stickerSet.packType === 'custom_emoji',\n        frameType,\n        forceCrop,\n        maxDuration\n      }, {\n        priority,\n        attempts: 1,\n        removeOnComplete: true\n      })\n      // Mark user as processing only after successful queue add\n      videoProcessing.set(ctx.from.id, Date.now())\n    } catch (err) {\n      return { error: { i18nKey: 'sticker.add.error.convert' } }\n    }\n\n    return { wait: true }\n  }\n\n  // Static image processing - rate limiting\n  const lastTime = lastStickerTime.get(ctx.from.id) || 0\n\n  if (Date.now() - lastTime < STICKER_COOLDOWN && !stickerSet?.boost) {\n    return { error: { i18nKey: 'sticker.add.error.wait_load' } }\n  }\n\n  lastStickerTime.set(ctx.from.id, Date.now())\n\n  if (!fileData) {\n    try {\n      fileData = await downloadFileByUrl(fileUrl)\n    } catch (err) {\n      return { error: { i18nKey: 'sticker.add.error.convert' } }\n    }\n  }\n\n  if (!fileData || fileData.length === 0) {\n    return { error: { i18nKey: 'sticker.add.error.invalid_image' } }\n  }\n\n  const imageSharp = sharp(fileData, {\n    failOnError: false,\n    limitInputPixels: 268402689,\n    pages: 1\n  })\n\n  const imageMetadata = await imageSharp.metadata().catch((err) => {\n    console.error('Sharp metadata error:', err.message, 'Buffer size:', fileData?.length, 'First bytes:', fileData?.slice(0, 20)?.toString('hex'))\n    return null\n  })\n\n  if (!imageMetadata) {\n    return { error: { i18nKey: 'sticker.add.error.invalid_image' } }\n  }\n\n  let pipeline = imageSharp.clone()\n\n  if (stickerSet.packType === 'custom_emoji') {\n    if (imageMetadata.width !== 100 || imageMetadata.height !== 100) {\n      pipeline = pipeline.resize(100, 100, {\n        fit: 'contain',\n        background: { r: 0, g: 0, b: 0, alpha: 0 }\n      })\n    }\n  } else {\n    let finalWidth = imageMetadata.width\n    let finalHeight = imageMetadata.height\n\n    if (imageMetadata.width > 512 || imageMetadata.height > 512) {\n      const scale = Math.min(512 / imageMetadata.width, 512 / imageMetadata.height)\n      finalWidth = Math.round(imageMetadata.width * scale)\n      finalHeight = Math.round(imageMetadata.height * scale)\n\n      pipeline = pipeline.resize(512, 512, {\n        fit: 'inside',\n        withoutEnlargement: true\n      })\n    }\n\n    if (finalWidth < 512 && finalHeight < 512) {\n      if (finalWidth >= finalHeight) {\n        const paddingLeft = Math.floor((512 - finalWidth) / 2)\n        const paddingRight = Math.ceil((512 - finalWidth) / 2)\n        pipeline = pipeline.extend({\n          left: paddingLeft,\n          right: paddingRight,\n          background: { r: 0, g: 0, b: 0, alpha: 0 }\n        })\n      } else {\n        const paddingTop = Math.floor((512 - finalHeight) / 2)\n        const paddingBottom = Math.ceil((512 - finalHeight) / 2)\n        pipeline = pipeline.extend({\n          top: paddingTop,\n          bottom: paddingBottom,\n          background: { r: 0, g: 0, b: 0, alpha: 0 }\n        })\n      }\n    }\n  }\n\n  stickerExtra.sticker = {\n    source: await pipeline.png({ compressionLevel: 6, effort: 3 }).toBuffer()\n  }\n\n  // Clear rate limit after successful processing\n  lastStickerTime.delete(ctx.from.id)\n\n  return uploadSticker(ctx.from.id, stickerSet, stickerFile, stickerExtra)\n}\n"
  },
  {
    "path": "utils/decode-sticker-set-id.js",
    "content": "/**\n * Decode Telegram sticker set ID to extract owner user ID and set number\n *\n * Two formats:\n * 1. Standard (32-bit user IDs): upper 32 bits = owner_id, lower 32 bits = set_number\n * 2. Extended (64-bit user IDs): when byte 24-31 = 0xff:\n *    - owner_id = upper32 + 0x180000000\n *    - set_number = lower 4 bits\n *    - dc_id = bits 20-23\n *\n * @param {BigInt} u64 - The sticker set ID as BigInt\n * @returns {{ ownerId: number, setId: number, dcId: number|null, isExtended: boolean }}\n */\nfunction decodeStickerSetId (u64) {\n  const upper32 = u64 >> 32n\n  const lower32 = u64 & 0xffffffffn\n  const byte24 = (u64 >> 24n) & 0xffn\n\n  let ownerId; let setId; let dcId = null; let isExtended = false\n\n  if (byte24 === 0xffn) {\n    // Extended format for 64-bit user IDs\n    // When byte24 = 0xff, bit31 of lower32 is always 1\n    // So: owner = upper32 + 0x100000000 + 0x80000000 = upper32 + 0x180000000\n    ownerId = upper32 + 0x180000000n\n    setId = lower32 & 0xfn // lower 4 bits\n    dcId = Number((lower32 >> 20n) & 0xfn) // bits 20-23\n    isExtended = true\n  } else {\n    // Standard format for 32-bit user IDs\n    ownerId = upper32\n    setId = lower32\n  }\n\n  return {\n    ownerId: Number(ownerId),\n    setId: Number(setId),\n    dcId,\n    isExtended\n  }\n}\n\nmodule.exports = decodeStickerSetId\n"
  },
  {
    "path": "utils/download-file-by-url.js",
    "content": "const https = require('https')\n\nmodule.exports = (fileUrl, timeout = 30000) => new Promise((resolve, reject) => {\n  const data = []\n  let totalSize = 0\n  const MAX_SIZE = 20 * 1024 * 1024 // 20MB limit\n\n  const req = https.get(fileUrl, (response) => {\n    // Check for successful response status\n    if (response.statusCode !== 200) {\n      req.destroy()\n      reject(new Error(`Download failed with status ${response.statusCode}`))\n      return\n    }\n\n    response.on('data', (chunk) => {\n      totalSize += chunk.length\n      if (totalSize > MAX_SIZE) {\n        req.destroy()\n        reject(new Error('File too large'))\n        return\n      }\n      data.push(chunk)\n    })\n\n    response.on('end', () => {\n      resolve(Buffer.concat(data))\n    })\n  })\n\n  req.on('error', reject)\n\n  req.setTimeout(timeout, () => {\n    req.destroy()\n    reject(new Error('Download timeout'))\n  })\n})\n"
  },
  {
    "path": "utils/escape-regex.js",
    "content": "function escapeRegex (str) {\n  return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\nmodule.exports = escapeRegex\n"
  },
  {
    "path": "utils/gramads.js",
    "content": "const got = require('got')\n\nmodule.exports = async (chatId) => {\n  const token = process.env.GRAMADS_TOKEN\n\n  const headers = {\n    Authorization: `bearer ${token}`,\n    'Content-Type': 'application/json'\n  }\n\n  const sendPostDto = { SendToChatId: chatId }\n  const response = await got.post('https://api.gramads.net/ad/SendPost', {\n    headers,\n    json: sendPostDto\n  }).catch((err) => {\n    return err\n  })\n\n  if (response.statusCode !== 200) {\n    // something went wrong\n    return\n  }\n\n  const result = response.body\n\n  return result\n}\n"
  },
  {
    "path": "utils/group-update.js",
    "content": "module.exports = async (ctx, next) => {\n  let group = await ctx.db.Group.findOne({ telegram_id: ctx.chat.id })\n\n  if (!group) {\n    group = new ctx.db.Group()\n    group.telegram_id = ctx.chat.id\n  }\n\n  group.title = ctx.chat.title\n  group.username = ctx.chat.username\n  group.settings = group.settings || new ctx.db.Group().settings\n\n  group.updatedAt = new Date()\n\n  await group.save()\n\n  return next()\n}\n"
  },
  {
    "path": "utils/html-escape.js",
    "content": "module.exports = (str) => (str == null ? '' : str).toString().replace(\n  /[&<>'\"]/g,\n  (tag) => ({\n    '&': '&amp;',\n    '<': '&lt;',\n    '>': '&gt;',\n    \"'\": '&#39;',\n    '\"': '&quot;'\n  }[tag] || tag)\n)\n"
  },
  {
    "path": "utils/index.js",
    "content": "const escapeHTML = require('./html-escape')\nconst userName = require('./user-name')\nconst addSticker = require('./add-sticker')\nconst addStickerText = require('./add-sticker-text')\nconst messaging = require('./messaging')\nconst updateUser = require('./user-update')\nconst updateGroup = require('./group-update')\nconst stats = require('./stats')\nconst tenor = require('./tenor')\nconst countUncodeChars = require('./unicode-chars-count')\nconst substrUnicode = require('./unicode-substr')\nconst telegramApi = require('./telegram-api')\nconst updateMonitor = require('./update-monitor')\nconst showGramAds = require('./gramads')\nconst downloadFileByURL = require('./download-file-by-url')\nconst moderatePack = require('./moderate-pack')\nconst escapeRegex = require('./escape-regex')\nconst { withRetry, isRateLimitError, getRetryAfter, retryMiddleware, clearBlockedChat, getRateLimitRemaining } = require('./retry-api')\nconst { perfStage, perfRecord, perfTick, perfSnapshot, ENABLED: PERF_TIMING_ENABLED } = require('./perf-timing')\n\nmodule.exports = {\n  escapeRegex,\n  escapeHTML,\n  userName,\n  addSticker,\n  addStickerText,\n  messaging,\n  updateUser,\n  updateGroup,\n  stats,\n  tenor,\n  countUncodeChars,\n  substrUnicode,\n  telegramApi,\n  updateMonitor,\n  showGramAds,\n  downloadFileByURL,\n  moderatePack,\n  withRetry,\n  isRateLimitError,\n  getRetryAfter,\n  retryMiddleware,\n  clearBlockedChat,\n  getRateLimitRemaining,\n  perfStage,\n  perfRecord,\n  perfTick,\n  perfSnapshot,\n  PERF_TIMING_ENABLED\n}\n"
  },
  {
    "path": "utils/last-seen.js",
    "content": "// Throttled \"last seen\" tracker for User.updatedAt.\n//\n// Context: updateUser runs on every incoming update. Previously we set\n// `user.updatedAt = new Date()` and then awaited `user.save()` — that's\n// a full Mongoose save (validation + version bump + write) on every\n// request, blocking the handler for ~7-10ms even when nothing else\n// changed. With pool saturation it balloons to 50-150ms.\n//\n// But scenes/messaging.js relies on updatedAt to filter \"users active\n// in the last month\" for broadcast campaigns, so we can't just drop it.\n//\n// Compromise: bump updatedAt via a fire-and-forget `updateOne` with\n// `$currentDate`, throttled per-user so we fire at most once per hour.\n// - No critical-path cost (fire-and-forget)\n// - No full save (no validation, no populate rehydration)\n// - updatedAt stays accurate enough for monthly activity windows\nconst THROTTLE_MS = parseInt(process.env.LAST_SEEN_THROTTLE_MS, 10) || 60 * 60 * 1000\nconst MAX_ENTRIES = parseInt(process.env.LAST_SEEN_MAX, 10) || 20000\n\nconst lastWrite = new Map()\n\nfunction evictIfFull () {\n  if (lastWrite.size < MAX_ENTRIES) return\n  // LRU-ish: Map iterates in insertion order → oldest-first drop.\n  const drop = Math.floor(MAX_ENTRIES * 0.1)\n  const it = lastWrite.keys()\n  for (let i = 0; i < drop; i++) {\n    const k = it.next().value\n    if (k === undefined) break\n    lastWrite.delete(k)\n  }\n}\n\nfunction touchLastSeen (User, userId) {\n  if (!User || !userId) return\n  const key = String(userId)\n  const now = Date.now()\n  const prev = lastWrite.get(key)\n  if (prev && now - prev < THROTTLE_MS) return\n  evictIfFull()\n  lastWrite.set(key, now)\n  User.updateOne({ _id: userId }, { $currentDate: { updatedAt: true } })\n    .catch(err => console.error('[last-seen] updateOne failed:', err.message))\n}\n\nmodule.exports = {\n  touchLastSeen,\n  _cacheSize: () => lastWrite.size\n}\n"
  },
  {
    "path": "utils/logger.js",
    "content": "// Minimal structured logger — no external deps, drop-in replacement\n// for console.{log,error,warn,debug}. Adds:\n//   - ISO timestamp on every line\n//   - explicit level (ERROR/WARN/INFO/DEBUG)\n//   - optional scope prefix per call site\n//\n// Why no winston/pino: single-process bot, log volume is moderate,\n// PM2 collects stdout/stderr already. Keeping zero deps means no\n// surprise behavior at startup. Migration to a heavier lib later is\n// a one-import-swap because we expose the same .error/.warn/.info/.debug\n// shape.\n\nconst LEVELS = ['error', 'warn', 'info', 'debug']\n\nconst requestedLevel = (process.env.LOG_LEVEL || 'info').toLowerCase()\nconst currentLevelIdx = (() => {\n  const idx = LEVELS.indexOf(requestedLevel)\n  return idx === -1 ? LEVELS.indexOf('info') : idx\n})()\n\nconst noop = () => {}\n\nconst formatPrefix = (level, scope) => {\n  const ts = new Date().toISOString()\n  const tag = `[${ts}] [${level.toUpperCase()}]`\n  return scope ? `${tag} [${scope}]` : tag\n}\n\nconst make = (scope) => {\n  const out = {}\n  LEVELS.forEach((level, idx) => {\n    if (idx > currentLevelIdx) {\n      out[level] = noop\n      return\n    }\n    const sink = level === 'error'\n      ? console.error\n      : level === 'warn'\n        ? console.warn\n        : console.log\n    out[level] = (...args) => sink(formatPrefix(level, scope), ...args)\n  })\n  // Allow caller to derive a sub-logger with a more specific scope.\n  out.scope = (childScope) => make(scope ? `${scope}:${childScope}` : childScope)\n  return out\n}\n\nmodule.exports = make()\n"
  },
  {
    "path": "utils/messaging.js",
    "content": "const fs = require('fs')\nconst getTelegram = require('./telegram').get\nconst replicators = require('telegraf/core/replicators')\nconst redis = require('./redis')\nconst {\n  db\n} = require('../database')\n\nconst delay = ms => new Promise(resolve => setTimeout(resolve, ms))\n\n// Broadcasts typically finish within hours; 7 days gives plenty of\n// headroom for slow/paused campaigns while guaranteeing cleanup.\nconst MESSAGING_TTL_SECONDS = 7 * 24 * 60 * 60\n\nconst telegram = getTelegram(process.env.MAIN_BOT_TOKEN)\n\n// Cache config at startup instead of reading on every call\nlet cachedConfig = null\nfunction getConfig () {\n  if (!cachedConfig) {\n    cachedConfig = JSON.parse(fs.readFileSync('./config.json', 'utf8'))\n  }\n  return cachedConfig\n}\n\n// Reload config every 5 minutes.\n// .unref() so this housekeeping timer doesn't keep the process alive on shutdown.\nsetInterval(() => {\n  try {\n    cachedConfig = JSON.parse(fs.readFileSync('./config.json', 'utf8'))\n  } catch (e) {\n    console.error('Failed to reload config:', e.message)\n  }\n}, 1000 * 60 * 5).unref()\n\nconst messaging = async (messagingData) => {\n  if (!redis) {\n    console.warn('[messaging] REDIS_HOST not set — broadcast disabled')\n    return {}\n  }\n  console.log(messagingData.id, `messaging ${messagingData.name} start`)\n\n  const key = `messaging:${messagingData.id}`\n\n  const usersCount = await redis.lrange(key + ':users', 0, -1).catch(console.error)\n\n  if (!usersCount) return {}\n\n  messagingData.status = 1\n  await messagingData.save()\n\n  let messagingCreator\n\n  try {\n    messagingCreator = await db.User.findById(messagingData.creator)\n  } catch (err) {\n    console.error('Failed to fetch messaging creator:', err.message)\n  }\n\n  const config = getConfig()\n\n  const count = config.messaging.limit.max || 10\n\n  const messagingSend = async () => {\n    const state = parseInt(await redis.get(key + ':state')) || 0\n\n    const users = await redis.lrange(key + ':users', state, state + count - 1).catch(err => {\n      console.error('Redis lrange failed:', err.message)\n      return []\n    })\n\n    if (users && users.length > 0) {\n      for (const chatId of users) {\n        let method = replicators.copyMethods[messagingData.message.type]\n        let opts = Object.assign({}, messagingData.message.data, {\n          chat_id: chatId,\n          disable_web_page_preview: true,\n          disable_notification: true\n        })\n\n        if (messagingData.message.type === 'forward') {\n          method = 'forwardMessage'\n          opts = {\n            chat_id: chatId,\n            from_chat_id: messagingData.message.data.chat_id,\n            message_id: messagingData.message.data.message_id\n          }\n        }\n\n        try {\n          const result = await telegram.callApi(method, opts)\n          await redis.set(key + ':messages:' + chatId, result.message_id, 'EX', MESSAGING_TTL_SECONDS)\n        } catch (error) {\n          // INCR creates the key without TTL on first use; re-apply EXPIRE\n          // alongside so the counter doesn't outlive the campaign.\n          const errKey = key + ':error'\n          await redis.incr(errKey)\n          await redis.expire(errKey, MESSAGING_TTL_SECONDS)\n          console.log(`messaging error ${messagingData.name}`, chatId, error.description)\n          if (error?.parameters?.retry_after) {\n            // Rate limited, will retry on next cycle\n          } else if (['blocked by the user', 'user is deactivated', 'chat not found'].some(e => new RegExp(e).test(error.description))) {\n            // Use updateOne to avoid race conditions and fire-and-forget issues\n            await db.User.updateOne({ telegram_id: chatId }, { blocked: true }).catch((err) => {\n              console.error('Failed to mark user as blocked:', err.message)\n            })\n          } else {\n            if (messagingCreator) {\n              await telegram.sendMessage(messagingCreator.telegram_id, `Error sending message \"${messagingData.name}\" to user ${chatId}: ${error.message}`, {\n                parse_mode: 'HTML'\n              }).catch((err) => {\n                console.error('Failed to notify creator:', err.message)\n              })\n            }\n\n            messagingData.sendErrors.push({\n              telegram_id: chatId,\n              errorMessage: error.message\n            })\n          }\n        }\n      }\n\n      const errorCount = parseInt(await redis.get(key + ':error')) || 0\n      messagingData.result.error = errorCount\n      messagingData.result.state = state + users.length\n\n      await redis.set(key + ':state', state + users.length, 'EX', MESSAGING_TTL_SECONDS)\n    }\n\n    if (state + users.length >= messagingData.result.total) {\n      console.log(`messaging ${messagingData.name} end`)\n      messagingData.status = 2\n    }\n\n    await messagingData.save()\n    return messagingData\n  }\n\n  while (true) {\n    const messagingData = await messagingSend()\n\n    // Exit when: completed (status>=2), no users (total===0), or all processed (state>=total)\n    if (messagingData.status >= 2 || messagingData.result.total === 0 || messagingData.result.state >= messagingData.result.total) {\n      return messagingData\n    }\n    await delay(config.messaging.limit.duration || 1000)\n  }\n}\n\nconst messagingEdit = (messagingData) => new Promise((resolve, reject) => {\n  if (!redis) {\n    console.warn('[messaging] REDIS_HOST not set — edit broadcast disabled')\n    return resolve()\n  }\n  console.log(`messaging edit ${messagingData.name} start`)\n\n  const startEdit = async () => {\n    messagingData.editStatus = 2\n    await messagingData.save()\n\n    const config = getConfig()\n\n    const key = `messaging:${messagingData.id}`\n    const count = config.messaging.limit.max || 10\n\n    const interval = setInterval(async () => {\n      try {\n        console.log('messaging edit')\n        const state = parseInt(await redis.get(key + ':edit_state')) || 0\n\n        if (state >= messagingData.result.total) {\n          console.log(`messaging edit ${messagingData.name} end`)\n          messagingData.editStatus = 0\n          await messagingData.save()\n          clearInterval(interval)\n          resolve()\n          return\n        }\n\n        const users = await redis.lrange(key, state, state + count).catch(() => {\n          clearInterval(interval)\n        })\n\n        if (users && users.length > 0) {\n          // Use for...of instead of forEach for proper async handling\n          for (const chatId of users) {\n            const messageId = await redis.get(key + ':messages:' + chatId)\n            if (messagingData.message.type === 'text') {\n              await telegram.editMessageText(chatId, messageId, null, messagingData.message.data.text, {\n                parse_mode: messagingData.message.data.parse_mode,\n                disable_web_page_preview: messagingData.message.data.disable_web_page_preview,\n                reply_markup: messagingData.message.data.reply_markup\n              }).catch((error) => {\n                console.error('Edit text error:', error.message)\n              })\n            } else {\n              await telegram.editMessageMedia(chatId, messageId, null, {\n                type: messagingData.message.type,\n                media: messagingData.message.data[messagingData.message.type],\n                caption: messagingData.message.data.caption || '',\n                parse_mode: messagingData.message.data.parse_mode\n              }, {\n                parse_mode: messagingData.message.data.parse_mode,\n                disable_web_page_preview: messagingData.message.data.disable_web_page_preview,\n                reply_markup: messagingData.message.data.reply_markup\n              }).catch((error) => {\n                console.error('Edit media error:', error.message)\n              })\n            }\n          }\n          await redis.set(key + ':edit_state', state + count, 'EX', MESSAGING_TTL_SECONDS)\n        }\n      } catch (error) {\n        console.error('Messaging edit interval error:', error.message)\n        clearInterval(interval)\n        resolve()\n      }\n    }, config.messaging.limit.duration || 1000)\n  }\n\n  startEdit().catch(reject)\n})\n\n// Process messaging queues without cursor/listener leak\nlet isProcessingMessaging = false\nlet isProcessingEdit = false\n\nasync function processMessagingQueue () {\n  if (isProcessingMessaging) return\n  isProcessingMessaging = true\n\n  try {\n    const pendingMessages = await db.Messaging.find({\n      status: { $lte: 0 },\n      date: { $ne: null, $lte: new Date() }\n    }).limit(10)\n\n    for (const msg of pendingMessages) {\n      await messaging(msg)\n    }\n  } catch (error) {\n    console.error('Error processing messaging queue:', error.message)\n  } finally {\n    isProcessingMessaging = false\n  }\n}\n\nasync function processEditQueue () {\n  if (isProcessingEdit) return\n  isProcessingEdit = true\n\n  try {\n    const pendingEdits = await db.Messaging.find({\n      editStatus: 1,\n      date: { $ne: null, $lte: new Date() }\n    }).limit(10)\n\n    for (const msg of pendingEdits) {\n      await messagingEdit(msg)\n    }\n  } catch (error) {\n    console.error('Error processing edit queue:', error.message)\n  } finally {\n    isProcessingEdit = false\n  }\n}\n\n// Track intervals for graceful shutdown\nconst messagingInterval = setInterval(processMessagingQueue, 5000)\nconst editInterval = setInterval(processEditQueue, 5000)\n\n// Graceful shutdown handler\nconst shutdownMessaging = () => {\n  console.log('Shutting down messaging queues...')\n  clearInterval(messagingInterval)\n  clearInterval(editInterval)\n}\n\nprocess.on('SIGTERM', shutdownMessaging)\nprocess.on('SIGINT', shutdownMessaging)\n\nconst restartMessaging = async () => {\n  const messagingData = await db.Messaging.findOne({\n    status: 1\n  })\n\n  if (messagingData) await messaging(messagingData)\n\n  const messagingDataEdit = await db.Messaging.findOne({\n    editStatus: 2\n  })\n\n  if (messagingDataEdit) await messagingEdit(messagingDataEdit)\n}\n\nrestartMessaging()\n"
  },
  {
    "path": "utils/moderate-pack.js",
    "content": "const { db } = require('../database')\nconst OpenAI = require('openai')\nconst got = require('got')\nconst sharp = require('sharp')\nconst telegram = require('./telegram')\n\nconst openai = new OpenAI({\n  apiKey: process.env.OPENAI_API_KEY\n})\nconst RELEVANT_CATEGORIES = [\n  'sexual/minors',\n  'violence/graphic',\n  'self-harm',\n  'hate',\n  'harassment',\n  'illicit/violent'\n]\n\nconst SCORE_THRESHOLDS = {\n  'sexual/minors': 0.2,\n  'violence/graphic': 0.5,\n  'self-harm': 0.5,\n  hate: 0.5,\n  harassment: 0.5,\n  'illicit/violent': 0.5\n}\n\nasync function combineImages (imageBuffers) {\n  try {\n    imageBuffers = imageBuffers.slice(0, 200)\n\n    const resizedBuffers = await Promise.all(imageBuffers.map(async (buffer) => {\n      const metadata = await sharp(buffer).metadata()\n      const resizeOptions = metadata.width > metadata.height\n        ? { width: 128 }\n        : { height: 128 }\n      return sharp(buffer).resize(resizeOptions).toBuffer()\n    }))\n\n    const imageWidth = 128\n    const imageHeight = 128\n    const columns = 5\n    const rows = Math.ceil(resizedBuffers.length / columns)\n    const combinedWidth = imageWidth * columns\n    const combinedHeight = imageHeight * rows\n\n    const compositeArray = resizedBuffers.map((buffer, index) => {\n      const x = (index % columns) * imageWidth\n      const y = Math.floor(index / columns) * imageHeight\n      return { input: buffer, top: y, left: x }\n    })\n\n    const combinedImageBuffer = await sharp({\n      create: {\n        width: combinedWidth,\n        height: combinedHeight,\n        channels: 4,\n        background: { r: 255, g: 255, b: 255, alpha: 1 }\n      }\n    }).composite(compositeArray).png().toBuffer()\n\n    return combinedImageBuffer\n  } catch (error) {\n    console.error('Error combining images:', error)\n    throw error\n  }\n}\n\nasync function moderateImage (fileLink, packTitle = '', packName = '') {\n  try {\n    const moderation = await openai.moderations.create({\n      model: 'omni-moderation-latest',\n      input: [\n        { type: 'text', text: `User created sticker pack: ${packTitle} (${packName})` },\n        {\n          type: 'image_url',\n          image_url: {\n            url: fileLink\n          }\n        }\n      ]\n    })\n\n    return moderation.results[0]\n  } catch (error) {\n    console.error('Error during NSFW check:', error)\n    return null\n  }\n}\n\nasync function moderatePack (packName) {\n  const stickers = await telegram.getStickerSet(packName).catch(() => null)\n\n  if (!stickers || !stickers.stickers || stickers.stickers.length === 0) {\n    return null\n  }\n\n  const stickerFiles = stickers.stickers.map((sticker) => sticker?.thumb?.file_id).filter((f) => f).slice(0, 200)\n\n  const stickerImages = await Promise.all(stickerFiles.map(async (fileId) => {\n    const fileLink = await telegram.getFileLink(fileId).catch(() => null)\n    if (!fileLink) {\n      return null\n    }\n    return got(fileLink, { responseType: 'buffer' }).then((response) => response.body)\n  })).then((images) => images.filter((image) => image !== null))\n\n  if (stickerImages.length === 0) {\n    return null\n  }\n\n  const combinedImageBuffer = await combineImages(stickerImages)\n\n  const moderation = await moderateImage(`data:image/png;base64,${combinedImageBuffer.toString('base64')}`, stickers.title, packName)\n\n  if (!moderation) {\n    return null\n  }\n\n  let isFlagged = false\n  const categoryScores = {}\n\n  for (const category of RELEVANT_CATEGORIES) {\n    if (moderation.category_applied_input_types[category]) {\n      if (moderation.category_scores[category] > SCORE_THRESHOLDS[category]) {\n        isFlagged = true\n        categoryScores[category] = moderation.category_scores[category]\n      }\n    }\n  }\n\n  return {\n    name: packName,\n    isFlagged,\n    categoryScores\n  }\n}\n\nasync function moderatePacks (skip = 0, maxDepth = 100) {\n  if (maxDepth <= 0) {\n    console.log('moderatePacks: max recursion depth reached, stopping')\n    return\n  }\n\n  const packs = await db.StickerSet.find({\n    thirdParty: false,\n    inline: { $ne: true },\n    'aiModeration.checked': { $ne: true }\n  }).sort({ createdAt: -1 }).skip(skip).limit(100).select('name').lean()\n\n  if (packs.length === 0) {\n    return\n  }\n\n  const results = (await Promise.all(packs.map((pack) => moderatePack(pack.name)))).filter((result) => result !== null)\n\n  results.filter((result) => result?.isFlagged).forEach((result) => {\n    console.log(result)\n  })\n\n  await Promise.all(results.map(async (result) => {\n    await db.StickerSet.updateOne({ name: result.name }, { $set: { aiModeration: { checked: true, isFlagged: result.isFlagged, categoryScores: result.categoryScores } } })\n  }))\n\n  return moderatePacks(skip + 100 - results.length, maxDepth - 1)\n}\n\n// moderatePacks(50000)\n\nmodule.exports = moderatePack\n"
  },
  {
    "path": "utils/mosaic-grid.js",
    "content": "const getGridSuggestions = (width, height, freeSlots = 200) => {\n  const ratio = width / height\n\n  // Determine type\n  if (ratio >= 2.5) return getStripSuggestions(ratio, 'horizontal', freeSlots)\n  if (ratio <= 0.4) return getStripSuggestions(1 / ratio, 'vertical', freeSlots)\n  return getGridOptions(ratio, freeSlots)\n}\n\nconst getStripSuggestions = (ratio, direction, freeSlots) => {\n  const count = Math.max(3, Math.min(10, Math.round(ratio)))\n  const isHorizontal = direction === 'horizontal'\n\n  const options = []\n  for (let delta = -2; delta <= 2; delta++) {\n    const n = count + delta\n    if (n < 3 || n > 10 || n > freeSlots) continue\n    const rows = isHorizontal ? 1 : n\n    const cols = isHorizontal ? n : 1\n    options.push({ rows, cols, total: n })\n  }\n\n  if (options.length === 0) return { type: 'no_space', options: [] }\n\n  const recommended = options.find(o => o.total === count) || options[Math.floor(options.length / 2)]\n  const alternatives = options.filter(o => o !== recommended).slice(0, 3)\n\n  return { type: 'strip', recommended, alternatives }\n}\n\nconst getGridOptions = (ratio, freeSlots) => {\n  const candidates = []\n\n  for (let rows = 2; rows <= 10; rows++) {\n    for (let cols = 2; cols <= 10; cols++) {\n      const total = rows * cols\n      if (total > 50 || total > freeSlots) continue\n\n      const gridRatio = cols / rows\n      const ratioScore = Math.abs(gridRatio - ratio) / ratio\n      const cellRatio = (ratio / gridRatio)\n      const squareScore = Math.abs(1 - cellRatio)\n      const sizeScore = Math.abs(total - 12) / 50\n\n      const score = ratioScore * 2 + squareScore + sizeScore * 0.5\n      candidates.push({ rows, cols, total, score })\n    }\n  }\n\n  if (candidates.length === 0) return { type: 'no_space', options: [] }\n\n  candidates.sort((a, b) => a.score - b.score)\n\n  const recommended = candidates[0]\n  const smaller = candidates.find(c => c.total < recommended.total && c !== recommended)\n  const larger = candidates.find(c => c.total > recommended.total && c !== recommended)\n  const largest = candidates.find(c => c.total > (larger?.total || 0) && c !== recommended && c !== larger)\n\n  const alternatives = [smaller, larger, largest].filter(Boolean).slice(0, 3)\n\n  return { type: 'grid', recommended, alternatives }\n}\n\nmodule.exports = { getGridSuggestions }\n"
  },
  {
    "path": "utils/mosaic-preview.js",
    "content": "const sharp = require('sharp')\n\nconst generatePreview = async (imageBuffer, rows, cols) => {\n  const image = sharp(imageBuffer, {\n    failOnError: false,\n    limitInputPixels: 268402689\n  })\n\n  const metadata = await image.metadata()\n\n  // Crop to target ratio first (same logic as splitImage)\n  const targetRatio = cols / rows\n  const sourceRatio = metadata.width / metadata.height\n  let srcCropW = metadata.width\n  let srcCropH = metadata.height\n  let srcCropLeft = 0\n  let srcCropTop = 0\n\n  if (sourceRatio > targetRatio) {\n    srcCropW = Math.round(metadata.height * targetRatio)\n    srcCropLeft = Math.floor((metadata.width - srcCropW) / 2)\n  } else if (sourceRatio < targetRatio) {\n    srcCropH = Math.round(metadata.width / targetRatio)\n    srcCropTop = Math.floor((metadata.height - srcCropH) / 2)\n  }\n\n  // Resize cropped area to max 512px on longest side\n  const scale = Math.min(512 / srcCropW, 512 / srcCropH, 1)\n  const previewWidth = Math.round(srcCropW * scale)\n  const previewHeight = Math.round(srcCropH * scale)\n\n  const cellW = Math.floor(previewWidth / cols)\n  const cellH = Math.floor(previewHeight / rows)\n  const cropWidth = cellW * cols\n  const cropHeight = cellH * rows\n\n  const strokeWidth = 2\n  const lines = []\n\n  // Vertical lines (at floor-based cell boundaries)\n  for (let c = 1; c < cols; c++) {\n    const x = c * cellW\n    lines.push(`<line x1=\"${x}\" y1=\"0\" x2=\"${x}\" y2=\"${cropHeight}\" stroke=\"white\" stroke-width=\"${strokeWidth}\" stroke-dasharray=\"8,6\" stroke-opacity=\"0.85\"/>`)\n    lines.push(`<line x1=\"${x}\" y1=\"0\" x2=\"${x}\" y2=\"${cropHeight}\" stroke=\"black\" stroke-width=\"${strokeWidth}\" stroke-dasharray=\"8,6\" stroke-dashoffset=\"8\" stroke-opacity=\"0.4\"/>`)\n  }\n\n  // Horizontal lines (at floor-based cell boundaries)\n  for (let r = 1; r < rows; r++) {\n    const y = r * cellH\n    lines.push(`<line x1=\"0\" y1=\"${y}\" x2=\"${cropWidth}\" y2=\"${y}\" stroke=\"white\" stroke-width=\"${strokeWidth}\" stroke-dasharray=\"8,6\" stroke-opacity=\"0.85\"/>`)\n    lines.push(`<line x1=\"0\" y1=\"${y}\" x2=\"${cropWidth}\" y2=\"${y}\" stroke=\"black\" stroke-width=\"${strokeWidth}\" stroke-dasharray=\"8,6\" stroke-dashoffset=\"8\" stroke-opacity=\"0.4\"/>`)\n  }\n\n  // Grid size label in center\n  const label = `${rows}×${cols}`\n  const fontSize = Math.max(24, Math.round(previewWidth / 10))\n  lines.push(`<rect x=\"${cropWidth / 2 - fontSize * 1.5}\" y=\"${cropHeight / 2 - fontSize * 0.7}\" width=\"${fontSize * 3}\" height=\"${fontSize * 1.4}\" rx=\"8\" fill=\"rgba(0,0,0,0.6)\"/>`)\n  lines.push(`<text x=\"${cropWidth / 2}\" y=\"${cropHeight / 2 + fontSize * 0.3}\" text-anchor=\"middle\" font-size=\"${fontSize}\" font-family=\"Arial,sans-serif\" font-weight=\"bold\" fill=\"white\">${label}</text>`)\n\n  const svgOverlay = Buffer.from(\n    `<svg width=\"${cropWidth}\" height=\"${cropHeight}\">${lines.join('')}</svg>`\n  )\n\n  const result = await image\n    .clone()\n    .extract({ left: srcCropLeft, top: srcCropTop, width: srcCropW, height: srcCropH })\n    .resize(previewWidth, previewHeight)\n    .extract({ left: 0, top: 0, width: cropWidth, height: cropHeight })\n    .composite([{ input: svgOverlay, top: 0, left: 0 }])\n    .webp({ quality: 80 })\n    .toBuffer()\n\n  return result\n}\n\nmodule.exports = { generatePreview }\n"
  },
  {
    "path": "utils/mosaic-split.js",
    "content": "const sharp = require('sharp')\n\nconst splitImage = async (imageBuffer, rows, cols) => {\n  const image = sharp(imageBuffer, {\n    failOnError: false,\n    limitInputPixels: 268402689\n  })\n\n  const metadata = await image.metadata()\n\n  // Crop source image so cells are square\n  // Target aspect ratio: cols/rows\n  // Crop whichever dimension is too large (center crop)\n  const targetRatio = cols / rows\n  const sourceRatio = metadata.width / metadata.height\n  let cropWidth = metadata.width\n  let cropHeight = metadata.height\n  let cropLeft = 0\n  let cropTop = 0\n\n  if (sourceRatio > targetRatio) {\n    // Image too wide — crop sides\n    cropWidth = Math.round(metadata.height * targetRatio)\n    cropLeft = Math.floor((metadata.width - cropWidth) / 2)\n  } else if (sourceRatio < targetRatio) {\n    // Image too tall — crop top/bottom\n    cropHeight = Math.round(metadata.width / targetRatio)\n    cropTop = Math.floor((metadata.height - cropHeight) / 2)\n  }\n\n  // Crop to target ratio first\n  const croppedBuf = await image.clone().extract({\n    left: cropLeft, top: cropTop, width: cropWidth, height: cropHeight\n  }).toBuffer()\n\n  const croppedImg = sharp(croppedBuf, { failOnError: false, limitInputPixels: false })\n  const cellWidth = Math.floor(cropWidth / cols)\n  const cellHeight = Math.floor(cropHeight / rows)\n\n  const cells = []\n\n  for (let r = 0; r < rows; r++) {\n    for (let c = 0; c < cols; c++) {\n      const cell = await croppedImg\n        .clone()\n        .extract({\n          left: c * cellWidth,\n          top: r * cellHeight,\n          width: cellWidth,\n          height: cellHeight\n        })\n        .resize(100, 100)\n        .webp({ quality: 90 })\n        .toBuffer()\n\n      cells.push(cell)\n    }\n  }\n\n  return cells\n}\n\nconst checkMinCellSize = (width, height, rows, cols) => {\n  const cellWidth = Math.floor(width / cols)\n  const cellHeight = Math.floor(height / rows)\n  return cellWidth >= 80 && cellHeight >= 80\n}\n\nmodule.exports = { splitImage, checkMinCellSize }\n"
  },
  {
    "path": "utils/perf-timing.js",
    "content": "// Per-stage middleware timing — lightweight wall-clock instrumentation so we\n// can see where response time is spent across the Telegraf chain without\n// pulling in a profiler. Each stage keeps a rolling buffer of SELF-time\n// samples (elapsed time MINUS downstream await), and every N updates we\n// log a one-liner with the current p50 per stage.\n//\n// Design notes:\n//   - Date.now() only. console.time/timeEnd stacks badly under concurrent\n//     updates (Telegraf processes multiple ctx in parallel).\n//   - Zero overhead when PERF_TIMING=0 — perfStage returns the fn unchanged.\n//   - Fixed-size ring buffer per stage (N=200) to cap memory.\n\nconst WINDOW = 200\nconst DEFAULT_INTERVAL = 50\n\nconst ENABLED = process.env.PERF_TIMING !== '0'\nconst LOG_INTERVAL = Math.max(1, parseInt(process.env.PERF_TIMING_INTERVAL, 10) || DEFAULT_INTERVAL)\n\n// stage name -> { buf: number[], idx: number, count: number }\n// buf is a ring; idx is the next write slot; count is total samples seen.\nconst stages = Object.create(null)\n\n// Global update counter — ticks once per recorded sample on ANY stage with\n// the designated \"tick\" name. To avoid double-counting we tick off the\n// LAST stage in the chain (handler), but any stage could drive it. We\n// keep a simple independent counter that perfRecord bumps for a nominated\n// \"primary\" stage. Simpler: bump on every record but only for one stage —\n// we let the caller explicitly tick via perfTick().\nlet updateCount = 0\n\nfunction getStage (name) {\n  let s = stages[name]\n  if (!s) {\n    s = { buf: [], idx: 0, count: 0 }\n    stages[name] = s\n  }\n  return s\n}\n\nfunction perfRecord (name, ms) {\n  const s = getStage(name)\n  if (s.buf.length < WINDOW) {\n    s.buf.push(ms)\n  } else {\n    s.buf[s.idx] = ms\n    s.idx = (s.idx + 1) % WINDOW\n  }\n  s.count++\n}\n\nfunction median (arr) {\n  if (!arr.length) return 0\n  // Copy + sort — buffers are small (<=200) so this is cheap.\n  const sorted = arr.slice().sort((a, b) => a - b)\n  const mid = sorted.length >> 1\n  if (sorted.length % 2) return sorted[mid]\n  return (sorted[mid - 1] + sorted[mid]) / 2\n}\n\nfunction fmt (ms) {\n  if (ms >= 10) return ms.toFixed(0) + 'ms'\n  return ms.toFixed(1) + 'ms'\n}\n\nfunction perfSnapshot () {\n  const out = {}\n  for (const name of Object.keys(stages)) {\n    const s = stages[name]\n    out[name] = {\n      p50: median(s.buf),\n      samples: s.buf.length,\n      total: s.count\n    }\n  }\n  return out\n}\n\nfunction logSummary (n) {\n  const names = Object.keys(stages)\n  if (!names.length) return\n  const parts = names.map(name => `${name}=${fmt(median(stages[name].buf))}`)\n  console.log(`[perf] ${parts.join(' ')} (n=${n})`)\n}\n\n// Tick the update counter. Call ONCE per update — we attach it to the\n// handler stage record since that's the last perf-instrumented step to\n// finish. This keeps the log cadence stable regardless of which stages\n// are wired up.\nfunction perfTick () {\n  if (!ENABLED) return\n  updateCount++\n  if (updateCount % LOG_INTERVAL === 0) {\n    logSummary(LOG_INTERVAL)\n  }\n}\n\n// Wrap a middleware fn so we record its SELF time (elapsed minus downstream\n// await). For the terminal stage pass { tick: true } to drive the periodic\n// log summary.\nfunction perfStage (name, fn, opts) {\n  if (!ENABLED) return fn\n  const tick = !!(opts && opts.tick)\n  return async function perfStageWrapped (ctx, next) {\n    const start = Date.now()\n    let downstreamMs = 0\n    try {\n      await fn(ctx, async () => {\n        const nextStart = Date.now()\n        try {\n          await next()\n        } finally {\n          downstreamMs = Date.now() - nextStart\n        }\n      })\n    } finally {\n      const selfMs = Date.now() - start - downstreamMs\n      perfRecord(name, selfMs < 0 ? 0 : selfMs)\n      if (tick) perfTick()\n    }\n  }\n}\n\nmodule.exports = {\n  perfStage,\n  perfRecord,\n  perfTick,\n  perfSnapshot,\n  ENABLED\n}\n"
  },
  {
    "path": "utils/queues.js",
    "content": "// Bull queue handles for offloaded background work.\n//\n// Redis is opt-in (see utils/redis.js): when REDIS_HOST isn't set,\n// queues become stubs that reject add() with a clear \"not configured\"\n// error. This stops Bull from silently retrying a localhost connection\n// forever — features that need queues (video convert, remove-bg, video\n// notes) degrade visibly to users instead of hanging their updates.\nconst Queue = require('bull')\n\nconst REDIS_ENABLED = !!process.env.REDIS_HOST\n\n// Keep this config deliberately minimal — this is exactly the shape that\n// worked before the refactor. Bull manages its own three clients (cmd,\n// subscriber, bclient) with their own retry/blocking behaviour; piling\n// extra ioredis options on top (maxRetriesPerRequest, retryStrategy,\n// keepAlive) interfered with Bull's internal pub/sub path and broke\n// job.finished() event delivery.\nconst bullRedisConfig = REDIS_ENABLED\n  ? {\n      host: process.env.REDIS_HOST,\n      port: process.env.REDIS_PORT,\n      password: process.env.REDIS_PASSWORD\n    }\n  : null\n\n// Stub that mimics the Bull queue surface we actually use:\n// add, getWaiting, getJobCounts, getJob, on. Enqueue rejects loudly;\n// introspection returns empty. Nothing silently succeeds — callers that\n// depend on queue completion (video/removebg paths) will see a reply-level\n// error in the user-facing flow instead of a stalled handler.\nfunction makeStubQueue (name) {\n  const err = () => {\n    const e = new Error(`queue[${name}] disabled: REDIS_HOST not set`)\n    e.code = 'QUEUE_DISABLED'\n    return e\n  }\n  return {\n    name,\n    disabled: true,\n    add: () => Promise.reject(err()),\n    getWaiting: () => Promise.resolve([]),\n    getJobCounts: () => Promise.resolve({ waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0 }),\n    getJob: () => Promise.resolve(null),\n    on: () => {},\n    close: () => Promise.resolve()\n  }\n}\n\nfunction makeRealQueue (name) {\n  return new Queue(name, { redis: bullRedisConfig })\n}\n\nconst make = REDIS_ENABLED ? makeRealQueue : makeStubQueue\n\nif (!REDIS_ENABLED) {\n  console.log('[queues] REDIS_HOST not set — queues disabled (video/removebg features unavailable)')\n}\n\nconst convertQueue = make('convert')\nconst removebgQueue = make('removebg')\nconst videoNoteQueue = make('videoNote')\n\nmodule.exports = {\n  convertQueue,\n  removebgQueue,\n  videoNoteQueue\n}\n"
  },
  {
    "path": "utils/redis.js",
    "content": "// Shared Redis client for broadcast campaigns.\n// Bull queues manage their own connections in utils/queues.js.\n// Returns null when REDIS_HOST isn't set — callers must null-check.\nconst Redis = require('ioredis')\n\nconst redis = process.env.REDIS_HOST\n  ? new Redis({\n      host: process.env.REDIS_HOST,\n      port: process.env.REDIS_PORT,\n      password: process.env.REDIS_PASSWORD\n    })\n  : null\n\nif (redis) redis.on('error', (err) => console.warn('[redis]', err.message))\n\nmodule.exports = redis\n"
  },
  {
    "path": "utils/retry-api.js",
    "content": "// Telegram API retry + transient-403 short-circuit, patched at the\n// Telegram.prototype level so every `ctx.reply*`, `ctx.editMessage*`,\n// `ctx.telegram.*` call inherits the behavior automatically.\n//\n// Two problems this solves:\n//   1. 429 rate limits — retries with jitter if retry_after is short\n//      enough to tolerate in a handler; otherwise fails fast. Long waits\n//      belong in a background Bull queue (utils/queues.js), not in a\n//      Telegraf handler slot, because the polling batch has a finite\n//      handlerTimeout and 29k+ pending updates is what happens when one\n//      rate-limited user parks a handler for 44s.\n//   2. 403 cascades — when a user blocks the bot, any subsequent reply\n//      (error-handler fallback, scene-level \"something went wrong\"\n//      follow-up, etc.) also 403s. We cache the chat_id briefly so the\n//      second/third/Nth attempt short-circuits with a synthetic 403,\n//      without hitting the network.\n//\n// Design principle: no hardcoded method names or error-description\n// strings. Uniform rules driven by payload shape and HTTP semantics.\nconst Telegram = require('telegraf/telegram')\nconst log = require('./logger').scope('retry-api')\n\nconst delay = ms => new Promise(resolve => setTimeout(resolve, ms))\n\n// ────────────────────────────────────────────────────────────────\n// Tunables — env-configurable so ops can tweak without a redeploy.\n// Defaults are what we actually want in prod for a bot at ~40 rps.\n// ────────────────────────────────────────────────────────────────\nconst RETRY_MAX_WAIT_S = parseInt(process.env.RETRY_MAX_WAIT_S, 10) || 5\nconst RETRY_MAX_ATTEMPTS = parseInt(process.env.RETRY_MAX_ATTEMPTS, 10) || 3\nconst BLOCKED_CACHE_TTL_MS = parseInt(process.env.BLOCKED_CACHE_TTL_MS, 10) || 60 * 1000\nconst BLOCKED_CACHE_MAX = parseInt(process.env.BLOCKED_CACHE_MAX, 10) || 10000\nconst RETRY_JITTER_MAX_MS = parseInt(process.env.RETRY_JITTER_MAX_MS, 10) || 1500\nconst RATE_LIMIT_CACHE_MAX = parseInt(process.env.RATE_LIMIT_CACHE_MAX, 10) || 5000\n\n// ────────────────────────────────────────────────────────────────\n// Blocked-chat cache\n// ────────────────────────────────────────────────────────────────\n// Any call with a chat_id or user_id that returns 403 caches that id\n// for a short TTL. Subsequent targeted calls to the same id short-\n// circuit with a synthetic 403 instead of hitting the network. The\n// retry middleware clears the cache as soon as we see an incoming\n// update from that chat — so a user who unblocks and writes back gets\n// replies immediately, no TTL wait.\nconst blockedChats = new Map()\n\nfunction cacheBlocked (chatId) {\n  if (!chatId) return\n  // LRU-ish eviction: when full, drop the oldest ~10%. Map iterates in\n  // insertion order so this is O(k) without a separate heap.\n  if (blockedChats.size >= BLOCKED_CACHE_MAX) {\n    const toRemove = Math.floor(BLOCKED_CACHE_MAX * 0.1)\n    const it = blockedChats.keys()\n    for (let i = 0; i < toRemove; i++) {\n      const key = it.next().value\n      if (key === undefined) break\n      blockedChats.delete(key)\n    }\n  }\n  blockedChats.set(chatId, Date.now() + BLOCKED_CACHE_TTL_MS)\n}\n\nfunction isBlockedCached (chatId) {\n  if (!chatId) return false\n  const expiresAt = blockedChats.get(chatId)\n  if (!expiresAt) return false\n  if (expiresAt < Date.now()) {\n    blockedChats.delete(chatId)\n    return false\n  }\n  return true\n}\n\nfunction clearBlockedChat (chatId) {\n  if (!chatId) return\n  blockedChats.delete(chatId)\n}\n\nfunction buildBlockedError (chatId, method) {\n  const err = new Error(`Forbidden: cached 403 for chat_id=${chatId} (${method})`)\n  err.code = 403\n  err.description = 'Forbidden: bot was blocked by the user'\n  err.on = { method }\n  err.__cachedBlock = true\n  return err\n}\n\n// ────────────────────────────────────────────────────────────────\n// Rate-limit cooldown cache (method + scope id)\n// ────────────────────────────────────────────────────────────────\n// Symmetric to blockedChats: when a call returns 429 with retry_after\n// larger than we can wait, we cache (method, scopeId) for retry_after\n// seconds. Subsequent identical calls short-circuit with a synthetic\n// 429 instead of hitting the network and producing another log line.\n// This kills the classic post-restart \"sendChatAction retry_after=7s\"\n// spam without a hardcoded method list — Telegram itself tells us\n// which (method, target) pair is in cooldown.\n//\n// Scope id is REQUIRED to cache. Without one we'd key by method only,\n// which collapses Telegram's per-chat / per-user / per-pack limits into\n// a single global lock — one user's per-pack 429 would block every other\n// user. See targetScopeId() for what counts as a scope.\nconst rateLimitedCalls = new Map()\n\nfunction rateLimitKey (method, scopeId) {\n  return `${method}:${scopeId}`\n}\n\nfunction cacheRateLimit (method, scopeId, retryAfterS) {\n  // No scope = no caching. Method-only keys are too coarse: Telegram's\n  // limits are per-chat / per-user / per-pack, never per-bot-method.\n  // Caching globally turns a local stall into a system-wide lockout.\n  // The cost of skipping is one extra network call next time; the upside\n  // is no false-positive blocks.\n  if (!scopeId) return\n\n  if (rateLimitedCalls.size >= RATE_LIMIT_CACHE_MAX) {\n    const toRemove = Math.floor(RATE_LIMIT_CACHE_MAX * 0.1)\n    const it = rateLimitedCalls.keys()\n    for (let i = 0; i < toRemove; i++) {\n      const key = it.next().value\n      if (key === undefined) break\n      rateLimitedCalls.delete(key)\n    }\n  }\n  rateLimitedCalls.set(rateLimitKey(method, scopeId), Date.now() + retryAfterS * 1000)\n}\n\nfunction isRateLimitCached (method, scopeId) {\n  const key = rateLimitKey(method, scopeId)\n  const expiresAt = rateLimitedCalls.get(key)\n  if (!expiresAt) return false\n  if (expiresAt < Date.now()) {\n    rateLimitedCalls.delete(key)\n    return false\n  }\n  return true\n}\n\n/**\n * Returns remaining cooldown in seconds for a (method, scopeId) pair, or\n * 0 if not cooled down. Intended for callers that want to SHORT-CIRCUIT\n * before starting expensive prep work (file download, sharp processing,\n * uploadStickerFile) that would only lead to another 429 on the real\n * action (addStickerToSet). Example: add-sticker.js checks this before\n * downloading a Telegram file that it would just re-upload anyway.\n *\n * @param {string} method\n * @param {number|string} [scopeId] chat_id, user_id, or sticker pack name\n * @returns {number} seconds remaining (0 if none)\n */\nfunction getRateLimitRemaining (method, scopeId) {\n  if (!scopeId) return 0\n  const key = rateLimitKey(method, scopeId)\n  const expiresAt = rateLimitedCalls.get(key)\n  if (!expiresAt) return 0\n  const remainingMs = expiresAt - Date.now()\n  if (remainingMs <= 0) {\n    rateLimitedCalls.delete(key)\n    return 0\n  }\n  return Math.ceil(remainingMs / 1000)\n}\n\nfunction buildRateLimitError (method, scopeId) {\n  const err = new Error(`Too Many Requests: cached 429 for ${method}@${scopeId}`)\n  err.code = 429\n  err.description = 'Too Many Requests: cached'\n  err.on = { method }\n  err.__cachedRateLimit = true\n  return err\n}\n\n// Extract the rate-limit scope id from a Bot API payload.\n// Telegram applies three layers of limits on sticker ops in parallel:\n// per-bot (global), per-user-owner, and per-pack. We cache on whichever\n// is visible in the payload, in order of narrowness:\n//   - chat_id → per-chat limits  (sendMessage, sendChatAction, …)\n//   - user_id → per-user limits  (createNewStickerSet, addStickerToSet,\n//                                 replaceStickerInSet,\n//                                 setStickerSetThumbnail)\n//   - name    → per-pack limits  (setStickerSetTitle, deleteStickerSet,\n//                                 getStickerSet, …)\n// Payloads with neither (deleteStickerFromSet({sticker}),\n// setStickerEmojiList({sticker}), getCustomEmojiStickers({ids})) give us\n// no honest scope — those identify objects, not a rate-limit boundary —\n// so we return null and cacheRateLimit() skips them. One extra network\n// call beats a false-positive global lock.\nfunction targetScopeId (data) {\n  if (!data || typeof data !== 'object') return null\n  return data.chat_id || data.user_id || data.name || null\n}\n\n// ────────────────────────────────────────────────────────────────\n// Retry\n// ────────────────────────────────────────────────────────────────\n\n/**\n * Wrap a Telegram API call with 429 retry. Uniform rule for all methods:\n *   - retry_after > maxWait → throw immediately (fail fast, let caller\n *     decide. Long waits belong in a background queue.)\n *   - retry_after ≤ maxWait → retry up to maxRetries with jitter\n *\n * Default maxWait is short (5s) because we're assumed to be in a\n * Telegraf handler. Background workers (Bull consumers) should pass\n * a longer maxWait and higher maxRetries — they're not in the polling\n * batch so they can afford to wait.\n *\n * @param {Function} fn\n * @param {Object}   [options]\n * @param {number}   [options.maxRetries=3]\n * @param {number}   [options.maxWait=5]   seconds\n * @param {string}   [options.method]      for logs\n */\nasync function withRetry (fn, options = {}) {\n  const {\n    maxRetries = RETRY_MAX_ATTEMPTS,\n    maxWait = RETRY_MAX_WAIT_S,\n    method = 'unknown'\n  } = options\n\n  for (let attempt = 0; attempt <= maxRetries; attempt++) {\n    try {\n      return await fn()\n    } catch (error) {\n      const retryAfter = getRetryAfter(error)\n\n      if (!retryAfter) throw error\n\n      if (retryAfter > maxWait) {\n        log.warn(\n          `429 on ${method}, retry_after=${retryAfter}s > maxWait=${maxWait}s — failing fast`\n        )\n        throw error\n      }\n\n      if (attempt >= maxRetries) throw error\n\n      const waitMs = retryAfter * 1000 + Math.floor(Math.random() * RETRY_JITTER_MAX_MS)\n      log.info(\n        `429 on ${method}, waiting ${(waitMs / 1000).toFixed(1)}s ` +\n        `(attempt ${attempt + 1}/${maxRetries})`\n      )\n      await delay(waitMs)\n    }\n  }\n  // Unreachable — the loop either returns or throws. Explicit throw\n  // makes control flow obvious to readers and linters.\n  throw new Error('withRetry: exhausted retries without result')\n}\n\n// ────────────────────────────────────────────────────────────────\n// Error helpers\n// ────────────────────────────────────────────────────────────────\n\nfunction isRateLimitError (error) {\n  return error?.code === 429 ||\n         error?.response?.error_code === 429 ||\n         /too many requests/i.test(error?.description || '') ||\n         /too many requests/i.test(error?.response?.description || '')\n}\n\nfunction getRetryAfter (error) {\n  return error?.parameters?.retry_after ||\n         error?.response?.parameters?.retry_after ||\n         null\n}\n\n// ────────────────────────────────────────────────────────────────\n// Prototype patch — install retry + blocked-cache on every Telegram\n// instance. One-shot, non-reentrant.\n// ────────────────────────────────────────────────────────────────\n\nfunction patchTelegramPrototype () {\n  if (!Telegram || !Telegram.prototype) return\n  if (Telegram.prototype.__retryPatched) return\n\n  const originalCallApi = Telegram.prototype.callApi\n\n  Telegram.prototype.callApi = function patchedCallApi (method, data = {}, ...rest) {\n    const scopeId = targetScopeId(data)\n\n    // Short-circuit: recently-seen 403 for this chat_id/user_id.\n    if (scopeId && isBlockedCached(scopeId)) {\n      return Promise.reject(buildBlockedError(scopeId, method))\n    }\n\n    // Short-circuit: server-confirmed 429 cooldown for (method, scope)\n    // still in its retry_after window — skip the network and the log.\n    if (isRateLimitCached(method, scopeId)) {\n      return Promise.reject(buildRateLimitError(method, scopeId))\n    }\n\n    return withRetry(\n      () => originalCallApi.call(this, method, data, ...rest),\n      { method }\n    ).catch((error) => {\n      // 403 on a private chat (positive id = user_id / DM chat_id) →\n      // cache briefly: \"blocked by user\", \"user deactivated\", \"chat not\n      // found\". Groups/supergroups have negative ids and 403 there is\n      // usually \"not enough rights\" (bot demoted) — caching would\n      // silently skip all sends for TTL, so we skip groups entirely.\n      if (error?.code === 403 && scopeId > 0) cacheBlocked(scopeId)\n\n      // 429 that withRetry already decided to fail-fast on (retry_after\n      // exceeds maxWait) → cache so siblings don't each re-hit the wall.\n      // cacheRateLimit() refuses to cache scopeless calls (see comment\n      // there) so we don't need to gate on scopeId here.\n      if (error?.code === 429) {\n        const retryAfter = getRetryAfter(error)\n        if (retryAfter && retryAfter > RETRY_MAX_WAIT_S) {\n          cacheRateLimit(method, scopeId, retryAfter)\n        }\n      }\n\n      throw error\n    })\n  }\n\n  Object.defineProperty(Telegram.prototype, '__retryPatched', {\n    value: true,\n    writable: false,\n    enumerable: false,\n    configurable: false\n  })\n}\n\npatchTelegramPrototype()\n\n// ────────────────────────────────────────────────────────────────\n// Middleware\n// ────────────────────────────────────────────────────────────────\n\n// Exposes ctx.withRetry for manual wrapping (e.g. explicit retry around\n// a non-Telegraf async call) and clears the blocked-chat cache the\n// moment we see an update from that chat — so users who unblock don't\n// have to wait out the TTL.\nfunction retryMiddleware () {\n  return async (ctx, next) => {\n    ctx.withRetry = (fn, options) => withRetry(fn, options)\n\n    const mcm = ctx?.update?.my_chat_member\n    const isKick = mcm?.new_chat_member?.status === 'kicked'\n\n    if (ctx.from?.id && !isKick) clearBlockedChat(ctx.from.id)\n    if (ctx.chat?.id && ctx.chat.id !== ctx.from?.id && !isKick) {\n      clearBlockedChat(ctx.chat.id)\n    }\n\n    return next()\n  }\n}\n\nmodule.exports = {\n  withRetry,\n  isRateLimitError,\n  getRetryAfter,\n  retryMiddleware,\n  clearBlockedChat,\n  getRateLimitRemaining,\n  _blockedCacheSize: () => blockedChats.size,\n  _rateLimitCacheSize: () => rateLimitedCalls.size\n}\n"
  },
  {
    "path": "utils/safe-edit.js",
    "content": "// Edits a callback message's text, falling back to a fresh reply if the\n// edit fails (message too old, not text, lost reply context, etc.).\n// Keeps the user always informed.\n\nconst isBenignEditError = (err) => {\n  const desc = err?.description || err?.message || ''\n  // \"message is not modified\" — same content, no-op edit. Common when\n  // admins click \"refresh\" on a status panel that hasn't changed.\n  return /message is not modified/i.test(desc)\n}\n\n// Edit-or-tolerate-no-op. Use for status panels that may be re-rendered\n// with identical content. Logs anything that *isn't* the not-modified\n// case so real failures stay visible.\nconst tolerantEditMessage = async (ctx, text, options = {}) => {\n  try {\n    await ctx.editMessageText(text, options)\n  } catch (err) {\n    if (!isBenignEditError(err)) {\n      console.error('tolerantEditMessage failed:', err.message)\n    }\n  }\n}\n\nconst safeEditMessage = async (ctx, text, options = {}) => {\n  try {\n    await ctx.editMessageText(text, options)\n    return { edited: true }\n  } catch (err) {\n    console.error('safeEditMessage: edit failed, falling back to reply:', err.message)\n    try {\n      await ctx.reply(text, {\n        ...options,\n        reply_to_message_id: ctx.callbackQuery?.message?.message_id,\n        allow_sending_without_reply: true\n      })\n      return { edited: false, replied: true }\n    } catch (replyErr) {\n      console.error('safeEditMessage: reply fallback also failed:', replyErr.message)\n      return { edited: false, replied: false }\n    }\n  }\n}\n\nmodule.exports = { safeEditMessage, tolerantEditMessage, isBenignEditError }\n"
  },
  {
    "path": "utils/send-sticker-as-document.js",
    "content": "const got = require('got')\nconst sharp = require('sharp')\n\n// Download a sticker by file_id and reply with it as a regular document.\n// Re-uploading converts the file out of Telegram's \"Sticker\" type so it\n// can be forwarded/saved as an ordinary file.\n//\n// Dispatch by the URL extension when present (fast path — webm/tgs are\n// forwarded by URL without a download). Otherwise download the bytes and\n// sniff the format: Telegram's getFile sometimes returns legacy file_paths\n// without an extension (e.g. `stickers/file_303092`) where extension-only\n// dispatch produces a silent no-op.\n\n// Returns true on success, false if an error was surfaced to the user.\nasync function sendStickerAsDocument (ctx, fileId, fileUniqueId, extra = {}) {\n  let fileLink\n  try {\n    fileLink = await ctx.telegram.getFileLink(fileId)\n  } catch (err) {\n    const key = err.message && err.message.includes('file is too big')\n      ? 'error.file_too_big'\n      : 'error.download'\n    await ctx.replyWithHTML(ctx.i18n.t(key), extra).catch(() => {})\n    return false\n  }\n\n  const replyTelegramError = (error) =>\n    ctx.replyWithHTML(\n      ctx.i18n.t('error.telegram', { error: error.description || error.message }),\n      extra\n    ).catch(() => {})\n\n  // Fast path: URL-forwarded document for the two sticker formats that don't\n  // need re-encoding. Telegram clients preview these inline from the filename.\n  try {\n    if (fileLink.endsWith('.webm')) {\n      await ctx.replyWithDocument({ url: fileLink, filename: `${fileUniqueId}.webm` }, extra)\n      return true\n    }\n    if (fileLink.endsWith('.tgs')) {\n      await ctx.replyWithDocument({ url: fileLink, filename: `${fileUniqueId}.tgs` }, extra)\n      return true\n    }\n  } catch (error) {\n    await replyTelegramError(error)\n    return false\n  }\n\n  // Slow path: download and sniff. Covers .webp (converted to PNG for inline\n  // preview) and legacy file_paths without an extension.\n  let buffer\n  try {\n    buffer = await got(fileLink).buffer()\n  } catch (err) {\n    await ctx.replyWithHTML(ctx.i18n.t('error.download'), extra).catch(() => {})\n    return false\n  }\n\n  const sniffed = sniffStickerFormat(buffer)\n\n  try {\n    if (sniffed === 'webp') {\n      const pngBuffer = await sharp(buffer, { failOnError: false }).png().toBuffer()\n      await ctx.replyWithDocument({ source: pngBuffer, filename: `${fileUniqueId}.png` }, extra)\n      return true\n    }\n    // webm/tgs reached here when URL had no extension; mp4/jpg/png cover\n    // legacy \"original input\" blobs (user uploaded a jpg, bot kept its file_id).\n    const ext = sniffed || 'bin'\n    await ctx.replyWithDocument({ source: buffer, filename: `${fileUniqueId}.${ext}` }, extra)\n    return true\n  } catch (error) {\n    await replyTelegramError(error)\n    return false\n  }\n}\n\n// Recognize the formats Telegram actually stores in sticker-adjacent files.\n// Magic-byte reference: https://en.wikipedia.org/wiki/List_of_file_signatures\nfunction sniffStickerFormat (buffer) {\n  if (!buffer || buffer.length < 12) return null\n  // WebP: RIFF....WEBP\n  if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&\n      buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) return 'webp'\n  // WebM (EBML): 1A 45 DF A3\n  if (buffer[0] === 0x1A && buffer[1] === 0x45 && buffer[2] === 0xDF && buffer[3] === 0xA3) return 'webm'\n  // TGS is gzipped JSON: 1F 8B\n  if (buffer[0] === 0x1F && buffer[1] === 0x8B) return 'tgs'\n  // MP4 (ftyp box at offset 4)\n  if (buffer[4] === 0x66 && buffer[5] === 0x74 && buffer[6] === 0x79 && buffer[7] === 0x70) return 'mp4'\n  // JPEG: FF D8\n  if (buffer[0] === 0xFF && buffer[1] === 0xD8) return 'jpg'\n  // PNG: 89 50 4E 47\n  if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) return 'png'\n  return null\n}\n\nmodule.exports = sendStickerAsDocument\n"
  },
  {
    "path": "utils/stats.js",
    "content": "const io = require('@pm2/io')\n\nconst stats = {\n  rpsAvrg: 0,\n  responseTimeAvrg: 0,\n  times: {}\n}\n\nconst rtOP = io.metric({\n  name: 'response time',\n  unit: 'ms'\n})\n\n// const usersCountIO = io.metric({\n//   name: 'Users count',\n//   unit: 'user'\n// })\n\n// .unref() so this stats sampler doesn't keep the process alive on shutdown.\nsetInterval(() => {\n  const keys = Object.keys(stats.times)\n\n  // Prevent memory accumulation: clean up old entries if too many\n  if (keys.length > 60) {\n    const keysToDelete = keys.slice(0, keys.length - 60)\n    keysToDelete.forEach(key => delete stats.times[key])\n  }\n\n  if (keys.length > 1) {\n    const time = keys[0]\n\n    const rps = stats.times[time].length\n    if (stats.rpsAvrg > 0) stats.rpsAvrg = (stats.rpsAvrg + rps) / 2\n    else stats.rpsAvrg = rps\n\n    const sumResponseTime = stats.times[time].reduce((a, b) => a + b, 0)\n    const lastResponseTimeAvrg = (sumResponseTime / stats.times[time].length) || 0\n    if (stats.responseTimeAvrg > 0) stats.responseTimeAvrg = (stats.responseTimeAvrg + lastResponseTimeAvrg) / 2\n    else stats.responseTimeAvrg = lastResponseTimeAvrg\n\n    console.log('🔄 rps last:', rps)\n    console.log('🔄 rps avrg:', stats.rpsAvrg)\n    console.log('🔄 response time avrg last:', lastResponseTimeAvrg)\n    console.log('🔄 response time avrg total:', stats.responseTimeAvrg)\n\n    rtOP.set(stats.responseTimeAvrg)\n\n    delete stats.times[time]\n  }\n}, 1000).unref()\n\n// setInterval(async () => {\n//   const usersCount = await db.User.count({\n//     updatedAt: {\n//       $gte: new Date(Date.now() - 24 * 60 * 60 * 1000)\n//     }\n//   })\n\n//   usersCountIO.set(usersCount)\n// }, 60 * 1000)\n\nmodule.exports = async (ctx, next) => {\n  const startMs = new Date()\n\n  ctx.stats = {\n    rps: stats.rpsAvrg,\n    rta: stats.responseTimeAvrg\n  }\n\n  return next().then(() => {\n    const now = Math.floor(new Date() / 1000)\n\n    if (!stats.times[now]) stats.times[now] = []\n    stats.times[now].push(new Date() - startMs)\n  })\n}\n"
  },
  {
    "path": "utils/sticker-inflight.js",
    "content": "// Per-user concurrency gate for the fire-and-forget sticker-add flow.\n//\n// Why: after detaching addSticker from the Telegraf handler (see\n// handlers/sticker.js), the handler returns immediately and the heavy\n// work (file download + uploadStickerFile + addStickerToSet) runs in\n// the background. Without a cap, one user sending 20 stickers fast\n// spawns 20 parallel in-flight chains — each hitting the pack's per-\n// user rate limit on addStickerToSet, duplicating Telegram bandwidth\n// and amplifying load for no benefit (Telegram will 429 anyway).\n//\n// Small cap (default 3) mirrors Telegram's own tolerance: up to three\n// in flight overlaps network latency without saturating Telegram's\n// per-user addStickerToSet limit. More just queues up 429s.\n\nconst MAX_PER_USER = parseInt(process.env.STICKER_INFLIGHT_PER_USER, 10) || 3\n\nconst counts = new Map()\n\nfunction acquire (userId) {\n  if (!userId) return true // no-op for anonymous (shouldn't happen, but defensive)\n  const current = counts.get(userId) || 0\n  if (current >= MAX_PER_USER) return false\n  counts.set(userId, current + 1)\n  return true\n}\n\nfunction release (userId) {\n  if (!userId) return\n  const current = counts.get(userId) || 0\n  if (current <= 1) counts.delete(userId)\n  else counts.set(userId, current - 1)\n}\n\nmodule.exports = {\n  acquire,\n  release,\n  _size: () => counts.size,\n  MAX_PER_USER\n}\n"
  },
  {
    "path": "utils/telegram-api.js",
    "content": "const { Api, TelegramClient } = require('telegram')\nconst { StringSession } = require('telegram/sessions')\nconst fs = require('fs')\nconst path = require('path')\n\nconst SESSION_FILE = path.join(__dirname, '../.mtproto-session')\n\nlet client = null\nlet isConnected = false\nlet connectionPromise = null\n\nasync function connect () {\n  // Return existing connection or in-progress attempt\n  if (isConnected && client) return client\n  if (connectionPromise) return connectionPromise\n\n  connectionPromise = (async () => {\n    try {\n      // Load saved session if exists\n      let savedSession = ''\n      if (fs.existsSync(SESSION_FILE)) {\n        savedSession = fs.readFileSync(SESSION_FILE, 'utf8').trim()\n      }\n\n      const session = new StringSession(savedSession)\n\n      client = new TelegramClient(\n        session,\n        parseInt(process.env.TELEGRAM_API_ID),\n        process.env.TELEGRAM_API_HASH,\n        { connectionRetries: 5 }\n      )\n\n      await client.start({\n        botAuthToken: process.env.BOT_TOKEN\n      })\n\n      client.setLogLevel('error')\n\n      // Save session for future restarts\n      const sessionString = client.session.save()\n      if (sessionString && sessionString !== savedSession) {\n        fs.writeFileSync(SESSION_FILE, sessionString)\n      }\n\n      isConnected = true\n      console.log('MTProto connected successfully')\n      return client\n    } catch (err) {\n      console.error('MTProto connection failed:', err.message)\n      client = null\n      isConnected = false\n      connectionPromise = null\n      throw err\n    }\n  })()\n\n  return connectionPromise\n}\n\n// Auto-connect on module load (don't block)\nconnect().catch(err => console.error('Telegram API connection failed:', err.message))\n\nmodule.exports = {\n  get client () {\n    return client\n  },\n  Api,\n  connect,\n  get isConnected () {\n    return isConnected\n  }\n}\n"
  },
  {
    "path": "utils/telegram-error.js",
    "content": "// Maps Telegram API errors to user-facing i18n keys under\n// `error.telegram_reasons.*`. Patterns are ordered most-specific\n// (longer / unambiguous identifier) → most-generic.\n\nconst ERROR_PATTERNS = [\n  { match: /STICKER_INVALID|STICKER_NOT_FOUND/i, reason: 'sticker_not_in_set' },\n  { match: /STICKERSET_INVALID|STICKERSET_NOT_FOUND/i, reason: 'pack_invalid' },\n  { match: /STICKERS_TOO_MUCH/i, reason: 'pack_full' },\n  { match: /STICKERSET_OWNER_ANOTHER/i, reason: 'not_pack_owner' },\n  { match: /sticker set name is already occupied/i, reason: 'pack_name_taken' },\n  { match: /PACK_SHORT_NAME_INVALID/i, reason: 'pack_name_invalid' },\n  { match: /STICKER_PNG_NOPNG|STICKER_PNG_DIMENSIONS|STICKER_TGS_NOTGS|STICKER_VIDEO_NOWEBM|STICKER_VIDEO_BIG|STICKER_FILE_INVALID|STICKER_DOCUMENT_INVALID/i, reason: 'invalid_sticker_format' },\n  { match: /STICKER_EMOJI_INVALID|EMOJI_INVALID/i, reason: 'invalid_emoji' },\n  { match: /Too Many Requests|FLOOD_WAIT/i, reason: 'rate_limited' },\n  { match: /^Forbidden|bot was blocked|user is deactivated|chat not found|PEER_ID_INVALID/i, reason: 'cannot_reach_user' }\n]\n\nconst RATE_LIMIT_CODE = 429\n\n// Telegram error descriptions are usually short, but rare 400s come back\n// with a multi-kilobyte JSON dump. We render this string into i18n\n// templates that may feed answerCbQuery (200-char hard limit) or\n// replyWithHTML (4096-char limit). Clamp at the source so the sink\n// can't blow up with MESSAGE_TOO_LONG.\nconst truncateDescription = (s, max) => (\n  s.length > max ? `${s.slice(0, max - 1)}…` : s\n)\n// Default fits answerCbQuery (200) once the i18n template wraps it in\n// \"Помилка Telegram: <code>…</code>\" (~30 chars of prefix/suffix).\nconst DEFAULT_MAX_DESCRIPTION_LEN = 150\n\nconst matchTelegramErrorReason = (error) => {\n  if (!error) return null\n  if (error.code === RATE_LIMIT_CODE) return 'rate_limited'\n  const description = error.description || error.message || ''\n  for (const { match, reason } of ERROR_PATTERNS) {\n    if (match.test(description)) return reason\n  }\n  return null\n}\n\nconst extractRetryAfterSeconds = (error) => {\n  if (!error) return null\n  // Telegram attaches `parameters.retry_after` on 429.\n  const fromParams = error.parameters?.retry_after ?? error.response?.parameters?.retry_after\n  if (Number.isFinite(fromParams)) return fromParams\n  const description = error.description || error.message || ''\n  const match = description.match(/FLOOD_WAIT_(\\d+)/i) || description.match(/retry after (\\d+)/i)\n  return match ? parseInt(match[1], 10) : null\n}\n\nconst humanizeTelegramError = (ctx, error, opts = {}) => {\n  const reason = matchTelegramErrorReason(error)\n\n  if (reason === 'rate_limited') {\n    const seconds = extractRetryAfterSeconds(error)\n    if (seconds) return ctx.i18n.t('error.rate_limit_seconds', { seconds })\n    return ctx.i18n.t('error.telegram_reasons.rate_limited')\n  }\n\n  if (reason) return ctx.i18n.t(`error.telegram_reasons.${reason}`)\n\n  const description = error?.description || error?.message || 'Unknown error'\n  const maxLen = opts.maxDescriptionLen || DEFAULT_MAX_DESCRIPTION_LEN\n  return ctx.i18n.t(opts.fallbackKey || 'error.telegram', {\n    error: truncateDescription(description, maxLen)\n  })\n}\n\nmodule.exports = {\n  matchTelegramErrorReason,\n  extractRetryAfterSeconds,\n  humanizeTelegramError,\n  truncateDescription\n}\n"
  },
  {
    "path": "utils/telegram.js",
    "content": "// utils/telegram.js\n// Ensure retry-api prototype patch is applied before any Telegram instance is created.\nrequire('./retry-api')\n\nconst Telegram = require('telegraf/telegram')\n\nconst instances = new Map()\n\n/**\n * Get (or lazily create) a shared Telegram client for a given bot token.\n * Using the same token always returns the same instance — reuses the HTTP agent\n * and prevents listener/memory accumulation across the codebase.\n */\nfunction getTelegram (token = process.env.BOT_TOKEN) {\n  if (!token) throw new Error('BOT_TOKEN is required')\n  let client = instances.get(token)\n  if (!client) {\n    client = new Telegram(token)\n    instances.set(token, client)\n  }\n  return client\n}\n\n// Convenience default for the primary bot token\nmodule.exports = getTelegram()\nmodule.exports.get = getTelegram\nmodule.exports.Telegram = Telegram\n"
  },
  {
    "path": "utils/tenor.js",
    "content": "const got = require('got')\n\nconst search = async (query, limit, pos) => {\n  const response = await got.get(`https://g.tenor.com/v1/search?q=${query}&key=${process.env.TENOR_KEY}&limit=${limit}&pos=${pos}&searchfilter=sticker`, {\n    timeout: {\n      lookup: 1000,\n      connect: 1000,\n      secureConnect: 1000,\n      socket: 10000,\n      send: 10000,\n      response: 8000\n    }\n  })\n\n  return JSON.parse(response.body)\n}\n\nconst trending = async (pos, locale) => {\n  const response = await got.get(`https://g.tenor.com/v1/trending?key=${process.env.TENOR_KEY}&locale=${locale}&limit=50${pos ? `&pos=${pos}` : ''}&searchfilter=sticker`, {\n    timeout: {\n      lookup: 1000,\n      connect: 1000,\n      secureConnect: 1000,\n      socket: 10000,\n      send: 10000,\n      response: 8000\n    }\n  })\n\n  return JSON.parse(response.body)\n}\n\nmodule.exports = {\n  search,\n  trending\n}\n"
  },
  {
    "path": "utils/unicode-chars-count.js",
    "content": "module.exports = (str) => {\n  if (typeof str !== 'string') throw new TypeError('Expected a string')\n\n  let count = 0\n  for (let i = 0; i < str.length; i++) {\n    const code = str.charCodeAt(i)\n    if (code >= 0xd800 && code <= 0xdbff && i < str.length - 1) {\n      // This is a surrogate pair, so we need to skip the next code unit\n      i++\n    }\n    count++\n  }\n  return count\n}\n"
  },
  {
    "path": "utils/unicode-substr.js",
    "content": "module.exports = (str, start, end) => {\n  if (typeof str !== 'string') throw new TypeError('Expected a string')\n  if (typeof start !== 'number') throw new TypeError('Expected a number start')\n  if (typeof end !== 'number') throw new TypeError('Expected a number end')\n\n  let startIndex = 0\n  let endIndex = str.length\n  let count = 0\n\n  // Find the start index based on Unicode code points\n  for (let i = 0; i < str.length && count < start; i++) {\n    const code = str.charCodeAt(i)\n    if (code >= 0xd800 && code <= 0xdbff && i < str.length - 1) {\n      // This is a surrogate pair, so we need to skip the next code unit\n      i++\n    }\n    count++\n    startIndex = i + 1\n  }\n\n  count = 0\n\n  // Find the end index based on Unicode code points\n  for (let i = startIndex; i < str.length && count < end - start; i++) {\n    const code = str.charCodeAt(i)\n    if (code >= 0xd800 && code <= 0xdbff && i < str.length - 1) {\n      // This is a surrogate pair, so we need to skip the next code unit\n      i++\n    }\n    count++\n    endIndex = i + 1\n  }\n\n  return str.substring(startIndex, endIndex)\n}\n"
  },
  {
    "path": "utils/update-monitor.js",
    "content": "/* eslint-disable camelcase */\nconst config = require('../config.json')\nconst telegram = require('./telegram')\nconst log = require('./logger').scope('update-monitor')\n\n// Backlog-size monitor. Note this is NOT a hang detector: it runs INSIDE\n// the event loop via setInterval — if the loop were truly blocked, this\n// function would never fire either. What it can detect is a growing\n// queue of pending updates (server-side, reported by getWebhookInfo),\n// which means we're not processing fast enough.\n//\n// Historical landmine: previous versions called `process.exit(1)` at\n// pending>100 to \"recover\". That caused a self-destructive loop on any\n// legitimate burst: exit → PM2 restart → Telegram replays all pending\n// (now even larger) → monitor fires again → exit → … Removed. If a real\n// hang ever needs self-healing, do it from a worker_thread watchdog, not\n// from within the loop that's \"hung\".\n//\n// Thresholds:\n//   - WARN (>40, every 10 updates of growth): post an alert, keep running.\n//     Burst recoveries decrease the count; only sustained growth is worth\n//     paging on.\n//   - ALERT (>250, sampled once per call): post an alert with a louder tag.\n//     Still don't exit — ops decides based on whether count stays high\n//     across the next samples.\nconst WARN_THRESHOLD = 40\nconst ALERT_THRESHOLD = 250\n\nconst updateMonitor = async () => {\n  const webhookInfo = await telegram.getWebhookInfo().catch((err) => {\n    log.error('getWebhookInfo failed:', err?.description || err?.message)\n    return null\n  })\n  if (!webhookInfo) return\n\n  const { pending_update_count } = webhookInfo\n\n  if (pending_update_count > ALERT_THRESHOLD) {\n    log.error(`pending=${pending_update_count} (high)`)\n    await telegram.sendMessage(\n      config.logChatId,\n      `❌ pending updates: <b>${pending_update_count}</b> — backlog is not clearing`,\n      { parse_mode: 'HTML' }\n    ).catch((err) => log.error('sendMessage to log channel failed:', err?.description || err?.message))\n    return\n  }\n\n  if (pending_update_count > WARN_THRESHOLD && pending_update_count % 10 === 0) {\n    log.warn(`pending=${pending_update_count}`)\n    await telegram.sendMessage(\n      config.logChatId,\n      `⚠️ pending updates: <b>${pending_update_count}</b>`,\n      { parse_mode: 'HTML' }\n    ).catch((err) => log.error('sendMessage to log channel failed:', err?.description || err?.message))\n  }\n}\n\nmodule.exports = updateMonitor\n"
  },
  {
    "path": "utils/user-name.js",
    "content": "module.exports = (user, url = false) => {\n  let name = user.first_name\n\n  if (user.last_name) name += ` ${user.last_name}`\n  name = name.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n\n  if (url) return `<a href=\"tg://user?id=${user.id}\">${name}</a>`\n  return name\n}\n"
  },
  {
    "path": "utils/user-update.js",
    "content": "module.exports = async (ctx) => {\n  if (!ctx.from) return false\n\n  // Only populate inlineStickerSet when the handler actually reads it —\n  // inline queries hit it hard, regular message/callback flows never do.\n  // Saves one findById per regular update (~3ms steady, ~30-100ms under\n  // pool pressure) for the ~95% of updates that aren't inline queries.\n  let query = ctx.db.User.findOne({ telegram_id: ctx.from.id }).populate('stickerSet')\n  if (ctx.inlineQuery) {\n    query = query.populate('inlineStickerSet')\n  }\n\n  let user = await query\n\n  // Bot API formally guarantees ctx.from.first_name, but deactivated /\n  // deleted accounts and rare anonymous-sender edges send it empty or\n  // missing. Coerce both to '' once so neither schema validation nor\n  // template-literal interpolation surprises us downstream.\n  const firstName = ctx.from.first_name || ''\n  const lastName = ctx.from.last_name || ''\n  const fullName = lastName ? `${firstName} ${lastName}` : firstName\n\n  if (!user) {\n    // First-message race: two parallel updates both see `null` here and\n    // would both `new User() + save()`, producing E11000 on the second.\n    // Atomic upsert ensures one wins and the other gets the inserted doc.\n    const now = Math.floor(Date.now() / 1000)\n    user = await ctx.db.User.findOneAndUpdate(\n      { telegram_id: ctx.from.id },\n      {\n        $setOnInsert: {\n          telegram_id: ctx.from.id,\n          first_act: now,\n          first_name: firstName,\n          last_name: lastName,\n          full_name: fullName,\n          username: ctx.from.username\n        }\n      },\n      { upsert: true, new: true, setDefaultsOnInsert: true }\n    )\n  }\n\n  if (ctx?.update?.my_chat_member?.new_chat_member?.status === 'kicked') {\n    user.blocked = true\n  } else {\n    user.blocked = false\n  }\n\n  user.first_name = firstName\n  user.last_name = lastName\n  user.full_name = fullName\n  user.username = ctx.from.username\n  // No manual updatedAt — see save-wrap in bot/middleware.js. We bump it\n  // via a throttled fire-and-forget updateOne instead, so unchanged-user\n  // updates don't trigger a full .save() on every request.\n\n  ctx.session.userInfo = user\n  if (ctx.session.userInfo.locale) ctx.i18n.locale(ctx.session.userInfo.locale)\n  else ctx.session.userInfo.locale = ctx.i18n.languageCode\n\n  return true\n}\n"
  }
]