Showing preview only (1,153K chars total). Download the full file or copy to clipboard to get everything.
Repository: LyoSU/fStikBot
Branch: master
Commit: b61ee491ee14
Files: 167
Total size: 987.5 KB
Directory structure:
gitextract_q1s5jkoo/
├── .dockerignore
├── .editorconfig
├── .eslintrc.json
├── .github/
│ └── FUNDING.yml
├── .gitignore
├── .vscode/
│ ├── launch.json
│ └── settings.json
├── Dockerfile
├── LICENSE
├── README.md
├── banners/
│ ├── DESIGN.md
│ ├── README.md
│ ├── build.js
│ ├── index.js
│ └── src/
│ ├── _system.css
│ ├── assets/
│ │ └── README.md
│ ├── boost.html
│ ├── catalog.html
│ ├── description.html
│ ├── donate.html
│ ├── emoji.html
│ ├── group.html
│ ├── help.html
│ ├── language.html
│ ├── mosaic.html
│ ├── new-pack.html
│ ├── origin.html
│ ├── packs.html
│ ├── publish.html
│ └── welcome.html
├── bot/
│ ├── commands.js
│ ├── launch.js
│ ├── locale-sync.js
│ ├── middleware.js
│ ├── preflight.js
│ └── session-store.js
├── bot.js
├── config.example.json
├── crowdin.yml
├── database/
│ ├── connection.js
│ ├── index.js
│ └── models/
│ ├── deeplink.js
│ ├── group.js
│ ├── index.js
│ ├── messaging.js
│ ├── payment.js
│ ├── sticker-set.js
│ ├── sticker.js
│ └── user.js
├── docker-compose.yml
├── docs/
│ └── superpowers/
│ ├── plans/
│ │ ├── 2026-04-05-emoji-mosaic.md
│ │ ├── 2026-04-15-mosaic-input-types.md
│ │ └── 2026-04-15-security-sweep-pr1.md
│ └── specs/
│ ├── 2026-04-05-emoji-mosaic-design.md
│ ├── 2026-04-15-mosaic-input-types-design.md
│ └── 2026-04-15-security-sweep-pr1-design.md
├── ecosystem.config.js
├── emoji_placeholder.webm
├── handlers/
│ ├── admin/
│ │ ├── _helpers.js
│ │ ├── index.js
│ │ ├── messaging.js
│ │ └── pack.js
│ ├── catalog.js
│ ├── catch.js
│ ├── coedit.js
│ ├── donate.js
│ ├── emoji.js
│ ├── group-settings.js
│ ├── help.js
│ ├── index.js
│ ├── inline-query.js
│ ├── language.js
│ ├── news-channel.js
│ ├── pack-boost.js
│ ├── pack-copy.js
│ ├── pack-hide.js
│ ├── pack-restore.js
│ ├── pack-select-group.js
│ ├── pack-select.js
│ ├── packs.js
│ ├── ping.js
│ ├── search-catalog.js
│ ├── start.js
│ ├── stats.js
│ ├── sticker-delete.js
│ ├── sticker-restore.js
│ ├── sticker-update.js
│ └── sticker.js
├── index.js
├── locales/
│ ├── ar.yaml
│ ├── az.yaml
│ ├── be.yaml
│ ├── de.yaml
│ ├── en.yaml
│ ├── es.yaml
│ ├── fr.yaml
│ ├── hy.yaml
│ ├── id.yaml
│ ├── ja.yaml
│ ├── kk.yaml
│ ├── pt.yaml
│ ├── ru.yaml
│ ├── tr.yaml
│ ├── uk.yaml
│ ├── uz.yaml
│ └── zh.yaml
├── package.json
├── privacy.html
├── scenes/
│ ├── admin-pack-bulk-delete.js
│ ├── admin-pack.js
│ ├── donate.js
│ ├── index.js
│ ├── messaging.js
│ ├── mosaic.js
│ ├── pack-about.js
│ ├── pack-catalog.js
│ ├── pack-delete.js
│ ├── pack-frame.js
│ ├── pack-new.js
│ ├── pack-rename.js
│ ├── pack-search.js
│ ├── photo-clear.js
│ ├── sticker-delete.js
│ ├── sticker-original.js
│ └── video-round.js
├── scripts/
│ ├── README.md
│ ├── inspect-db.js
│ ├── test-perf-timing.js
│ ├── test-retry-api.js
│ ├── top-sets.js
│ ├── update-packs.js
│ └── update-sticker.js
├── sticker_placeholder.tgs
├── sticker_placeholder.webm
└── utils/
├── add-sticker-text.js
├── add-sticker.js
├── decode-sticker-set-id.js
├── download-file-by-url.js
├── escape-regex.js
├── gramads.js
├── group-update.js
├── html-escape.js
├── index.js
├── last-seen.js
├── logger.js
├── messaging.js
├── moderate-pack.js
├── mosaic-grid.js
├── mosaic-preview.js
├── mosaic-split.js
├── perf-timing.js
├── queues.js
├── redis.js
├── retry-api.js
├── safe-edit.js
├── send-sticker-as-document.js
├── stats.js
├── sticker-inflight.js
├── telegram-api.js
├── telegram-error.js
├── telegram.js
├── tenor.js
├── unicode-chars-count.js
├── unicode-substr.js
├── update-monitor.js
├── user-name.js
└── user-update.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
node_modules
npm-debug.log
================================================
FILE: .editorconfig
================================================
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
================================================
FILE: .eslintrc.json
================================================
{
"env": {
"es6": true,
"node": true
},
"extends": "standard",
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"rules": {
}
}
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: ['https://donate.lyo.su/']
================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# next.js build output
.next
tmp
tgsnake
session
.mtproto-session
.locale-sync-mtime
.DS_Store
config.json
.superpowers/
================================================
FILE: .vscode/launch.json
================================================
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Program",
"program": "${workspaceFolder}/index.js",
"request": "launch",
"skipFiles": [
"<node_internals>/**"
],
"type": "pwa-node"
}
]
}
================================================
FILE: .vscode/settings.json
================================================
{
"i18n-ally.localesPaths": [
"locales"
]
}
================================================
FILE: Dockerfile
================================================
FROM node:lts-alpine as base
FROM base as builder
RUN mkdir /install
WORKDIR /install
COPY package.json .
RUN npm i --production
FROM base
RUN addgroup -S bot && adduser -S bot -G bot
COPY --from=builder /install/node_modules /app/node_modules
COPY ./ /app
ENV NODE_WORKDIR /app
WORKDIR $NODE_WORKDIR
USER bot
CMD ["node", "index.js"]
================================================
FILE: LICENSE
================================================
# PolyForm Noncommercial License 1.0.0
<https://polyformproject.org/licenses/noncommercial/1.0.0>
Required Notice: Copyright (c) 2019-2026 LyoSU (https://github.com/LyoSU)
## Acceptance
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
## Copyright License
The 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).
## Distribution License
The 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).
## Notices
You 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:
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
## Changes and New Works License
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
## Patent License
The 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.
## Noncommercial Purposes
Any noncommercial purpose is a permitted purpose.
## Personal Uses
Personal 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.
## Noncommercial Organizations
Use 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.
## Fair Use
You may have "fair use" rights for the software under the law. These terms do not limit them.
## No Other Rights
These 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.
## Patent Defense
If 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.
## Violations
The 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.
## No Liability
***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.***
## Definitions
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
**You** refers to the individual or entity agreeing to these terms.
**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.
**Your licenses** are all the licenses granted to you for the software under these terms.
**Use** means anything you do with the software requiring one of your licenses.
================================================
FILE: README.md
================================================
# fStikBot
Telegram sticker bot. Make packs, copy packs, edit stickers, search a public catalog. Runs [@fStikBot](https://t.me/fStikBot).
## What it does
- Create and edit sticker, emoji and video packs
- Copy any existing pack into your own
- Inline search across a public catalog (plus Tenor GIFs)
- Frame, mosaic, round-video and background-removal tools
- Co-edit packs with other users
- Group-mode packs and boosts
- Admin panel, broadcasts, moderation (OpenAI)
## Stack
Node.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.
## Run it
```bash
git clone https://github.com/LyoSU/fStikBot.git
cd fStikBot
cp .env.example .env
cp config.example.json config.json
# fill in BOT_TOKEN and friends
docker compose up -d
```
Without Docker: install Node LTS, MongoDB and Redis, then `npm i && npm start`.
## Configuration
Two files:
- `.env` — runtime secrets (bot token, MTProto keys, MongoDB URI, Redis host, OpenAI key, Tenor key)
- `config.json` — non-secret app config (admin id, log chat, sticker link prefix, messaging limits)
Minimum 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).
## Scripts
```bash
npm start # run the bot
npm run lint # eslint
npm run lint:fix # eslint --fix
npm run banners:build # rebuild banner assets
```
Webhook mode turns on when `BOT_DOMAIN` is set. Otherwise the bot uses long polling.
## License
[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.
================================================
FILE: banners/DESIGN.md
================================================
# Banner Design System
Visual language for the hero banners that sit above `/start` and section
entry messages. Reference this doc when adding new banners or adjusting
existing ones — palette choices, type decisions, and pattern density are
all encoded here.
---
## 1. Concept
**fStikBot promo slides.** Each banner is one issue in a consistent series,
the same way Telegram's own promo banners (Premium, Stars, Business) share
one layout language and change only the colour/icon/title per product.
Three ingredients define every banner:
1. **Coloured gradient page** with a soft bottom-vignette for depth
2. **Doodle-pattern wallpaper** (Tabler Icons stroked white) tiled over the
gradient via `mix-blend-mode: soft-light`
3. **Left wordmark + right tile** composition — bold italic condensed
typography on the left, a rounded-square app-icon-style tile on the right
What makes the series recognisable is the **combination**: cohesive bold
italic type + doodle texture + tilted app-icon tile. Change any one and it
stops feeling like fStikBot.
---
## 2. Canvas
| Spec | Value | Why |
|---|---|---|
| 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. |
| Retina scale | 2× | Final PNG ships at 1920 × 720. Sharp on high-DPI devices, ~500–700 KB per file. |
| 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. |
---
## 3. Colour
### 3.1 Palette structure
Every banner defines **3 gradient stops** (lightest → mid → deepest) plus
**2 ink shadow values** (mid + deep) that are derived from the palette's
deepest colour at low opacity. Centralising this in three vars means each
banner file is ~4 lines of palette override.
```css
.page {
--sky-0: #5BAEEF; /* lightest — top-left of gradient */
--sky-1: #4693DA; /* mid — middle of gradient */
--sky-2: #2E7BC8; /* deepest — bottom-right, used as icon stroke */
--ink-shadow: rgba(10, 45, 95, 0.22); /* soft press shadow */
--ink-shadow-deep: rgba(10, 45, 95, 0.32); /* deep drop shadow */
}
```
### 3.2 Per-issue palettes
Colour is the primary signal for which section you're in. Palettes are
picked so adjacent sections in the flow contrast (welcome blue → catalog
teal doesn't feel samey) and so warm/cool alternate nicely across the set.
| Issue | Spot hue | Intent |
|---|---|---|
| `welcome` | sky blue `#2E7BC8` | Brand primary, matches marketplace promos |
| `packs` | indigo/violet `#3A3BAF` | Personal collection, inward/private energy |
| `catalog` | teal/mint `#0C8A78` | Discovery, freshness |
| `new-pack` | amber/orange `#D86820` | Creation, action, warm |
| `boost` | magenta/pink `#A62A6E` | High energy, promotion |
| `help` | green/sage `#2C8F46` | Calm, supportive |
| `donate` | gold/yellow `#C88617` | Appreciation, Stars-adjacent |
**Adjacent-section rule**: if you add a new banner, pick a hue ≥ 60° away
on the wheel from any banner it's reachable from. Keeps the transition
visible even in quick navigation.
### 3.3 Text / foreground
All wordmarks are white `#FFFFFF`. Taglines are `rgba(255,255,255,0.88)`.
This is intentional — coloured bg + white text reads as app promo; white
bg + coloured text would read as SaaS landing. Do not break this.
---
## 4. Typography
### 4.1 Font
**Barlow Condensed** (Google Fonts, OFL) — one family, two voices:
- **Wordmark**: 900 italic at 140 px, line-height 1, tracking −0.01em
- **Tagline**: 700 italic at 26 px UPPERCASE, tracking 0.04em
Why Barlow Condensed:
- Has proper italic designs (not slanted upright) — critical for the
App Store promo look
- Condensed fits bold big wordmarks without crowding
- Full Cyrillic coverage (needed when we localise later)
- Not overused by AI landing pages the way Inter / Unbounded are
**Don't use**: Inter, Unbounded, Space Grotesk, Poppins — they read as
generic AI output. Don't mix a second typeface in — one family, two
weights, two styles is the whole system.
### 4.2 Shadows (critical for legibility)
White text on coloured bg with a busy pattern underneath needs layered
shadow to lift off the surface:
```css
text-shadow:
0 3px 0 var(--ink-shadow), /* crisp press shadow — old-poster feel */
0 12px 24px var(--ink-shadow), /* soft drop — implies elevation */
0 0 36px rgba(255, 255, 255, 0.18); /* halo — ties text to the glossy tile */
```
The third layer (white halo) is what separates the S-tier polish from
flat-print. It visually links the wordmark to the glass tile on the right.
### 4.3 Descender clearance
Wordmark line-height is `1` and tag margin-top is `18 px`. This prevents
"g" / "p" / "y" descenders from touching the tagline. If you add a
wordmark with a descender on its last character and tag underneath, check
visually — nudge margin-top if needed.
---
## 5. Layout
```
┌───────────────────────────────────────────────────────┐
│ │
│ ┌─── .brand (left 56px, vcentered) │
│ │ ┌─── .tile (right 48px,
│ │ Wordmark │ vcentered,
│ │ TAGLINE · DOTS │ 230×230,
│ │ │ rotate 8deg)
│ │ │
│ └──────────── └────
│ │
└───────────────────────────────────────────────────────┘
960 × 360
```
- **Absolutely positioned**: both `.brand` and `.tile` use absolute
positioning with `top: 50%; transform: translateY(-50%)`. Keeps vertical
centre math trivial regardless of content length.
- **Max-width 620 px on .brand**: prevents long titles from overlapping
the tile area.
- **Tile rotates 8°** — signature "sticker peeled onto page" feel.
Always the same angle across all banners for consistency.
---
## 6. Pattern wallpaper
One SVG (`assets/pattern.svg`) is tiled across every banner. It's a
480×480 composition of 18 Tabler Icons (star, heart, sparkles, cloud,
leaf, bolt, music, gift, feather, ghost, mushroom) positioned at varied
angles, strokes white, licensed MIT.
**`soft-light` blend mode** lets the underlying palette tint through the
strokes — same asset reads differently against every hue without needing
re-export per colour.
### 6.1 Per-issue density tuning
Each banner overrides two vars:
```css
.page {
--pattern-size: 340px; /* smaller = denser */
--pattern-opacity: 0.48; /* higher = more visible */
}
```
| Issue | Size | Opacity | Intent |
|---|---|---|---|
| `welcome` | 340 px | 0.48 | Vibrant, "lots going on" |
| `packs` | 420 px | 0.36 | Subtle — focus is the collection |
| `catalog` | 320 px | 0.46 | Busy, "many discoveries" |
| `new-pack` | 460 px | 0.55 | Sparse + bold — creation space |
| `boost` | 300 px | 0.55 | Dense + loud — high energy |
| `help` | 520 px | 0.32 | Sparsest — calm, quiet |
| `donate` | 380 px | 0.42 | Balanced baseline |
Rule of thumb: denser pattern = higher energy. Quieter sections (help)
use larger tile + lower opacity; energetic sections (boost, new-pack) go
denser + more visible.
---
## 7. Tile (right-side hero)
Two variants, same position/size/tilt so the family feels cohesive.
### 7.1 `.tile--mascot` — the fStikBot app icon
Used on `welcome` only. The actual bot avatar (yellow star on blue-yellow
gradient) inside a rounded-square frame at 8° tilt. Treats the real brand
asset as the hero — no synthetic illustration.
### 7.2 `.tile--icon` — section glyph on a glass tile
Used on every other banner. A white rounded-square with:
- Subtle tonal gradient (`#FFFFFF` → `#EFF3F9` at 100%) — adds depth so
it doesn't read as flat paper
- Glossy top-half highlight (`.tile--icon::before`) — curved gradient
fading to transparent, masks as an enamel/glass app icon would catch
overhead light
- Tabler icon inside at 134 px, stroke `var(--sky-2)` — icon takes the
banner's deepest palette colour so it connects to the bg
### 7.3 Picking an icon
Every section icon comes from Tabler Icons (outline set, MIT licensed).
When adding a new banner:
1. Pick an icon that directly represents the section's *verb* (what the
user does there), not a decoration of its *noun*. `search` for catalog
(user searches), not `book` (which would decorate "catalog" as a
concept).
2. Paste the path data from `https://tabler.io/icons/icon/<name>` into
the banner's `.tile--icon svg` slot.
3. Keep stroke-width at 2.2 — matches the wordmark's visual weight.
**Current icon choices:**
| Issue | Icon | Why |
|---|---|---|
| `welcome` | mascot PNG | Real brand |
| `packs` | `stack-2` | Three horizontal layers = stacked sticker packs |
| `catalog` | `search` | Direct verb — "find packs" |
| `new-pack` | `sparkles` | Creation/magic hint, more interesting than a plus |
| `boost` | `bolt` | Energy/reach — common promotion metaphor |
| `help` | `help-circle` | Universal — question in circle |
| `donate` | `heart` | Universal — appreciation |
---
## 8. What NOT to put on banners
Things we explicitly rejected during design, listed here so we don't drift
back to them:
- **Personalised text** ("Hi, Yuri!" "You have 12 packs") — kills the
file_id cache, forces per-user render. Put dynamic state in the message
caption below the banner.
- **Slogans** ("Create magic from every moment!") — read as AI-slop.
Subtitles stay functional: section title + at most one short tagline.
- **Multiple decorative SVGs** (ribbons, registration marks, washi tape,
starbursts, etc.) — tried during early iteration, felt fussy. One
strong visual element (tile) beats ten small ones.
- **Pastel gradient with centered title + 3 overlapping rounded cards** —
the textbook AI-generated hero. Banned.
- **Kicker labels** with a coloured dot ("● TELEGRAM · STICKER BOT") —
Vercel / Linear cliché.
- **Gradient words** (`f` in blue, rest in white) — early attempt, reads
as tired SaaS.
- **Emoji characters** in SVG/type — font rendering is unreliable across
librsvg / Puppeteer / Chromium versions. Use Tabler paths instead.
---
## 9. Adding a new banner
1. `cp src/help.html src/<name>.html` — pick the existing banner closest
in tone to what you're making.
2. Update the `.page` block:
- 3 palette stops (use tools like [huemint](https://huemint.com/) to
pick a palette ≥60° from adjacent banners)
- 2 `--ink-shadow*` values derived from the deepest palette colour at
0.22 / 0.32 opacity
- `--pattern-size` and `--pattern-opacity` matched to the section's
energy (see 6.1)
3. Change `.brand__name` to the section title (one or two words max) and
`.brand__tag` to a factual sub-line (no slogans — see §8).
4. Swap the `<svg viewBox="0 0 24 24">…</svg>` inside `.tile--icon` with
a new Tabler icon's paths.
5. Add `{ name: '<name>', file: '<name>.html' }` to the `BANNERS` array in
`build.js`.
6. `npm run banners:build` → verify `dist/<name>.png` visually.
7. Commit both the `src/<name>.html` and `dist/<name>.png`.
8. Wire it up in the relevant handler using `sendBanner` / `editBanner` /
`replyOrEditBanner` from `banners/index.js`.
---
## 10. Future extensions
Design-space decisions deferred for later:
- **Per-locale titles** — 7 banners × 3 locales = 21 PNGs (~10 MB). Would
require a per-locale output dir and a lookup in `sendBanner`. Skipped
at v1, worth doing when a non-English market actually matters.
- **Seasonal variants** — swap `welcome.png` → `welcome-holiday.png` on a
date range. `sendBanner` wouldn't need to change; the build script
would pick a variant.
- **Stats footer on welcome** (e.g. "14M+ packs created") — doesn't break
file_id caching because the banner stays static between rebuilds. Would
require a build-time DB query to avoid fabricated numbers.
- **Transparent mascot** — current mascot carries its own blue-yellow
square bg. On welcome it reads fine as an "app icon". If we ever want
the mascot "peeking" from behind a tile edge, we'd need a transparent
PNG.
================================================
FILE: banners/README.md
================================================
# Banners
Build-time generated hero banners that sit above `/start` and section entry
messages. Built from HTML+CSS with Puppeteer, shipped as PNGs in `dist/`,
uploaded to Telegram on first send, then reused by `file_id`.
## Layout
```
banners/
├── src/ # templates (dev)
│ ├── _system.css # shared design system — palette, pattern, wordmark
│ ├── welcome.html # /start
│ ├── catalog.html # search_catalog
│ ├── new-pack.html # (available, not yet wired)
│ └── assets/
│ ├── mascot.jpg # fStikBot app icon
│ └── pattern.svg # doodle wallpaper (Tabler Icons, MIT)
├── dist/ # committed PNG output
│ └── *.png # 2400×800 (retina), ship these
├── build.js # Puppeteer → PNG export
└── index.js # bot runtime: sendBanner / editBanner / editMenu
```
## Adding a new banner
1. `cp src/welcome.html src/<name>.html`
2. Change the `.page` palette vars (3 gradient stops + 2 shadow colors) and
the `.brand__name` / `.brand__tag` copy.
3. Add `{ name: '<name>', file: '<name>.html' }` to `BANNERS` in `build.js`.
4. `npm run banners:build` → check `dist/<name>.png`.
5. Commit both `src/<name>.html` and `dist/<name>.png`.
To iterate on design: open the HTML file directly in a browser. Tweak CSS,
refresh. Run `npm run banners:build` only when ready to export.
## Using in handlers
```js
const { sendBanner, editBanner, editMenu } = require('../banners')
// Fresh send (from /command or plain message)
await sendBanner(ctx, 'welcome', captionHTML, {
reply_markup: Markup.inlineKeyboard(keyboard)
})
// Navigate between different banners (swap media + caption)
await editBanner(ctx, 'catalog', captionHTML, {
reply_markup: Markup.inlineKeyboard(keyboard)
})
// Stay on same banner, just update text/keyboard
await editMenu(ctx, captionHTML, {
reply_markup: Markup.inlineKeyboard(keyboard)
})
```
## Caching
First `sendBanner` reads the PNG from disk → Telegram returns a `file_id` →
we cache it in RAM keyed by `{name}:{mtimeMs}`. Subsequent sends reuse the
`file_id` string — no file transfer, served from Telegram's CDN.
Cache wipes on process restart; one re-upload per banner per deploy is
acceptable overhead. Rebuilding a PNG changes its mtime → new cache key →
automatic invalidation, no manual bust.
## Telegram edit-API note
Telegram allows **text → text+media** (`editMessageMedia`), but NOT the
reverse. Once a message is a photo, it stays a photo; you can only swap the
photo, caption, or buttons. Helpers respect this:
- `editBanner` — uses `editMessageMedia`, works from either text or photo
source.
- `editMenu` — auto-picks `editMessageCaption` (photo source) or
`editMessageText` (text source) to update text without touching the banner.
================================================
FILE: banners/build.js
================================================
#!/usr/bin/env node
/**
* Banner build script.
* Reads HTML templates from banners/src/ and exports high-DPI PNG renders
* into banners/dist/. Run: `npm run banners:build` (or `node banners/build.js`).
*
* Puppeteer is a devDependency — the resulting PNGs are what the bot ships,
* so production Docker never touches a browser.
*/
const fs = require('fs')
const path = require('path')
let puppeteer
try {
puppeteer = require('puppeteer')
} catch (err) {
console.error('\n[banners] puppeteer is not installed.')
console.error('Run once: npm install --save-dev puppeteer\n')
process.exit(1)
}
const WIDTH = 960
const HEIGHT = 360
const SCALE = 2 // retina output → 1920×720, looks crisp on high-DPI devices
const SRC = path.join(__dirname, 'src')
const DIST = path.join(__dirname, 'dist')
// Banners to build. Add a new entry when you create a new template.
// `name` becomes the output filename (dist/<name>.png).
// `width`/`height` override the defaults for banners that ship at different
// aspect ratios (e.g. Telegram's description picture is 640×360, not 960×360).
const BANNERS = [
{ name: 'welcome', file: 'welcome.html' },
{ name: 'packs', file: 'packs.html' },
{ name: 'catalog', file: 'catalog.html' },
{ name: 'new-pack', file: 'new-pack.html' },
{ name: 'boost', file: 'boost.html' },
{ name: 'help', file: 'help.html' },
{ name: 'donate', file: 'donate.html' },
{ name: 'origin', file: 'origin.html' },
{ name: 'publish', file: 'publish.html' },
{ name: 'language', file: 'language.html' },
{ name: 'emoji', file: 'emoji.html' },
{ name: 'group', file: 'group.html' },
{ name: 'mosaic', file: 'mosaic.html' },
{ name: 'description', file: 'description.html', width: 640, height: 360 }
]
async function main () {
fs.mkdirSync(DIST, { recursive: true })
const browser = await puppeteer.launch({
defaultViewport: { width: WIDTH, height: HEIGHT, deviceScaleFactor: SCALE }
})
try {
for (const { name, file, width, height } of BANNERS) {
const src = path.join(SRC, file)
if (!fs.existsSync(src)) {
console.warn(`[banners] skip ${name}: ${file} not found`)
continue
}
const w = width || WIDTH
const h = height || HEIGHT
const page = await browser.newPage()
await page.setViewport({ width: w, height: h, deviceScaleFactor: SCALE })
const url = 'file://' + src
const missing = []
page.on('requestfailed', req => {
const u = req.url()
if (u.startsWith('file://') && !u.endsWith('.html')) missing.push(u)
})
await page.goto(url, { waitUntil: 'networkidle0' })
// Wait for web fonts (Google Fonts via <link>) to actually load before shot.
await page.evaluate(() => document.fonts.ready)
if (missing.length) {
console.warn(`[banners] ⚠ ${name} is missing local assets:`)
missing.forEach(u => console.warn(' ' + u.replace('file://', '')))
}
const out = path.join(DIST, `${name}.png`)
await page.screenshot({
path: out,
clip: { x: 0, y: 0, width: w, height: h },
omitBackground: false
})
await page.close()
const bytes = fs.statSync(out).size
console.log(`[banners] ✓ ${name.padEnd(10)} → ${path.relative(process.cwd(), out)} (${(bytes / 1024).toFixed(1)} KB)`)
}
} finally {
await browser.close()
}
}
main().catch(err => { console.error(err); process.exit(1) })
================================================
FILE: banners/index.js
================================================
// Banner runtime helpers.
//
// Flow: first call uploads the PNG from disk, Telegram returns a file_id,
// we cache it in RAM keyed by {name}:{mtimeMs}. Every subsequent call uses
// the cached file_id — Telegram serves it from its own CDN, no file transfer.
// Cache wipes on restart (one re-upload per banner per deploy). Cache key
// includes mtime, so rebuilding banners/dist/*.png auto-invalidates without
// any manual bust.
//
// Why RAM not Redis: banners are a tiny number (~3–10), file_ids are short
// strings, losing cache on restart costs one re-upload per banner — Redis
// complexity isn't worth it here.
//
// Navigation note: Telegram allows editing a text message INTO a media
// message via editMessageMedia, but NOT the reverse. So once /start sends
// a banner (photo + caption + keyboard), subsequent navigation within that
// message stays media-based forever — we use editMessageCaption to change
// only the text/keyboard (banner unchanged), or editMessageMedia to swap
// to a different banner.
const fs = require('fs')
const path = require('path')
const DIST = path.join(__dirname, 'dist')
const cache = new Map()
function resolveBanner (name) {
const file = path.join(DIST, `${name}.png`)
if (!fs.existsSync(file)) return null
const { mtimeMs } = fs.statSync(file)
return { file, cacheKey: `${name}:${Math.floor(mtimeMs)}` }
}
function photoInput (banner) {
return cache.get(banner.cacheKey) || { source: fs.createReadStream(banner.file) }
}
function rememberFileId (banner, message) {
const photos = message?.photo
if (!photos?.length) return
// Largest size — Telegram reuses this file_id across all size requests
cache.set(banner.cacheKey, photos[photos.length - 1].file_id)
}
function assertBanner (name) {
const b = resolveBanner(name)
if (!b) throw new Error(`[banners] missing dist/${name}.png — run: node banners/build.js`)
return b
}
// First-time send (from a /command or plain message trigger).
async function sendBanner (ctx, name, caption = '', extra = {}) {
const banner = assertBanner(name)
const msg = await ctx.replyWithPhoto(photoInput(banner), {
caption,
parse_mode: 'HTML',
...extra
})
rememberFileId(banner, msg)
return msg
}
// Swap the current message's banner (use when navigating between *different*
// banners: e.g. /start welcome → catalog). Works whether the prior message
// was text (upgrades it) or already a photo (replaces the media).
//
// Single-edit guarantee: we send photo + caption + keyboard in ONE API call.
// Telegraf 3.40 serializes `ctx.editMessageMedia(media, extra)` correctly —
// `caption`/`parse_mode` ride inside the InputMedia JSON, `reply_markup` is
// top-level (see node_modules/telegraf/telegram.js:316). So no keyboard-less
// flash between calls, which used to cause visible flicker on navigation.
//
// Same-banner fast path: if the message already shows this exact banner
// (cached file_id matches the largest PhotoSize), skip the media swap and
// do a caption-only edit. That's the pagination case (e.g. packs:N → N+1)
// where Telegram would otherwise re-render the identical photo and briefly
// drop the keyboard.
async function editBanner (ctx, name, caption = '', extra = {}) {
const banner = assertBanner(name)
const msg = ctx.callbackQuery?.message
const currentFileId = msg?.photo?.[msg.photo.length - 1]?.file_id
const cachedFileId = cache.get(banner.cacheKey)
if (currentFileId && cachedFileId && currentFileId === cachedFileId) {
try {
return await ctx.editMessageCaption(caption, {
parse_mode: 'HTML',
reply_markup: extra.reply_markup
})
} catch (err) {
// MESSAGE_NOT_MODIFIED is benign; anything else falls through to a
// full media edit (and ultimately sendBanner) below.
if (err?.description?.includes('message is not modified')) return
}
}
const source = photoInput(banner)
const media = {
type: 'photo',
media: typeof source === 'string' ? source : { source: fs.createReadStream(banner.file) },
caption,
parse_mode: 'HTML'
}
try {
const edited = await ctx.editMessageMedia(media, { reply_markup: extra.reply_markup })
if (edited && typeof edited === 'object') rememberFileId(banner, edited)
return edited
} catch (err) {
// Message too old / not editable — fall back to a fresh send so the user
// still sees something rather than a silent no-op.
return sendBanner(ctx, name, caption, extra)
}
}
// In-place text/keyboard edit without touching the banner. Use when the user
// navigates WITHIN the same banner section (e.g. paging through packs).
// Auto-picks editMessageCaption (if current message is a photo) or
// editMessageText (if it's still plain text) — this keeps legacy text-only
// flows working while photo-based flows just work too.
async function editMenu (ctx, text, extra = {}) {
const msg = ctx.callbackQuery?.message
const isPhoto = !!(msg && msg.photo)
const opts = { parse_mode: 'HTML', ...extra }
try {
if (isPhoto) {
return await ctx.editMessageCaption(text, opts)
}
return await ctx.editMessageText(text, opts)
} catch (err) {
// benign: message-not-modified / message-to-edit-not-found
}
}
// Convenience: pick sendBanner vs editBanner by trigger type. Use in handlers
// that can be reached both as a command and as a callback from another menu.
async function replyOrEditBanner (ctx, name, caption = '', extra = {}) {
if (ctx.callbackQuery) return editBanner(ctx, name, caption, extra)
return sendBanner(ctx, name, caption, extra)
}
module.exports = { sendBanner, editBanner, editMenu, replyOrEditBanner }
================================================
FILE: banners/src/_system.css
================================================
/* ==========================================================================
fStikBot · Banner design system
Shared base across every banner. Each banner overrides only the palette
(--sky-0/1/2), pattern density (--pattern-size / --pattern-opacity), and
its title text. Composition, typography, tile treatment stay consistent
so the series reads as one family.
========================================================================== */
:root {
--w: 960px;
--h: 360px;
/* Default palette — overridden per banner */
--sky-0: #5BAEEF;
--sky-1: #4693DA;
--sky-2: #2E7BC8;
--fg: #FFFFFF;
--fg-dim: rgba(255, 255, 255, 0.88);
/* Ink shadows — overridden per palette */
--ink-shadow: rgba(10, 45, 95, 0.22);
--ink-shadow-deep: rgba(10, 45, 95, 0.32);
/* Pattern tuning — per banner, gives each section its own texture energy */
--pattern-size: 380px;
--pattern-opacity: 0.42;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: var(--w);
height: var(--h);
font-family: 'Barlow Condensed', -apple-system, system-ui, sans-serif;
color: var(--fg);
-webkit-font-smoothing: antialiased;
text-rendering: geometricPrecision;
}
/* ---- Background ----------------------------------------------------
Layered: base gradient + soft highlights at top-right / bottom-left +
a subtle bottom-corner vignette that darkens toward the edges. Gives
the banner a "lit from above-center" feel instead of flat print. */
.page {
position: relative;
width: var(--w);
height: var(--h);
overflow: hidden;
background:
radial-gradient(900px 500px at 20% 120%, rgba(255, 255, 255, 0.20) 0%, transparent 55%),
radial-gradient(700px 400px at 90% 0%, rgba(255, 255, 255, 0.14) 0%, transparent 55%),
radial-gradient(600px 260px at 0% 100%, rgba(0, 0, 0, 0.16) 0%, transparent 60%),
radial-gradient(600px 260px at 100% 100%, rgba(0, 0, 0, 0.14) 0%, transparent 60%),
linear-gradient(165deg, var(--sky-0) 0%, var(--sky-1) 50%, var(--sky-2) 100%);
}
/* Doodle pattern overlay — 480×480 SVG tile, Tabler Icons stroked white.
Soft-light blend lets the underlying gradient tint through. Size and
opacity are tunable per banner so each section has its own density. */
.pattern {
position: absolute;
inset: 0;
background-image: url("./assets/pattern.svg");
background-size: var(--pattern-size) var(--pattern-size);
background-repeat: repeat;
opacity: var(--pattern-opacity);
mix-blend-mode: soft-light;
pointer-events: none;
}
/* ---- Wordmark block ------------------------------------------------- */
.brand {
position: absolute;
left: 56px;
top: 50%;
transform: translateY(-50%);
max-width: 620px;
}
.brand__name {
font-family: 'Barlow Condensed', sans-serif;
font-weight: 900;
font-style: italic;
font-size: 140px;
line-height: 1;
letter-spacing: -0.01em;
color: var(--fg);
text-shadow:
0 3px 0 var(--ink-shadow),
0 12px 24px var(--ink-shadow),
0 0 36px rgba(255, 255, 255, 0.18); /* soft halo — lifts text off the pattern */
margin-left: -4px;
}
.brand__tag {
margin-top: 18px;
font-family: 'Barlow Condensed', sans-serif;
font-weight: 700;
font-style: italic;
font-size: 26px;
line-height: 1;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--fg-dim);
text-shadow: 0 2px 0 var(--ink-shadow);
}
/* ---- Tile (right-side hero element) --------------------------------
.tile--mascot → the fStikBot app icon (welcome only)
.tile--icon → white app-icon-style tile with a section glyph
-------------------------------------------------------------------- */
.tile {
position: absolute;
right: 48px;
top: 50%;
width: 230px;
height: 230px;
transform: translateY(-50%) rotate(8deg);
border-radius: 50px;
overflow: hidden;
box-shadow:
0 26px 36px var(--ink-shadow-deep),
0 10px 18px var(--ink-shadow),
inset 0 0 0 1px rgba(255, 255, 255, 0.28),
inset 0 -3px 0 rgba(0, 0, 0, 0.07);
}
.tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Icon tile — off-white with a subtle tonal gradient, plus a glossy
highlight across the top half (like a glass/enamel app icon).
Mascot tile skips both so the star isn't washed out. */
.tile--icon {
background: linear-gradient(180deg, #FFFFFF 0%, #FFFFFF 55%, #EFF3F9 100%);
display: flex;
align-items: center;
justify-content: center;
}
.tile--icon::before {
content: "";
position: absolute;
top: 4px; left: 4px; right: 4px;
height: 48%;
border-radius: 46px 46px 50% 50% / 46px 46px 100% 100%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.85) 0%, rgba(255, 255, 255, 0) 100%);
opacity: 0.55;
pointer-events: none;
}
.tile--icon svg {
width: 134px;
height: 134px;
stroke: var(--sky-2);
fill: none;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 3px 0 rgba(10, 45, 95, 0.08));
position: relative;
z-index: 1;
}
/* ---- Stats footer (optional, used on welcome) ---------------------
Factual numbers baked into the PNG. Update copy + rebuild on
milestones (every few months). Subtle tracking + small size so it
doesn't compete with the wordmark. */
.stats {
position: absolute;
left: 56px;
right: 48px;
bottom: 22px;
display: flex;
gap: 10px;
align-items: center;
font-family: 'Barlow Condensed', sans-serif;
font-weight: 700;
font-style: italic;
font-size: 19px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--fg-dim);
text-shadow: 0 1px 0 var(--ink-shadow);
}
.stats b {
font-weight: 900;
color: var(--fg);
}
.stats__dot {
width: 5px; height: 5px;
border-radius: 50%;
background: var(--fg);
opacity: 0.5;
}
================================================
FILE: banners/src/assets/README.md
================================================
# Banner assets
Drop brand imagery here. Referenced from templates as `./assets/<file>`.
## Required
- **`mascot.png`** — the fStikBot yellow-star icon, ≥512×512, ideally
transparent background (square with built-in bg also works — the template
masks it to a rounded shape).
Shortcut to save from macOS clipboard:
```
pngpaste banners/src/assets/mascot.png # brew install pngpaste
```
Or simply drag-drop the PNG into this folder in Finder / your IDE.
================================================
FILE: banners/src/boost.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>fStikBot · Boost</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" href="./_system.css">
<style>
/* Boost issue — magenta/pink. Energetic, signals promotion. */
.page {
--sky-0: #F56BB0;
--sky-1: #D4458F;
--sky-2: #A62A6E;
--ink-shadow: rgba(80, 20, 55, 0.22);
--ink-shadow-deep: rgba(80, 20, 55, 0.32);
--pattern-size: 300px;
--pattern-opacity: 0.55;
}
</style>
</head>
<body>
<div class="page">
<div class="pattern"></div>
<div class="brand">
<h1 class="brand__name">Boost</h1>
<p class="brand__tag">promote your pack</p>
</div>
<div class="tile tile--icon">
<svg viewBox="0 0 24 24">
<path d="M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11"/>
</svg>
</div>
</div>
</body>
</html>
================================================
FILE: banners/src/catalog.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>fStikBot · Catalog</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" href="./_system.css">
<style>
/* Catalog issue — teal/mint palette. Distinguishes "discovery" from
"welcome" while staying within the same brand temperature range. */
.page {
--sky-0: #3FC5B0;
--sky-1: #1FA793;
--sky-2: #0C8A78;
--ink-shadow: rgba(8, 60, 50, 0.22);
--ink-shadow-deep: rgba(8, 60, 50, 0.32);
--pattern-size: 320px;
--pattern-opacity: 0.46;
}
</style>
</head>
<body>
<div class="page">
<div class="pattern"></div>
<div class="brand">
<h1 class="brand__name">Catalog</h1>
<p class="brand__tag">discover sticker packs</p>
</div>
<div class="tile tile--icon">
<svg viewBox="0 0 24 24">
<path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/>
<path d="M21 21l-6 -6"/>
</svg>
</div>
</div>
</body>
</html>
================================================
FILE: banners/src/description.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>fStikBot · Description</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" href="./_system.css">
<style>
/* Description picture — 640×360, shown in Telegram's "What can this bot do?"
block when a user opens the chat with the bot for the first time.
Same palette + mascot as welcome, re-flowed for the narrower canvas. */
:root { --w: 640px; }
.page {
--sky-0: #5BAEEF; --sky-1: #4693DA; --sky-2: #2E7BC8;
--ink-shadow: rgba(10, 45, 95, 0.22);
--ink-shadow-deep: rgba(10, 45, 95, 0.32);
--pattern-size: 260px;
--pattern-opacity: 0.46;
}
/* 640 wide gives ~380px of runway for the wordmark once the mascot tile
claims the right side — shrink text + tile proportionally. */
.brand { left: 44px; max-width: 380px; }
.brand__name { font-size: 104px; }
.brand__tag { margin-top: 14px; font-size: 22px; }
.tile--mascot { right: 40px; width: 200px; height: 200px; border-radius: 44px; }
.stats { left: 44px; right: 40px; bottom: 20px; font-size: 16px; gap: 8px; }
</style>
</head>
<body>
<div class="page">
<div class="pattern"></div>
<div class="brand">
<h1 class="brand__name">fStikBot</h1>
<p class="brand__tag">stickers · emoji · packs</p>
</div>
<div class="tile tile--mascot"><img src="./assets/mascot.jpg" alt=""></div>
<div class="stats">
<span><b>400M+</b> stickers</span>
<span class="stats__dot"></span>
<span><b>30M+</b> packs</span>
</div>
</div>
</body>
</html>
================================================
FILE: banners/src/donate.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>fStikBot · Support</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" href="./_system.css">
<style>
/* Donate issue — warm gold. Appreciation, Telegram Stars adjacent. */
.page {
--sky-0: #FFD85C;
--sky-1: #F2AE2C;
--sky-2: #C88617;
--ink-shadow: rgba(85, 55, 5, 0.22);
--ink-shadow-deep: rgba(85, 55, 5, 0.32);
--pattern-size: 380px;
--pattern-opacity: 0.42;
}
</style>
</head>
<body>
<div class="page">
<div class="pattern"></div>
<div class="brand">
<h1 class="brand__name">Support</h1>
<p class="brand__tag">keep fStikBot alive</p>
</div>
<div class="tile tile--icon">
<svg viewBox="0 0 24 24">
<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"/>
</svg>
</div>
</div>
</body>
</html>
================================================
FILE: banners/src/emoji.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>fStikBot · Emoji</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" href="./_system.css">
<style>
/* Emoji issue — lime. Playful, matches the joyful nature of custom emoji. */
.page {
--sky-0: #BEF264;
--sky-1: #84CC16;
--sky-2: #4D7C0F;
--ink-shadow: rgba(25, 55, 5, 0.22);
--ink-shadow-deep: rgba(25, 55, 5, 0.32);
--pattern-size: 360px;
--pattern-opacity: 0.44;
}
</style>
</head>
<body>
<div class="page">
<div class="pattern"></div>
<div class="brand">
<h1 class="brand__name">Emoji</h1>
<p class="brand__tag">your own custom emoji</p>
</div>
<div class="tile tile--icon">
<svg viewBox="0 0 24 24">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/>
<path d="M9 10l.01 0"/>
<path d="M15 10l.01 0"/>
<path d="M9.5 15a3.5 3.5 0 0 0 5 0"/>
</svg>
</div>
</div>
</body>
</html>
================================================
FILE: banners/src/group.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>fStikBot · Group</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" href="./_system.css">
<style>
/* Group issue — slate blue-gray. Neutral, admin/settings tone. */
.page {
--sky-0: #94A3B8;
--sky-1: #475569;
--sky-2: #1E293B;
--ink-shadow: rgba(10, 15, 25, 0.28);
--ink-shadow-deep: rgba(10, 15, 25, 0.38);
--pattern-size: 440px;
--pattern-opacity: 0.30;
}
</style>
</head>
<body>
<div class="page">
<div class="pattern"></div>
<div class="brand">
<h1 class="brand__name">Group</h1>
<p class="brand__tag">shared pack for the chat</p>
</div>
<div class="tile tile--icon">
<svg viewBox="0 0 24 24">
<path d="M10 13a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"/>
<path d="M8 21v-1a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v1"/>
<path d="M15 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"/>
<path d="M17 10h2a2 2 0 0 1 2 2v1"/>
<path d="M5 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"/>
<path d="M3 13v-1a2 2 0 0 1 2 -2h2"/>
</svg>
</div>
</div>
</body>
</html>
================================================
FILE: banners/src/help.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>fStikBot · Help</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" href="./_system.css">
<style>
/* Help issue — mint/sage. Calm, supportive tone. */
.page {
--sky-0: #8CD48B;
--sky-1: #4FB464;
--sky-2: #2C8F46;
--ink-shadow: rgba(10, 60, 30, 0.22);
--ink-shadow-deep: rgba(10, 60, 30, 0.32);
--pattern-size: 520px;
--pattern-opacity: 0.32;
}
</style>
</head>
<body>
<div class="page">
<div class="pattern"></div>
<div class="brand">
<h1 class="brand__name">Help</h1>
<p class="brand__tag">guides & commands</p>
</div>
<div class="tile tile--icon">
<svg viewBox="0 0 24 24">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/>
<path d="M12 16v.01"/>
<path d="M12 13a2 2 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"/>
</svg>
</div>
</div>
</body>
</html>
================================================
FILE: banners/src/language.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>fStikBot · Language</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" href="./_system.css">
<style>
/* Language issue — cyan. Fresh, communicative, distinct from welcome blue. */
.page {
--sky-0: #67E8F9;
--sky-1: #0891B2;
--sky-2: #164E63;
--ink-shadow: rgba(5, 45, 65, 0.22);
--ink-shadow-deep: rgba(5, 45, 65, 0.32);
--pattern-size: 420px;
--pattern-opacity: 0.36;
}
</style>
</head>
<body>
<div class="page">
<div class="pattern"></div>
<div class="brand">
<h1 class="brand__name">Language</h1>
<p class="brand__tag">pick your language</p>
</div>
<div class="tile tile--icon">
<svg viewBox="0 0 24 24">
<path d="M9 6.371c0 4.418 -2.239 6.629 -5 6.629"/>
<path d="M4 6.371h7"/>
<path d="M5 9c0 2.144 2.252 3.908 6 4"/>
<path d="M12 20l4 -9l4 9"/>
<path d="M19.1 18h-6.2"/>
<path d="M6.694 3l.793 .582"/>
</svg>
</div>
</div>
</body>
</html>
================================================
FILE: banners/src/mosaic.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>fStikBot · Mosaic</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" href="./_system.css">
<style>
/* Mosaic issue — fuchsia. Bold, creative, stands apart from boost magenta. */
.page {
--sky-0: #F0ABFC;
--sky-1: #C026D3;
--sky-2: #701A75;
--ink-shadow: rgba(65, 10, 75, 0.22);
--ink-shadow-deep: rgba(65, 10, 75, 0.32);
--pattern-size: 320px;
--pattern-opacity: 0.50;
}
</style>
</head>
<body>
<div class="page">
<div class="pattern"></div>
<div class="brand">
<h1 class="brand__name">Mosaic</h1>
<p class="brand__tag">split image into emoji tiles</p>
</div>
<div class="tile tile--icon">
<svg viewBox="0 0 24 24">
<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"/>
<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"/>
<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"/>
<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"/>
</svg>
</div>
</div>
</body>
</html>
================================================
FILE: banners/src/new-pack.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>fStikBot · New pack</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" href="./_system.css">
<style>
/* New pack issue — warm amber, signals creation / action. */
.page {
--sky-0: #F7B94A;
--sky-1: #EE8F2A;
--sky-2: #D86820;
--ink-shadow: rgba(90, 45, 5, 0.22);
--ink-shadow-deep: rgba(90, 45, 5, 0.32);
--pattern-size: 460px;
--pattern-opacity: 0.55;
}
</style>
</head>
<body>
<div class="page">
<div class="pattern"></div>
<div class="brand">
<h1 class="brand__name">New pack</h1>
<p class="brand__tag">turn anything into stickers</p>
</div>
<div class="tile tile--icon">
<svg viewBox="0 0 24 24">
<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"/>
</svg>
</div>
</div>
</body>
</html>
================================================
FILE: banners/src/origin.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>fStikBot · Origin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" href="./_system.css">
<style>
/* Identify issue — deep violet. "Find where this sticker came from." */
.page {
--sky-0: #A78BFA;
--sky-1: #7C4EE4;
--sky-2: #5B21B6;
--ink-shadow: rgba(40, 10, 90, 0.22);
--ink-shadow-deep: rgba(40, 10, 90, 0.32);
--pattern-size: 380px;
--pattern-opacity: 0.40;
}
</style>
</head>
<body>
<div class="page">
<div class="pattern"></div>
<div class="brand">
<h1 class="brand__name">Origin</h1>
<p class="brand__tag">original file & author</p>
</div>
<div class="tile tile--icon">
<svg viewBox="0 0 24 24">
<path d="M14 3v4a1 1 0 0 0 1 1h4"/>
<path d="M12 21h-5a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v4.5"/>
<path d="M14 17.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0"/>
<path d="M18.5 19.5l2.5 2.5"/>
</svg>
</div>
</div>
</body>
</html>
================================================
FILE: banners/src/packs.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>fStikBot · My Packs</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" href="./_system.css">
<style>
/* Packs issue — indigo/violet. Reads as "personal collection". */
.page {
--sky-0: #7A7BE8;
--sky-1: #5758D4;
--sky-2: #3A3BAF;
--ink-shadow: rgba(25, 20, 80, 0.22);
--ink-shadow-deep: rgba(25, 20, 80, 0.32);
--pattern-size: 420px;
--pattern-opacity: 0.36;
}
</style>
</head>
<body>
<div class="page">
<div class="pattern"></div>
<div class="brand">
<h1 class="brand__name">My packs</h1>
<p class="brand__tag">your sticker collection</p>
</div>
<div class="tile tile--icon">
<svg viewBox="0 0 24 24">
<path d="M12 4l-8 4l8 4l8 -4l-8 -4"/>
<path d="M4 12l8 4l8 -4"/>
<path d="M4 16l8 4l8 -4"/>
</svg>
</div>
</div>
</body>
</html>
================================================
FILE: banners/src/publish.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>fStikBot · Publish</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" href="./_system.css">
<style>
/* Publish issue — coral/tomato. Bold, "share it with the world." */
.page {
--sky-0: #FB7185;
--sky-1: #E11D48;
--sky-2: #9F1239;
--ink-shadow: rgba(80, 10, 30, 0.22);
--ink-shadow-deep: rgba(80, 10, 30, 0.32);
--pattern-size: 360px;
--pattern-opacity: 0.46;
}
</style>
</head>
<body>
<div class="page">
<div class="pattern"></div>
<div class="brand">
<h1 class="brand__name">Publish</h1>
<p class="brand__tag">share your pack in the catalog</p>
</div>
<div class="tile tile--icon">
<svg viewBox="0 0 24 24">
<path d="M10 14l11 -11"/>
<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"/>
</svg>
</div>
</div>
</body>
</html>
================================================
FILE: banners/src/welcome.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>fStikBot · Welcome</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" href="./_system.css">
<style>
/* Welcome issue — sky-blue palette, the same the marketplace promos use. */
.page {
--sky-0: #5BAEEF; --sky-1: #4693DA; --sky-2: #2E7BC8;
--ink-shadow: rgba(10, 45, 95, 0.22);
--ink-shadow-deep: rgba(10, 45, 95, 0.32);
--pattern-size: 340px;
--pattern-opacity: 0.48;
}
</style>
</head>
<body>
<div class="page">
<div class="pattern"></div>
<div class="brand">
<h1 class="brand__name">fStikBot</h1>
<p class="brand__tag">stickers · emoji · packs</p>
</div>
<div class="tile tile--mascot"><img src="./assets/mascot.jpg" alt=""></div>
<div class="stats">
<span><b>400M+</b> stickers</span>
<span class="stats__dot"></span>
<span><b>30M+</b> packs</span>
<span class="stats__dot"></span>
<span>since 2017</span>
</div>
</div>
</body>
</html>
================================================
FILE: bot/commands.js
================================================
// All bot commands, actions, and hears. Preserves the exact registration
// order from the original bot.js — order matters for the
// addstickers/addemoji restore→copy chain and for /start payload routing.
const Composer = require('telegraf/composer')
const sendStickerAsDocument = require('../utils/send-sticker-as-document')
module.exports = (bot, privateMessage, {
handlers,
limitPublicPack,
privacyHtml,
db,
scenes
}) => {
const {
handleStats,
handlePing,
handleStart,
handleHelp,
handleDonate,
handleSticker,
handleDeleteSticker,
handleRestoreSticker,
handlePacks,
handleSelectPack,
handleSelectGroupPack,
handleHidePack,
handleRestorePack,
handleBoostPack,
handleCatalog,
handleSearchCatalog,
handleCopyPack,
handleCoedit,
handleLanguage,
handleEmoji,
handleStickerUpdate,
handleInlineQuery,
handleGroupSettings
} = handlers
// --- Admin-only /json dump ---
// Used to be public; now gated to the main admin to avoid leaking arbitrary
// message payloads (forwarded chats can carry sensitive content).
bot.command('json', Composer.privateChat((ctx) => {
if (ctx.config.mainAdminId !== ctx.from.id) return
return ctx.replyWithHTML('<code>' + JSON.stringify(ctx.message, null, 2) + '</code>')
}))
// Scenes (Stage) mount — must come before any composer that uses ctx.scene.enter
bot.use(scenes)
// Admin panel + news-channel onboarding
privateMessage.use(require('../handlers/admin'))
privateMessage.use(require('../handlers/news-channel'))
bot.use(handleStats)
bot.use(handlePing)
// --- /start with merged startPayload routing ---
// Originally there were three separate bot.start() calls branching on
// different payload values — merged here for legibility. Falls through
// via next() so handleDonate (mounted later) can still intercept the
// 'donate' payload, and the final bot.start(handleStart) catches the rest.
bot.start(async (ctx, next) => {
const payload = ctx.startPayload
if (payload === 'inline_pack') {
ctx.state.type = 'inline'
return handlePacks(ctx)
}
if (payload === 'pack' || payload === 'packs') return handlePacks(ctx)
if (payload && /^s_(.*)/.test(payload)) return handleSelectPack(ctx)
return next()
})
// Bot added to a new group → run start flow
bot.on('new_chat_members', (ctx, next) => {
if (ctx.message.new_chat_members.find((m) => m.id === ctx.botInfo.id)) {
return handleStart(ctx, next)
}
return next()
})
// Pack navigation
privateMessage.command('help', handleHelp)
bot.command('packs', handlePacks)
bot.command('pack', handleSelectGroupPack)
bot.use(handleGroupSettings)
privateMessage.action(/packs:(type):(.*)/, handlePacks)
privateMessage.action(/packs:(.*)/, handlePacks)
// Support / legal
privateMessage.command('paysupport', (ctx) => ctx.replyWithHTML(ctx.i18n.t('cmd.paysupport')))
privateMessage.command('privacy', (ctx) => ctx.replyWithHTML(privacyHtml))
// Pack link handler chain: restore (if owner) → copy (if not owner).
// Both hears use the same regex; handleRestorePack calls next() when the
// pack isn't owned, which lets handleCopyPack fire.
privateMessage.hears(/(addstickers|addemoji)\/(.*)/, handleRestorePack)
privateMessage.command('report', (ctx) => ctx.replyWithHTML(ctx.i18n.t('cmd.report')))
privateMessage.hears(/\/new/, (ctx) => ctx.scene.enter('newPack'))
privateMessage.action(/new_pack:(.*)/, async (ctx) => {
const packType = ctx.match[1]
if (packType === 'inline') {
ctx.session.scene = ctx.session.scene || {}
ctx.session.scene.newPack = {
inline: true,
packType: 'regular'
}
}
// Scene sends its own new-pack banner below (reply keyboards can't
// attach via editMessageMedia, so we don't swap the /start message —
// user keeps their welcome banner in history + sees the scene flow
// as the next message).
return ctx.scene.enter('newPack')
})
privateMessage.hears(/(addstickers|addemoji)\/(.*)/, handleCopyPack)
privateMessage.command('publish', (ctx) => ctx.scene.enter('catalogPublishNew'))
privateMessage.action(/publish/, (ctx) => ctx.scene.enter('catalogPublishNew'))
privateMessage.command('frame', (ctx) => ctx.scene.enter('packFrame'))
privateMessage.action(/frame/, (ctx) => ctx.scene.enter('packFrame'))
privateMessage.command('delete', (ctx) => ctx.scene.enter('deleteSticker'))
privateMessage.action(/^delete_sticker$/, (ctx) => ctx.scene.enter('deleteSticker'))
privateMessage.command('catalog', handleCatalog)
privateMessage.action(/search_catalog/, handleSearchCatalog)
privateMessage.action(/^catalog$/, handleCatalog)
privateMessage.command('public', handleSelectPack)
privateMessage.command('emoji', handleEmoji)
privateMessage.command('copy', (ctx) => ctx.replyWithHTML(ctx.i18n.t('cmd.copy')))
privateMessage.command('restore', (ctx) => ctx.replyWithHTML(ctx.i18n.t('cmd.restore')))
privateMessage.command('original', (ctx) => ctx.scene.enter('originalSticker'))
privateMessage.action(/^original$/, (ctx) => ctx.scene.enter('originalSticker'))
privateMessage.command('about', (ctx) => ctx.scene.enter('packAbout'))
privateMessage.action(/about/, (ctx) => ctx.scene.enter('packAbout'))
// Download-original — used by the /about scene's "Download original" button.
// Tries to resend the stored original sticker directly; on any failure
// (expired file_id, emoji/regular mismatch, etc.) re-uploads via URL as a
// document. Never falls back to sendPhoto/sendVideo — Telegram rejects
// sticker file_ids there with "can't use file of type Sticker as Photo".
privateMessage.action(/^download_original$/, async (ctx) => {
await ctx.answerCbQuery()
const sticker = ctx.session?.lastStickerForDownload
if (!sticker) {
return ctx.replyWithHTML(ctx.i18n.t('scenes.original.error.not_found'))
}
// Query supports both new (original) and legacy (file) schema
const stickerInfo = await db.Sticker.findOne({
fileUniqueId: sticker.file_unique_id,
$or: [
{ 'original.fileId': { $ne: null } },
{ 'file.file_id': { $ne: null } }
]
})
if (stickerInfo && stickerInfo.hasOriginal()) {
const originalFileId = stickerInfo.getOriginalFileId()
const originalFileUniqueId = stickerInfo.getOriginalFileUniqueId()
try {
await ctx.replyWithSticker(originalFileId, { caption: stickerInfo.emojis })
return
} catch (_) { /* fall through to document fallback */ }
await sendStickerAsDocument(ctx, originalFileId, originalFileUniqueId)
return
}
await sendStickerAsDocument(ctx, sticker.file_id, sticker.file_unique_id)
})
// Show-all-packs — companion to /about scene. Chunks responses to 70 packs
// per message to stay under Telegram's message-length limit.
privateMessage.action(/^show_all_packs$/, async (ctx) => {
await ctx.answerCbQuery()
const data = ctx.session?.showAllPacksData
if (!data) return
const packs = await db.StickerSet.find({
ownerTelegramId: data.ownerId,
_id: { $ne: data.excludeSetId }
}).limit(500)
if (packs.length === 0) return
const chunkSize = 70
const formattedPacks = packs.map((pack) => {
if (pack.name.toLowerCase().endsWith('fstikbot') && pack.public !== true) {
if (
ctx.from.id === data.ownerId ||
ctx.from.id === ctx.config.mainAdminId ||
ctx?.session?.userInfo?.adminRights?.includes('pack')
) {
return `<a href="https://t.me/addstickers/${pack.name}"><s>${pack.name}</s></a>`
} else {
return ctx.i18n.t('scenes.packAbout.hidden')
}
}
return `<a href="https://t.me/addstickers/${pack.name}">${pack.name}</a>`
})
// Skip first 70 (already shown) and send the rest in chunks
const remainingPacks = formattedPacks.slice(chunkSize)
const chunks = []
for (let i = 0; i < remainingPacks.length; i += chunkSize) {
chunks.push(remainingPacks.slice(i, i + chunkSize))
}
for (const chunk of chunks) {
await ctx.replyWithHTML(chunk.join(', '), { disable_web_page_preview: true })
}
})
// Media-edit scenes
privateMessage.command('clear', (ctx) => ctx.scene.enter('photoClearSelect'))
privateMessage.command('round', (ctx) => ctx.scene.enter('videoRound'))
privateMessage.command('mosaic', (ctx) => ctx.scene.enter('mosaic'))
privateMessage.action(/clear/, (ctx) => ctx.scene.enter('photoClearSelect'))
privateMessage.action(/catalog:publish:(.*)/, (ctx) => ctx.scene.enter('catalogPublish'))
privateMessage.action(/catalog:unpublish:(.*)/, (ctx) => ctx.scene.enter('catalogUnpublish'))
// Language picker
bot.command('lang', handleLanguage)
bot.action(/set_language:(.*)/, handleLanguage)
privateMessage.action(/delete_pack:(.*)/, (ctx) => ctx.scene.enter('packDelete'))
privateMessage.action('mosaic:enter', (ctx) => {
ctx.answerCbQuery()
return ctx.scene.enter('mosaic')
})
// Donate (Stars) + boost + coedit
bot.use(handleDonate)
privateMessage.use(handleBoostPack)
privateMessage.use(handleCoedit)
// Inline queries (packs or GIFs)
bot.use(handleInlineQuery)
// Final /start catch-all — if none of the startPayload branches matched
// AND handleDonate's composer didn't handle it, run the menu.
bot.start(handleStart)
// Pack management callbacks
privateMessage.action(/(set_pack):(.*)/, handlePacks)
privateMessage.action(/(hide_pack):(.*)/, handleHidePack)
privateMessage.action(/(rename_pack):(.*)/, (ctx) => ctx.scene.enter('packRename'))
privateMessage.action(/(delete_sticker):(.*)/, limitPublicPack, handleDeleteSticker)
privateMessage.action(/(restore_sticker):(.*)/, limitPublicPack, handleRestoreSticker)
// /ss — quote-reply style sticker creation (works in groups too)
bot.command('ss', handleSticker)
// Sticker detection in private chats (images, videos, video notes, etc.)
privateMessage.on(['sticker', 'document', 'photo', 'video', 'video_note'], limitPublicPack, handleSticker)
privateMessage.on('message', (ctx, next) => {
if (ctx.message && ctx.message.entities && ctx.message.entities[0] && ctx.message.entities[0].type === 'custom_emoji') {
return handleSticker(ctx)
}
return next()
})
privateMessage.action(/add_sticker/, handleSticker)
// Sticker metadata updates (emoji suffix edit). These listen for free-form
// text and must be registered BEFORE bot.use(privateMessage) so the
// composer is fully populated at mount time.
privateMessage.on('text', handleStickerUpdate)
privateMessage.on('message', handleStart)
// Mount privateMessage only after every handler is attached
bot.use(privateMessage)
}
================================================
FILE: bot/launch.js
================================================
// Bot launch + graceful shutdown.
// Webhook mode when BOT_DOMAIN is set, polling otherwise.
//
// allowedUpdates cuts channel_post, edited_channel_post, and poll updates
// at the Telegram side — the bot doesn't handle them, and previously there
// was a no-op bot.on([...]) catcher that still consumed network + CPU.
const ALLOWED_UPDATES = [
'message',
'edited_message',
'callback_query',
'inline_query',
'pre_checkout_query',
'my_chat_member'
]
module.exports = async function launch (bot) {
if (process.env.BOT_DOMAIN) {
// Keep the original raw-token path — server nginx is configured to
// proxy exactly this route to the bot port. Changing to sha256(token)
// requires a coordinated nginx update; revisit as a separate change.
const hookPath = `/fStikBot:${process.env.BOT_TOKEN}`
await bot.launch({
webhook: {
domain: process.env.BOT_DOMAIN,
hookPath,
port: process.env.WEBHOOK_PORT || 2500
},
allowedUpdates: ALLOWED_UPDATES
})
console.log('bot start webhook')
} else {
await bot.launch({ allowedUpdates: ALLOWED_UPDATES })
console.log('bot start polling')
}
}
module.exports.ALLOWED_UPDATES = ALLOWED_UPDATES
================================================
FILE: bot/locale-sync.js
================================================
// Locale sync — pushes bot name/description/commands to Telegram for every
// locale in locales/. This is idempotent but expensive: up to ~8 API calls
// per locale × 18 locales = ~144 calls on every process start.
//
// PM2 restarts the process every 6h, which used to trigger the whole sync.
// We now cache a hash of the locales/ directory's max mtime in a dotfile;
// if nothing changed since last run, sync is skipped entirely.
const fs = require('fs')
const path = require('path')
const LOCALES_DIR = path.resolve(__dirname, '..', 'locales')
const CACHE_FILE = path.resolve(__dirname, '..', '.locale-sync-mtime')
function computeMaxMtime () {
let max = 0
for (const name of fs.readdirSync(LOCALES_DIR)) {
const stat = fs.statSync(path.join(LOCALES_DIR, name))
if (stat.mtimeMs > max) max = stat.mtimeMs
}
return Math.floor(max)
}
function readCachedMtime () {
try {
const raw = fs.readFileSync(CACHE_FILE, 'utf8').trim()
return parseInt(raw, 10) || 0
} catch {
return 0
}
}
function writeCachedMtime (mtime) {
try {
fs.writeFileSync(CACHE_FILE, String(mtime))
} catch (err) {
console.warn('[locale-sync] failed to persist mtime cache:', err.message)
}
}
async function syncOneLocale (bot, i18n, localeName, enDescriptionLong, enDescriptionShort) {
// NAME
const name = i18n.t(localeName, 'name')
const myName = await bot.telegram.callApi('getMyName', { language_code: localeName })
if (myName.name !== name) {
try {
await bot.telegram.callApi('setMyName', { name, language_code: localeName })
console.log('setMyName', localeName)
} catch (error) {
console.error('setMyName', localeName, error.description)
}
}
// LONG DESCRIPTION
const myDescription = await bot.telegram.callApi('getMyDescription', { language_code: localeName })
const descriptionLong = i18n.t(localeName, 'description.long')
const newDescriptionLong = localeName === 'en' || descriptionLong !== enDescriptionLong
? descriptionLong.replace(/[\r\n]/gm, '')
: ''
if (newDescriptionLong !== myDescription.description.replace(/[\r\n]/gm, '')) {
try {
const description = newDescriptionLong ? i18n.t(localeName, 'description.long') : ''
await bot.telegram.callApi('setMyDescription', { description, language_code: localeName })
console.log('setMyDescription', localeName)
} catch (error) {
console.error('setMyDescription', localeName, error.description)
}
}
// SHORT DESCRIPTION
const myShortDescription = await bot.telegram.callApi('getMyShortDescription', { language_code: localeName })
const descriptionShort = i18n.t(localeName, 'description.short')
const newDescriptionShort = localeName === 'en' || descriptionShort !== enDescriptionShort
? descriptionShort.replace(/[\r\n]/gm, '')
: ''
if (newDescriptionShort !== myShortDescription.short_description.replace(/[\r\n]/gm, '')) {
try {
const shortDescription = newDescriptionShort ? i18n.t(localeName, 'description.short') : ''
await bot.telegram.callApi('setMyShortDescription', { short_description: shortDescription, language_code: localeName })
console.log('setMyShortDescription', localeName)
} catch (error) {
console.error('setMyShortDescription', localeName, error.description)
}
}
// PRIVATE COMMANDS
// Slim menu — contextual commands (delete/copy/publish/about/privacy) are
// available through pack buttons or direct typing, not surfaced in
// Telegram's command picker.
const privateCommands = [
{ command: 'start', description: i18n.t(localeName, 'cmd.start.commands.start') },
{ command: 'packs', description: i18n.t(localeName, 'cmd.start.commands.packs') },
{ command: 'new', description: i18n.t(localeName, 'cmd.start.commands.new') },
{ command: 'catalog', description: i18n.t(localeName, 'cmd.start.commands.catalog') },
{ command: 'clear', description: i18n.t(localeName, 'cmd.start.commands.clear') },
{ command: 'round', description: i18n.t(localeName, 'cmd.start.commands.round') },
{ command: 'original', description: i18n.t(localeName, 'cmd.start.commands.original') },
{ command: 'donate', description: i18n.t(localeName, 'cmd.start.commands.donate') },
{ command: 'lang', description: i18n.t(localeName, 'cmd.start.commands.lang') }
]
const myCommandsInPrivate = await bot.telegram.callApi('getMyCommands', {
language_code: localeName,
scope: JSON.stringify({ type: 'all_private_chats' })
})
let needUpdatePrivate = myCommandsInPrivate.length !== privateCommands.length
if (!needUpdatePrivate) {
for (const cmd of privateCommands) {
const existing = myCommandsInPrivate.find(c => c.command === cmd.command)
if (!existing || existing.description !== cmd.description) {
needUpdatePrivate = true
break
}
}
}
if (needUpdatePrivate) {
await bot.telegram.callApi('setMyCommands', {
commands: privateCommands,
language_code: localeName,
scope: JSON.stringify({ type: 'all_private_chats' })
})
}
// GROUP COMMANDS
const groupCommands = [
{ command: 'ss', description: i18n.t(localeName, 'cmd.start.commands.ss') },
{ command: 'packs', description: i18n.t(localeName, 'cmd.start.commands.packs') }
]
const myCommandsInGroup = await bot.telegram.callApi('getMyCommands', {
language_code: localeName,
scope: JSON.stringify({ type: 'all_group_chats' })
})
let needUpdateGroup = myCommandsInGroup.length !== groupCommands.length
if (!needUpdateGroup) {
for (const cmd of groupCommands) {
const existing = myCommandsInGroup.find(c => c.command === cmd.command)
if (!existing || existing.description !== cmd.description) {
needUpdateGroup = true
break
}
}
}
if (needUpdateGroup) {
await bot.telegram.callApi('setMyCommands', {
commands: groupCommands,
language_code: localeName,
scope: JSON.stringify({ type: 'all_group_chats' })
})
}
}
module.exports = async function syncLocales (bot, i18n) {
const currentMtime = computeMaxMtime()
const cachedMtime = readCachedMtime()
if (currentMtime === cachedMtime) {
console.log('[locale-sync] locales unchanged since last run — skipping')
return
}
console.log('[locale-sync] locales changed, running full sync')
const locales = fs.readdirSync(LOCALES_DIR)
const enDescriptionLong = i18n.t('en', 'description.long')
const enDescriptionShort = i18n.t('en', 'description.short')
const results = await Promise.allSettled(locales.map((locale) => {
const localeName = locale.split('.')[0]
return syncOneLocale(bot, i18n, localeName, enDescriptionLong, enDescriptionShort)
}))
const failed = results.filter(r => r.status === 'rejected').length
if (failed > 0) {
console.warn(`[locale-sync] ${failed}/${results.length} locale(s) failed — not caching mtime, will retry next boot`)
return
}
writeCachedMtime(currentMtime)
console.log('[locale-sync] completed successfully')
}
================================================
FILE: bot/middleware.js
================================================
// All `bot.use(...)` middleware + the privateMessage composer construction.
// Order matters — preserves the exact chain from the original bot.js.
const Composer = require('telegraf/composer')
const rateLimit = require('telegraf-ratelimit')
const { perfStage, perfRecord, perfTick, ENABLED: PERF_TIMING_ENABLED } = require('../utils/perf-timing')
const { touchLastSeen } = require('../utils/last-seen')
const handleError = require('../handlers/catch')
const log = require('../utils/logger').scope('middleware')
const MAX_CHAIN_ACTIONS = 15
// Polling detach: enabled by default (set POLLING_DETACH=0 to disable).
// Default ON because we've verified the tradeoffs are covered:
// - Errors routed through handleError (same pipeline as bot.catch)
// - Heavy work already fire-and-forget at handler level (addSticker)
// - Session save-wrap awaits user persist inline
const POLLING_DETACH = process.env.POLLING_DETACH !== '0'
module.exports = (bot, {
i18n,
sessionMiddleware,
updateUser,
updateGroup,
stats,
retryMiddleware
}) => {
// Detach from Telegraf's batch-await loop.
//
// Telegraf 3.40's fetchUpdates does:
// handleUpdates(batch).then(() => fetchUpdates()) // next poll
// which waits for Promise.all of all handleUpdate(u) in the batch to
// resolve before issuing the next getUpdates. Returning a resolved
// Promise from the FIRST middleware short-circuits that wait: the
// batch Promise.all completes immediately, fetchUpdates re-polls, and
// the downstream middleware chain still executes in the background.
//
// This preserves throughput under rare bursts where any middleware
// gets slow. Trade-offs we consciously accept:
// - Telegraf's handlerTimeout (60s) cannot interrupt detached work.
// We don't rely on it — all slow paths are already fire-and-forget
// via Bull queues (convert/removebg) or the sticker-handler IIFE.
// - Two rapid updates from the same user run concurrently, so a
// session SET race is theoretically possible. Session is Redis-
// backed and small; dirty-check cuts writes; last writer wins for
// the rare race. Scene state advances one step at a time via user
// actions spaced >>100ms apart — not observed in practice.
// - Errors don't reach bot.catch. We route them through handleError
// manually so the log channel still gets git blame + stack +
// chainActions.
if (POLLING_DETACH) {
bot.use((ctx, next) => {
next().catch((err) => handleError(err, ctx).catch((e) => {
console.error('[polling-detach] handleError itself failed:', e)
}))
return Promise.resolve()
})
}
// i18n
bot.use(i18n)
// Retry 429s at the ctx level (prototype-level patch already handles the
// underlying Telegram.callApi; this just exposes ctx.withRetry helper)
// AND clears the blocked-chat cache for the current chat_id so a user
// who unblocked us can receive replies immediately.
bot.use(retryMiddleware())
// Rate-limit writes to public packs (1 sticker per minute) to prevent
// vandalism on shared "public" sets.
const limitPublicPack = Composer.optional(
(ctx) => ctx?.session?.userInfo?.stickerSet?.passcode === 'public',
rateLimit({
window: 1000 * 60,
limit: 1,
onLimitExceeded: (ctx) => ctx.reply(ctx.i18n.t('ratelimit'))
})
)
// Response-time stats
bot.use(stats)
// Session (in-memory telegraf/session — see bot/session-store.js)
bot.use(perfStage('session', sessionMiddleware))
// Chain-actions logger: records the last N actions per session to help
// reproduce error traces. Also prepares answerCbQuery/answerInlineQuery
// state arrays so handlers can mutate them and the middleware finalizes.
bot.use(async (ctx, next) => {
if (ctx.session && !ctx.session.chainActions) ctx.session.chainActions = []
let action
if (ctx.message && ctx.message.text) action = ctx.message.text
else if (ctx.callbackQuery) action = ctx.callbackQuery.data
else if (ctx.updateType) action = `{${ctx.updateType}} `
if (ctx.updateSubTypes) action += ` [${ctx.updateSubTypes.join(', ')}]`
if (!action) action = 'undefined'
if (ctx.session) {
if (ctx.session.chainActions.length > MAX_CHAIN_ACTIONS) ctx.session.chainActions.shift()
ctx.session.chainActions.push(action)
}
if (ctx.inlineQuery) ctx.state.answerIQ = []
if (ctx.callbackQuery) ctx.state.answerCbQuery = []
return next(ctx).then(() => {
// Auto-answer the callback. Silently swallow failures: with
// handlerTimeout=60s, a long-running handler can outlive Telegram's
// ~5-10 min callback_query_id TTL. Propagating that would spam the
// global error handler with "query is too old" noise.
if (ctx.callbackQuery) {
return ctx.answerCbQuery(...ctx.state.answerCbQuery).catch(() => {})
}
})
})
// Group chat commands upsert the group record
bot.use(Composer.groupChat(Composer.command(updateGroup)))
// User upsert — hydrates ctx.session.userInfo with a fresh Mongoose doc
// from the DB. Runs BEFORE locale auto-switch and banned guard because
// those read userInfo; without this ordering they'd see stale
// Redis-hydrated plain objects (no save() method, stale flags).
bot.use(perfStage('updateUser', async (ctx, next) => {
await updateUser(ctx)
return next()
}))
// Лагідна українізація — auto-switch ru → uk when Telegram reports uk.
// Now runs after updateUser so userInfo is a live Mongoose doc and
// its .save() actually fires.
bot.use((ctx, next) => {
if (
ctx?.session?.userInfo?.locale === 'ru' &&
ctx.from && ctx.from.language_code === 'uk'
) {
ctx.session.userInfo.locale = 'uk'
if (typeof ctx.session.userInfo.save === 'function') {
ctx.session.userInfo.save().catch(err => log.error('Failed to save user locale:', err.message))
}
ctx.i18n.locale('uk')
}
return next()
})
// Banned user guard — runs after updateUser so the flag is fresh.
bot.use((ctx, next) => {
if (ctx?.session?.userInfo?.banned) {
return ctx.replyWithHTML(ctx.i18n.t('error.banned'))
}
return next()
})
// Persist userInfo after the handler runs. Split from the updateUser
// middleware above so locale/banned middlewares can sit between
// hydration and handler execution.
//
// Perf instrumentation is inlined (not via perfStage) because we want
// to split the measurement: 'handler' captures the full downstream
// next() — i.e. the rest of the middleware chain + handler body —
// and 'userSave' captures just the post-next save() duration.
// Persist the user doc only if a handler actually modified it. Unmodified
// requests just throttle-bump updatedAt via a fire-and-forget updateOne
// (see utils/last-seen.js). This turns ~every-update saves into ~once-
// per-hour-per-user cheap updates + real saves only on real changes.
const persistUserIfDirty = (ctx) => {
const user = ctx.session?.userInfo
if (!user || typeof user.save !== 'function') return null
if (user.isModified && user.isModified()) {
return user.save().catch(err => log.error('Failed to save user:', err.message))
}
// Not dirty — no save, just bump last-seen (throttled, async).
touchLastSeen(ctx.db.User, user._id)
return null
}
bot.use(async (ctx, next) => {
if (!PERF_TIMING_ENABLED) {
await next(ctx)
const maybeSave = persistUserIfDirty(ctx)
if (maybeSave) await maybeSave
return
}
const handlerStart = Date.now()
try {
try {
await next(ctx)
} finally {
// Wall-clock handler duration — recorded on success and on error
// so perf samples reflect real load even when handlers throw.
perfRecord('handler', Date.now() - handlerStart)
}
// Persist only on normal completion (preserves original behavior:
// don't write userInfo after a handler error).
const saveStart = Date.now()
const maybeSave = persistUserIfDirty(ctx)
try {
if (maybeSave) await maybeSave
} finally {
perfRecord('userSave', Date.now() - saveStart)
}
} finally {
// perfTick fires regardless of handler outcome so log cadence stays
// stable under error load.
perfTick()
}
})
// my_chat_member updates are noisy — ignore them after user-update above
// (which handles the blocked-flag flip).
bot.use((ctx, next) => {
if (ctx.update.my_chat_member) return false
return next()
})
// privateMessage composer — only runs for 1:1 chats
const privateMessage = new Composer()
privateMessage.use((ctx, next) => {
if (ctx.chat && ctx.chat.type === 'private') return next()
return false
})
return { privateMessage, limitPublicPack }
}
================================================
FILE: bot/preflight.js
================================================
// Preflight checks — verify env + connectivity before the bot starts
// accepting updates. Fast-fail with a clear message instead of starting
// a half-broken bot that shows up as PM2-alive but mysteriously silent.
//
// Each check returns { ok, name, detail } so the caller can decide
// whether to abort or proceed (some checks are advisory).
const defaultLog = require('../utils/logger').scope('preflight')
const requireBotToken = () => {
const token = process.env.BOT_TOKEN
if (!token) {
return { ok: false, name: 'BOT_TOKEN', detail: 'env var is empty or unset' }
}
// Format: <bot_id>:<35-char alphanumeric+_-> — bot id is numeric.
if (!/^\d+:[A-Za-z0-9_-]{30,}$/.test(token)) {
return { ok: false, name: 'BOT_TOKEN', detail: 'malformed (expected `<digits>:<35+ chars>`)' }
}
return { ok: true, name: 'BOT_TOKEN' }
}
const requireMongoUri = () => {
const uri = process.env.MONGODB_URI
if (!uri) {
return { ok: false, name: 'MONGODB_URI', detail: 'env var is empty or unset' }
}
if (!/^mongodb(\+srv)?:\/\//.test(uri)) {
return { ok: false, name: 'MONGODB_URI', detail: 'must start with mongodb:// or mongodb+srv://' }
}
return { ok: true, name: 'MONGODB_URI' }
}
// Wait for a Mongoose connection's first `open` event with a timeout.
// Without this, a misconfigured MONGODB_URI leaves the bot hanging
// indefinitely with no progress past "Connecting…".
const waitForMongo = (connection, timeoutMs = 30_000) => new Promise((resolve) => {
if (connection.readyState === 1) {
return resolve({ ok: true, name: 'mongo' })
}
const timer = setTimeout(() => {
connection.removeListener('open', onOpen)
resolve({ ok: false, name: 'mongo', detail: `did not open within ${timeoutMs}ms — check MONGODB_URI and that the cluster is reachable` })
}, timeoutMs)
const onOpen = () => {
clearTimeout(timer)
resolve({ ok: true, name: 'mongo' })
}
connection.once('open', onOpen)
})
// Verify the bot token actually works by hitting Telegram getMe.
// 401 → bad token, network errors → infrastructure issue. Both should
// surface immediately, not 30 seconds into a polling loop.
const pingTelegram = async (bot) => {
try {
const me = await bot.telegram.getMe()
return { ok: true, name: 'telegram', detail: `@${me.username} (id=${me.id})` }
} catch (err) {
return {
ok: false,
name: 'telegram',
detail: err?.description || err?.message || String(err)
}
}
}
// Run all checks; abort process if any required check fails.
const runPreflight = async ({ bot, dbConnection, log = defaultLog }) => {
const checks = []
// Required env validations — synchronous, run first so we don't spend
// 30s waiting for Mongo only to fail on a missing token afterwards.
checks.push(requireBotToken())
checks.push(requireMongoUri())
// Async connectivity probes only run if env passes.
if (checks.every((c) => c.ok)) {
const [mongo, telegram] = await Promise.all([
waitForMongo(dbConnection),
pingTelegram(bot)
])
checks.push(mongo, telegram)
}
for (const check of checks) {
if (check.ok) {
log.info(`✓ ${check.name}${check.detail ? ` — ${check.detail}` : ''}`)
} else {
log.error(`✗ ${check.name} — ${check.detail}`)
}
}
const failed = checks.filter((c) => !c.ok)
if (failed.length > 0) {
log.error(`${failed.length} check(s) failed — aborting startup`)
process.exit(1)
}
}
module.exports = {
runPreflight,
requireBotToken,
requireMongoUri,
waitForMongo,
pingTelegram
}
================================================
FILE: bot/session-store.js
================================================
// In-memory Telegraf session.
//
// For a single-process bot (PM2, 6h restarts) Redis sessions were net
// negative: free-tier latency spikes, +1 network write per update, extra
// failure surface. Scenes are short-lived — losing state across a restart
// is the same UX as the bot briefly going offline.
//
// Redis is still used for multi-process state that genuinely needs
// persistence (broadcast campaigns — see utils/messaging.js).
const session = require('telegraf/session')
const SESSION_TTL_SECONDS = 60 * 60 // 1 hour — telegraf checks expires on read
function getSessionKey (ctx) {
if ((ctx.from && ctx.chat && ctx.chat.id === ctx.from.id) || (!ctx.chat && ctx.from)) {
return `user:${ctx.from.id}`
}
if (ctx.from && ctx.chat) {
return `${ctx.from.id}:${ctx.chat.id}`
}
return undefined
}
// telegraf/session stores `{ session, expires }`; the default `new Map()`
// never evicts. Wrap it so idle keys get collected and the Map doesn't
// grow unbounded over a long-running process.
const MEM_MAX = 50000
const MEM_SWEEP_MS = 5 * 60 * 1000
function createMemoryStore () {
const data = new Map()
const interval = setInterval(() => {
const now = Date.now()
for (const [key, entry] of data) {
if (entry && entry.expires && entry.expires < now) data.delete(key)
}
if (data.size > MEM_MAX) {
// Drop oldest entries by insertion order until under limit.
const excess = data.size - MEM_MAX
let i = 0
for (const key of data.keys()) {
if (i++ >= excess) break
data.delete(key)
}
}
}, MEM_SWEEP_MS)
if (interval.unref) interval.unref()
return data
}
const store = createMemoryStore()
function sessionMiddleware () {
return session({ store, getSessionKey, ttl: SESSION_TTL_SECONDS })
}
module.exports = {
sessionMiddleware,
getSessionKey
}
================================================
FILE: bot.js
================================================
// Entrypoint — thin orchestrator. The old 681-line monolith was split into
// focused modules under bot/:
// - bot/session-store.js in-memory telegraf/session with bounded Map
// - bot/middleware.js all bot.use(...) middleware
// - bot/commands.js all commands / actions / hears registrations
// - bot/locale-sync.js mtime-cached locale push to Telegram
// - bot/launch.js webhook vs polling, allowedUpdates
const fs = require('fs')
const path = require('path')
const Telegraf = require('telegraf')
const I18n = require('telegraf-i18n')
const { db } = require('./database')
const handlers = require('./handlers')
const scenes = require('./scenes')
const {
updateUser,
updateGroup,
stats,
updateMonitor,
retryMiddleware
} = require('./utils')
const { sessionMiddleware } = require('./bot/session-store')
const registerMiddleware = require('./bot/middleware')
const registerCommands = require('./bot/commands')
const launch = require('./bot/launch')
const syncLocales = require('./bot/locale-sync')
const { runPreflight } = require('./bot/preflight')
const log = require('./utils/logger').scope('bot')
global.startDate = new Date()
// Was 1000ms — aborted any handler that touched Bull or a slow Telegram call.
// 60s is generous but bounded; PM2 will kill the process on true hangs.
const HANDLER_TIMEOUT_MS = 60_000
const MONITOR_INTERVAL_MS = 25 * 1000
const bot = new Telegraf(process.env.BOT_TOKEN, {
telegram: { webhookReply: false },
handlerTimeout: HANDLER_TIMEOUT_MS
})
bot.catch(handlers.handleError)
bot.context.config = require('./config.json')
bot.context.db = db
const i18n = new I18n({
directory: path.resolve(__dirname, 'locales'),
defaultLanguage: 'en',
defaultLanguageOnMissing: true
})
// Cached at startup — privacy policy is static HTML.
const privacyHtml = fs.readFileSync(path.resolve(__dirname, 'privacy.html'), 'utf-8')
const { privateMessage, limitPublicPack } = registerMiddleware(bot, {
i18n,
sessionMiddleware: sessionMiddleware(),
updateUser,
updateGroup,
stats,
retryMiddleware
})
registerCommands(bot, privateMessage, {
handlers,
limitPublicPack,
privacyHtml,
db,
scenes
})
// Preflight runs the gauntlet before we accept any updates: validates
// env vars, waits for Mongo with a hard timeout, and pings Telegram
// getMe to verify the token. Any failure aborts with exit(1) so PM2
// surfaces the problem immediately instead of restarting a silent bot.
;(async () => {
await runPreflight({ bot, dbConnection: db.connection })
await launch(bot)
// Don't block startup on the locale sync — it's eventually consistent.
syncLocales(bot, i18n).catch((err) => log.error('[locale-sync] failed:', err.message))
// Side-effect import: starts messaging queue polling
require('./utils/messaging')
const monitorInterval = setInterval(() => updateMonitor(), MONITOR_INTERVAL_MS)
if (monitorInterval.unref) monitorInterval.unref()
})().catch((err) => {
log.error('Startup failed:', err?.stack || err)
process.exit(1)
})
// Graceful shutdown — PM2 sends SIGTERM before killing
const gracefulShutdown = (signal) => {
log.info(`${signal} received, shutting down gracefully…`)
bot.stop(signal)
process.exit(0)
}
process.on('SIGTERM', gracefulShutdown)
process.on('SIGINT', gracefulShutdown)
// Postmortem logging for crashes. We don't suppress the default Node
// behavior (it exits the process), we just make sure the cause is in
// the log channel before PM2 restarts us. Without these, all we'd see
// in PM2 logs is "process exited" with no stack trace.
process.on('unhandledRejection', (reason) => {
log.error('Unhandled rejection:', reason instanceof Error ? reason.stack : reason)
// Re-throw so Node's default termination kicks in — promise state may
// be inconsistent, restart is safer than continuing on corrupted state.
// Use setImmediate so the error bubbles to uncaughtException with full
// context, not swallowed by the rejection handler chain.
setImmediate(() => { throw reason })
})
process.on('uncaughtException', (err, origin) => {
log.error(`Uncaught exception (origin=${origin}):`, err?.stack || err)
// Don't try to clean up — state is unknown. PM2 will restart us.
process.exit(1)
})
================================================
FILE: config.example.json
================================================
{
"mainAdminId": 66478514,
"logChatId": -1001665705393,
"stickerLinkPrefix": "t.me/addstickers/",
"emojiLinkPrefix": "t.me/addemoji/",
"charTitleMax": 35,
"premiumCharTitleMax": 64,
"messaging": {
"limit": {
"max": 20,
"duration": 1500
}
}
}
================================================
FILE: crowdin.yml
================================================
files:
- source: /locales/en.yaml
translation: /locales/%two_letters_code%.yaml
================================================
FILE: database/connection.js
================================================
const mongoose = require('mongoose')
// Визначаємо чи це SRV URI (mongodb+srv://)
const isSrvUri = (uri) => uri && uri.startsWith('mongodb+srv://')
// Основне з'єднання.
// Pool sized for burst recovery: after a PM2 restart with ~300 pending
// updates, the bot processes them concurrently. Each update does ~4 Mongo
// ops (updateUser: findOne + 2 populates + user.save). With pool=10 that
// queued 120+ deep per connection, forcing each query to wait ~600-1300ms.
// Pool=50 keeps the burst queue ≤20 deep so each query waits <100ms.
// Memory cost is trivial (~1MB per connection client-side).
const mainUri = process.env.MONGODB_URI
const connection = mongoose.createConnection(mainUri, {
...(isSrvUri(mainUri) ? {} : { directConnection: true }),
autoIndex: false,
maxPoolSize: parseInt(process.env.MONGO_POOL_SIZE, 10) || 50,
minPoolSize: parseInt(process.env.MONGO_POOL_MIN, 10) || 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 30000,
retryWrites: true,
retryReads: true
})
connection.on('error', error => {
console.error('MongoDB connection error:', error)
})
connection.on('disconnected', () => {
console.warn('MongoDB disconnected')
})
connection.on('reconnected', () => {
console.log('MongoDB reconnected')
})
// Atlas з'єднання (для аналітики/top-sets)
const atlasUri = process.env.ATLAS_MONGODB_URI || process.env.MONGODB_URI
const atlasConnection = mongoose.createConnection(atlasUri, {
...(isSrvUri(atlasUri) ? {} : { directConnection: true }),
maxPoolSize: 5,
minPoolSize: 1,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 30000,
retryWrites: true,
retryReads: true
})
atlasConnection.on('error', error => {
console.error('Atlas MongoDB error:', error)
})
module.exports = {
connection,
atlasConnection
}
================================================
FILE: database/index.js
================================================
const collections = require('./models')
const {
connection,
atlasConnection
} = require('./connection')
const db = {
connection
}
Object.keys(collections).forEach((collectionName) => {
db[collectionName] = connection.model(collectionName, collections[collectionName])
})
const atlasDb = {
connection: atlasConnection
}
Object.keys(collections).forEach((collectionName) => {
atlasDb[collectionName] = atlasConnection.model(collectionName, collections[collectionName])
})
// Truncate string to max length
const truncate = (str, maxLength) => {
if (!str) return null
return str.length > maxLength ? str.slice(0, maxLength) : str
}
db.User.getData = async (tgUser) => {
let telegramId
if (tgUser.telegram_id) telegramId = tgUser.telegram_id
else telegramId = tgUser.id
// Optimized: single populate call with select for only needed fields
let user = await db.User.findOne({ telegram_id: telegramId })
.populate({
path: 'stickerSet',
select: '_id name title packType inline create emojiSuffix frameType boost hide owner passcode'
})
.populate({
path: 'inlineStickerSet',
select: '_id name title inline'
})
if (!user) {
user = new db.User()
user.telegram_id = tgUser.id
}
return user
}
db.User.updateData = async (tgUser) => {
const user = await db.User.getData(tgUser)
// Coerce missing/empty Telegram fields to '' once — same reason as
// utils/user-update.js: deleted/deactivated accounts can omit
// first_name, and we don't want undefined sneaking into the DB.
user.first_name = tgUser.first_name || ''
user.last_name = tgUser.last_name || ''
user.username = tgUser.username
user.updatedAt = new Date()
await user.save()
return user
}
db.StickerSet.newSet = async (stickerSetInfo) => {
const oldStickerSet = await db.StickerSet.findOneAndDelete({ name: stickerSetInfo.name })
if (oldStickerSet) {
await db.Sticker.updateMany(
{ stickerSet: oldStickerSet._id },
{ $set: { deleted: true, deletedAt: new Date() } }
)
}
const stickerSet = new db.StickerSet()
stickerSet.owner = stickerSetInfo.owner
stickerSet.ownerTelegramId = stickerSetInfo.ownerTelegramId
stickerSet.name = stickerSetInfo.name
stickerSet.title = stickerSetInfo.title
stickerSet.inline = stickerSetInfo.inline || false
stickerSet.packType = stickerSetInfo.packType || 'regular'
stickerSet.emojiSuffix = stickerSetInfo.emojiSuffix
stickerSet.create = stickerSetInfo.create || false
stickerSet.boost = stickerSetInfo.boost || false
await stickerSet.save()
// Increment user's pack count
if (stickerSetInfo.owner && stickerSetInfo.create) {
const countField = stickerSetInfo.inline
? 'packsCount.inline'
: `packsCount.${stickerSetInfo.packType || 'regular'}`
await db.User.updateOne(
{ _id: stickerSetInfo.owner },
{ $inc: { [countField]: 1 } }
)
}
return stickerSet
}
db.StickerSet.getSet = async (stickerSetInfo) => {
let stickerSet = await db.StickerSet.findOne({ name: stickerSetInfo.name })
if (!stickerSet) {
stickerSet = await db.StickerSet.newSet(stickerSetInfo)
}
return stickerSet
}
/**
* Add a new sticker to the database
* Uses optimized flat structure for new documents (backwards-compatible)
*
* @param {ObjectId|string} stickerSet - The sticker set ID
* @param {string|string[]} emojisText - Emoji(s) associated with the sticker
* @param {Object} info - Current sticker info from Telegram API
* @param {Object} [originalFile] - Original file data (if different from current)
* @returns {Promise<Document>} The created sticker document
*/
db.Sticker.addSticker = async (stickerSet, emojisText = '', info, originalFile = null) => {
if (!info || !info.file_unique_id) {
throw new Error('Sticker info with file_unique_id is required')
}
const emojis = Array.isArray(emojisText)
? emojisText.join(' ')
: truncate(emojisText, 150)
const stickerData = {
stickerSet,
fileUniqueId: info.file_unique_id,
emojis,
// New flat fields (optimized storage)
fileId: info.file_id,
stickerType: info.stickerType || null,
caption: truncate(info.caption, 150)
}
// Store original only if provided AND different from current
if (originalFile && originalFile.file_id && originalFile.file_id !== info.file_id) {
stickerData.original = {
fileId: originalFile.file_id,
fileUniqueId: originalFile.file_unique_id,
stickerType: originalFile.stickerType || null
}
}
const sticker = new db.Sticker(stickerData)
await sticker.save()
return sticker
}
module.exports = {
db,
atlasDb
}
================================================
FILE: database/models/deeplink.js
================================================
const mongoose = require('mongoose')
const deeplinkSchema = mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
deepLink: {
type: String
}
}, {
timestamps: true
})
// Compound index for findOne({ deepLink, user }) queries
// deepLink first as it's more selective
deeplinkSchema.index({ deepLink: 1, user: 1 })
module.exports = deeplinkSchema
================================================
FILE: database/models/group.js
================================================
const mongoose = require('mongoose')
const groupSchema = mongoose.Schema({
telegram_id: {
type: Number,
index: true,
unique: true,
required: true
},
title: String,
username: String,
memberCount: Number,
stickerSet: {
type: mongoose.Schema.Types.ObjectId,
ref: 'StickerSet',
index: true
},
settings: {
rights: {
add: {
type: String,
default: 'all'
},
delete: {
type: String,
default: 'all'
}
}
}
}, {
timestamps: true
})
module.exports = groupSchema
================================================
FILE: database/models/index.js
================================================
const User = require('./user')
const Group = require('./group')
const Sticker = require('./sticker')
const StickerSet = require('./sticker-set')
const Messaging = require('./messaging')
const Payment = require('./payment')
const DeepLink = require('./deeplink')
module.exports = {
User,
Group,
Sticker,
StickerSet,
Messaging,
Payment,
DeepLink
}
================================================
FILE: database/models/messaging.js
================================================
const mongoose = require('mongoose')
const schema = mongoose.Schema({
creator: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
name: String,
message: {
type: { type: String },
data: Object
},
sendErrors: Array,
status: {
type: Number,
default: 0
},
editStatus: {
type: Number,
default: 0
},
result: {
total: {
type: Number,
default: 0
},
state: {
type: Number,
default: 0
},
error: {
type: Number,
default: 0
}
},
date: Date
}, {
timestamps: true
})
// Indexes for queue processing queries
schema.index({ status: 1, date: 1 })
schema.index({ editStatus: 1, date: 1 })
schema.index({ creator: 1 })
schema.index({ createdAt: -1 })
module.exports = schema
================================================
FILE: database/models/payment.js
================================================
const mongoose = require('mongoose')
const paymentsSchema = mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
index: true
},
amount: {
type: Number
// Note: index removed - no queries filter by amount alone
},
price: {
type: Number
// Note: index removed - no queries filter by price alone
},
currency: {
type: String
// Note: index removed - no queries filter by currency alone
},
paymentSystem: {
type: String
// Note: index removed - no queries filter by paymentSystem alone
},
paymentId: {
type: String
// Note: index removed - queries use resultData.telegram_payment_charge_id instead
},
status: {
type: String,
index: true
},
resultData: {
type: Object
}
}, {
timestamps: true
})
// Index for admin refund lookups by Telegram charge ID
paymentsSchema.index({ 'resultData.telegram_payment_charge_id': 1 })
module.exports = paymentsSchema
================================================
FILE: database/models/sticker-set.js
================================================
const mongoose = require('mongoose')
const stickerSetsSchema = mongoose.Schema({
owner: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
// Note: No separate index needed - covered by compound indexes below
},
ownerTelegramId: {
type: Number,
index: true
},
passcode: {
type: String,
index: true
},
name: {
type: String,
unique: true,
required: true
},
title: {
type: String,
required: true
},
inline: {
type: Boolean,
default: false
},
packType: {
type: String,
default: 'regular'
},
boost: {
type: Boolean,
default: false
},
frameType: String,
emojiSuffix: String,
create: {
type: Boolean,
default: false
},
thirdParty: {
type: Boolean,
default: false
},
hide: {
type: Boolean,
default: false
},
deleted: {
type: Boolean,
default: false
},
public: {
type: Boolean,
default: false
},
publishDate: {
type: Date
},
about: {
description: String,
tags: [String],
languages: [String],
safe: {
type: Boolean,
default: false
},
verified: {
type: Boolean,
default: false
}
},
reaction: {
like: {
type: Number,
default: 0
},
dislike: {
type: Number,
default: 0
},
total: {
type: Number,
default: 0
}
},
installations: {
day: {
type: Number,
default: 0
},
week: {
type: Number,
default: 0
},
month: {
type: Number,
default: 0
},
total: {
type: Number,
default: 0
}
},
moderated: {
type: Boolean,
default: false
},
aiModeration: {
checked: {
type: Boolean,
default: false
},
isFlagged: {
type: Boolean,
default: false
},
categoryScores: {
type: Object
}
},
stickerChannel: {
messageId: Number
}
}, {
timestamps: true
})
// Compound indexes for /packs query performance
// Covers: find({ owner, create, hide, inline/packType }).sort({ updatedAt: -1 })
stickerSetsSchema.index({ owner: 1, create: 1, hide: 1, inline: 1, packType: 1, updatedAt: -1 })
// For inline queries: find({ owner, inline }).sort({ updatedAt: -1 })
stickerSetsSchema.index({ owner: 1, inline: 1, updatedAt: -1 })
// Note: { owner: 1, hide: 1 } removed - covered by the main compound index above
module.exports = stickerSetsSchema
================================================
FILE: database/models/sticker.js
================================================
const mongoose = require('mongoose')
// NOTE on schema coexistence:
// The collection holds ~488M docs, of which ~94% still use the nested
// info.* / file.* shape from 2019-2022. A bulk rewrite is not viable
// at that scale (~weeks of writes on a single-node setup), so the
// legacy shape is treated as a FIRST-CLASS format, not tech debt.
// Every read path uses $or against both shapes; getter methods below
// normalize reads transparently. Writes go to the new shape only.
// See scripts/README.md for the full rationale.
const stickersSchema = mongoose.Schema({
stickerSet: {
type: mongoose.Schema.Types.ObjectId,
ref: 'StickerSet'
// Note: No separate index - covered by compound { stickerSet: 1, deleted: 1 } below
},
fileUniqueId: {
type: String,
index: true,
required: true
},
emojis: String,
// NEW: Flat fields (used for new documents)
fileId: String,
stickerType: String,
caption: String,
// NEW: Original file data (only if different from current)
original: {
fileId: String,
fileUniqueId: String,
stickerType: String
},
// LEGACY: Keep for backwards compatibility (old documents)
info: {
stickerType: String,
file_id: String,
file_unique_id: String,
caption: String
},
file: {
stickerType: String,
file_id: String,
file_unique_id: String
},
deleted: {
type: Boolean,
default: false
},
// NEW: For TTL auto-cleanup
deletedAt: {
type: Date,
default: null
}
}, {
timestamps: true
})
// ===================
// GETTER METHODS
// Read from new format OR fallback to legacy
// ===================
stickersSchema.methods.getFileId = function () {
return this.fileId || (this.info && this.info.file_id)
}
stickersSchema.methods.getStickerType = function () {
return this.stickerType || (this.info && this.info.stickerType) || 'sticker'
}
stickersSchema.methods.getCaption = function () {
return this.caption || (this.info && this.info.caption)
}
stickersSchema.methods.getOriginalFileId = function () {
return (this.original && this.original.fileId) || (this.file && this.file.file_id)
}
stickersSchema.methods.getOriginalFileUniqueId = function () {
return (this.original && this.original.fileUniqueId) || (this.file && this.file.file_unique_id)
}
stickersSchema.methods.hasOriginal = function () {
return !!((this.original && this.original.fileId) || (this.file && this.file.file_id))
}
stickersSchema.methods.getOriginalStickerType = function () {
return (this.original && this.original.stickerType) || (this.file && this.file.stickerType)
}
// ===================
// INDEXES
// ===================
// Text index for search (supports both old and new caption fields)
stickersSchema.index({ caption: 'text', 'info.caption': 'text' })
// Compound index for inline queries (stickerSet + deleted)
stickersSchema.index({ stickerSet: 1, deleted: 1 })
// Single field index - highly selective, covers most lookups
stickersSchema.index({ fileUniqueId: 1 })
// Index for duplicate detection on original files
stickersSchema.index({ 'original.fileUniqueId': 1 }, { sparse: true })
// TTL index - auto-delete documents 30 days after deletedAt is set
// Note: Created manually in MongoDB, not via Mongoose to avoid recreation issues
// db.stickers.createIndex({ deletedAt: 1 }, { expireAfterSeconds: 2592000, partialFilterExpression: { deletedAt: { $type: "date" } } })
// stickersSchema.index(
// { deletedAt: 1 },
// {
// expireAfterSeconds: 30 * 24 * 60 * 60, // 30 days
// partialFilterExpression: { deletedAt: { $type: 'date' } }
// }
// )
module.exports = stickersSchema
================================================
FILE: database/models/user.js
================================================
const mongoose = require('mongoose')
const userSchema = mongoose.Schema({
telegram_id: {
type: Number,
index: true,
unique: true,
required: true
},
// Display-only field, never load-bearing. Telegram User.first_name is
// formally required by Bot API, but in practice it can arrive empty or
// missing (deleted/deactivated accounts, rare anonymous-sender edges).
// Mongoose String `required: true` rejects empty strings too, which
// would crash persistUserIfDirty on those updates — so we keep the
// field optional and let renderers handle the empty case.
first_name: String,
last_name: String,
username: String,
stickerSet: {
type: mongoose.Schema.Types.ObjectId,
ref: 'StickerSet',
index: true
},
inlineStickerSet: {
type: mongoose.Schema.Types.ObjectId,
ref: 'StickerSet',
index: true
},
inlineType: {
type: String
},
newsSubscribedDate: {
type: Date
},
balance: {
type: Number,
default: 0
},
locale: {
type: String
// Note: No separate index - covered by compound { locale: 1, blocked: 1 } below
},
blocked: {
type: Boolean,
default: false,
index: true
},
adminRights: {
type: Array,
default: []
},
webapp: {
country: String,
platform: String,
browser: String,
version: String,
os: String
},
moderator: {
type: Boolean,
default: false
},
banned: {
type: Boolean,
default: false
},
publicBan: {
type: Boolean,
default: false
},
packsCount: {
regular: { type: Number, default: 0 },
custom_emoji: { type: Number, default: 0 },
inline: { type: Number, default: 0 }
}
}, {
timestamps: true
})
// Compound index for messaging queries
userSchema.index({ locale: 1, blocked: 1 })
module.exports = userSchema
================================================
FILE: docker-compose.yml
================================================
services:
mongo:
image: mongo
restart: always
volumes:
- mongo-data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
redis:
image: redis:7-alpine
restart: always
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
fstikbot:
build: .
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
restart: always
env_file:
- .env
volumes:
mongo-data:
================================================
FILE: docs/superpowers/plans/2026-04-05-emoji-mosaic.md
================================================
# Emoji Mosaic Implementation Plan
> **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.
**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.
**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.
**Tech Stack:** Telegraf v3 scenes, Sharp (image processing), MongoDB/Mongoose (sticker count queries), Telegram Bot API (uploadStickerFile, addStickerToSet, sendMessage with custom_emoji entities)
**Spec:** `docs/superpowers/specs/2026-04-05-emoji-mosaic-design.md`
---
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `utils/mosaic-grid.js` | Create | Grid recommendation algorithm (aspect ratio → rows/cols suggestions) |
| `utils/mosaic-preview.js` | Create | Sharp: generate preview image with dashed grid overlay |
| `utils/mosaic-split.js` | Create | Sharp: split image into NxM cells, each 100×100 WEBP |
| `scenes/mosaic.js` | Create | Scene with looping flow: waitPhoto → waitGrid → upload → result → loop |
| `scenes/index.js` | Modify | Register mosaic scene in Stage |
| `handlers/packs.js` | Modify | Add "Mosaic" button for custom_emoji packs |
| `locales/en.yaml` | Modify | English strings for mosaic feature |
| `locales/uk.yaml` | Modify | Ukrainian strings for mosaic feature |
---
### Task 1: Grid Recommendation Algorithm (`utils/mosaic-grid.js`)
**Files:**
- Create: `utils/mosaic-grid.js`
This is a pure function with no external dependencies — good starting point.
- [ ] **Step 1: Create `utils/mosaic-grid.js` with `getGridSuggestions`**
```javascript
const getGridSuggestions = (width, height, freeSlots = 200) => {
const ratio = width / height
// Determine type
if (ratio >= 2.5) return getStripSuggestions(ratio, 'horizontal', freeSlots)
if (ratio <= 0.4) return getStripSuggestions(1 / ratio, 'vertical', freeSlots)
return getGridOptions(ratio, freeSlots)
}
const getStripSuggestions = (ratio, direction, freeSlots) => {
const count = Math.max(3, Math.min(10, Math.round(ratio)))
const isHorizontal = direction === 'horizontal'
const options = []
for (let delta = -2; delta <= 2; delta++) {
const n = count + delta
if (n < 3 || n > 10 || n > freeSlots) continue
const rows = isHorizontal ? 1 : n
const cols = isHorizontal ? n : 1
options.push({ rows, cols, total: n })
}
if (options.length === 0) return { type: 'no_space', options: [] }
const recommended = options.find(o => o.total === count) || options[Math.floor(options.length / 2)]
const alternatives = options.filter(o => o !== recommended).slice(0, 3)
return { type: 'strip', recommended, alternatives }
}
const getGridOptions = (ratio, freeSlots) => {
const candidates = []
for (let rows = 2; rows <= 10; rows++) {
for (let cols = 2; cols <= 10; cols++) {
const total = rows * cols
if (total > 50 || total > freeSlots) continue
const gridRatio = cols / rows
const ratioScore = Math.abs(gridRatio - ratio) / ratio
// How close each cell is to square (1:1)
const cellRatio = (ratio / gridRatio)
const squareScore = Math.abs(1 - cellRatio)
// Prefer medium-sized grids
const sizeScore = Math.abs(total - 12) / 50
const score = ratioScore * 2 + squareScore + sizeScore * 0.5
candidates.push({ rows, cols, total, score })
}
}
if (candidates.length === 0) return { type: 'no_space', options: [] }
candidates.sort((a, b) => a.score - b.score)
const recommended = candidates[0]
// Pick alternatives: one smaller, one medium, one larger than recommended
const smaller = candidates.find(c => c.total < recommended.total && c !== recommended)
const larger = candidates.find(c => c.total > recommended.total && c !== recommended)
const largest = candidates.find(c => c.total > (larger?.total || 0) && c !== recommended && c !== larger)
const alternatives = [smaller, larger, largest].filter(Boolean).slice(0, 3)
return { type: 'grid', recommended, alternatives }
}
module.exports = { getGridSuggestions }
```
- [ ] **Step 2: Manual test with node REPL**
Run: `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))"`
Expected:
- 1200×800 (3:2 landscape) → type: "grid", recommended ~3×4 or 2×3
- 2000×400 (5:1 panorama) → type: "strip", recommended 1×5
- 800×800 (square) → type: "grid", recommended ~3×3
- 600×1200 (1:2 portrait) → type: "grid", recommended ~4×2 or similar
- [ ] **Step 3: Commit**
```bash
git add utils/mosaic-grid.js
git commit -m "feat(mosaic): add grid recommendation algorithm"
```
---
### Task 2: Preview Generation (`utils/mosaic-preview.js`)
**Files:**
- Create: `utils/mosaic-preview.js`
**Depends on:** Nothing (standalone Sharp utility)
- [ ] **Step 1: Create `utils/mosaic-preview.js`**
```javascript
const sharp = require('sharp')
const generatePreview = async (imageBuffer, rows, cols) => {
const image = sharp(imageBuffer, {
failOnError: false,
limitInputPixels: false
})
const metadata = await image.metadata()
// Resize to max 512px on longest side for preview
const scale = Math.min(512 / metadata.width, 512 / metadata.height, 1)
const previewWidth = Math.round(metadata.width * scale)
const previewHeight = Math.round(metadata.height * scale)
// Use floor-based coordinates to match actual split boundaries
// (same math as splitImage uses on the original)
const cellW = Math.floor(previewWidth / cols)
const cellH = Math.floor(previewHeight / rows)
// Crop preview to exact grid area (discard remainder pixels)
const cropWidth = cellW * cols
const cropHeight = cellH * rows
const strokeWidth = 2
const lines = []
// Vertical lines (at floor-based cell boundaries)
for (let c = 1; c < cols; c++) {
const x = c * cellW
lines.push(`<line x1="${x}" y1="0" x2="${x}" y2="${cropHeight}" stroke="white" stroke-width="${strokeWidth}" stroke-dasharray="8,6" stroke-opacity="0.85"/>`)
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"/>`)
}
// Horizontal lines (at floor-based cell boundaries)
for (let r = 1; r < rows; r++) {
const y = r * cellH
lines.push(`<line x1="0" y1="${y}" x2="${cropWidth}" y2="${y}" stroke="white" stroke-width="${strokeWidth}" stroke-dasharray="8,6" stroke-opacity="0.85"/>`)
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"/>`)
}
// Grid size label in center
const label = `${rows}×${cols}`
const fontSize = Math.max(24, Math.round(previewWidth / 10))
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)"/>`)
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>`)
const svgOverlay = Buffer.from(
`<svg width="${cropWidth}" height="${cropHeight}">${lines.join('')}</svg>`
)
const result = await image
.clone()
.resize(previewWidth, previewHeight, { fit: 'fill' })
.extract({ left: 0, top: 0, width: cropWidth, height: cropHeight })
.composite([{ input: svgOverlay, top: 0, left: 0 }])
.webp({ quality: 80 })
.toBuffer()
return result
}
module.exports = { generatePreview }
```
- [ ] **Step 2: Manual test — generate preview and save to disk**
Run: `cd /Users/ly/dev/fStikBot && node -e "
const sharp = require('sharp');
const { generatePreview } = require('./utils/mosaic-preview');
// Create a test image 600x400
sharp({ create: { width: 600, height: 400, channels: 3, background: { r: 100, g: 150, b: 200 } } })
.jpeg().toBuffer()
.then(buf => generatePreview(buf, 3, 4))
.then(result => { require('fs').writeFileSync('/tmp/mosaic-preview-test.webp', result); console.log('Preview saved to /tmp/mosaic-preview-test.webp, size:', result.length); })
.catch(err => console.error(err))
"`
Expected: File created at `/tmp/mosaic-preview-test.webp`, viewable, shows a 3×4 grid overlay.
- [ ] **Step 3: Commit**
```bash
git add utils/mosaic-preview.js
git commit -m "feat(mosaic): add preview generation with grid overlay"
```
---
### Task 3: Image Splitting (`utils/mosaic-split.js`)
**Files:**
- Create: `utils/mosaic-split.js`
**Depends on:** Nothing (standalone Sharp utility)
- [ ] **Step 1: Create `utils/mosaic-split.js`**
```javascript
const sharp = require('sharp')
const splitImage = async (imageBuffer, rows, cols) => {
const image = sharp(imageBuffer, {
failOnError: false,
limitInputPixels: false
})
const metadata = await image.metadata()
const cellWidth = Math.floor(metadata.width / cols)
const cellHeight = Math.floor(metadata.height / rows)
const cells = []
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const cell = await image
.clone()
.extract({
left: c * cellWidth,
top: r * cellHeight,
width: cellWidth,
height: cellHeight
})
.resize(100, 100, { fit: 'fill' })
.webp({ quality: 90 })
.toBuffer()
cells.push(cell)
}
}
return cells
}
const checkMinCellSize = (width, height, rows, cols) => {
const cellWidth = Math.floor(width / cols)
const cellHeight = Math.floor(height / rows)
return cellWidth >= 80 && cellHeight >= 80
}
module.exports = { splitImage, checkMinCellSize }
```
- [ ] **Step 2: Manual test — split a test image**
Run: `cd /Users/ly/dev/fStikBot && node -e "
const sharp = require('sharp');
const { splitImage, checkMinCellSize } = require('./utils/mosaic-split');
sharp({ create: { width: 600, height: 400, channels: 3, background: { r: 100, g: 150, b: 200 } } })
.jpeg().toBuffer()
.then(buf => splitImage(buf, 3, 4))
.then(cells => {
console.log('Cells count:', cells.length);
console.log('First cell size:', cells[0].length, 'bytes');
return sharp(cells[0]).metadata();
})
.then(meta => console.log('Cell dimensions:', meta.width, 'x', meta.height, meta.format))
.catch(err => console.error(err));
console.log('Min cell check 600x400 3x4:', checkMinCellSize(600, 400, 3, 4));
console.log('Min cell check 150x200 3x4:', checkMinCellSize(150, 200, 3, 4));
"`
Expected:
- 12 cells (3×4)
- Each cell: 100×100 webp
- `checkMinCellSize(600,400,3,4)` → true (150×133)
- `checkMinCellSize(150,200,3,4)` → false (37×66)
- [ ] **Step 3: Commit**
```bash
git add utils/mosaic-split.js
git commit -m "feat(mosaic): add image splitting utility"
```
---
### Task 4: Locale Strings
**Files:**
- Modify: `locales/en.yaml`
- Modify: `locales/uk.yaml`
**Depends on:** Nothing
- [ ] **Step 1: Add English locale strings to `locales/en.yaml`**
Add at the end of the file:
```yaml
mosaic:
enter: |
🔲 Mosaic mode for <b>${packTitle}</b>
Send a photo to split into custom emoji grid.
no_pack: |
You need a custom emoji pack first.
Use /new to create one and select "Custom Emoji" type.
choose_grid: |
📐 Choose grid size:
btn:
recommended: "✅ ${rows}×${cols}"
option: "${rows}×${cols} · ${total}pcs"
custom: "✏️ Custom size"
cancel: "❌ Cancel"
exit: "🚪 Exit mosaic"
undo: "🗑 Remove this mosaic"
custom_prompt: |
Enter grid size (e.g. 3x4):
custom_invalid: |
Invalid format. Use e.g. 3x4 (rows from 1 to 10, cols from 1 to 10, max 50 total).
no_space: |
Not enough space in pack. ${freeSlots} slots left, but ${total} needed.
Choose a smaller grid or create a new pack with /new.
blurry_warning: |
⚠️ Source image is small — result may be blurry at this grid size.
uploading: "⏳ Uploading ${current}/${total}..."
done: |
✅ Mosaic ${rows}×${cols} added to pack!
done_link: "📦 Use pack"
undo_done: |
🗑 Mosaic removed (${count} emoji deleted from pack).
undo_failed: |
❌ Could not remove some emoji. Try deleting manually.
wait_photo: |
Send another photo or tap Exit.
```
- [ ] **Step 2: Add Ukrainian locale strings to `locales/uk.yaml`**
Add at the end of the file:
```yaml
mosaic:
enter: |
🔲 Режим мозаїки для <b>${packTitle}</b>
Надішліть фото для розрізання на сітку емодзі.
no_pack: |
Спочатку потрібен пак кастомних емодзі.
Використайте /new і оберіть тип "Custom Emoji".
choose_grid: |
📐 Оберіть розмір сітки:
btn:
recommended: "✅ ${rows}×${cols}"
option: "${rows}×${cols} · ${total}шт"
custom: "✏️ Свій розмір"
cancel: "❌ Скасувати"
exit: "🚪 Вийти з мозаїки"
undo: "🗑 Видалити цю мозаїку"
custom_prompt: |
Введіть розмір сітки (напр. 3x4):
custom_invalid: |
Невірний формат. Наприклад 3x4 (рядки від 1 до 10, стовпці від 1 до 10, макс 50 всього).
no_space: |
Недостатньо місця в паку. Вільно ${freeSlots} слотів, потрібно ${total}.
Оберіть меншу сітку або створіть новий пак через /new.
blurry_warning: |
⚠️ Зображення замале — результат може бути розмитим при цьому розмірі сітки.
uploading: "⏳ Завантаження ${current}/${total}..."
done: |
✅ Мозаїка ${rows}×${cols} додана в пак!
done_link: "📦 Використати пак"
undo_done: |
🗑 Мозаїку видалено (${count} емодзі видалено з пака).
undo_failed: |
❌ Не вдалося видалити деякі емодзі. Спробуйте вручну.
wait_photo: |
Надішліть інше фото або натисніть Вийти.
```
- [ ] **Step 3: Commit**
```bash
git add locales/en.yaml locales/uk.yaml
git commit -m "feat(mosaic): add en/uk locale strings"
```
---
### Task 5: Mosaic Scene (`scenes/mosaic.js`)
**Files:**
- Create: `scenes/mosaic.js`
**Depends on:** Tasks 1-4
This is the core file. It wires together grid suggestions, preview, splitting, upload, and mosaic message.
- [ ] **Step 1: Create `scenes/mosaic.js` — scene setup and enter handler**
```javascript
const Scene = require('telegraf/scenes/base')
const Markup = require('telegraf/markup')
const { getGridSuggestions } = require('../utils/mosaic-grid')
const { generatePreview } = require('../utils/mosaic-preview')
const { splitImage, checkMinCellSize } = require('../utils/mosaic-split')
const https = require('https')
const mosaic = new Scene('mosaic')
// Helper: download file buffer from Telegram
const downloadFile = (fileUrl, timeout = 30000) => new Promise((resolve, reject) => {
const data = []
let totalSize = 0
const MAX_SIZE = 20 * 1024 * 1024
const req = https.get(fileUrl, (response) => {
if (response.statusCode !== 200) {
req.destroy()
reject(new Error(`Download failed: ${response.statusCode}`))
return
}
response.on('data', (chunk) => {
totalSize += chunk.length
if (totalSize > MAX_SIZE) {
req.destroy()
reject(new Error('File too large'))
return
}
data.push(chunk)
})
response.on('end', () => resolve(Buffer.concat(data)))
})
req.on('error', reject)
req.setTimeout(timeout, () => { req.destroy(); reject(new Error('Timeout')) })
})
// Helper: build inline keyboard for grid selection
const buildGridKeyboard = (ctx, suggestions) => {
const { recommended, alternatives } = suggestions
const buttons = []
// Row 1: recommended
buttons.push([
Markup.callbackButton(
ctx.i18n.t('cmd.mosaic.btn.recommended', { rows: recommended.rows, cols: recommended.cols }),
`mosaic:grid:${recommended.rows}:${recommended.cols}`
)
])
// Row 2: alternatives
if (alternatives.length > 0) {
buttons.push(alternatives.map(alt =>
Markup.callbackButton(
ctx.i18n.t('cmd.mosaic.btn.option', { rows: alt.rows, cols: alt.cols, total: alt.total }),
`mosaic:grid:${alt.rows}:${alt.cols}`
)
))
}
// Row 3: custom + cancel
buttons.push([
Markup.callbackButton(ctx.i18n.t('cmd.mosaic.btn.custom'), 'mosaic:custom'),
Markup.callbackButton(ctx.i18n.t('cmd.mosaic.btn.cancel'), 'mosaic:cancel')
])
// Row 4: exit
buttons.push([
Markup.callbackButton(ctx.i18n.t('cmd.mosaic.btn.exit'), 'mosaic:exit')
])
return Markup.inlineKeyboard(buttons)
}
mosaic.enter(async (ctx) => {
if (!ctx.session.scene) ctx.session.scene = {}
ctx.session.scene.mosaic = {}
// Check if user has a custom_emoji pack selected
const userInfo = ctx.session.userInfo
if (!userInfo || !userInfo.stickerSet) {
await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_pack'))
return ctx.scene.leave()
}
const stickerSet = await ctx.db.StickerSet.findById(userInfo.stickerSet)
if (!stickerSet || stickerSet.packType !== 'custom_emoji') {
await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_pack'))
return ctx.scene.leave()
}
ctx.session.scene.mosaic.packId = stickerSet.id
ctx.session.scene.mosaic.packName = stickerSet.name
await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.enter', {
packTitle: stickerSet.title
}), {
reply_markup: Markup.keyboard([
[{ text: ctx.i18n.t('cmd.mosaic.btn.exit') }]
]).resize()
})
})
module.exports = mosaic
```
- [ ] **Step 2: Add photo handler — generate preview and show grid options**
Append to `scenes/mosaic.js` before `module.exports`:
```javascript
mosaic.on('photo', async (ctx) => {
if (!ctx.session.scene?.mosaic) return ctx.scene.leave()
// Block new photos while uploading
if (ctx.session.scene.mosaic.uploading) {
return ctx.replyWithHTML('⏳ Please wait, upload in progress...')
}
const photo = ctx.message.photo
const largest = photo[photo.length - 1]
// Download the photo
const fileUrl = await ctx.telegram.getFileLink(largest.file_id)
const imageBuffer = await downloadFile(fileUrl.href || fileUrl)
const width = largest.width
const height = largest.height
// Count existing stickers in pack
const stickerSet = await ctx.db.StickerSet.findById(ctx.session.scene.mosaic.packId)
const currentCount = await ctx.db.Sticker.countDocuments({
stickerSet: stickerSet.id,
deleted: false
})
const freeSlots = 200 - currentCount
const suggestions = getGridSuggestions(width, height, freeSlots)
if (suggestions.type === 'no_space') {
await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_space', { freeSlots, total: 4 }))
return
}
// Store in scene state
ctx.session.scene.mosaic.photoFileId = largest.file_id
ctx.session.scene.mosaic.photoWidth = width
ctx.session.scene.mosaic.photoHeight = height
ctx.session.scene.mosaic.imageBuffer = null // Don't store buffer in session
ctx.session.scene.mosaic.freeSlots = freeSlots
// Generate preview with recommended grid
const { recommended } = suggestions
const previewBuffer = await generatePreview(imageBuffer, recommended.rows, recommended.cols)
// Check for blurry warning
const isBlurry = !checkMinCellSize(width, height, recommended.rows, recommended.cols)
const blurryText = isBlurry ? '\n' + ctx.i18n.t('cmd.mosaic.blurry_warning') : ''
const msg = await ctx.replyWithPhoto(
{ source: previewBuffer },
{
caption: ctx.i18n.t('cmd.mosaic.choose_grid') + blurryText,
parse_mode: 'HTML',
reply_markup: buildGridKeyboard(ctx, suggestions)
}
)
ctx.session.scene.mosaic.previewMessageId = msg.message_id
})
```
- [ ] **Step 3: Add grid selection callback — split, upload, send mosaic**
Append to `scenes/mosaic.js` before `module.exports`:
```javascript
mosaic.action(/^mosaic:grid:(\d+):(\d+)$/, async (ctx) => {
if (!ctx.session.scene?.mosaic) return ctx.scene.leave()
const rows = parseInt(ctx.match[1])
const cols = parseInt(ctx.match[2])
const total = rows * cols
const state = ctx.session.scene.mosaic
await ctx.answerCbQuery()
// Validate space
if (total > state.freeSlots) {
return ctx.answerCbQuery(ctx.i18n.t('cmd.mosaic.no_space', {
freeSlots: state.freeSlots, total
}), true)
}
// Download photo again (not stored in session)
const fileUrl = await ctx.telegram.getFileLink(state.photoFileId)
const imageBuffer = await downloadFile(fileUrl.href || fileUrl)
// Check min cell size
const isBlurry = !checkMinCellSize(state.photoWidth, state.photoHeight, rows, cols)
// Send progress message
const progressMsg = await ctx.replyWithHTML(
ctx.i18n.t('cmd.mosaic.uploading', { current: 0, total })
)
// Split image
const cells = await splitImage(imageBuffer, rows, cols)
// Upload all cells to the pack
const stickerSet = await ctx.db.StickerSet.findById(state.packId)
const uploadedIds = []
const uploadedFileIds = []
for (let i = 0; i < cells.length; i++) {
const r = Math.floor(i / cols) + 1
const c = (i % cols) + 1
// Upload sticker file
const uploaded = await ctx.telegram.callApi('uploadStickerFile', {
user_id: ctx.from.id,
sticker_format: 'static',
sticker: { source: cells[i] }
})
// Add to set
await ctx.telegram.callApi('addStickerToSet', {
user_id: ctx.from.id,
name: stickerSet.name,
sticker: {
sticker: uploaded.file_id,
format: 'static',
emoji_list: ['🔲'],
keywords: ['mosaic', `r${r}c${c}`]
}
})
// Get the sticker info to find custom_emoji_id
const setInfo = await ctx.telegram.callApi('getStickerSet', { name: stickerSet.name })
const lastSticker = setInfo.stickers[setInfo.stickers.length - 1]
uploadedIds.push(lastSticker.custom_emoji_id)
uploadedFileIds.push(lastSticker.file_id)
// Save sticker to DB
await ctx.db.Sticker.addSticker(stickerSet.id, '🔲', {
file_id: lastSticker.file_id,
file_unique_id: lastSticker.file_unique_id,
stickerType: 'custom_emoji'
})
// Update progress every 3 uploads
if ((i + 1) % 3 === 0 || i === cells.length - 1) {
await ctx.telegram.editMessageText(
ctx.chat.id,
progressMsg.message_id,
null,
ctx.i18n.t('cmd.mosaic.uploading', { current: i + 1, total })
).catch(() => {})
await ctx.telegram.callApi('sendChatAction', {
chat_id: ctx.chat.id,
action: 'upload_document'
}).catch(() => {})
}
}
// Delete progress message
await ctx.telegram.deleteMessage(ctx.chat.id, progressMsg.message_id).catch(() => {})
// Build mosaic message with custom_emoji entities
const placeholder = '\u2B1C' // ⬜ white square as placeholder
let text = ''
const entities = []
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const idx = r * cols + c
const offset = text.length
text += placeholder
entities.push({
type: 'custom_emoji',
offset,
length: placeholder.length,
custom_emoji_id: uploadedIds[idx]
})
}
if (r < rows - 1) text += '\n'
}
// Add pack link
const packLink = `${ctx.config.emojiLinkPrefix}${stickerSet.name}`
text += '\n\n'
const linkOffset = text.length
text += ctx.i18n.t('cmd.mosaic.done', { rows, cols })
await ctx.telegram.callApi('sendMessage', {
chat_id: ctx.chat.id,
text,
entities,
reply_markup: Markup.inlineKeyboard([
[Markup.urlButton(ctx.i18n.t('cmd.mosaic.done_link'), packLink)],
[Markup.callbackButton(ctx.i18n.t('cmd.mosaic.btn.undo'), 'mosaic:undo')]
])
})
// Store uploaded file IDs for undo
state.lastMosaicIds = uploadedFileIds
state.lastMosaicCount = total
// Ready for next photo
await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.wait_photo'))
})
```
- [ ] **Step 4: Add custom size handler**
Append to `scenes/mosaic.js` before `module.exports`:
```javascript
mosaic.action('mosaic:custom', async (ctx) => {
if (!ctx.session.scene?.mosaic) return ctx.scene.leave()
await ctx.answerCbQuery()
ctx.session.scene.mosaic.waitingCustom = true
await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.custom_prompt'))
})
mosaic.on('text', async (ctx) => {
if (!ctx.session.scene?.mosaic) return ctx.scene.leave()
// Only handle text if waiting for custom input
if (!ctx.session.scene.mosaic.waitingCustom) return
const text = ctx.message.text.trim()
// Flexible parsing: 3x4, 3×4, 3*4, 3:4, 3 на 4
const match = text.match(/^(\d+)\s*[x×*:]\s*(\d+)$/i) ||
text.match(/^(\d+)\s+(?:на|by|on)\s+(\d+)$/i)
if (!match) {
return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.custom_invalid'))
}
const rows = parseInt(match[1])
const cols = parseInt(match[2])
const total = rows * cols
if (rows < 1 || rows > 10 || cols < 1 || cols > 10 || total > 50) {
return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.custom_invalid'))
}
const state = ctx.session.scene.mosaic
if (total > state.freeSlots) {
return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_space', {
freeSlots: state.freeSlots, total
}))
}
state.waitingCustom = false
// Trigger the same logic as grid callback
// Simulate the action by calling the handler logic directly
ctx.match = [null, String(rows), String(cols)]
return mosaic.middleware()[0] // This won't work — we need a different approach
})
```
Actually, extract the split-upload-send logic into a shared function. **Revise Step 3 and Step 4:**
Replace the grid action handler and custom text handler with a shared `processMosaic` function. The full revised code for steps 3+4:
```javascript
// Retry helper with exponential backoff
const retry = async (fn, maxRetries = 3) => {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn()
} catch (err) {
if (attempt === maxRetries) throw err
const delay = Math.pow(2, attempt) * 1000 // 1s, 2s, 4s
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
// Colored square fallbacks for variety in emoji search
const FALLBACK_EMOJI = ['🟥', '🟧', '🟨', '🟩', '🟦', '🟪', '🟫', '⬛', '⬜', '🔲']
// Shared function: split, upload, send mosaic
const processMosaic = async (ctx, rows, cols) => {
const state = ctx.session.scene.mosaic
const total = rows * cols
// Lock: prevent concurrent processing
if (state.uploading) {
await ctx.replyWithHTML('⏳ Please wait, upload in progress...')
return
}
state.uploading = true
try {
// Download photo again
const fileUrl = await ctx.telegram.getFileLink(state.photoFileId)
const imageBuffer = await downloadFile(fileUrl.href || fileUrl)
// Send progress message
const progressMsg = await ctx.replyWithHTML(
ctx.i18n.t('cmd.mosaic.uploading', { current: 0, total })
)
// Split image
const cells = await splitImage(imageBuffer, rows, cols)
// Upload all cells to the pack
const stickerSet = await ctx.db.StickerSet.findById(state.packId)
const uploadedIds = []
const uploadedFileIds = []
for (let i = 0; i < cells.length; i++) {
const r = Math.floor(i / cols) + 1
const c = (i % cols) + 1
const fallbackEmoji = FALLBACK_EMOJI[i % FALLBACK_EMOJI.length]
try {
const uploaded = await retry(() =>
ctx.telegram.callApi('uploadStickerFile', {
user_id: ctx.from.id,
sticker_format: 'static',
sticker: { source: cells[i] }
})
)
await retry(() =>
ctx.telegram.callApi('addStickerToSet', {
user_id: ctx.from.id,
name: stickerSet.name,
sticker: {
sticker: uploaded.file_id,
format: 'static',
emoji_list: [fallbackEmoji],
keywords: ['mosaic', `r${r}c${c}`]
}
})
)
const setInfo = await ctx.telegram.callApi('getStickerSet', { name: stickerSet.name })
const lastSticker = setInfo.stickers[setInfo.stickers.length - 1]
uploadedIds.push(lastSticker.custom_emoji_id)
uploadedFileIds.push(lastSticker.file_id)
await ctx.db.Sticker.addSticker(stickerSet.id, fallbackEmoji, {
file_id: lastSticker.file_id,
file_unique_id: lastSticker.file_unique_id,
stickerType: 'custom_emoji'
})
} catch (err) {
// Upload failed after retries — rollback all uploaded stickers
for (const fileId of uploadedFileIds) {
await ctx.telegram.callApi('deleteStickerFromSet', { sticker: fileId }).catch(() => {})
await ctx.db.Sticker.updateOne(
{ fileId, stickerSet: stickerSet.id },
{ $set: { deleted: true, deletedAt: new Date() } }
).catch(() => {})
}
await ctx.telegram.deleteMessage(ctx.chat.id, progressMsg.message_id).catch(() => {})
await ctx.replyWithHTML(`❌ Upload failed at piece ${i + 1}/${total}. All uploaded pieces rolled back. Try again.`)
return
}
if ((i + 1) % 3 === 0 || i === cells.length - 1) {
await ctx.telegram.editMessageText(
ctx.chat.id, progressMsg.message_id, null,
ctx.i18n.t('cmd.mosaic.uploading', { current: i + 1, total })
).catch(() => {})
await ctx.telegram.callApi('sendChatAction', {
chat_id: ctx.chat.id, action: 'choose_sticker'
}).catch(() => {})
}
}
await ctx.telegram.deleteMessage(ctx.chat.id, progressMsg.message_id).catch(() => {})
// Build mosaic message
const placeholder = '\u2B1C'
let text = ''
const entities = []
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const idx = r * cols + c
const offset = text.length
text += placeholder
entities.push({
type: 'custom_emoji',
offset,
length: placeholder.length,
custom_emoji_id: uploadedIds[idx]
})
}
if (r < rows - 1) text += '\n'
}
const packLink = `${ctx.config.emojiLinkPrefix}${stickerSet.name}`
text += '\n\n' + ctx.i18n.t('cmd.mosaic.done', { rows, cols })
await ctx.telegram.callApi('sendMessage', {
chat_id: ctx.chat.id,
text,
entities,
reply_markup: Markup.inlineKeyboard([
[Markup.urlButton(ctx.i18n.t('cmd.mosaic.done_link'), packLink)],
[Markup.callbackButton(ctx.i18n.t('cmd.mosaic.btn.undo'), 'mosaic:undo')]
])
})
state.lastMosaicIds = uploadedFileIds
state.lastMosaicCount = total
state.waitingCustom = false
await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.wait_photo'))
} finally {
state.uploading = false
}
}
// Grid selection callback
mosaic.action(/^mosaic:grid:(\d+):(\d+)$/, async (ctx) => {
if (!ctx.session.scene?.mosaic) return ctx.scene.leave()
await ctx.answerCbQuery()
const rows = parseInt(ctx.match[1])
const cols = parseInt(ctx.match[2])
const total = rows * cols
const state = ctx.session.scene.mosaic
if (total > state.freeSlots) {
return ctx.answerCbQuery(ctx.i18n.t('cmd.mosaic.no_space', {
freeSlots: state.freeSlots, total
}), true)
}
return processMosaic(ctx, rows, cols)
})
// Custom size: prompt
mosaic.action('mosaic:custom', async (ctx) => {
if (!ctx.session.scene?.mosaic) return ctx.scene.leave()
await ctx.answerCbQuery()
ctx.session.scene.mosaic.waitingCustom = true
await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.custom_prompt'))
})
// Custom size: parse text input
mosaic.on('text', async (ctx) => {
if (!ctx.session.scene?.mosaic?.waitingCustom) return
const text = ctx.message.text.trim()
const match = text.match(/^(\d+)\s*[x×*:]\s*(\d+)$/i) ||
text.match(/^(\d+)\s+(?:на|by|on)\s+(\d+)$/i)
if (!match) {
return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.custom_invalid'))
}
const rows = parseInt(match[1])
const cols = parseInt(match[2])
const total = rows * cols
if (rows < 1 || rows > 10 || cols < 1 || cols > 10 || total > 50) {
return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.custom_invalid'))
}
const state = ctx.session.scene.mosaic
if (total > state.freeSlots) {
return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_space', {
freeSlots: state.freeSlots, total
}))
}
return processMosaic(ctx, rows, cols)
})
```
- [ ] **Step 5: Add cancel, undo, and exit handlers**
Append to `scenes/mosaic.js` before `module.exports`:
```javascript
// Cancel current photo
mosaic.action('mosaic:cancel', async (ctx) => {
if (!ctx.session.scene?.mosaic) return ctx.scene.leave()
await ctx.answerCbQuery()
ctx.session.scene.mosaic.photoFileId = null
ctx.session.scene.mosaic.waitingCustom = false
await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.wait_photo'))
})
// Undo: remove last mosaic from pack
mosaic.action('mosaic:undo', async (ctx) => {
if (!ctx.session.scene?.mosaic) return ctx.scene.leave()
await ctx.answerCbQuery()
const state = ctx.session.scene.mosaic
if (!state.lastMosaicIds || state.lastMosaicIds.length === 0) {
return ctx.answerCbQuery('Nothing to undo', true)
}
let deleted = 0
for (const fileId of state.lastMosaicIds) {
try {
await ctx.telegram.callApi('deleteStickerFromSet', { sticker: fileId })
await ctx.db.Sticker.updateOne(
{ fileId, stickerSet: state.packId },
{ $set: { deleted: true, deletedAt: new Date() } }
)
deleted++
} catch (e) {
// Sticker may already be deleted
}
}
state.lastMosaicIds = []
if (deleted > 0) {
await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.undo_done', { count: deleted }))
} else {
await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.undo_failed'))
}
})
// Exit scene
mosaic.action('mosaic:exit', async (ctx) => {
await ctx.answerCbQuery()
delete ctx.session.scene.mosaic
await ctx.scene.leave()
})
mosaic.hears(/🚪/, async (ctx) => {
delete ctx.session.scene.mosaic
await ctx.scene.leave()
})
```
- [ ] **Step 6: Test scene loads without errors**
Run: `cd /Users/ly/dev/fStikBot && node -e "const mosaic = require('./scenes/mosaic'); console.log('Scene name:', mosaic.id); console.log('Type:', typeof mosaic.middleware)"`
Expected: `Scene name: mosaic`, `Type: function`
- [ ] **Step 7: Commit**
```bash
git add scenes/mosaic.js
git commit -m "feat(mosaic): add mosaic scene with full split/upload/preview flow"
```
---
### Task 6: Register Scene and Command
**Files:**
- Modify: `scenes/index.js:1-85`
- Modify: `bot.js` (command registration section)
**Depends on:** Task 5
- [ ] **Step 1: Register mosaic scene in `scenes/index.js`**
Add import after line 23 (`const donate = require('./donate')`):
```javascript
const mosaic = require('./mosaic')
```
Add `mosaic` to the Stage array (after `donate` on line 39):
```javascript
const stage = new Stage([].concat(
sceneNewPack,
originalSticker,
deleteSticker,
messaging,
packEdit,
adminPackBulkDelete,
searchStickerSet,
photoClear,
videoRound,
packCatalog,
packFrame,
packRename,
packDelete,
packAbout,
donate,
mosaic
))
```
Add `/mosaic` to the command passthrough list (line 66-82):
```javascript
stage.hears(([
'/start',
'/help',
'/packs',
'/emoji',
'/lang',
'/donate',
'/publish',
'/delete',
'/frame',
'/rename',
'/catalog',
'/mosaic'
]), async (ctx, next) => {
```
- [ ] **Step 2: Add /mosaic command in `bot.js`**
Find the section where scene entry commands are defined (near `privateMessage.hears(/\/new/`). Add:
```javascript
privateMessage.command('mosaic', (ctx) => ctx.scene.enter('mosaic'))
```
- [ ] **Step 3: Verify bot starts without errors**
Run: `cd /Users/ly/dev/fStikBot && timeout 5 node -e "require('./bot')" 2>&1 || true`
Expected: No immediate crash errors (may timeout waiting for DB, that's ok).
- [ ] **Step 4: Commit**
```bash
git add scenes/index.js bot.js
git commit -m "feat(mosaic): register scene and /mosaic command"
```
---
### Task 7: Add Mosaic Button to Pack Menu
**Files:**
- Modify: `handlers/packs.js:176-196`
**Depends on:** Task 6
- [ ] **Step 1: Add mosaic button for custom_emoji packs in `handlers/packs.js`**
Find 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):
```javascript
// Existing:
[
Markup.callbackButton(ctx.i18n.t('callback.pack.btn.frame'), 'set_frame')
],
// Add this:
...(stickerSet.packType === 'custom_emoji' ? [[
Markup.callbackButton('🔲 ' + ctx.i18n.t('callback.pack.btn.mosaic'), 'mosaic:enter')
]] : []),
```
- [ ] **Step 2: Add callback handler for mosaic:enter in `bot.js` or `handlers/packs.js`**
Add callback action handler (wherever other pack-related actions are handled):
```javascript
bot.action('mosaic:enter', (ctx) => {
ctx.answerCbQuery()
return ctx.scene.enter('mosaic')
})
```
- [ ] **Step 3: Add locale string for the button**
In `locales/en.yaml`, add under `callback.pack.btn`:
```yaml
mosaic: Mosaic
```
In `locales/uk.yaml`, add under `callback.pack.btn`:
```yaml
mosaic: Мозаїка
```
- [ ] **Step 4: Commit**
```bash
git add handlers/packs.js bot.js locales/en.yaml locales/uk.yaml
git commit -m "feat(mosaic): add mosaic button to pack menu for custom_emoji packs"
```
---
### Task 8: End-to-End Testing
**Files:** None (manual testing)
**Depends on:** Tasks 1-7
- [ ] **Step 1: Verify bot starts**
Run: `cd /Users/ly/dev/fStikBot && node index.js`
Check: No startup errors.
- [ ] **Step 2: Test /mosaic command**
In Telegram:
1. Ensure you have a custom_emoji pack selected
2. Send `/mosaic`
3. Expected: Bot replies with "Mosaic mode for {pack}. Send a photo."
- [ ] **Step 3: Test photo → preview → grid selection**
1. Send a landscape photo
2. Expected: Bot replies with preview image (photo with grid overlay) + inline buttons
3. Tap recommended grid button
4. Expected: Progress messages → mosaic message with custom emoji → pack link + undo button
- [ ] **Step 4: Test undo**
1. Tap "Remove this mosaic" button
2. Expected: Bot confirms deletion with count
- [ ] **Step 5: Test custom size**
1. Send another photo
2. Tap "Custom size"
3. Type "2x3"
4. Expected: Mosaic created with 2×3 grid
- [ ] **Step 6: Test edge cases**
1. Send a very wide panorama image → should get strip suggestions (1×N)
2. Send a small image (< 300px) → should see blurry warning
3. Type invalid custom size (e.g. "abc") → should see error message
4. Test exit button → should leave scene
- [ ] **Step 7: Final commit if any fixes were needed**
```bash
git add -A
git commit -m "fix(mosaic): fixes from e2e testing"
```
================================================
FILE: docs/superpowers/plans/2026-04-15-mosaic-input-types.md
================================================
# Mosaic Input Types Implementation Plan
> **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.
**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.
**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.
**Tech Stack:** Telegraf v3 scenes, Sharp (image processing), existing `utils/mosaic-*` modules.
**Spec:** `docs/superpowers/specs/2026-04-15-mosaic-input-types-design.md`
**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.
---
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `locales/uk.yaml` | Modify (lines 318–353 region) | Add 3 keys under `cmd.mosaic`: `reject_animated`, `reject_document`, `reject_media` |
| `locales/en.yaml` | Modify (lines 325–360 region) | Same 3 keys in English |
| `scenes/mosaic.js` | Modify | Add `getMosaicSource` helper; replace `mosaic.on('photo', ...)` with multi-type handler; add animation/video reject handler |
**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.
---
### Task 1: Add i18n keys for rejection messages
**Files:**
- Modify: `locales/uk.yaml` (after line 353, before `donate:` on line 354)
- Modify: `locales/en.yaml` (after line 360, before `donate:` on line 361)
This task is independent and safe to land alone. The keys have no runtime effect until Task 3 uses them.
- [ ] **Step 1: Add 3 keys to `locales/uk.yaml`**
Find 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):
```yaml
reject_animated: |
Анімовані/відео стікери поки не підтримую. Надішліть статичний стікер, фото або PNG/JPEG/WebP файлом.
reject_document: |
Підтримую тільки зображення (JPEG/PNG/WebP). Надішліть файл у цьому форматі.
reject_media: |
Анімації та відео поки не підтримую. Надішліть статичний стікер, фото або PNG/JPEG/WebP файлом.
```
- [ ] **Step 2: Add 3 keys to `locales/en.yaml`**
Find 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:
```yaml
reject_animated: |
Animated/video stickers aren't supported yet. Send a static sticker, a photo, or a PNG/JPEG/WebP file.
reject_document: |
Only images are supported (JPEG/PNG/WebP). Please send a file in one of these formats.
reject_media: |
Animations and videos aren't supported yet. Send a static sticker, a photo, or a PNG/JPEG/WebP file.
```
- [ ] **Step 3: Verify YAML parses**
Run:
```bash
node -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')"
```
Expected output: `ok`
If 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.
- [ ] **Step 4: Commit**
```bash
git add locales/uk.yaml locales/en.yaml
git commit -m "i18n(mosaic): add rejection messages for unsupported input types"
```
---
### Task 2: Add `getMosaicSource` helper
**Files:**
- Modify: `scenes/mosaic.js` (add helper above line 108 where `// --- Photo handler ---` comment is)
Pure 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).
- [ ] **Step 1: Insert the helper**
Find line 107 in `scenes/mosaic.js` (blank line before `// --- Photo handler ---`). Insert the following **above** the `// --- Photo handler ---` comment:
```javascript
// Normalize any accepted message into { fileId, width, height } or { error: <i18n-key> }.
// For documents, width/height come from the optional thumb — may be null, caller reads from buffer.
const IMAGE_DOCUMENT_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp'])
const getMosaicSource = (message) => {
if (message.photo && message.photo.length > 0) {
const largest = message.photo[message.photo.length - 1]
return { fileId: largest.file_id, width: largest.width, height: largest.height }
}
if (message.sticker) {
if (message.sticker.is_animated || message.sticker.is_video) {
return { error: 'cmd.mosaic.reject_animated' }
}
return {
fileId: message.sticker.file_id,
width: message.sticker.width,
height: message.sticker.height
}
}
if (message.document) {
const mime = message.document.mime_type
if (!mime || !IMAGE_DOCUMENT_MIMES.has(mime)) {
return { error: 'cmd.mosaic.reject_document' }
}
return {
fileId: message.document.file_id,
width: message.document.thumb ? message.document.thumb.width : null,
height: message.document.thumb ? message.document.thumb.height : null
}
}
// Should not be reachable — handler only binds to photo/document/sticker.
return { error: 'cmd.mosaic.reject_media' }
}
```
- [ ] **Step 2: Syntax check**
Run:
```bash
node -c scenes/mosaic.js
```
Expected: no output (silent success). If there's a syntax error, fix indentation/brackets before proceeding.
- [ ] **Step 3: Commit**
```bash
git add scenes/mosaic.js
git commit -m "feat(mosaic): add getMosaicSource helper to normalize input types"
```
---
### Task 3: Rewire handler to accept photo, document, and sticker
**Files:**
- Modify: `scenes/mosaic.js:108-167` (the `mosaic.on('photo', ...)` block)
The 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).
- [ ] **Step 1: Add sharp require**
Sharp 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.
Add this line directly after the existing `const https = require('https')` line (currently line 6):
```javascript
const sharp = require('sharp')
```
- [ ] **Step 2: Replace the photo handler**
Locate 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`).
Replace the **entire block** (lines 110–167) with:
```javascript
mosaic.on(['photo', 'document', 'sticker'], async (ctx) => {
if (!ctx.session.scene?.mosaic) return ctx.scene.leave()
// Block new input while uploading
if (ctx.session.scene.mosaic.uploading) {
return ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.uploading', { current: '...', total: '...' }))
}
const source = getMosaicSource(ctx.message)
if (source.error) {
return ctx.replyWithHTML(ctx.i18n.t(source.error))
}
// Download the source
const fileUrl = await ctx.telegram.getFileLink(source.fileId)
const imageBuffer = await downloadFile(fileUrl.href || fileUrl)
// Documents don't carry width/height on the message itself — read from buffer.
let { width, height } = source
if (!width || !height) {
const meta = await sharp(imageBuffer).metadata()
width = meta.width
height = meta.height
}
// Count existing stickers in pack
const stickerSet = await ctx.db.StickerSet.findById(ctx.session.scene.mosaic.packId)
const currentCount = await ctx.db.Sticker.countDocuments({
stickerSet: stickerSet.id,
deleted: false
})
const freeSlots = 200 - currentCount
const suggestions = getGridSuggestions(width, height, freeSlots)
if (suggestions.type === 'no_space') {
await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.no_space', { freeSlots, total: 4 }))
return
}
// Store in scene state
ctx.session.scene.mosaic.photoFileId = source.fileId
ctx.session.scene.mosaic.photoWidth = width
ctx.session.scene.mosaic.photoHeight = height
ctx.session.scene.mosaic.freeSlots = freeSlots
// Generate preview with recommended grid
const { recommended } = suggestions
const previewBuffer = await generatePreview(imageBuffer, recommended.rows, recommended.cols)
// Check for blurry warning
const isBlurry = !checkMinCellSize(width, height, recommended.rows, recommended.cols)
const blurryText = isBlurry ? '\n' + ctx.i18n.t('cmd.mosaic.blurry_warning') : ''
const msg = await ctx.replyWithPhoto(
{ source: previewBuffer },
{
caption: ctx.i18n.t('cmd.mosaic.choose_grid') + blurryText,
parse_mode: 'HTML',
reply_markup: buildGridKeyboard(ctx, suggestions)
}
)
ctx.session.scene.mosaic.previewMessageId = msg.message_id
})
```
Key diffs from the original (for reviewers):
- `on('photo'` → `on(['photo', 'document', 'sticker']`
- Removed `const photo = ctx.message.photo; const largest = photo[photo.length - 1]`
- Added `getMosaicSource` call with error short-circuit
- Width/height now taken from `source`, with sharp fallback for docs
- `largest.file_id` → `source.fileId` at the state-storage site
- [ ] **Step 3: Syntax check**
Run:
```bash
node -c scenes/mosaic.js
```
Expected: no output.
- [ ] **Step 4: Commit**
```bash
git add scenes/mosaic.js
git commit -m "feat(mosaic): accept image documents and static stickers as input"
```
---
### Task 4: Reject handler for animations and videos
**Files:**
- Modify: `scenes/mosaic.js` (insert after the block just modified in Task 3)
Separate handler because animation/video/video_note never have a valid mosaic path — we just want a friendly reply, no downloads, no state changes.
- [ ] **Step 1: Insert the reject handler**
After the closing `})` of the multi-type handler (the new end of what used to be line 167), insert:
```javascript
// --- Reject animated/video inputs ---
mosaic.on(['animation', 'video', 'video_note'], async (ctx) => {
if (!ctx.session.scene?.mosaic) return ctx.scene.leave()
if (ctx.session.scene.mosaic.uploading) return
await ctx.replyWithHTML(ctx.i18n.t('cmd.mosaic.reject_media'))
})
```
Placement 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.
- [ ] **Step 2: Syntax check**
Run:
```bash
node -c scenes/mosaic.js
```
Expected: no output.
- [ ] **Step 3: Commit**
```bash
git add scenes/mosaic.js
git commit -m "feat(mosaic): reject animations, videos, and video notes with a friendly message"
```
---
### Task 5: Manual smoke test
**Files:** none (verification only)
No 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.
- [ ] **Step 1: Start the bot in dev mode**
Run:
```bash
npm start
```
(Or whatever the project's dev-run command is — see `package.json` scripts and `README`.)
- [ ] **Step 2: Execute the test matrix**
In 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**.
| # | Input | Expected |
|---|---|---|
| 1 | A regular photo (camera icon) | Preview with grid keyboard appears (existing behaviour) |
| 2 | A JPEG file sent via paperclip → "File" | Preview appears, mosaic generates crisply |
| 3 | A PNG file sent as document | Preview appears |
| 4 | A WebP file sent as document | Preview appears |
| 5 | A static sticker from any pack | Preview appears |
| 6 | An animated (.tgs) sticker | Reply: "Анімовані/відео стікери поки не підтримую…". Scene stays open. |
| 7 | A video (.webm) sticker | Same reject as #6. Scene stays open. |
| 8 | A GIF (sent as animation) | Reply: "Анімації та відео поки не підтримую…". Scene stays open. |
| 9 | An MP4 video | Same reject as #8. |
| 10 | A PDF or any non-image document | Reply: "Підтримую тільки зображення (JPEG/PNG/WebP)…". Scene stays open. |
| 11 | After any reject (say #6), send a regular photo | Scene proceeds normally — confirms rejects don't break state |
| 12 | Complete a full mosaic from a PNG document (tap a grid button through to done) | Mosaic appears in chat correctly |
- [ ] **Step 3: Check for visual regressions**
For tests #2, #3, #5, look at the final mosaic message in chat and compare to a photo-source mosaic of the same image:
- Are the emoji aligned? (No orphan newlines.)
- Does the transparency of a WebP sticker source produce acceptable visual output? (Transparent pixels visible through emoji.)
If a cell looks broken, screenshot it and stop. Otherwise proceed.
- [ ] **Step 4: Announce done**
If all 12 rows pass, the feature is complete. Report:
- Which inputs were tested
- Any notable visual quirks observed
- Whether any follow-up tasks emerged (e.g. "WebP stickers with heavy alpha look weird — file for later")
No further commit — the code commits in Tasks 1–4 are the deliverable.
---
## Self-review checklist (for plan author)
- 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 ✅
- No placeholders — every step shows the exact code or command
- Type consistency — `getMosaicSource` returns `{ fileId, width, height }` or `{ error }` in every task reference
- File paths and line numbers match what's in the repo at the time of writing
================================================
FILE: docs/superpowers/plans/2026-04-15-security-sweep-pr1.md
================================================
# Security Sweep PR-1 Implementation Plan
> **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.
**Goal:** Close 22 Dependabot alerts by bumping `moment` and `@pm2/io` in `package.json` and running `npm audit fix` to resolve transitive vulnerabilities.
**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.
**Tech Stack:** npm 10.9, Node 22.14. No application code modified.
**Spec:** `docs/superpowers/specs/2026-04-15-security-sweep-pr1-design.md`
**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.
---
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `package.json` | Modify | Bump `moment` from `^2.29.2` to `^2.30.1` and `@pm2/io` from `^5.0.0` to `^6.1.0` |
| `package-lock.json` | Regenerated | Reflects direct bumps plus transitive fixes from `npm audit fix` |
---
### Task 1: Bump direct dependencies
**Files:**
- Modify: `package.json` (lines 22–42 area — the `dependencies` block)
- Modify: `package-lock.json` (regenerated by npm)
`npm install pkg@range` updates both `package.json` and the lock in one shot.
- [ ] **Step 1: Bump moment and @pm2/io together**
Run:
```bash
npm install moment@^2.30.1 @pm2/io@^6.1.0
```
Expected: 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.
- [ ] **Step 2: Confirm the ranges landed**
Run:
```bash
node -e "const p=require('./package.json'); console.log(p.dependencies.moment, p.dependencies['@pm2/io'])"
```
Expected output: `^2.30.1 ^6.1.0` (exact match).
If the output shows the old ranges, npm didn't write — rerun Step 1.
---
### Task 2: Run `npm audit fix` for transitive fixes
**Files:**
- Modify: `package-lock.json`
`npm audit fix` (without `--force`) only updates packages within their existing declared ranges. It cannot touch `package.json`. This is the safe path.
- [ ] **Step 1: Run audit fix**
Run:
```bash
npm audit fix
```
Expected: 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.
- [ ] **Step 2: Sanity-check the remaining audit**
Run:
```bash
npm audit --audit-level=high 2>&1 | tail -15
```
Expected: 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.
---
### Task 3: Verify lint and syntax
**Files:** none modified — verification only
- [ ] **Step 1: Run project lint**
Run:
```bash
npm run lint
```
Expected: 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).
- [ ] **Step 2: Syntax-check files that require the bumped packages**
Run (one command, all files):
```bash
node -c utils/stats.js && node -c scenes/messaging.js && node -c handlers/admin/messaging.js && node -c index.js && echo "all ok"
```
Expected: `all ok`.
- [ ] **Step 3: Runtime-load the `@pm2/io` consumer**
Run:
```bash
timeout 2 node -e "require('./utils/stats.js'); console.log('loaded'); process.exit(0)" 2>&1 || true
```
Expected: 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).
If 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.
---
### Task 4: Commit and push
**Files:** `package.json` + `package-lock.json`
- [ ] **Step 1: Inspect the diff**
Run:
```bash
git diff --stat package.json package-lock.json
```
Expected: both files modified. `package-lock.json` will show a large line-count diff — that's normal for transitive resolution updates.
- [ ] **Step 2: Commit**
Run:
```bash
git add package.json package-lock.json
git commit -m "$(cat <<'EOF'
chore(deps): security sweep — npm audit fix + moment/@pm2/io bumps
Closes 22 Dependabot alerts including 5 critical (cipher-base, elliptic,
form-data, pbkdf2, sha.js) via transitive resolution, plus direct bumps
of moment (2.29.2 -> 2.30.1) and @pm2/io (5.0.0 -> 6.1.0) which both
keep public APIs stable across the bumped ranges.
Remaining open alerts (ip, tmp, low-severity elliptic, mongoose) are
either marked no-fix upstream or addressed in PR-2 (mongoose 5 -> 6).
Spec: docs/superpowers/specs/2026-04-15-security-sweep-pr1-design.md
EOF
)"
```
Expected: commit succeeds. No pre-commit hooks configured in this repo, so nothing to bypass.
- [ ] **Step 3: Push**
Run:
```bash
git push origin master
```
Expected: push succeeds, outputs `master -> master`.
---
### Task 5: Post-push verification
**Files:** none — verification by user on live bot
- [ ] **Step 1: User restarts the bot**
The user runs the bot's deploy process (whatever it is for this project — pm2, docker, bare `node index.js`, etc.) and confirms:
- Bot connects to Mongo without error
- Bot connects to Telegram without error
- A sticker-related command (any one) works end-to-end
- [ ] **Step 2: Confirm alert-count drop**
After GitHub re-scans (usually within an hour of push), run:
```bash
gh api repos/LyoSU/fStikBot/dependabot/alerts --paginate | node -e "
const arr = JSON.parse(require('fs').readFileSync(0,'utf8'));
const open = arr.filter(a => a.state === 'open');
console.log('open alerts:', open.length);
const bySev = {};
for (const a of open) { const s = a.security_advisory.severity; bySev[s]=(bySev[s]||0)+1; }
console.log('by severity:', JSON.stringify(bySev));
"
```
Expected: open count drops from 31 to somewhere in the 3–6 range.
- [ ] **Step 3: Hand off to PR-2**
Once 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.
---
## Rollback procedure
If anything breaks at Task 3 or after push:
```bash
git checkout HEAD~1 -- package.json package-lock.json
npm install
```
If push already happened and the bot is broken in prod:
```bash
git revert HEAD
git push origin master
```
No app code was changed, so there is nothing application-level to revert.
---
## Self-review (plan author)
- Spec covered: direct bumps ✅ (Task 1), transitive fixes ✅ (Task 2), verification ✅ (Task 3), commit ✅ (Task 4), post-push ✅ (Task 5)
- No placeholders — every step has exact command + expected output
- No "appropriate error handling" hand-waving — failure paths are explicit (stop and investigate)
- Naming consistency — `moment@^2.30.1` and `@pm2/io@^6.1.0` used the same way in Tasks 1 and 4
================================================
FILE: docs/superpowers/specs/2026-04-05-emoji-mosaic-design.md
================================================
# Emoji Mosaic Feature — Design Spec
## Overview
Add 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.
## User Flow
```
/mosaic (or button in pack menu)
→ Bot: "Mosaic mode enabled for pack {packName}. Send a photo."
→ (check: does user have a current custom_emoji pack? if not — prompt to create one)
User sends photo
→ Bot analyzes aspect ratio
→ Determines split type:
• ratio ≥ 2.5 → horizontal strip (1 row × N cols)
• ratio ≤ 0.4 → vertical strip (N rows × 1 col)
• otherwise → grid
→ Sharp generates preview (photo with dashed grid overlay)
→ Sends preview + inline keyboard:
For grid:
[✅ Split 3×4] ← recommended (highlighted)
[2×3 · 6pcs] [4×6 · 24pcs] [5×7 · 35pcs] ← alternatives
[✏️ Custom size]
For strip (e.g. panorama 5:1):
[✅ Split 1×5]
[1×4] [1×6] [1×8]
[✏️ Custom size]
→ If not enough space in pack — warn user, suggest smaller grid or new pack
User taps a button
→ Sharp splits photo into parts (each → 100×100 WEBP)
→ Uploads all parts to current custom_emoji pack
→ Sends:
• Message with mosaic (custom emoji entities in grid with newlines)
• For strips: emoji in a single line
• Link to pack (t.me/addemoji/{packName})
→ Scene waits for next photo (loop)
/mosaic or "Exit" button
→ Leave scene
```
## Grid Selection Algorithm
```
Input: width × height of photo
1. Compute ratio = width / height
2. Determine type:
• ratio ≥ 2.5 → horizontal strip (1 row)
• ratio ≤ 0.4 → vertical strip (1 column)
• otherwise → grid
3. For strips:
• count = round(ratio) for horizontal, round(1/ratio) for vertical
• Clamp to 3..10
• Alternatives: ±1, ±2 from recommended
4. For grids:
• Find all combinations rows × cols where:
- rows: 2..10, cols: 2..10
- rows × cols ≤ 50
- (cols/rows) close to ratio (proportionality)
• Sort by how close each cell's aspect ratio is to 1:1
(square emoji look best)
• Recommendation = best balance of proportionality and count
• Alternatives = one smaller, one medium, one larger grid
5. Pack space check:
• freeSlots = 200 - currentEmojiCount
• Filter out options where rows × cols > freeSlots
• If recommendation doesn't fit — pick largest that fits
• If none fit (freeSlots < 4) — notify user
```
## Image Processing Pipeline (Sharp)
### Preview Generation (before user chooses grid)
1. Load photo via Sharp
2. Resize to max 512px on longest side
3. Draw dashed grid lines via SVG overlay composite
4. Output as WEBP → send as photo message
### Splitting (after user chooses grid)
1. Load original at full resolution
2. **Min cell size check**: if `width/cols < 80` or `height/rows < 80` — warn user
that result may be blurry, suggest fewer divisions
3. `cellWidth = floor(width / cols)`, `cellHeight = floor(height / rows)`
4. For each cell `[r, c]`:
- `sharp.extract({ left: c*cellWidth, top: r*cellHeight, width: cellWidth, height: cellHeight })`
- Resize to 100×100 (custom emoji size)
- Convert to WEBP
5. Result: `Buffer[]` left-to-right, top-to-bottom
### Upload to Pack
1. Send progress message: "Uploading 0/{total}..."
2. For each buffer sequentially:
- `uploadStickerFile` (format: static)
- `addStickerToSet` with `emoji_list: ["🔲"]`, `keywords: ["mosaic", "r{row}c{col}"]`
- Edit progress message every 3-5 uploads: "Uploading 12/35..."
- `sendChatAction("upload_document")` to keep typing indicator
3. Store `file_id` of each added emoji
4. Store list of added sticker file_ids in scene state (for undo)
### Mosaic Message
1. Build text with custom_emoji entities:
- Row 1: `emoji[0] emoji[1] emoji[2] emoji[3]`
- Row 2: `emoji[4] emoji[5] emoji[6] emoji[7]`
- Rows separated by `\n`
2. Telegram Bot API: `sendMessage` with `entities` array, each entry:
- `type: "custom_emoji"`
- `custom_emoji_id` from the uploaded sticker
3. Append pack link: `t.me/addemoji/{packName}`
4. Add inline button: `[🗑 Remove this mosaic]` → deletes all stickers
from this mosaic from the pack via `deleteStickerFromSet`
## Scene Structure
```
Scene: "mosaic"
Entry:
• Command /mosaic
• Callback button from pack menu
• Validate: user has a current custom_emoji pack
→ if not: offer to create one inline (enter pack title → create → continue)
→ not a dead end — seamless onboarding
Scene state (ctx.scene.state):
• photoFileId — file_id of current photo
• photoWidth — width
• photoHeight — height
• messageId — preview message id (for editing)
• gridRows — chosen rows (null until chosen)
• gridCols — chosen columns
• lastMosaicIds — file_ids of last uploaded mosaic (for undo)
Steps (non-linear loop):
waitPhoto:
→ on("photo") → save fileId/dimensions to state
→ generate preview
→ send with inline keyboard
→ transition to waitGrid
waitGrid:
→ on callback "mosaic:grid:{rows}:{cols}"
→ split, upload, send mosaic
→ clear state
→ back to waitPhoto
→ on callback "mosaic:custom"
→ send "Enter size (e.g. 3x4):"
→ transition to waitCustom
→ on callback "mosaic:cancel"
→ back to waitPhoto
waitCustom:
→ on text → parse flexible formats: "RxC", "R×C", "R*C", "R:C", "R на C"
→ validate (2-10 each dimension, space in pack)
→ split, upload, send mosaic
→ back to waitPhoto
Exit:
→ /mosaic again or "Exit" button
→ ctx.scene.leave()
Callback data format:
"mosaic:grid:3:4" — select 3×4 grid
"mosaic:custom" — enter custom size
"mosaic:cancel" — cancel current photo
"mosaic:undo" — remove last mosaic from pack
"mosaic:exit" — leave scene
```
## File Structure
### New files
| File | Purpose |
|------|---------|
| `scenes/mosaic.js` | Scene logic (waitPhoto → waitGrid → loop) |
| `utils/mosaic-split.js` | Sharp: split photo into grid cells |
| `utils/mosaic-preview.js` | Sharp: generate preview with grid overlay |
| `utils/mosaic-grid.js` | Grid recommendation algorithm |
### Modified files
| File | Change |
|------|--------|
| `bot.js` | Register mosaic scene + `/mosaic` command |
| `handlers/packs.js` | Add "🔲 Mosaic" button in pack menu (custom_emoji packs only) |
| `locales/*.yaml` | Mosaic-related text strings |
### Not modified
| File | Reason |
|------|--------|
| `utils/add-sticker.js` | Mosaic upload logic differs enough to warrant its own module |
| `database/models/*` | No new models needed — mosaic emoji are regular stickers in existing packs |
## Constraints
- Custom emoji are 100×100px and render small in chat
- Max 200 emoji per pack
- Grid max 50 cells (practical limit for usability)
- Individual dimensions: 2–10 for grid, 3–10 for strips
- If pack has insufficient space: warn user, suggest smaller grid or new pack
- Sequential upload required (Telegram rate limits)
- Min source cell size: 80×80px before resize (warn if smaller — blurry result)
- Custom emoji render with small gaps in Telegram — mosaic won't be pixel-perfect seamless
- Note in onboarding: emoji appear small in chat (~20px per emoji visually)
================================================
FILE: docs/superpowers/specs/2026-04-15-mosaic-input-types-design.md
================================================
# Mosaic Input Types — Design Spec
## Overview
Extend 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.
## Motivation
- `message.photo` is recompressed by Telegram to ~1280px max, hurting mosaic quality. Users who want crisp mosaics currently have no path.
- People naturally try to mosaic an existing sticker; the bot silently ignores it (no feedback).
- Sharp (already in the pipeline) decodes JPEG/PNG/WebP natively, so the processing pipeline needs **zero changes**.
## Scope
### In scope
| Input | Condition | Source field |
|---|---|---|
| `message.photo` | any | largest variant `file_id` |
| `message.document` | `mime_type` ∈ `image/jpeg`, `image/png`, `image/webp` | `document.file_id` |
| `message.sticker` | `!is_animated && !is_video` | `sticker.file_id` |
### Out of scope (reject with clear message)
- Animated stickers (`.tgs`)
- Video stickers (`.webm`)
- `message.animation`, `message.video`, `message.video_note` (GIFs/videos)
- Documents with non-image MIME types
### Non-goals
- Producing an animated/video mosaic
- Converting `.tgs`/`.webm` to static before processing
- Respecting `has_spoiler` flag (treat as regular image)
## Architecture
### Single handler, multiple types
Replace:
```js
mosaic.on('photo', async (ctx) => { ... })
```
With 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`.
### Source normalization helper
An inline private function `getMosaicSource(message)` returns one of:
- `{ fileId, width, height }` — on success
- `{ error: '<i18n-key>' }` — on rejection
Keeps 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).
| Input | Return |
|---|---|
| `message.photo` present | `{ fileId: largest.file_id, width: largest.width, height: largest.height }` |
| `message.document` with image MIME | `{ fileId, width: document.thumb?.width, height: document.thumb?.height }` — see note below |
| `message.sticker`, static only | `{ fileId, width: sticker.width, height: sticker.height }` |
| `message.sticker`, animated/video | `{ error: 'cmd.mosaic.reject_animated' }` |
| `message.document`, non-image MIME | `{ error: 'cmd.mosaic.reject_document' }` |
**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.
### Processing pipeline — unchanged
`cropToAspectRatio`, `splitImage`, `generatePreview`, upload flow — all untouched. Sharp already handles JPEG/PNG/WebP transparently.
## UX
### New rejection messages (i18n)
Added under `cmd.mosaic.*` in all locales (following existing mosaic key structure in `locales/*.yaml:318`):
```yaml
cmd.mosaic.reject_animated: |
Анімовані/відео стікери поки не підтримую. Надішліть статичний стікер, фото або PNG/JPEG/WebP файлом.
cmd.mosaic.reject_document: |
Підтримую тільки зображення (JPEG/PNG/WebP). Надішліть файл у цьому форматі.
cmd.mosaic.reject_media: |
Анімації та відео поки не підтримую. Надішліть статичний стікер, фото або PNG/JPEG/WebP файлом.
```
Ukrainian and English locales get proper translations; other 15 locales fall back to English (existing pattern).
### Rejection behaviour
- Reply with the appropriate message
- **Do not leave the scene** — user can immediately resend a valid input
- No cleanup of scene state needed (the failed input never wrote any state)
### Success behaviour
Identical to current photo flow: compute grid suggestions, send preview, await button tap.
## Error handling
- **Document download fails** → existing `downloadFile` error path applies (20MB cap, 3 retries)
- **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.
- **Document with image MIME but actually not an image** (spoofed) → sharp throws, falls through to existing error handling. Good enough.
## Risks & mitigations
| Risk | Likelihood | Mitigation |
|---|---|---|
| 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. |
| WebP with alpha produces emoji with transparent edges | Medium | Acceptable — Telegram custom emoji support alpha. Verify visually after first build. |
| 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. |
| 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. |
## Testing
Manual smoke after implementation:
1. Send a photo → works as before
2. Send a PNG as document → processes, produces mosaic
3. Send a JPEG as document → works
4. Send a WebP as document → works
5. Send a static sticker → works
6. Send an animated (.tgs) sticker → rejection message, scene stays open
7. Send a video (.webm) sticker → rejection message, scene stays open
8. Send a GIF (animation) → rejection message
9. Send a PDF document → rejection message
10. After rejection, send a valid photo → works (scene didn't leave)
No automated tests added (consistent with existing mosaic code — no tests exist for the scene today).
## Files touched
- `scenes/mosaic.js` — replace the `mosaic.on('photo')` handler with multi-type handler + add `getMosaicSource` helper + add animation/video reject handler
- `locales/uk.yaml` — add 3 new keys under `cmd.mosaic`
- `locales/en.yaml` — add 3 new keys under `cmd.mosaic`
## Out of scope for this spec (explicit)
- Video mosaic (split `.webm` sticker into NxM video custom emoji) — separate spec when/if demand appears
- Animated Lottie (`.tgs`) rendering — would require Lottie renderer, separate spec
- GIF/video inputs — same as above
- Changing the grid algorithm or upload pipeline
- Adding automated tests for the mosaic scene
================================================
FILE: docs/superpowers/specs/2026-04-15-security-sweep-pr1-design.md
================================================
# Security Sweep PR-1 — Design Spec
## Overview
Close 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.
This is PR-1 of a two-PR security cleanup. **PR-2 is a separate mongoose 5 → 6 migration** — out of scope here.
## Scope
### In scope
**Direct dependency bumps in `package.json`:**
- `moment` `^2.29.2` → `^2.30.1` — closes CVE (high), no breaking API changes
- `@pm2/io` `^5.0.0` → `^6.1.0` — closes CVE (high), public API (`metric`, `.set()`) stable across 5→6
**Transitive fixes via `npm audit fix` (no `--force`):**
- cipher-base, elliptic, form-data, pbkdf2, sha.js (critical)
- cross-spawn, flatted, lodash, minimatch, semver, socks, tar-fs (high)
- ajv, bn.js, brace-expansion, js-yaml, store2 (moderate)
**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.
### Out of scope
- **mongoose 5 → 6** — PR-2, separate spec/plan
- **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
- **`ip` package (no-fix alert)** — comes in via `telegram` or `socks-proxy-agent`, nothing we can do without upstream fix
- **telegraf 3 → 4** — explicit user decision, stay on v3
- **ioredis, bull, sharp, openai** — not flagged, don't touch
## Call sites (dependency surface)
| Dep | File | Line | Usage |
|---|---|---|---|
| `moment` | `handlers/admin/messaging.js` | 4 | `require('moment')` |
| `moment` | `scenes/messaging.js` | 6 | `require('moment')` |
| `@pm2/io` | `utils/stats.js` | 1 | `io.metric({ name, unit })` + `.set(value)` |
All three sites use stable public APIs. No code changes needed alongside the bumps.
## Procedure
1. `npm install moment@^2.30.1 @pm2/io@^6.1.0` — updates `package.json` and `package-lock.json`
2. `npm audit fix` (no `--force`) — sub-range bumps in lock only
3. `npm audit` — verify criticals cleared (mongoose will remain — by design)
4. `npm run lint` — full lint passes
5. `node -c index.js` + `node -c utils/stats.js` + `node -c scenes/messaging.js` + `node -c handlers/admin/messaging.js` — syntax check
6. Optional: `node -e "require('./utils/stats.js')"` — confirms @pm2/io loads without runtime error
7. Commit as single change: `chore(deps): security sweep (npm audit fix + moment + @pm2/io bumps)`
## Verification
After PR-1 lands:
```bash
npm audit --audit-level=high 2>&1 | tail -10
```
Expected: only mongoose remains as high/critical (it's the PR-2 target).
```bash
gh api repos/LyoSU/fStikBot/dependabot/alerts --paginate | \
jq '[.[] | select(.state=="open")] | length'
```
Expected: drop from 31 open → 3–6 remaining (mongoose + `ip` + `tmp` + ellipt
gitextract_q1s5jkoo/
├── .dockerignore
├── .editorconfig
├── .eslintrc.json
├── .github/
│ └── FUNDING.yml
├── .gitignore
├── .vscode/
│ ├── launch.json
│ └── settings.json
├── Dockerfile
├── LICENSE
├── README.md
├── banners/
│ ├── DESIGN.md
│ ├── README.md
│ ├── build.js
│ ├── index.js
│ └── src/
│ ├── _system.css
│ ├── assets/
│ │ └── README.md
│ ├── boost.html
│ ├── catalog.html
│ ├── description.html
│ ├── donate.html
│ ├── emoji.html
│ ├── group.html
│ ├── help.html
│ ├── language.html
│ ├── mosaic.html
│ ├── new-pack.html
│ ├── origin.html
│ ├── packs.html
│ ├── publish.html
│ └── welcome.html
├── bot/
│ ├── commands.js
│ ├── launch.js
│ ├── locale-sync.js
│ ├── middleware.js
│ ├── preflight.js
│ └── session-store.js
├── bot.js
├── config.example.json
├── crowdin.yml
├── database/
│ ├── connection.js
│ ├── index.js
│ └── models/
│ ├── deeplink.js
│ ├── group.js
│ ├── index.js
│ ├── messaging.js
│ ├── payment.js
│ ├── sticker-set.js
│ ├── sticker.js
│ └── user.js
├── docker-compose.yml
├── docs/
│ └── superpowers/
│ ├── plans/
│ │ ├── 2026-04-05-emoji-mosaic.md
│ │ ├── 2026-04-15-mosaic-input-types.md
│ │ └── 2026-04-15-security-sweep-pr1.md
│ └── specs/
│ ├── 2026-04-05-emoji-mosaic-design.md
│ ├── 2026-04-15-mosaic-input-types-design.md
│ └── 2026-04-15-security-sweep-pr1-design.md
├── ecosystem.config.js
├── emoji_placeholder.webm
├── handlers/
│ ├── admin/
│ │ ├── _helpers.js
│ │ ├── index.js
│ │ ├── messaging.js
│ │ └── pack.js
│ ├── catalog.js
│ ├── catch.js
│ ├── coedit.js
│ ├── donate.js
│ ├── emoji.js
│ ├── group-settings.js
│ ├── help.js
│ ├── index.js
│ ├── inline-query.js
│ ├── language.js
│ ├── news-channel.js
│ ├── pack-boost.js
│ ├── pack-copy.js
│ ├── pack-hide.js
│ ├── pack-restore.js
│ ├── pack-select-group.js
│ ├── pack-select.js
│ ├── packs.js
│ ├── ping.js
│ ├── search-catalog.js
│ ├── start.js
│ ├── stats.js
│ ├── sticker-delete.js
│ ├── sticker-restore.js
│ ├── sticker-update.js
│ └── sticker.js
├── index.js
├── locales/
│ ├── ar.yaml
│ ├── az.yaml
│ ├── be.yaml
│ ├── de.yaml
│ ├── en.yaml
│ ├── es.yaml
│ ├── fr.yaml
│ ├── hy.yaml
│ ├── id.yaml
│ ├── ja.yaml
│ ├── kk.yaml
│ ├── pt.yaml
│ ├── ru.yaml
│ ├── tr.yaml
│ ├── uk.yaml
│ ├── uz.yaml
│ └── zh.yaml
├── package.json
├── privacy.html
├── scenes/
│ ├── admin-pack-bulk-delete.js
│ ├── admin-pack.js
│ ├── donate.js
│ ├── index.js
│ ├── messaging.js
│ ├── mosaic.js
│ ├── pack-about.js
│ ├── pack-catalog.js
│ ├── pack-delete.js
│ ├── pack-frame.js
│ ├── pack-new.js
│ ├── pack-rename.js
│ ├── pack-search.js
│ ├── photo-clear.js
│ ├── sticker-delete.js
│ ├── sticker-original.js
│ └── video-round.js
├── scripts/
│ ├── README.md
│ ├── inspect-db.js
│ ├── test-perf-timing.js
│ ├── test-retry-api.js
│ ├── top-sets.js
│ ├── update-packs.js
│ └── update-sticker.js
├── sticker_placeholder.tgs
├── sticker_placeholder.webm
└── utils/
├── add-sticker-text.js
├── add-sticker.js
├── decode-sticker-set-id.js
├── download-file-by-url.js
├── escape-regex.js
├── gramads.js
├── group-update.js
├── html-escape.js
├── index.js
├── last-seen.js
├── logger.js
├── messaging.js
├── moderate-pack.js
├── mosaic-grid.js
├── mosaic-preview.js
├── mosaic-split.js
├── perf-timing.js
├── queues.js
├── redis.js
├── retry-api.js
├── safe-edit.js
├── send-sticker-as-document.js
├── stats.js
├── sticker-inflight.js
├── telegram-api.js
├── telegram-error.js
├── telegram.js
├── tenor.js
├── unicode-chars-count.js
├── unicode-substr.js
├── update-monitor.js
├── user-name.js
└── user-update.js
SYMBOL INDEX (139 symbols across 40 files)
FILE: banners/build.js
constant WIDTH (line 23) | const WIDTH = 960
constant HEIGHT (line 24) | const HEIGHT = 360
constant SCALE (line 25) | const SCALE = 2 // retina output → 1920×720, looks crisp on high-DPI dev...
constant SRC (line 27) | const SRC = path.join(__dirname, 'src')
constant DIST (line 28) | const DIST = path.join(__dirname, 'dist')
constant BANNERS (line 34) | const BANNERS = [
function main (line 51) | async function main () {
FILE: banners/index.js
constant DIST (line 24) | const DIST = path.join(__dirname, 'dist')
function resolveBanner (line 28) | function resolveBanner (name) {
function photoInput (line 35) | function photoInput (banner) {
function rememberFileId (line 39) | function rememberFileId (banner, message) {
function assertBanner (line 46) | function assertBanner (name) {
function sendBanner (line 53) | async function sendBanner (ctx, name, caption = '', extra = {}) {
function editBanner (line 79) | async function editBanner (ctx, name, caption = '', extra = {}) {
function editMenu (line 121) | async function editMenu (ctx, text, extra = {}) {
function replyOrEditBanner (line 137) | async function replyOrEditBanner (ctx, name, caption = '', extra = {}) {
FILE: bot.js
constant HANDLER_TIMEOUT_MS (line 36) | const HANDLER_TIMEOUT_MS = 60_000
constant MONITOR_INTERVAL_MS (line 37) | const MONITOR_INTERVAL_MS = 25 * 1000
FILE: bot/launch.js
constant ALLOWED_UPDATES (line 7) | const ALLOWED_UPDATES = [
FILE: bot/locale-sync.js
constant LOCALES_DIR (line 11) | const LOCALES_DIR = path.resolve(__dirname, '..', 'locales')
constant CACHE_FILE (line 12) | const CACHE_FILE = path.resolve(__dirname, '..', '.locale-sync-mtime')
function computeMaxMtime (line 14) | function computeMaxMtime () {
function readCachedMtime (line 23) | function readCachedMtime () {
function writeCachedMtime (line 32) | function writeCachedMtime (mtime) {
function syncOneLocale (line 40) | async function syncOneLocale (bot, i18n, localeName, enDescriptionLong, ...
FILE: bot/middleware.js
constant MAX_CHAIN_ACTIONS (line 11) | const MAX_CHAIN_ACTIONS = 15
constant POLLING_DETACH (line 18) | const POLLING_DETACH = process.env.POLLING_DETACH !== '0'
FILE: bot/session-store.js
constant SESSION_TTL_SECONDS (line 12) | const SESSION_TTL_SECONDS = 60 * 60 // 1 hour — telegraf checks expires ...
function getSessionKey (line 14) | function getSessionKey (ctx) {
constant MEM_MAX (line 27) | const MEM_MAX = 50000
constant MEM_SWEEP_MS (line 28) | const MEM_SWEEP_MS = 5 * 60 * 1000
function createMemoryStore (line 30) | function createMemoryStore () {
function sessionMiddleware (line 53) | function sessionMiddleware () {
FILE: handlers/admin/_helpers.js
constant ADMIN_RIGHTS (line 11) | const ADMIN_RIGHTS = ['messaging', 'pack', 'finance', 'users']
FILE: handlers/admin/index.js
constant AWAITING (line 33) | const AWAITING = {
FILE: handlers/catch.js
constant PROJECT_ROOT (line 12) | const PROJECT_ROOT = path.resolve(__dirname, '..')
constant HAS_GIT_DIR (line 13) | const HAS_GIT_DIR = (() => {
function pickBlameFrame (line 26) | function pickBlameFrame (errorInfo) {
function errorLog (line 38) | async function errorLog (error, ctx) {
function isExpectedNoise (line 88) | function isExpectedNoise (error) {
FILE: handlers/group-settings.js
function onlyGroupAdmin (line 5) | async function onlyGroupAdmin (ctx, next) {
FILE: handlers/inline-query.js
constant INLINE_QUERY_LIMIT (line 7) | const INLINE_QUERY_LIMIT = 50
function getStickerFileId (line 17) | function getStickerFileId (sticker) {
function getStickerType (line 35) | function getStickerType (sticker) {
function getStickerCaption (line 45) | function getStickerCaption (sticker) {
function detectStickerTypes (line 61) | function detectStickerTypes (stickers) {
function buildInlineResult (line 74) | function buildInlineResult (sticker, stickerType) {
FILE: scenes/donate.js
constant PRICING_TIERS (line 7) | const PRICING_TIERS = {
constant TIER_MULTIPLIERS (line 16) | const TIER_MULTIPLIERS = {
constant CREDIT_PACKAGES (line 23) | const CREDIT_PACKAGES = {
FILE: scenes/messaging.js
constant MESSAGING_TTL_SECONDS (line 10) | const MESSAGING_TTL_SECONDS = 7 * 24 * 60 * 60
FILE: scenes/mosaic.js
constant FALLBACK_EMOJI (line 33) | const FALLBACK_EMOJI = ['🟥', '🟧', '🟨', '🟩', '🟦', '🟪', '🟫', '⬛', '⬜', '🔲']
constant IMAGE_DOCUMENT_MIMES (line 105) | const IMAGE_DOCUMENT_MIMES = new Set(['image/jpeg', 'image/png', 'image/...
FILE: scenes/pack-about.js
constant DC_REGIONS (line 16) | const DC_REGIONS = {
FILE: scenes/pack-catalog.js
function stickerSetIdToOwnerId (line 11) | function stickerSetIdToOwnerId (u64) {
FILE: scenes/video-round.js
function getQueuePosition (line 23) | async function getQueuePosition (jobId) {
function processVideo (line 32) | async function processVideo (ctx, fileUrl) {
FILE: scripts/inspect-db.js
function pct (line 8) | function pct (a, b) {
function run (line 13) | async function run () {
FILE: scripts/test-perf-timing.js
function run (line 20) | async function run () {
FILE: scripts/test-retry-api.js
function touch (line 40) | function touch (chatId) {
function resetCache (line 44) | function resetCache () {
function test (line 49) | async function test (name, fn) {
FILE: scripts/top-sets.js
function getPopularStickerPacks (line 10) | async function getPopularStickerPacks () {
function postPopularStickerPacksToChannel (line 32) | async function postPopularStickerPacksToChannel () {
FILE: scripts/update-packs.js
function processStickerSets (line 16) | async function processStickerSets (stickerSets) {
FILE: utils/add-sticker-text.js
constant SEND_MESSAGE_DESCRIPTION_MAX (line 10) | const SEND_MESSAGE_DESCRIPTION_MAX = 1000
constant TELEGRAM_ERROR_MAP (line 21) | const TELEGRAM_ERROR_MAP = [
FILE: utils/add-sticker.js
constant VIDEO_PROCESSING_TTL (line 14) | const VIDEO_PROCESSING_TTL = 1000 * 60 * 2 // 2 minutes auto-unlock
function updateConvertQueueMessages (line 44) | async function updateConvertQueueMessages () {
constant STICKER_COOLDOWN (line 267) | const STICKER_COOLDOWN = 1000 * 30 // 30 seconds
FILE: utils/decode-sticker-set-id.js
function decodeStickerSetId (line 14) | function decodeStickerSetId (u64) {
FILE: utils/escape-regex.js
function escapeRegex (line 1) | function escapeRegex (str) {
FILE: utils/last-seen.js
constant THROTTLE_MS (line 17) | const THROTTLE_MS = parseInt(process.env.LAST_SEEN_THROTTLE_MS, 10) || 6...
constant MAX_ENTRIES (line 18) | const MAX_ENTRIES = parseInt(process.env.LAST_SEEN_MAX, 10) || 20000
function evictIfFull (line 22) | function evictIfFull () {
function touchLastSeen (line 34) | function touchLastSeen (User, userId) {
FILE: utils/logger.js
constant LEVELS (line 13) | const LEVELS = ['error', 'warn', 'info', 'debug']
FILE: utils/messaging.js
constant MESSAGING_TTL_SECONDS (line 13) | const MESSAGING_TTL_SECONDS = 7 * 24 * 60 * 60
function getConfig (line 19) | function getConfig () {
function processMessagingQueue (line 229) | async function processMessagingQueue () {
function processEditQueue (line 249) | async function processEditQueue () {
FILE: utils/moderate-pack.js
constant RELEVANT_CATEGORIES (line 10) | const RELEVANT_CATEGORIES = [
constant SCORE_THRESHOLDS (line 19) | const SCORE_THRESHOLDS = {
function combineImages (line 28) | async function combineImages (imageBuffers) {
function moderateImage (line 69) | async function moderateImage (fileLink, packTitle = '', packName = '') {
function moderatePack (line 91) | async function moderatePack (packName) {
function moderatePacks (line 139) | async function moderatePacks (skip = 0, maxDepth = 100) {
FILE: utils/perf-timing.js
constant WINDOW (line 13) | const WINDOW = 200
constant DEFAULT_INTERVAL (line 14) | const DEFAULT_INTERVAL = 50
constant ENABLED (line 16) | const ENABLED = process.env.PERF_TIMING !== '0'
constant LOG_INTERVAL (line 17) | const LOG_INTERVAL = Math.max(1, parseInt(process.env.PERF_TIMING_INTERV...
function getStage (line 31) | function getStage (name) {
function perfRecord (line 40) | function perfRecord (name, ms) {
function median (line 51) | function median (arr) {
function fmt (line 60) | function fmt (ms) {
function perfSnapshot (line 65) | function perfSnapshot () {
function logSummary (line 78) | function logSummary (n) {
function perfTick (line 89) | function perfTick () {
function perfStage (line 100) | function perfStage (name, fn, opts) {
FILE: utils/queues.js
constant REDIS_ENABLED (line 10) | const REDIS_ENABLED = !!process.env.REDIS_HOST
function makeStubQueue (line 31) | function makeStubQueue (name) {
function makeRealQueue (line 49) | function makeRealQueue (name) {
FILE: utils/retry-api.js
constant RETRY_MAX_WAIT_S (line 29) | const RETRY_MAX_WAIT_S = parseInt(process.env.RETRY_MAX_WAIT_S, 10) || 5
constant RETRY_MAX_ATTEMPTS (line 30) | const RETRY_MAX_ATTEMPTS = parseInt(process.env.RETRY_MAX_ATTEMPTS, 10) ...
constant BLOCKED_CACHE_TTL_MS (line 31) | const BLOCKED_CACHE_TTL_MS = parseInt(process.env.BLOCKED_CACHE_TTL_MS, ...
constant BLOCKED_CACHE_MAX (line 32) | const BLOCKED_CACHE_MAX = parseInt(process.env.BLOCKED_CACHE_MAX, 10) ||...
constant RETRY_JITTER_MAX_MS (line 33) | const RETRY_JITTER_MAX_MS = parseInt(process.env.RETRY_JITTER_MAX_MS, 10...
constant RATE_LIMIT_CACHE_MAX (line 34) | const RATE_LIMIT_CACHE_MAX = parseInt(process.env.RATE_LIMIT_CACHE_MAX, ...
function cacheBlocked (line 47) | function cacheBlocked (chatId) {
function isBlockedCached (line 63) | function isBlockedCached (chatId) {
function clearBlockedChat (line 74) | function clearBlockedChat (chatId) {
function buildBlockedError (line 79) | function buildBlockedError (chatId, method) {
function rateLimitKey (line 105) | function rateLimitKey (method, scopeId) {
function cacheRateLimit (line 109) | function cacheRateLimit (method, scopeId, retryAfterS) {
function isRateLimitCached (line 129) | function isRateLimitCached (method, scopeId) {
function getRateLimitRemaining (line 152) | function getRateLimitRemaining (method, scopeId) {
function buildRateLimitError (line 165) | function buildRateLimitError (method, scopeId) {
function targetScopeId (line 189) | function targetScopeId (data) {
function withRetry (line 215) | async function withRetry (fn, options = {}) {
function isRateLimitError (line 256) | function isRateLimitError (error) {
function getRetryAfter (line 263) | function getRetryAfter (error) {
function patchTelegramPrototype (line 274) | function patchTelegramPrototype () {
function retryMiddleware (line 338) | function retryMiddleware () {
FILE: utils/send-sticker-as-document.js
function sendStickerAsDocument (line 15) | async function sendStickerAsDocument (ctx, fileId, fileUniqueId, extra =...
function sniffStickerFormat (line 80) | function sniffStickerFormat (buffer) {
FILE: utils/sticker-inflight.js
constant MAX_PER_USER (line 15) | const MAX_PER_USER = parseInt(process.env.STICKER_INFLIGHT_PER_USER, 10)...
function acquire (line 19) | function acquire (userId) {
function release (line 27) | function release (userId) {
FILE: utils/telegram-api.js
constant SESSION_FILE (line 6) | const SESSION_FILE = path.join(__dirname, '../.mtproto-session')
function connect (line 12) | async function connect () {
method client (line 65) | get client () {
method isConnected (line 70) | get isConnected () {
FILE: utils/telegram-error.js
constant ERROR_PATTERNS (line 5) | const ERROR_PATTERNS = [
constant RATE_LIMIT_CODE (line 18) | const RATE_LIMIT_CODE = 429
constant DEFAULT_MAX_DESCRIPTION_LEN (line 30) | const DEFAULT_MAX_DESCRIPTION_LEN = 150
FILE: utils/telegram.js
function getTelegram (line 14) | function getTelegram (token = process.env.BOT_TOKEN) {
FILE: utils/update-monitor.js
constant WARN_THRESHOLD (line 26) | const WARN_THRESHOLD = 40
constant ALERT_THRESHOLD (line 27) | const ALERT_THRESHOLD = 250
Condensed preview — 167 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,167K chars).
[
{
"path": ".dockerignore",
"chars": 27,
"preview": "node_modules\nnpm-debug.log\n"
},
{
"path": ".editorconfig",
"chars": 147,
"preview": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\nindent_sty"
},
{
"path": ".eslintrc.json",
"chars": 269,
"preview": "{\n \"env\": {\n \"es6\": true,\n \"node\": true\n },\n \"extends\": \"standard\",\n \"globals\": {\n \"Atomics\": \"readon"
},
{
"path": ".github/FUNDING.yml",
"chars": 666,
"preview": "# These are supported funding model platforms\n\ngithub: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [u"
},
{
"path": ".gitignore",
"chars": 1008,
"preview": "# 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# Directo"
},
{
"path": ".vscode/launch.json",
"chars": 451,
"preview": "{\n // Use IntelliSense to learn about possible attributes.\n // Hover to view descriptions of existing attributes.\n //"
},
{
"path": ".vscode/settings.json",
"chars": 60,
"preview": "{\n \"i18n-ally.localesPaths\": [\n \"locales\"\n ]\n}\n"
},
{
"path": "Dockerfile",
"chars": 335,
"preview": "FROM node:lts-alpine as base\nFROM base as builder\nRUN mkdir /install\nWORKDIR /install\nCOPY package.json .\nRUN npm i --pr"
},
{
"path": "LICENSE",
"chars": 4635,
"preview": "# PolyForm Noncommercial License 1.0.0\n\n<https://polyformproject.org/licenses/noncommercial/1.0.0>\n\nRequired Notice: Cop"
},
{
"path": "README.md",
"chars": 1844,
"preview": "# fStikBot\n\nTelegram sticker bot. Make packs, copy packs, edit stickers, search a public catalog. Runs [@fStikBot](https"
},
{
"path": "banners/DESIGN.md",
"chars": 12186,
"preview": "# Banner Design System\n\nVisual language for the hero banners that sit above `/start` and section\nentry messages. Referen"
},
{
"path": "banners/README.md",
"chars": 2817,
"preview": "# Banners\n\nBuild-time generated hero banners that sit above `/start` and section entry\nmessages. Built from HTML+CSS wit"
},
{
"path": "banners/build.js",
"chars": 3524,
"preview": "#!/usr/bin/env node\n/**\n * Banner build script.\n * Reads HTML templates from banners/src/ and exports high-DPI PNG rende"
},
{
"path": "banners/index.js",
"chars": 5664,
"preview": "// Banner runtime helpers.\n//\n// Flow: first call uploads the PNG from disk, Telegram returns a file_id,\n// we cache it "
},
{
"path": "banners/src/_system.css",
"chars": 5794,
"preview": "/* ==========================================================================\n fStikBot · Banner design system\n Shar"
},
{
"path": "banners/src/assets/README.md",
"chars": 473,
"preview": "# Banner assets\n\nDrop brand imagery here. Referenced from templates as `./assets/<file>`.\n\n## Required\n\n- **`mascot.png`"
},
{
"path": "banners/src/boost.html",
"chars": 1090,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Boost</title>\n<link rel=\"preconnect\" hr"
},
{
"path": "banners/src/catalog.html",
"chars": 1209,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Catalog</title>\n<link rel=\"preconnect\" "
},
{
"path": "banners/src/description.html",
"chars": 1838,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Description</title>\n<link rel=\"preconne"
},
{
"path": "banners/src/donate.html",
"chars": 1142,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Support</title>\n<link rel=\"preconnect\" "
},
{
"path": "banners/src/emoji.html",
"chars": 1218,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Emoji</title>\n<link rel=\"preconnect\" hr"
},
{
"path": "banners/src/group.html",
"chars": 1362,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Group</title>\n<link rel=\"preconnect\" hr"
},
{
"path": "banners/src/help.html",
"chars": 1190,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Help</title>\n<link rel=\"preconnect\" hre"
},
{
"path": "banners/src/language.html",
"chars": 1302,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Language</title>\n<link rel=\"preconnect\""
},
{
"path": "banners/src/mosaic.html",
"chars": 1454,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Mosaic</title>\n<link rel=\"preconnect\" h"
},
{
"path": "banners/src/new-pack.html",
"chars": 1265,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · New pack</title>\n<link rel=\"preconnect\""
},
{
"path": "banners/src/origin.html",
"chars": 1278,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Origin</title>\n<link rel=\"preconnect\" h"
},
{
"path": "banners/src/packs.html",
"chars": 1165,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · My Packs</title>\n<link rel=\"preconnect\""
},
{
"path": "banners/src/publish.html",
"chars": 1186,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Publish</title>\n<link rel=\"preconnect\" "
},
{
"path": "banners/src/welcome.html",
"chars": 1262,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<title>fStikBot · Welcome</title>\n<link rel=\"preconnect\" "
},
{
"path": "bot/commands.js",
"chars": 10871,
"preview": "// All bot commands, actions, and hears. Preserves the exact registration\n// order from the original bot.js — order matt"
},
{
"path": "bot/launch.js",
"chars": 1219,
"preview": "// Bot launch + graceful shutdown.\n// Webhook mode when BOT_DOMAIN is set, polling otherwise.\n//\n// allowedUpdates cuts "
},
{
"path": "bot/locale-sync.js",
"chars": 7065,
"preview": "// Locale sync — pushes bot name/description/commands to Telegram for every\n// locale in locales/. This is idempotent bu"
},
{
"path": "bot/middleware.js",
"chars": 8885,
"preview": "// All `bot.use(...)` middleware + the privateMessage composer construction.\n// Order matters — preserves the exact chai"
},
{
"path": "bot/preflight.js",
"chars": 3558,
"preview": "// Preflight checks — verify env + connectivity before the bot starts\n// accepting updates. Fast-fail with a clear messa"
},
{
"path": "bot/session-store.js",
"chars": 1855,
"preview": "// In-memory Telegraf session.\n//\n// For a single-process bot (PM2, 6h restarts) Redis sessions were net\n// negative: fr"
},
{
"path": "bot.js",
"chars": 4261,
"preview": "// Entrypoint — thin orchestrator. The old 681-line monolith was split into\n// focused modules under bot/:\n// - bot/se"
},
{
"path": "config.example.json",
"chars": 278,
"preview": "{\n \"mainAdminId\": 66478514,\n \"logChatId\": -1001665705393,\n \"stickerLinkPrefix\": \"t.me/addstickers/\",\n \"emojiLinkPref"
},
{
"path": "crowdin.yml",
"chars": 86,
"preview": "files:\n - source: /locales/en.yaml\n translation: /locales/%two_letters_code%.yaml\n"
},
{
"path": "database/connection.js",
"chars": 1780,
"preview": "const mongoose = require('mongoose')\n\n// Визначаємо чи це SRV URI (mongodb+srv://)\nconst isSrvUri = (uri) => uri && uri."
},
{
"path": "database/index.js",
"chars": 4662,
"preview": "const collections = require('./models')\nconst {\n connection,\n atlasConnection\n} = require('./connection')\n\nconst db = "
},
{
"path": "database/models/deeplink.js",
"chars": 394,
"preview": "const mongoose = require('mongoose')\n\nconst deeplinkSchema = mongoose.Schema({\n user: {\n type: mongoose.Schema.Types"
},
{
"path": "database/models/group.js",
"chars": 563,
"preview": "const mongoose = require('mongoose')\n\nconst groupSchema = mongoose.Schema({\n telegram_id: {\n type: Number,\n index"
},
{
"path": "database/models/index.js",
"chars": 361,
"preview": "const User = require('./user')\nconst Group = require('./group')\nconst Sticker = require('./sticker')\nconst StickerSet = "
},
{
"path": "database/models/messaging.js",
"chars": 783,
"preview": "const mongoose = require('mongoose')\n\nconst schema = mongoose.Schema({\n creator: {\n type: mongoose.Schema.Types.Obje"
},
{
"path": "database/models/payment.js",
"chars": 971,
"preview": "const mongoose = require('mongoose')\n\nconst paymentsSchema = mongoose.Schema({\n user: {\n type: mongoose.Schema.Types"
},
{
"path": "database/models/sticker-set.js",
"chars": 2456,
"preview": "const mongoose = require('mongoose')\n\nconst stickerSetsSchema = mongoose.Schema({\n owner: {\n type: mongoose.Schema.T"
},
{
"path": "database/models/sticker.js",
"chars": 3651,
"preview": "const mongoose = require('mongoose')\n\n// NOTE on schema coexistence:\n// The collection holds ~488M docs, of which ~94% s"
},
{
"path": "database/models/user.js",
"chars": 1826,
"preview": "const mongoose = require('mongoose')\n\nconst userSchema = mongoose.Schema({\n telegram_id: {\n type: Number,\n index:"
},
{
"path": "docker-compose.yml",
"chars": 670,
"preview": "services:\n\n mongo:\n image: mongo\n restart: always\n volumes:\n - mongo-data:/data/db\n healthcheck:\n "
},
{
"path": "docs/superpowers/plans/2026-04-05-emoji-mosaic.md",
"chars": 39291,
"preview": "# Emoji Mosaic Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-devel"
},
{
"path": "docs/superpowers/plans/2026-04-15-mosaic-input-types.md",
"chars": 14809,
"preview": "# Mosaic Input Types Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven"
},
{
"path": "docs/superpowers/plans/2026-04-15-security-sweep-pr1.md",
"chars": 7522,
"preview": "# Security Sweep PR-1 Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-drive"
},
{
"path": "docs/superpowers/specs/2026-04-05-emoji-mosaic-design.md",
"chars": 7355,
"preview": "# Emoji Mosaic Feature — Design Spec\n\n## Overview\n\nAdd a \"mosaic mode\" to fStikBot that splits a photo into a grid of cu"
},
{
"path": "docs/superpowers/specs/2026-04-15-mosaic-input-types-design.md",
"chars": 6698,
"preview": "# Mosaic Input Types — Design Spec\n\n## Overview\n\nExtend the mosaic feature (`scenes/mosaic.js`) to accept more input typ"
},
{
"path": "docs/superpowers/specs/2026-04-15-security-sweep-pr1-design.md",
"chars": 4453,
"preview": "# Security Sweep PR-1 — Design Spec\n\n## Overview\n\nClose 22 Dependabot alerts (including 5 critical: cipher-base, ellipti"
},
{
"path": "ecosystem.config.js",
"chars": 710,
"preview": "module.exports = {\n apps: [{\n name: 'fStikBot',\n script: './index.js',\n max_memory_restart: '2000M',\n watch"
},
{
"path": "handlers/admin/_helpers.js",
"chars": 2299,
"preview": "// Shared admin guards / introspection. Single source of truth for the\n// rights model — every command and action must f"
},
{
"path": "handlers/admin/index.js",
"chars": 20433,
"preview": "const path = require('path')\nconst Composer = require('telegraf/composer')\nconst Markup = require('telegraf/markup')\ncon"
},
{
"path": "handlers/admin/messaging.js",
"chars": 9173,
"preview": "const Composer = require('telegraf/composer')\nconst Markup = require('telegraf/markup')\nconst replicators = require('tel"
},
{
"path": "handlers/admin/pack.js",
"chars": 894,
"preview": "const Composer = require('telegraf/composer')\nconst Markup = require('telegraf/markup')\n\nconst composer = new Composer()"
},
{
"path": "handlers/catalog.js",
"chars": 721,
"preview": "const { replyOrEditBanner } = require('../banners')\n\nmodule.exports = async (ctx) => {\n const caption = ctx.i18n.t('cmd"
},
{
"path": "handlers/catch.js",
"chars": 4921,
"preview": "const fs = require('fs')\nconst path = require('path')\nconst util = require('util')\nconst execFile = util.promisify(requi"
},
{
"path": "handlers/coedit.js",
"chars": 2835,
"preview": "const StegCloak = require('stegcloak')\nconst Composer = require('telegraf/composer')\nconst crypto = require('crypto')\nco"
},
{
"path": "handlers/donate.js",
"chars": 2104,
"preview": "const Composer = require('telegraf/composer')\nconst { match } = require('telegraf-i18n')\n\nconst composer = new Composer("
},
{
"path": "handlers/emoji.js",
"chars": 1085,
"preview": "const emojiRegex = require('emoji-regex')\nconst { sendBanner } = require('../banners')\n\nmodule.exports = async (ctx) => "
},
{
"path": "handlers/group-settings.js",
"chars": 1080,
"preview": "const Composer = require('telegraf/composer')\n\nconst composer = new Composer()\n\nasync function onlyGroupAdmin (ctx, next"
},
{
"path": "handlers/help.js",
"chars": 367,
"preview": "const Markup = require('telegraf/markup')\nconst { replyOrEditBanner } = require('../banners')\n\nmodule.exports = async (c"
},
{
"path": "handlers/index.js",
"chars": 1043,
"preview": "module.exports = {\n handleError: require('./catch'),\n handleStats: require('./stats'),\n handlePing: require('./ping')"
},
{
"path": "handlers/inline-query.js",
"chars": 9978,
"preview": "const StegCloak = require('stegcloak')\nconst Composer = require('telegraf/composer')\nconst { tenor, escapeRegex } = requ"
},
{
"path": "handlers/language.js",
"chars": 1345,
"preview": "const fs = require('fs')\nconst path = require('path')\nconst Markup = require('telegraf/markup')\nconst I18n = require('te"
},
{
"path": "handlers/news-channel.js",
"chars": 2429,
"preview": "const Composer = require('telegraf/composer')\nconst Markup = require('telegraf/markup')\nconst handleStart = require('./s"
},
{
"path": "handlers/pack-boost.js",
"chars": 3336,
"preview": "const Composer = require('telegraf/composer')\nconst Markup = require('telegraf/markup')\nconst rateLimit = require('teleg"
},
{
"path": "handlers/pack-copy.js",
"chars": 1841,
"preview": "const Markup = require('telegraf/markup')\nconst { humanizeTelegramError, matchTelegramErrorReason } = require('../utils/"
},
{
"path": "handlers/pack-hide.js",
"chars": 2277,
"preview": "const Markup = require('telegraf/markup')\n\nmodule.exports = async (ctx) => {\n if (!ctx.session.userInfo) ctx.session.us"
},
{
"path": "handlers/pack-restore.js",
"chars": 3600,
"preview": "const { escapeHTML } = require('../utils')\n\nmodule.exports = async (ctx, next) => {\n let messageText = ctx.i18n.t('call"
},
{
"path": "handlers/pack-select-group.js",
"chars": 1721,
"preview": "const Markup = require('telegraf/markup')\nconst { escapeHTML } = require('../utils')\n\nmodule.exports = async (ctx, next)"
},
{
"path": "handlers/pack-select.js",
"chars": 1915,
"preview": "const Markup = require('telegraf/markup')\nconst { escapeHTML } = require('../utils')\n\nmodule.exports = async (ctx) => {\n"
},
{
"path": "handlers/packs.js",
"chars": 11597,
"preview": "const StegCloak = require('stegcloak')\nconst Markup = require('telegraf/markup')\nconst { escapeHTML } = require('../util"
},
{
"path": "handlers/ping.js",
"chars": 527,
"preview": "const Composer = require('telegraf/composer')\nconst { convertQueue } = require('../utils/queues')\n\nconst composer = new "
},
{
"path": "handlers/search-catalog.js",
"chars": 728,
"preview": "const { replyOrEditBanner } = require('../banners')\n\nmodule.exports = async (ctx) => {\n const caption = ctx.i18n.t('cmd"
},
{
"path": "handlers/start.js",
"chars": 3253,
"preview": "const Markup = require('telegraf/markup')\nconst { userName } = require('../utils')\nconst { sendBanner } = require('../ba"
},
{
"path": "handlers/stats.js",
"chars": 631,
"preview": "const Composer = require('telegraf/composer')\n\nconst composer = new Composer()\n\ncomposer.use(async (ctx, next) => {\n if"
},
{
"path": "handlers/sticker-delete.js",
"chars": 4838,
"preview": "const Markup = require('telegraf/markup')\nconst escapeHTML = require('../utils/html-escape')\nconst { humanizeTelegramErr"
},
{
"path": "handlers/sticker-restore.js",
"chars": 3368,
"preview": "const Markup = require('telegraf/markup')\nconst {\n addSticker\n} = require('../utils')\nconst { humanizeTelegramError } ="
},
{
"path": "handlers/sticker-update.js",
"chars": 2432,
"preview": "const emojiRegex = require('emoji-regex')\n\nmodule.exports = async (ctx, next) => {\n if (ctx.session.previousSticker && "
},
{
"path": "handlers/sticker.js",
"chars": 14672,
"preview": "const Markup = require('telegraf/markup')\nconst {\n escapeHTML,\n showGramAds,\n countUncodeChars,\n substrUnicode,\n ad"
},
{
"path": "index.js",
"chars": 62,
"preview": "require('dotenv').config({ path: './.env' })\nrequire('./bot')\n"
},
{
"path": "locales/ar.yaml",
"chars": 23181,
"preview": "---\nlanguage_name: '🇸🇦 عربي'\nname: fStik — ملصقات وإيموجي\ndescription:\n long: |\n أنشئ ملصقات وإيموجي من الصور والفيد"
},
{
"path": "locales/az.yaml",
"chars": 25965,
"preview": "---\nlanguage_name: '🇦🇿 Azərbaycanca'\nname: fStik — Stikerlər və Emoji\ndescription:\n long: |\n Foto, video və GIF-lərd"
},
{
"path": "locales/be.yaml",
"chars": 25820,
"preview": "---\nlanguage_name: '🇧🇾 Беларуская'\nname: fStik — Стыкеры і эмодзі\ndescription:\n long: |\n Ствараеце стыкеры і эмодзі "
},
{
"path": "locales/de.yaml",
"chars": 28204,
"preview": "---\nlanguage_name: '🇩🇪 Deutsch'\nname: fStik — Sticker & Emoji\ndescription:\n long: |\n Erstelle Sticker und Emojis aus"
},
{
"path": "locales/en.yaml",
"chars": 28596,
"preview": "---\nlanguage_name: '🇺🇸 English'\nname: fStik — Stickers & Emoji\ndescription:\n long: |\n Create stickers and emoji from"
},
{
"path": "locales/es.yaml",
"chars": 26996,
"preview": "---\nlanguage_name: '🇪🇸 Español'\nname: fStik — Stickers y Emoji\ndescription:\n long: |\n Crea stickers y emojis desde f"
},
{
"path": "locales/fr.yaml",
"chars": 28391,
"preview": "---\nlanguage_name: '🇫🇷 Français'\nname: fStik — Stickers & Emoji\ndescription:\n long: |\n Créez des stickers et emojis "
},
{
"path": "locales/hy.yaml",
"chars": 26613,
"preview": "---\nlanguage_name: '🇦🇲 Հայերեն'\nname: fStik — Stickers & Emoji\ndescription:\n long: |\n Ստեղծեք կպչուկներ և էմոջիներ լ"
},
{
"path": "locales/id.yaml",
"chars": 26657,
"preview": "---\nlanguage_name: '🇮🇩 Indonesia'\nname: fStik — Stiker & Emoji\ndescription:\n long: |\n Buat stiker dan emoji dari fot"
},
{
"path": "locales/ja.yaml",
"chars": 18064,
"preview": "---\nlanguage_name: '🇯🇵 日本語'\nname: fStik — ステッカー&絵文字\ndescription:\n long: |\n 写真、動画、GIFからステッカーと絵文字を作成 – 手動変換不要、ボットが全て処理"
},
{
"path": "locales/kk.yaml",
"chars": 25593,
"preview": "---\nlanguage_name: '🇰🇿 Қазақша'\nname: fStik — Стикерлер мен эмодзи\ndescription:\n long: |\n Сурет, бейне және GIF-тен "
},
{
"path": "locales/pt.yaml",
"chars": 26829,
"preview": "---\nlanguage_name: '🇧🇷 Português'\nname: fStik — Figurinhas e Emoji\ndescription:\n long: |\n Crie stickers e emojis de "
},
{
"path": "locales/ru.yaml",
"chars": 26074,
"preview": "---\nlanguage_name: '🇷🇺 Русский'\nname: fStik — Стикеры и эмодзи\ndescription:\n long: |\n Создавай стикеры и эмодзи из ф"
},
{
"path": "locales/tr.yaml",
"chars": 25885,
"preview": "---\nlanguage_name: '🇹🇷 Türkçe'\nname: fStik — Çıkartmalar ve Emoji\ndescription:\n long: |\n Fotoğraf, video ve GIF'lerd"
},
{
"path": "locales/uk.yaml",
"chars": 28267,
"preview": "---\nlanguage_name: '🇺🇦 Українська'\nname: fStik — Стікери та емодзі\ndescription:\n long: |\n Створюй стікери та емодзі "
},
{
"path": "locales/uz.yaml",
"chars": 27287,
"preview": "---\nlanguage_name: '🇺🇿 O''zbek'\nname: fStik — Stikerlar va Emoji\ndescription:\n long: |\n Foto, video va GIF-lardan ko"
},
{
"path": "locales/zh.yaml",
"chars": 15609,
"preview": "---\nlanguage_name: '🇨🇳 简体中文'\nname: fStik — 贴纸和表情\ndescription:\n long: |\n 从照片、视频和GIF创建贴纸和表情,无需手动转换 - 机器人处理一切!\n\n 功能:"
},
{
"path": "package.json",
"chars": 1365,
"preview": "{\n \"name\": \"fstikbot\",\n \"version\": \"1.27.2\",\n \"description\": \"\",\n \"main\": \"index.js\",\n \"scripts\": {\n \"start\": \"n"
},
{
"path": "privacy.html",
"chars": 683,
"preview": "<b>🔒 @fStikBot Privacy Policy</b>\n\nHello! I care about your privacy. Here's a quick overview of my privacy policy:\n\n• 👤 "
},
{
"path": "scenes/admin-pack-bulk-delete.js",
"chars": 3908,
"preview": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst { escapeHTML } = require('"
},
{
"path": "scenes/admin-pack.js",
"chars": 6829,
"preview": "const Markup = require('telegraf/markup')\nconst Scene = require('telegraf/scenes/base')\nconst { escapeHTML } = require('"
},
{
"path": "scenes/donate.js",
"chars": 3604,
"preview": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst mongoose = require('mongoo"
},
{
"path": "scenes/index.js",
"chars": 2023,
"preview": "const Stage = require('telegraf/stage')\nconst I18n = require('telegraf-i18n')\nconst {\n handleStart\n} = require('../hand"
},
{
"path": "scenes/messaging.js",
"chars": 18350,
"preview": "const mongoose = require('mongoose')\nconst Markup = require('telegraf/markup')\nconst Scene = require('telegraf/scenes/ba"
},
{
"path": "scenes/mosaic.js",
"chars": 18008,
"preview": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst I18n = require('telegraf-i"
},
{
"path": "scenes/pack-about.js",
"chars": 11650,
"preview": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst { sendBanner } = require('"
},
{
"path": "scenes/pack-catalog.js",
"chars": 13663,
"preview": "const fs = require('fs')\nconst path = require('path')\nconst Scene = require('telegraf/scenes/base')\nconst Markup = requi"
},
{
"path": "scenes/pack-delete.js",
"chars": 2669,
"preview": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst { match } = require('teleg"
},
{
"path": "scenes/pack-frame.js",
"chars": 2315,
"preview": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst {\n match\n} = require('tel"
},
{
"path": "scenes/pack-new.js",
"chars": 23777,
"preview": "const got = require('got')\nconst slug = require('limax')\nconst StegCloak = require('stegcloak')\nconst Scene = require('t"
},
{
"path": "scenes/pack-rename.js",
"chars": 2602,
"preview": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst {\n escapeHTML,\n countUnc"
},
{
"path": "scenes/pack-search.js",
"chars": 1959,
"preview": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst { escapeHTML } = require('"
},
{
"path": "scenes/photo-clear.js",
"chars": 5477,
"preview": "const Scene = require('telegraf/scenes/base')\nconst sharp = require('sharp')\nconst {\n showGramAds\n} = require('../utils"
},
{
"path": "scenes/sticker-delete.js",
"chars": 1469,
"preview": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\n\nconst deleteSticker = new Scene"
},
{
"path": "scenes/sticker-original.js",
"chars": 3601,
"preview": "const Scene = require('telegraf/scenes/base')\nconst Markup = require('telegraf/markup')\nconst escapeHTML = require('../u"
},
{
"path": "scenes/video-round.js",
"chars": 4528,
"preview": "const Scene = require('telegraf/scenes/base')\nconst { showGramAds } = require('../utils')\nconst { videoNoteQueue } = req"
},
{
"path": "scripts/README.md",
"chars": 1313,
"preview": "# Maintenance scripts\n\nOne-shot operational scripts. Run from the project root:\n\n```bash\nnode scripts/<name>.js\n```\n\nEac"
},
{
"path": "scripts/inspect-db.js",
"chars": 6471,
"preview": "// Read-only investigation of the Sticker + StickerSet collections.\n// Produces schema stats for migration planning with"
},
{
"path": "scripts/test-perf-timing.js",
"chars": 3805,
"preview": "// Standalone smoke test for utils/perf-timing.js\n// Run: node scripts/test-perf-timing.js\n//\n// Verifies:\n// 1. perfS"
},
{
"path": "scripts/test-retry-api.js",
"chars": 17354,
"preview": "// Standalone smoke test for utils/retry-api.js\n// Run: node scripts/test-retry-api.js\n//\n// Verifies:\n// 1. Blocked-c"
},
{
"path": "scripts/top-sets.js",
"chars": 3424,
"preview": "const Telegram = require('telegraf/telegram')\nconst cron = require('node-cron')\nconst { atlasDb } = require('../database"
},
{
"path": "scripts/update-packs.js",
"chars": 4147,
"preview": "const { telegramApi } = require('../utils')\nconst Telegram = require('telegraf/telegram')\nconst {\n db\n} = require('../d"
},
{
"path": "scripts/update-sticker.js",
"chars": 644,
"preview": "require('dotenv').config({ path: '../.env' })\nconst Telegram = require('telegraf/telegram')\nconst {\n db\n} = require('.."
},
{
"path": "utils/add-sticker-text.js",
"chars": 3993,
"preview": "const path = require('path')\nconst Markup = require('telegraf/markup')\nconst I18n = require('telegraf-i18n')\nconst escap"
},
{
"path": "utils/add-sticker.js",
"chars": 21753,
"preview": "const path = require('path')\nconst sharp = require('sharp')\nconst I18n = require('telegraf-i18n')\nconst emojiRegex = req"
},
{
"path": "utils/decode-sticker-set-id.js",
"chars": 1299,
"preview": "/**\n * Decode Telegram sticker set ID to extract owner user ID and set number\n *\n * Two formats:\n * 1. Standard (32-bit "
},
{
"path": "utils/download-file-by-url.js",
"chars": 884,
"preview": "const https = require('https')\n\nmodule.exports = (fileUrl, timeout = 30000) => new Promise((resolve, reject) => {\n cons"
},
{
"path": "utils/escape-regex.js",
"chars": 113,
"preview": "function escapeRegex (str) {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\nmodule.exports = escapeRegex\n"
},
{
"path": "utils/gramads.js",
"chars": 541,
"preview": "const got = require('got')\n\nmodule.exports = async (chatId) => {\n const token = process.env.GRAMADS_TOKEN\n\n const head"
},
{
"path": "utils/group-update.js",
"chars": 406,
"preview": "module.exports = async (ctx, next) => {\n let group = await ctx.db.Group.findOne({ telegram_id: ctx.chat.id })\n\n if (!g"
},
{
"path": "utils/html-escape.js",
"chars": 206,
"preview": "module.exports = (str) => (str == null ? '' : str).toString().replace(\n /[&<>'\"]/g,\n (tag) => ({\n '&': '&',\n "
},
{
"path": "utils/index.js",
"chars": 1493,
"preview": "const escapeHTML = require('./html-escape')\nconst userName = require('./user-name')\nconst addSticker = require('./add-st"
},
{
"path": "utils/last-seen.js",
"chars": 1856,
"preview": "// Throttled \"last seen\" tracker for User.updatedAt.\n//\n// Context: updateUser runs on every incoming update. Previously"
},
{
"path": "utils/logger.js",
"chars": 1532,
"preview": "// Minimal structured logger — no external deps, drop-in replacement\n// for console.{log,error,warn,debug}. Adds:\n// -"
},
{
"path": "utils/messaging.js",
"chars": 9895,
"preview": "const fs = require('fs')\nconst getTelegram = require('./telegram').get\nconst replicators = require('telegraf/core/replic"
},
{
"path": "utils/moderate-pack.js",
"chars": 4737,
"preview": "const { db } = require('../database')\nconst OpenAI = require('openai')\nconst got = require('got')\nconst sharp = require("
},
{
"path": "utils/mosaic-grid.js",
"chars": 2306,
"preview": "const getGridSuggestions = (width, height, freeSlots = 200) => {\n const ratio = width / height\n\n // Determine type\n i"
},
{
"path": "utils/mosaic-preview.js",
"chars": 3199,
"preview": "const sharp = require('sharp')\n\nconst generatePreview = async (imageBuffer, rows, cols) => {\n const image = sharp(image"
},
{
"path": "utils/mosaic-split.js",
"chars": 1987,
"preview": "const sharp = require('sharp')\n\nconst splitImage = async (imageBuffer, rows, cols) => {\n const image = sharp(imageBuffe"
},
{
"path": "utils/perf-timing.js",
"chars": 3844,
"preview": "// Per-stage middleware timing — lightweight wall-clock instrumentation so we\n// can see where response time is spent ac"
},
{
"path": "utils/queues.js",
"chars": 2358,
"preview": "// Bull queue handles for offloaded background work.\n//\n// Redis is opt-in (see utils/redis.js): when REDIS_HOST isn't s"
},
{
"path": "utils/redis.js",
"chars": 500,
"preview": "// Shared Redis client for broadcast campaigns.\n// Bull queues manage their own connections in utils/queues.js.\n// Retur"
},
{
"path": "utils/retry-api.js",
"chars": 13969,
"preview": "// Telegram API retry + transient-403 short-circuit, patched at the\n// Telegram.prototype level so every `ctx.reply*`, `"
},
{
"path": "utils/safe-edit.js",
"chars": 1609,
"preview": "// Edits a callback message's text, falling back to a fresh reply if the\n// edit fails (message too old, not text, lost "
},
{
"path": "utils/send-sticker-as-document.js",
"chars": 3874,
"preview": "const got = require('got')\nconst sharp = require('sharp')\n\n// Download a sticker by file_id and reply with it as a regul"
},
{
"path": "utils/stats.js",
"chars": 1981,
"preview": "const io = require('@pm2/io')\n\nconst stats = {\n rpsAvrg: 0,\n responseTimeAvrg: 0,\n times: {}\n}\n\nconst rtOP = io.metri"
},
{
"path": "utils/sticker-inflight.js",
"chars": 1375,
"preview": "// Per-user concurrency gate for the fire-and-forget sticker-add flow.\n//\n// Why: after detaching addSticker from the Te"
},
{
"path": "utils/telegram-api.js",
"chars": 1854,
"preview": "const { Api, TelegramClient } = require('telegram')\nconst { StringSession } = require('telegram/sessions')\nconst fs = re"
},
{
"path": "utils/telegram-error.js",
"chars": 3329,
"preview": "// Maps Telegram API errors to user-facing i18n keys under\n// `error.telegram_reasons.*`. Patterns are ordered most-spec"
},
{
"path": "utils/telegram.js",
"chars": 838,
"preview": "// utils/telegram.js\n// Ensure retry-api prototype patch is applied before any Telegram instance is created.\nrequire('./"
},
{
"path": "utils/tenor.js",
"chars": 874,
"preview": "const got = require('got')\n\nconst search = async (query, limit, pos) => {\n const response = await got.get(`https://g.te"
},
{
"path": "utils/unicode-chars-count.js",
"chars": 381,
"preview": "module.exports = (str) => {\n if (typeof str !== 'string') throw new TypeError('Expected a string')\n\n let count = 0\n f"
},
{
"path": "utils/unicode-substr.js",
"chars": 1092,
"preview": "module.exports = (str, start, end) => {\n if (typeof str !== 'string') throw new TypeError('Expected a string')\n if (ty"
},
{
"path": "utils/update-monitor.js",
"chars": 2418,
"preview": "/* eslint-disable camelcase */\nconst config = require('../config.json')\nconst telegram = require('./telegram')\nconst log"
},
{
"path": "utils/user-name.js",
"chars": 287,
"preview": "module.exports = (user, url = false) => {\n let name = user.first_name\n\n if (user.last_name) name += ` ${user.last_name"
},
{
"path": "utils/user-update.js",
"chars": 2321,
"preview": "module.exports = async (ctx) => {\n if (!ctx.from) return false\n\n // Only populate inlineStickerSet when the handler ac"
}
]
// ... and 3 more files (download for full content)
About this extraction
This page contains the full source code of the LyoSU/fStikBot GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 167 files (987.5 KB), approximately 290.5k tokens, and a symbol index with 139 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.