From 5b89dcee3bcf68b078dbdd00d49f13a4f9129384 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Thu, 17 Sep 2026 10:22:59 +0800 Subject: [PATCH 01/13] feat(community): config keys, Tip/Invite ledger types, User fields, first-login migration --- .env.example | 7 +++ src/common/models/Balance.test.ts | 5 ++ src/common/models/Balance.ts | 2 + src/common/models/User.ts | 76 ++++++++++++++++++++++++++++++- src/config.ts | 13 ++++++ src/main.ts | 4 +- 6 files changed, 105 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index dcde16fc..999460f7 100644 --- a/.env.example +++ b/.env.example @@ -41,3 +41,10 @@ GENERATION_TRY_COST_VOTES=100 STARS_TOPUP_VOTES_PER_STAR=10 # $CUBE credited to the referrer when an invited user's pass is minted. 0 = off. REFERRAL_MINT_REWARD_VOTES=200 +# Community chats: JSON array of chat ids, e.g. [-1001234567890,-1009876543210]. Empty = off. +COMMUNITY_CHAT_IDS=[] +TIP_VOTES=50 +TIP_BASE_PER_DAY=3 +TIP_REP_PER_EXTRA=100 +TIP_EMOJI=🧊 +INVITE_LOGIN_REWARD_VOTES=20 diff --git a/src/common/models/Balance.test.ts b/src/common/models/Balance.test.ts index 72a2411b..072d7e4a 100644 --- a/src/common/models/Balance.test.ts +++ b/src/common/models/Balance.test.ts @@ -41,3 +41,8 @@ test('getBalanceChangeTypeName maps the generation sink type', () => { assert.equal(getBalanceChangeTypeName(BalanceChangeType.Generation), 'Generation') assert.equal(getBalanceChangeTypeName(9), 'Generation') }) + +test('Tip and Invite ledger types resolve to their names', () => { + assert.equal(getBalanceChangeTypeName(BalanceChangeType.Tip), 'Tip') + assert.equal(getBalanceChangeTypeName(BalanceChangeType.Invite), 'Invite') +}) diff --git a/src/common/models/Balance.ts b/src/common/models/Balance.ts index 15977cd9..234defef 100644 --- a/src/common/models/Balance.ts +++ b/src/common/models/Balance.ts @@ -18,6 +18,8 @@ export enum BalanceChangeType { StarsTopup = 10, // CUBE faucet: purchased with Telegram Stars Stake = 11, // CUBE sink: Bali visit stake/fee (negative) or refund (positive) Payout = 12, // CUBE faucet: Bali window payout + Tip = 13, // CUBE faucet: received a holder's chat tip + Invite = 14, // CUBE faucet: an invited chat member opened the app for the first time } export function getBalanceChangeTypeName( diff --git a/src/common/models/User.ts b/src/common/models/User.ts index d0225545..b02a2781 100644 --- a/src/common/models/User.ts +++ b/src/common/models/User.ts @@ -65,7 +65,7 @@ export class UserRep { } @index({ 'pass.index': 1 }) -@modelOptions({ schemaOptions: { timestamps: true, id: false } }) +@modelOptions({ schemaOptions: { timestamps: true, id: false }, options: { allowMixed: Severity.ALLOW } }) export class User extends TimeStamps { @prop({ type: Number, required: true, unique: true }) id!: number @@ -125,6 +125,24 @@ export class User extends TimeStamps { @prop({ type: Date }) mintedAt?: Date + // Community: stamped once by /api/auth/login. Missing ⇒ never opened the app + // (a shell created from a chat tip or a chat join). + @prop({ type: Date }) + firstLoginAt?: Date + + // Shell created by the community feature (join via invite link / tipped as + // a stranger). Login pays the inviter's drop only for these. + @prop({ type: Boolean }) + joinedViaChat?: boolean + + // The one-time "open the bot to claim" reply went out. + @prop({ type: Date }) + communityNudgedAt?: Date + + // chatId -> personal invite url, created lazily by /api/users/community. + @prop({ type: () => Object }) + inviteLinks?: Record + @prop({ type: Boolean }) diceWinner?: boolean @@ -189,6 +207,62 @@ export async function ensureLegacyStateMigration(): Promise { return res.modifiedCount } +// Community: everyone who existed before the feature counts as "registered", +// so nobody is nudged or pays an invite drop retroactively. Chat-created +// shells carry joinedViaChat and are left alone. Idempotent; runs at boot. +export async function ensureFirstLoginMigration(): Promise { + const res = await UserModel.updateMany( + { firstLoginAt: { $exists: false }, joinedViaChat: { $ne: true } }, + [{ $set: { firstLoginAt: '$createdAt' } }], + ) + return res.modifiedCount +} + +// CAS: stamps firstLoginAt only when absent. True ⇒ this call was the first login. +export async function setFirstLoginAt(userId: number, now: Date): Promise { + const res = await UserModel.updateOne( + { id: userId, firstLoginAt: { $exists: false } }, + { $set: { firstLoginAt: now } }, + ) + return res.modifiedCount === 1 +} + +// First invite link wins; wallet-bound users are never re-attributed. +export async function setChatReferral( + joinerId: number, + inviterId: number, + language: 'en' | 'ru', +): Promise { + const res = await UserModel.updateOne( + { id: joinerId, wallet: { $exists: false }, referalId: { $exists: false } }, + { $set: { referalId: inviterId, joinedViaChat: true, language, languageSelected: true } }, + ) + return res.modifiedCount === 1 +} + +export async function markJoinedViaChat(userId: number): Promise { + await UserModel.updateOne( + { id: userId, firstLoginAt: { $exists: false } }, + { $set: { joinedViaChat: true } }, + ) +} + +export async function markCommunityNudged(userId: number, now: Date): Promise { + await UserModel.updateOne({ id: userId }, { $set: { communityNudgedAt: now } }) +} + +export async function setInviteLink(userId: number, chatId: number, url: string): Promise { + await UserModel.updateOne({ id: userId }, { $set: { [`inviteLinks.${chatId}`]: url } }) +} + +export async function countInvitedLoggedIn(userId: number): Promise { + return UserModel.countDocuments({ + referalId: userId, + joinedViaChat: true, + firstLoginAt: { $exists: true }, + }) +} + export async function findOrCreateUser(id: number): Promise { const isEmptyRecords = (await countUserBalanceRecords(id)) === 0 if (isEmptyRecords) { diff --git a/src/config.ts b/src/config.ts index 440dd274..199ec2de 100644 --- a/src/config.ts +++ b/src/config.ts @@ -76,6 +76,19 @@ function createConfigFromEnvironment(environment: NodeJS.ProcessEnv) { // $CUBE credited to the referrer when an invited user's pass is minted. // Mint approval is human-gated, which makes this reward unfarmable. 0 ⇒ off. REFERRAL_MINT_REWARD_VOTES: z.number().int().default(200), + // Community chats (EN/RU) where holder tips and personal invite links are + // active. Empty ⇒ the feature is off (no handlers fire, no links minted). + COMMUNITY_CHAT_IDS: z.array(z.number()).default([]), + // $CUBE credited to the author of a tipped message. + TIP_VOTES: z.number().int().min(0).default(50), + // Tips a pass holder may give per UTC day, before the rep bonus. + TIP_BASE_PER_DAY: z.number().int().min(0).default(3), + // +1 daily tip per this much cooperative rep (helped + gave). + TIP_REP_PER_EXTRA: z.number().int().min(1).default(100), + // The reaction emoji that tips. + TIP_EMOJI: z.string().default('🧊'), + // $CUBE credited to the inviter when a chat-invited user first opens the app. + INVITE_LOGIN_REWARD_VOTES: z.number().int().min(0).default(20), }) if (config.BOT_MODE === 'webhook') { diff --git a/src/main.ts b/src/main.ts index beef7b8c..622fb14b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ import { onShutdown } from 'node-graceful-shutdown' import { setMenuButton, syncBotCommands } from '#root/bot/handlers/commands/sync-commands' import { createBot } from '#root/bot/index' import { ensureClaimUniquenessMigration } from '#root/common/models/Claim' -import { createInitialBalancesIfNotExists, ensureLegacyStateMigration } from '#root/common/models/User' +import { createInitialBalancesIfNotExists, ensureFirstLoginMigration, ensureLegacyStateMigration } from '#root/common/models/User' import { config } from '#root/config' import { startResolver } from '#root/game/resolver-start' import { logger } from '#root/logger' @@ -19,6 +19,8 @@ try { await createInitialBalancesIfNotExists() const legacyStates = await ensureLegacyStateMigration() if (legacyStates > 0) logger.info(`State migration: reset ${legacyStates} v1 mid-mint user(s) to WaitNothing`) + const firstLogins = await ensureFirstLoginMigration() + if (firstLogins > 0) logger.info(`Community migration: stamped firstLoginAt on ${firstLogins} pre-existing user(s)`) const migration = await ensureClaimUniquenessMigration() if (migration.duplicateGroups > 0) { logger.info( From b91d31c41d67af0d130ee1c2d6e94fe9296aaa2b Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Thu, 17 Sep 2026 10:27:11 +0800 Subject: [PATCH 02/13] feat(community): Tip and ChatMessage models --- src/common/models/ChatMessage.ts | 40 +++++++++++++++++++++++ src/common/models/Tip.ts | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/common/models/ChatMessage.ts create mode 100644 src/common/models/Tip.ts diff --git a/src/common/models/ChatMessage.ts b/src/common/models/ChatMessage.ts new file mode 100644 index 00000000..c6ddc8ef --- /dev/null +++ b/src/common/models/ChatMessage.ts @@ -0,0 +1,40 @@ +import { getModelForClass, index, modelOptions, prop } from '@typegoose/typegoose' + +// chat+message -> author, so a reaction (which carries no author) can be +// turned into a tip. Rows expire after 7 days; a reaction on an older message +// is silently ignored. +const TTL_SECONDS = 7 * 24 * 60 * 60 + +@modelOptions({ + schemaOptions: { timestamps: { createdAt: true, updatedAt: false } }, +}) +@index({ chatId: 1, messageId: 1 }, { unique: true }) +@index({ createdAt: 1 }, { expireAfterSeconds: TTL_SECONDS }) +class ChatMessage { + @prop({ type: Number, required: true }) + chatId!: number + + @prop({ type: Number, required: true }) + messageId!: number + + @prop({ type: Number, required: true }) + authorId!: number + + @prop({ type: Date }) + createdAt?: Date +} + +const ChatMessageModel = getModelForClass(ChatMessage) + +export async function indexChatMessage(chatId: number, messageId: number, authorId: number): Promise { + await ChatMessageModel.updateOne( + { chatId, messageId }, + { $setOnInsert: { chatId, messageId, authorId, createdAt: new Date() } }, + { upsert: true }, + ) +} + +export async function findChatMessageAuthor(chatId: number, messageId: number): Promise { + const row = await ChatMessageModel.findOne({ chatId, messageId }, { authorId: 1 }).lean() + return row?.authorId ?? null +} diff --git a/src/common/models/Tip.ts b/src/common/models/Tip.ts new file mode 100644 index 00000000..f5d6a64b --- /dev/null +++ b/src/common/models/Tip.ts @@ -0,0 +1,54 @@ +import { getModelForClass, index, modelOptions, prop } from '@typegoose/typegoose' + +// One row per holder tip. The compound unique index makes a re-added reaction +// or a repeated /tip on the same message a no-op (E11000 ⇒ not credited). +@modelOptions({ + schemaOptions: { timestamps: { createdAt: true, updatedAt: false } }, +}) +@index({ chatId: 1, messageId: 1, tipperId: 1 }, { unique: true }) +@index({ tipperId: 1, createdAt: 1 }) +class Tip { + @prop({ type: Number, required: true }) + tipperId!: number + + @prop({ type: Number, required: true }) + recipientId!: number + + @prop({ type: Number, required: true }) + chatId!: number + + @prop({ type: Number, required: true }) + messageId!: number + + @prop({ type: BigInt, required: true }) + votes!: bigint + + @prop({ type: Date }) + createdAt?: Date +} + +const TipModel = getModelForClass(Tip) + +export interface TipInput { + tipperId: number + recipientId: number + chatId: number + messageId: number + votes: bigint +} + +// True if this call inserted the row; false if the same tipper already tipped +// this message (duplicate update — do not credit). +export async function recordTip(tip: TipInput): Promise { + try { + await TipModel.create(tip) + return true + } catch (error) { + if ((error as { code?: number }).code === 11000) return false + throw error + } +} + +export async function countTipsSince(tipperId: number, since: Date): Promise { + return TipModel.countDocuments({ tipperId, createdAt: { $gte: since } }) +} From ce88eb9cb1e5a63f5d471807ca49ad8689b91723 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Thu, 17 Sep 2026 10:31:50 +0800 Subject: [PATCH 03/13] feat(community): pure giveTip with daily allowance and record-then-credit --- src/bot/features/community/give-tip.test.ts | 144 ++++++++++++++++++++ src/bot/features/community/give-tip.ts | 119 ++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 src/bot/features/community/give-tip.test.ts create mode 100644 src/bot/features/community/give-tip.ts diff --git a/src/bot/features/community/give-tip.test.ts b/src/bot/features/community/give-tip.test.ts new file mode 100644 index 00000000..2d1552d1 --- /dev/null +++ b/src/bot/features/community/give-tip.test.ts @@ -0,0 +1,144 @@ +/* eslint-disable test/no-import-node-test */ +import type { GiveTipDependencies, TipUser } from '#root/bot/features/community/give-tip' +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { buildGiveTip, tipsPerDay, utcMidnight } from '#root/bot/features/community/give-tip' +import { BalanceChangeType } from '#root/common/models/Balance' + +const NOW = new Date('2026-09-17T10:00:00Z') + +interface Ctx { + users: Map + tips: Array<{ tipperId: number, recipientId: number, chatId: number, messageId: number, votes: bigint }> + credits: Array<[number, bigint, BalanceChangeType]> + reactions: Array<{ chatId: number, messageId: number }> + replies: Array<{ chatId: number, messageId: number, text: string }> + joinedViaChat: number[] + nudged: number[] + errors: string[] + deps: GiveTipDependencies +} + +function createCtx(overrides: Partial = {}, tipperOverrides: Partial = {}): Ctx { + const users = new Map([ + [1, { id: 1, pass: { index: 7 }, rep: { helped: 0, gave: 0 }, firstLoginAt: NOW, ...tipperOverrides }], + [2, { id: 2, firstLoginAt: NOW, name: 'bob' }], + ]) + const ctx = { + users, tips: [], credits: [], reactions: [], replies: [], joinedViaChat: [], nudged: [], errors: [], + } as unknown as Ctx + ctx.deps = { + tipVotes: 50n, + basePerDay: 3, + repPerExtra: 100, + now: () => NOW, + findUserById: async id => users.get(id) ?? null, + findOrCreateUser: async (id) => { + if (!users.has(id)) users.set(id, { id }) + return users.get(id)! + }, + countTipsSince: async tipperId => ctx.tips.filter(t => t.tipperId === tipperId).length, + recordTip: async (tip) => { + if (ctx.tips.some(t => t.chatId === tip.chatId && t.messageId === tip.messageId && t.tipperId === tip.tipperId)) return false + ctx.tips.push(tip) + return true + }, + addPoints: async (userId, votes, reason) => { + ctx.credits.push([userId, votes, reason]) + return votes + }, + markJoinedViaChat: async (id) => { ctx.joinedViaChat.push(id) }, + markCommunityNudged: async (id) => { ctx.nudged.push(id) }, + react: async (chatId, messageId) => { ctx.reactions.push({ chatId, messageId }) }, + reply: async (chatId, messageId, text) => { ctx.replies.push({ chatId, messageId, text }) }, + translate: (locale, key, vars) => `${locale}:${key}:${JSON.stringify(vars)}`, + logError: (message) => { ctx.errors.push(message) }, + ...overrides, + } + return ctx +} + +const INPUT = { tipperId: 1, recipientId: 2, chatId: -100, messageId: 55, recipientName: 'bob' } + +test('utcMidnight truncates to 00:00 UTC', () => { + assert.equal(utcMidnight(NOW).toISOString(), '2026-09-17T00:00:00.000Z') +}) + +test('tipsPerDay = base + floor((helped+gave)/repPerExtra)', () => { + assert.equal(tipsPerDay(undefined, 3, 100), 3) + assert.equal(tipsPerDay({ helped: 40, gave: 59 }, 3, 100), 3) + assert.equal(tipsPerDay({ helped: 150, gave: 60 }, 3, 100), 5) +}) + +test('a holder tip credits the recipient and reacts on the message', async () => { + const ctx = createCtx() + const result = await buildGiveTip(ctx.deps)(INPUT) + assert.deepEqual(result, { ok: true, votes: 50n }) + assert.deepEqual(ctx.credits, [[2, 50n, BalanceChangeType.Tip]]) + assert.deepEqual(ctx.reactions, [{ chatId: -100, messageId: 55 }]) + assert.equal(ctx.replies.length, 0, 'registered recipient gets no text') + assert.deepEqual(ctx.joinedViaChat, []) +}) + +test('self tip is rejected before any write', async () => { + const ctx = createCtx() + const result = await buildGiveTip(ctx.deps)({ ...INPUT, recipientId: 1 }) + assert.deepEqual(result, { ok: false, reason: 'self' }) + assert.equal(ctx.tips.length, 0) +}) + +test('a non-holder cannot tip', async () => { + const ctx = createCtx({}, { pass: undefined }) + const result = await buildGiveTip(ctx.deps)(INPUT) + assert.deepEqual(result, { ok: false, reason: 'holders_only' }) + assert.equal(ctx.tips.length, 0) +}) + +test('allowance is exhausted after base + rep tips', async () => { + const ctx = createCtx({}, { rep: { helped: 100, gave: 0 } }) + const give = buildGiveTip(ctx.deps) + for (let m = 1; m <= 4; m++) assert.equal((await give({ ...INPUT, messageId: m })).ok, true) + assert.deepEqual(await give({ ...INPUT, messageId: 5 }), { ok: false, reason: 'no_allowance' }) + assert.equal(ctx.credits.length, 4) +}) + +test('the same tipper on the same message is a duplicate no-op', async () => { + const ctx = createCtx() + const give = buildGiveTip(ctx.deps) + await give(INPUT) + assert.deepEqual(await give(INPUT), { ok: false, reason: 'duplicate' }) + assert.equal(ctx.credits.length, 1) +}) + +test('tipping a stranger creates a shell, marks joinedViaChat, nudges once', async () => { + const ctx = createCtx() + const give = buildGiveTip(ctx.deps) + await give({ ...INPUT, recipientId: 9, recipientName: 'carol', recipientLanguage: 'ru' }) + assert.ok(ctx.users.has(9), 'shell created') + assert.deepEqual(ctx.joinedViaChat, [9]) + assert.equal(ctx.replies.length, 1) + assert.match(ctx.replies[0].text, /^ru:community_nudge:/) + assert.match(ctx.replies[0].text, /"name":"carol"/) + assert.deepEqual(ctx.nudged, [9]) + // second tip from another message: still unregistered, but already nudged + ctx.users.get(9)!.communityNudgedAt = NOW + await give({ ...INPUT, recipientId: 9, messageId: 56, recipientName: 'carol' }) + assert.equal(ctx.replies.length, 1) +}) + +test('side-effect failures never fail the tip', async () => { + const ctx = createCtx({ + react: async () => { throw new Error('telegram down') }, + reply: async () => { throw new Error('telegram down') }, + }) + const result = await buildGiveTip(ctx.deps)({ ...INPUT, recipientId: 9, recipientName: 'carol' }) + assert.equal(result.ok, true) + assert.equal(ctx.errors.length, 2) +}) + +test('credit failure is reported and logged', async () => { + const ctx = createCtx({ addPoints: async () => { throw new Error('mongo down') } }) + const result = await buildGiveTip(ctx.deps)(INPUT) + assert.deepEqual(result, { ok: false, reason: 'credit_failed' }) + assert.equal(ctx.errors.length, 1) +}) diff --git a/src/bot/features/community/give-tip.ts b/src/bot/features/community/give-tip.ts new file mode 100644 index 00000000..5d526594 --- /dev/null +++ b/src/bot/features/community/give-tip.ts @@ -0,0 +1,119 @@ +// Pure tip logic: allowance check, record-then-credit, best-effort chat +// feedback. The composer (index.ts) wires Telegram + Mongo deps. +import type { BalanceChangeType } from '#root/common/models/Balance' +import { BalanceChangeType as Ledger } from '#root/common/models/Balance' + +export interface TipUser { + id: number + pass?: unknown + rep?: { helped: number, gave: number } + firstLoginAt?: Date + communityNudgedAt?: Date + language?: string + name?: string +} + +export interface TipInput { + tipperId: number + recipientId: number + chatId: number + messageId: number + votes: bigint +} + +export interface GiveTipDependencies { + tipVotes: bigint + basePerDay: number + repPerExtra: number + now: () => Date + findUserById: (id: number) => Promise + findOrCreateUser: (id: number) => Promise + countTipsSince: (tipperId: number, since: Date) => Promise + recordTip: (tip: TipInput) => Promise + addPoints: (userId: number, votes: bigint, reason: BalanceChangeType) => Promise + markJoinedViaChat: (userId: number) => Promise + markCommunityNudged: (userId: number, now: Date) => Promise + react: (chatId: number, messageId: number) => Promise + reply: (chatId: number, messageId: number, text: string) => Promise + translate: (locale: string, key: string, vars: Record) => string + logError: (message: string) => void +} + +export type GiveTipResult + = | { ok: true, votes: bigint } + | { ok: false, reason: 'self' | 'holders_only' | 'no_allowance' | 'duplicate' | 'credit_failed' } + +export function utcMidnight(now: Date): Date { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())) +} + +export function tipsPerDay( + rep: { helped: number, gave: number } | undefined, + base: number, + repPerExtra: number, +): number { + const score = (rep?.helped ?? 0) + (rep?.gave ?? 0) + return base + Math.floor(score / repPerExtra) +} + +export function buildGiveTip(deps: GiveTipDependencies) { + return async function giveTip(input: { + tipperId: number + recipientId: number + chatId: number + messageId: number + recipientName: string + recipientLanguage?: string + }): Promise { + const { tipperId, recipientId, chatId, messageId } = input + if (tipperId === recipientId) return { ok: false, reason: 'self' } + + const tipper = await deps.findUserById(tipperId) + if (!tipper?.pass) return { ok: false, reason: 'holders_only' } + + const now = deps.now() + // ponytail: count-then-insert without a lock; two simultaneous reactions + // can overshoot the allowance by one. Single process today. + const used = await deps.countTipsSince(tipperId, utcMidnight(now)) + if (used >= tipsPerDay(tipper.rep, deps.basePerDay, deps.repPerExtra)) { + return { ok: false, reason: 'no_allowance' } + } + + // Record FIRST: a redelivered update loses on the unique index and never + // double-credits. + const fresh = await deps.recordTip({ tipperId, recipientId, chatId, messageId, votes: deps.tipVotes }) + if (!fresh) return { ok: false, reason: 'duplicate' } + + const recipient = await deps.findOrCreateUser(recipientId) + const unregistered = !recipient?.firstLoginAt + if (unregistered) await deps.markJoinedViaChat(recipientId) + + try { + await deps.addPoints(recipientId, deps.tipVotes, Ledger.Tip) + } catch (err) { + deps.logError(`!!! Tip credit failed for ${recipientId} on ${chatId}/${messageId}: ${(err as Error).message}`) + return { ok: false, reason: 'credit_failed' } + } + + try { + await deps.react(chatId, messageId) + } catch (err) { + deps.logError(`Tip react failed on ${chatId}/${messageId}: ${(err as Error).message}`) + } + + if (unregistered && !recipient?.communityNudgedAt) { + const locale = input.recipientLanguage === 'ru' || recipient?.language === 'ru' ? 'ru' : 'en' + try { + await deps.reply(chatId, messageId, deps.translate(locale, 'community_nudge', { + name: input.recipientName, + votes: deps.tipVotes.toString(), + })) + await deps.markCommunityNudged(recipientId, now) + } catch (err) { + deps.logError(`Tip nudge failed for ${recipientId}: ${(err as Error).message}`) + } + } + + return { ok: true, votes: deps.tipVotes } + } +} From 38785cfdb9103fb9d49e0320aced43075a3bd0f4 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Thu, 17 Sep 2026 10:36:23 +0800 Subject: [PATCH 04/13] feat(community): message indexer and invite-link join handler --- .../community/indexer-handler.test.ts | 24 ++++++++ src/bot/features/community/indexer-handler.ts | 19 +++++++ .../features/community/join-handler.test.ts | 57 +++++++++++++++++++ src/bot/features/community/join-handler.ts | 39 +++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 src/bot/features/community/indexer-handler.test.ts create mode 100644 src/bot/features/community/indexer-handler.ts create mode 100644 src/bot/features/community/join-handler.test.ts create mode 100644 src/bot/features/community/join-handler.ts diff --git a/src/bot/features/community/indexer-handler.test.ts b/src/bot/features/community/indexer-handler.test.ts new file mode 100644 index 00000000..890d1a1a --- /dev/null +++ b/src/bot/features/community/indexer-handler.test.ts @@ -0,0 +1,24 @@ +/* eslint-disable test/no-import-node-test */ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { buildIndexerHandler } from '#root/bot/features/community/indexer-handler' + +function ctx() { + const rows: Array<[number, number, number]> = [] + const handle = buildIndexerHandler({ indexChatMessage: async (c, m, a) => { rows.push([c, m, a]) } }) + return { rows, handle } +} + +test('indexes a human message', async () => { + const { rows, handle } = ctx() + await handle({ chat: { id: -100 }, message_id: 7, from: { id: 42, is_bot: false } }) + assert.deepEqual(rows, [[-100, 7, 42]]) +}) + +test('skips bots, anonymous admins and missing senders', async () => { + const { rows, handle } = ctx() + await handle({ chat: { id: -100 }, message_id: 8, from: { id: 1, is_bot: true } }) + await handle({ chat: { id: -100 }, message_id: 9, from: { id: 42, is_bot: false }, sender_chat: { id: -100 } }) + await handle({ chat: { id: -100 }, message_id: 10 }) + assert.deepEqual(rows, []) +}) diff --git a/src/bot/features/community/indexer-handler.ts b/src/bot/features/community/indexer-handler.ts new file mode 100644 index 00000000..6982e32c --- /dev/null +++ b/src/bot/features/community/indexer-handler.ts @@ -0,0 +1,19 @@ +// Records chat+message -> author so a later reaction can be attributed. +export interface IndexerMessage { + chat: { id: number } + message_id: number + from?: { id: number, is_bot: boolean } + sender_chat?: unknown +} + +export interface IndexerDependencies { + indexChatMessage: (chatId: number, messageId: number, authorId: number) => Promise +} + +export function buildIndexerHandler(deps: IndexerDependencies) { + return async function indexMessage(message: IndexerMessage): Promise { + const from = message.from + if (!from || from.is_bot || message.sender_chat) return + await deps.indexChatMessage(message.chat.id, message.message_id, from.id) + } +} diff --git a/src/bot/features/community/join-handler.test.ts b/src/bot/features/community/join-handler.test.ts new file mode 100644 index 00000000..fde1200f --- /dev/null +++ b/src/bot/features/community/join-handler.test.ts @@ -0,0 +1,57 @@ +/* eslint-disable test/no-import-node-test */ +import type { JoinUpdate } from '#root/bot/features/community/join-handler' +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { buildJoinHandler, parseInviterId } from '#root/bot/features/community/join-handler' + +function ctx() { + const created: number[] = [] + const referrals: Array<[number, number, string]> = [] + const handle = buildJoinHandler({ + findOrCreateUser: async (id) => { created.push(id); return { id } }, + setChatReferral: async (j, i, l) => { referrals.push([j, i, l]); return true }, + logInfo: () => {}, + }) + return { created, referrals, handle } +} + +function join(overrides: Partial = {}): JoinUpdate { + return { + chat: { id: -100 }, + from: { id: 9 }, + old_chat_member: { status: 'left' }, + new_chat_member: { status: 'member', user: { id: 9, is_bot: false, language_code: 'ru' } }, + invite_link: { name: '42' }, + ...overrides, + } +} + +test('parseInviterId accepts a positive integer name only', () => { + assert.equal(parseInviterId('42'), 42) + assert.equal(parseInviterId('abc'), null) + assert.equal(parseInviterId('-5'), null) + assert.equal(parseInviterId(undefined), null) +}) + +test('a join via a personal link creates the shell and sets the referral', async () => { + const { created, referrals, handle } = ctx() + await handle(join()) + assert.deepEqual(created, [9]) + assert.deepEqual(referrals, [[9, 42, 'ru']]) +}) + +test('non-ru language codes fall back to en', async () => { + const { referrals, handle } = ctx() + await handle(join({ new_chat_member: { status: 'member', user: { id: 9, is_bot: false, language_code: 'de' } } })) + assert.deepEqual(referrals, [[9, 42, 'en']]) +}) + +test('ignores self-invites, bots, non-joins and links without a numeric name', async () => { + const { referrals, handle } = ctx() + await handle(join({ invite_link: { name: '9' } })) + await handle(join({ new_chat_member: { status: 'member', user: { id: 9, is_bot: true } } })) + await handle(join({ old_chat_member: { status: 'member' }, new_chat_member: { status: 'administrator', user: { id: 9, is_bot: false } } })) + await handle(join({ invite_link: { name: 'EN main link' } })) + await handle(join({ invite_link: undefined })) + assert.deepEqual(referrals, []) +}) diff --git a/src/bot/features/community/join-handler.ts b/src/bot/features/community/join-handler.ts new file mode 100644 index 00000000..9354ae5b --- /dev/null +++ b/src/bot/features/community/join-handler.ts @@ -0,0 +1,39 @@ +// A member joined via someone's personal invite link (link name = inviter's +// user id, see community-api-handler). Creates the shell and attributes the +// referral; the payout happens at first app login (auth-handler). +export interface JoinUpdate { + chat: { id: number } + from: { id: number } + old_chat_member: { status: string } + new_chat_member: { status: string, user: { id: number, is_bot: boolean, language_code?: string } } + invite_link?: { name?: string } +} + +export interface JoinDependencies { + findOrCreateUser: (id: number) => Promise + setChatReferral: (joinerId: number, inviterId: number, language: 'en' | 'ru') => Promise + logInfo: (message: string) => void +} + +const JOINED_FROM = new Set(['left', 'kicked']) + +export function parseInviterId(linkName: string | undefined): number | null { + if (!linkName || !/^\d+$/.test(linkName)) return null + const id = Number(linkName) + return Number.isSafeInteger(id) && id > 0 ? id : null +} + +export function buildJoinHandler(deps: JoinDependencies) { + return async function handleJoin(update: JoinUpdate): Promise { + const joiner = update.new_chat_member.user + if (joiner.is_bot) return + if (update.new_chat_member.status !== 'member' || !JOINED_FROM.has(update.old_chat_member.status)) return + const inviterId = parseInviterId(update.invite_link?.name) + if (!inviterId || inviterId === joiner.id) return + + await deps.findOrCreateUser(joiner.id) + const language = joiner.language_code === 'ru' ? 'ru' : 'en' + const set = await deps.setChatReferral(joiner.id, inviterId, language) + if (set) deps.logInfo(`Community: ${joiner.id} joined ${update.chat.id} via ${inviterId}`) + } +} From 8cfdc7845a9abeaa74215c2d0f47eadbcdb420a4 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Thu, 17 Sep 2026 10:40:34 +0800 Subject: [PATCH 05/13] feat(community): reaction and /tip command handlers --- .../community/command-tip-handler.test.ts | 55 ++++++++++++++++++ .../features/community/command-tip-handler.ts | 56 +++++++++++++++++++ .../community/reaction-tip-handler.test.ts | 46 +++++++++++++++ .../community/reaction-tip-handler.ts | 40 +++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 src/bot/features/community/command-tip-handler.test.ts create mode 100644 src/bot/features/community/command-tip-handler.ts create mode 100644 src/bot/features/community/reaction-tip-handler.test.ts create mode 100644 src/bot/features/community/reaction-tip-handler.ts diff --git a/src/bot/features/community/command-tip-handler.test.ts b/src/bot/features/community/command-tip-handler.test.ts new file mode 100644 index 00000000..d11099de --- /dev/null +++ b/src/bot/features/community/command-tip-handler.test.ts @@ -0,0 +1,55 @@ +/* eslint-disable test/no-import-node-test */ +import type { TipCommandMessage } from '#root/bot/features/community/command-tip-handler' +import type { GiveTipResult } from '#root/bot/features/community/give-tip' +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { buildCommandTipHandler } from '#root/bot/features/community/command-tip-handler' + +function ctx(result: GiveTipResult = { ok: true, votes: 50n }) { + const tips: Array> = [] + const replies: string[] = [] + const handle = buildCommandTipHandler({ + giveTip: async (input) => { tips.push(input); return result }, + reply: async (_chat, _to, text) => { replies.push(text) }, + translate: (locale, key) => `${locale}:${key}`, + logError: () => {}, + }) + return { tips, replies, handle } +} + +function msg(overrides: Partial = {}): TipCommandMessage { + return { + chat: { id: -100 }, + from: { id: 1 }, + reply_to_message: { message_id: 55, from: { id: 2, is_bot: false, first_name: 'Bob', username: 'bob', language_code: 'ru' } }, + ...overrides, + } +} + +test('/tip as a reply tips the replied author with name and language', async () => { + const { tips, replies, handle } = ctx() + await handle(msg(), 'en') + assert.equal(tips.length, 1) + assert.equal(tips[0].recipientId, 2) + assert.equal(tips[0].recipientName, 'bob') + assert.equal(tips[0].recipientLanguage, 'ru') + assert.deepEqual(replies, [], 'success is silent') +}) + +test('rejections are echoed with a translated reason', async () => { + const { replies, handle } = ctx({ ok: false, reason: 'no_allowance' }) + await handle(msg(), 'ru') + assert.deepEqual(replies, ['ru:tip_no_allowance']) +}) + +test('duplicate is silent; not-a-reply, bot and anonymous targets get tip_invalid_target', async () => { + const dup = ctx({ ok: false, reason: 'duplicate' }) + await dup.handle(msg(), 'en') + assert.deepEqual(dup.replies, []) + const { tips, replies, handle } = ctx() + await handle(msg({ reply_to_message: undefined }), 'en') + await handle(msg({ reply_to_message: { message_id: 1, from: { id: 3, is_bot: true, first_name: 'B' } } }), 'en') + await handle(msg({ reply_to_message: { message_id: 1, from: { id: 3, is_bot: false, first_name: 'B' }, sender_chat: {} } }), 'en') + assert.equal(tips.length, 0) + assert.deepEqual(replies, ['en:tip_invalid_target', 'en:tip_invalid_target', 'en:tip_invalid_target']) +}) diff --git a/src/bot/features/community/command-tip-handler.ts b/src/bot/features/community/command-tip-handler.ts new file mode 100644 index 00000000..766910af --- /dev/null +++ b/src/bot/features/community/command-tip-handler.ts @@ -0,0 +1,56 @@ +import type { GiveTipFn } from './reaction-tip-handler' + +export interface TipCommandMessage { + chat: { id: number } + from?: { id: number } + reply_to_message?: { + message_id: number + from?: { id: number, is_bot: boolean, first_name: string, username?: string, language_code?: string } + sender_chat?: unknown + } +} + +export interface CommandTipDependencies { + giveTip: GiveTipFn + reply: (chatId: number, replyTo: number, text: string) => Promise + translate: (locale: string, key: string) => string + logError: (message: string) => void +} + +const REASON_KEY: Record = { + self: 'tip_invalid_target', + holders_only: 'tip_holders_only', + no_allowance: 'tip_no_allowance', + credit_failed: 'tip_failed', +} + +export function buildCommandTipHandler(deps: CommandTipDependencies) { + return async function handleTipCommand(message: TipCommandMessage, locale: string): Promise { + const tipper = message.from + if (!tipper) return + const target = message.reply_to_message + const author = target?.from + const replyTo = target?.message_id ?? 0 + const say = async (key: string) => { + try { + await deps.reply(message.chat.id, replyTo, deps.translate(locale, key)) + } catch (err) { + deps.logError(`/tip reply failed: ${(err as Error).message}`) + } + } + if (!target || !author || author.is_bot || target.sender_chat) { + await say('tip_invalid_target') + return + } + const result = await deps.giveTip({ + tipperId: tipper.id, + recipientId: author.id, + chatId: message.chat.id, + messageId: target.message_id, + recipientName: author.username ?? author.first_name, + recipientLanguage: author.language_code, + }) + if (result.ok || result.reason === 'duplicate') return + await say(REASON_KEY[result.reason]) + } +} diff --git a/src/bot/features/community/reaction-tip-handler.test.ts b/src/bot/features/community/reaction-tip-handler.test.ts new file mode 100644 index 00000000..f0f09892 --- /dev/null +++ b/src/bot/features/community/reaction-tip-handler.test.ts @@ -0,0 +1,46 @@ +/* eslint-disable test/no-import-node-test */ +import type { ReactionUpdate } from '#root/bot/features/community/reaction-tip-handler' +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { buildReactionTipHandler } from '#root/bot/features/community/reaction-tip-handler' + +function ctx(author: number | null = 2) { + const tips: Array> = [] + const handle = buildReactionTipHandler({ + tipEmoji: '🧊', + findChatMessageAuthor: async () => author, + giveTip: async (input) => { tips.push(input); return { ok: true, votes: 50n } }, + }) + return { tips, handle } +} + +function update(overrides: Partial = {}): ReactionUpdate { + return { + chat: { id: -100 }, + message_id: 55, + user: { id: 1 }, + old_reaction: [], + new_reaction: [{ type: 'emoji', emoji: '🧊' }], + ...overrides, + } +} + +test('a newly added tip emoji tips the indexed author', async () => { + const { tips, handle } = ctx() + await handle(update()) + assert.equal(tips.length, 1) + assert.equal(tips[0].tipperId, 1) + assert.equal(tips[0].recipientId, 2) + assert.equal(tips[0].messageId, 55) +}) + +test('ignores other emojis, already-present emoji, anonymous reactors and unknown messages', async () => { + const { tips, handle } = ctx() + await handle(update({ new_reaction: [{ type: 'emoji', emoji: '🔥' }] })) + await handle(update({ old_reaction: [{ type: 'emoji', emoji: '🧊' }], new_reaction: [{ type: 'emoji', emoji: '🧊' }, { type: 'emoji', emoji: '🔥' }] })) + await handle(update({ user: undefined })) + assert.equal(tips.length, 0) + const unknown = ctx(null) + await unknown.handle(update()) + assert.equal(unknown.tips.length, 0) +}) diff --git a/src/bot/features/community/reaction-tip-handler.ts b/src/bot/features/community/reaction-tip-handler.ts new file mode 100644 index 00000000..3c2d75b2 --- /dev/null +++ b/src/bot/features/community/reaction-tip-handler.ts @@ -0,0 +1,40 @@ +import type { buildGiveTip } from './give-tip' + +export type GiveTipFn = ReturnType + +export interface ReactionUpdate { + chat: { id: number } + message_id: number + user?: { id: number } + old_reaction: Array<{ type: string, emoji?: string }> + new_reaction: Array<{ type: string, emoji?: string }> +} + +export interface ReactionTipDependencies { + tipEmoji: string + findChatMessageAuthor: (chatId: number, messageId: number) => Promise + giveTip: GiveTipFn +} + +function has(list: ReactionUpdate['new_reaction'], emoji: string): boolean { + return list.some(r => r.type === 'emoji' && r.emoji === emoji) +} + +export function buildReactionTipHandler(deps: ReactionTipDependencies) { + return async function handleReaction(update: ReactionUpdate): Promise { + // Anonymous reactions carry actor_chat instead of user — nothing to attribute. + if (!update.user) return + if (!has(update.new_reaction, deps.tipEmoji) || has(update.old_reaction, deps.tipEmoji)) return + const authorId = await deps.findChatMessageAuthor(update.chat.id, update.message_id) + if (authorId === null) return + // The reaction update has no author name; giveTip only needs it for the + // one-time nudge, where a mention by id is enough. + await deps.giveTip({ + tipperId: update.user.id, + recipientId: authorId, + chatId: update.chat.id, + messageId: update.message_id, + recipientName: `id${authorId}`, + }) + } +} From 79e29e9e45fd43b44e68c590403936fc789b7f07 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Thu, 17 Sep 2026 10:44:23 +0800 Subject: [PATCH 06/13] feat(community): tip and nudge locale strings --- locales/en.ftl | 8 ++++++++ locales/ru.ftl | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/locales/en.ftl b/locales/en.ftl index c441be96..c060a5f8 100644 --- a/locales/en.ftl +++ b/locales/en.ftl @@ -60,3 +60,11 @@ queue = .new_nft_dice = {$emoji1} Dice victory! {$emoji2} Congratulations to the winner on receiving a new NFT #{$number} in {$collectionLink}! .new_nft_button = Open NFT + +tip_command = + .description = 🧊 Reply to a message to tip its author $CUBE +community_nudge = 🧊 @{$name} got {$votes} $CUBE from a holder. Open @cube_worlds_bot to claim it. +tip_invalid_target = Reply to a member's message with /tip to tip them. +tip_holders_only = Only Cube Worlds pass holders can tip. +tip_no_allowance = You have used all your tips for today. Play in Bali to earn more. +tip_failed = Tip failed, please try again later. diff --git a/locales/ru.ftl b/locales/ru.ftl index a5c5f1db..62543dbc 100644 --- a/locales/ru.ftl +++ b/locales/ru.ftl @@ -60,3 +60,11 @@ queue = .new_nft_dice = {$emoji1} Победа в кубиках! {$emoji2} Поздравляем победителя с получением нового NFT №{$number} в {$collectionLink}! .new_nft_button = Открыть NFT + +tip_command = + .description = 🧊 Ответь на сообщение, чтобы дать автору $CUBE +community_nudge = 🧊 @{$name} получил {$votes} $CUBE от холдера. Открой @cube_worlds_bot, чтобы забрать. +tip_invalid_target = Ответь командой /tip на сообщение участника, чтобы дать ему $CUBE. +tip_holders_only = Давать $CUBE могут только держатели пропуска Cube Worlds. +tip_no_allowance = На сегодня твои типы закончились. Играй на Бали, чтобы получить больше. +tip_failed = Не получилось, попробуй позже. From 1cce3f96e592197cca6268b862ea3301530d90af Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Thu, 17 Sep 2026 10:53:28 +0800 Subject: [PATCH 07/13] feat(community): composer wired before attachUser, wiring tests --- src/bot/bot-wiring.test.ts | 50 +++++++++++++++ src/bot/features/community/index.ts | 94 +++++++++++++++++++++++++++++ src/bot/features/index.ts | 1 + src/bot/index.ts | 2 + 4 files changed, 147 insertions(+) create mode 100644 src/bot/features/community/index.ts diff --git a/src/bot/bot-wiring.test.ts b/src/bot/bot-wiring.test.ts index 06b85980..a40ebfcc 100644 --- a/src/bot/bot-wiring.test.ts +++ b/src/bot/bot-wiring.test.ts @@ -41,6 +41,7 @@ process.env.STABILITY_API_KEY = 'wiring-invalid-key' process.env.OPENAI_API_KEY = 'wiring-invalid-key' process.env.TELEMETREE_API_KEY = 'wiring' process.env.TELEMETREE_PROJECT_ID = 'wiring' +process.env.COMMUNITY_CHAT_IDS = '[-100777]' const mongo = await MongoMemoryServer.create() process.env.MONGO = mongo.getUri() @@ -154,6 +155,55 @@ test('an unknown command still reaches a handler instead of falling through', as ) }) +test('a plain message in a community chat is indexed and does not upsert the sender', async () => { + const { bot } = makeBot() + const { findUserById } = await import('#root/common/models/User') + const { findChatMessageAuthor } = await import('#root/common/models/ChatMessage') + + const update = messageUpdate('hello world', 10) + ;(update.message as { chat: { id: number, type: string } }).chat = { id: -100777, type: 'supergroup' } + ;(update.message as { from: { id: number } }).from.id = 6161 + await bot.handleUpdate(update) + + assert.equal(await findChatMessageAuthor(-100777, 10), 6161, 'indexer wrote the row') + assert.equal(await findUserById(6161), null, 'attachUser did not run for group traffic') +}) + +test('a 🧊 reaction from a holder in a community chat credits the author', async () => { + const { bot, calls } = makeBot() + const { findOrCreateUser, findUserById, setUserPass } = await import('#root/common/models/User') + const { getAggregatedBalance } = await import('#root/common/models/Balance') + + await findOrCreateUser(7001) + await setUserPass(7001, { index: 1, address: 'EQ_A', name: 'holder', image: '' }, new Date()) + + const msg = messageUpdate('tip me', 11) + ;(msg.message as { chat: { id: number, type: string } }).chat = { id: -100777, type: 'supergroup' } + ;(msg.message as { from: { id: number } }).from.id = 6262 + await bot.handleUpdate(msg) + + await bot.handleUpdate({ + update_id: 12, + message_reaction: { + chat: { id: -100777, type: 'supergroup', title: 'EN' }, + message_id: 11, + user: { id: 7001, is_bot: false, first_name: 'Holder' }, + date: Math.floor(Date.now() / 1000), + old_reaction: [], + new_reaction: [{ type: 'emoji', emoji: '🧊' }], + }, + } as unknown as Update) + + // getAggregatedBalance is typed Promise, but Mongo's $sum over a + // real (memory-server) aggregation hands back a plain number here, not a + // bigint — pre-existing Balance.ts behavior, unrelated to this feature. + // Compare numerically rather than against a bigint literal. + assert.equal(Number(await getAggregatedBalance(6262)), 50, 'author credited TIP_VOTES') + assert.ok(await findUserById(6262), 'stranger shell created') + assert.ok(calls.some(c => c.method === 'setMessageReaction'), 'bot reacted 🔥') + assert.ok(calls.some(c => c.method === 'sendMessage' && String(c.payload.text).includes('50 $CUBE')), 'newcomer nudged') +}) + test.after(async () => { await mongoose.disconnect() await mongo.stop() diff --git a/src/bot/features/community/index.ts b/src/bot/features/community/index.ts new file mode 100644 index 00000000..4b65a7c5 --- /dev/null +++ b/src/bot/features/community/index.ts @@ -0,0 +1,94 @@ +import type { Context } from '#root/bot/context' +import { Composer } from 'grammy' +import { logHandle } from '#root/common/helpers/logging' +import { i18n } from '#root/common/i18n' +import { findChatMessageAuthor, indexChatMessage } from '#root/common/models/ChatMessage' +import { countTipsSince, recordTip } from '#root/common/models/Tip' +import { + addPoints, + findOrCreateUser, + findUserById, + markCommunityNudged, + markJoinedViaChat, + setChatReferral, +} from '#root/common/models/User' +import { config } from '#root/config' +import { logger } from '#root/logger' +import { buildCommandTipHandler } from './command-tip-handler' +import { buildGiveTip } from './give-tip' +import { buildIndexerHandler } from './indexer-handler' +import { buildJoinHandler } from './join-handler' +import { buildReactionTipHandler } from './reaction-tip-handler' + +// Community chats (EN/RU): holder tips + invite-link attribution. Mounted +// BEFORE attachUser and never calls next() for allow-listed chats, so group +// traffic does not upsert a User per message. +const composer = new Composer() + +const chatIds = new Set(config.COMMUNITY_CHAT_IDS) +const community = composer.filter(ctx => ctx.chat !== undefined && chatIds.has(ctx.chat.id)) + +// The composer has no api handle of its own; the handlers below stash the +// current ctx.api before calling giveTip. Single-threaded per update. +let currentApi: Context['api'] | null = null +function composerApi(): Context['api'] { + if (!currentApi) throw new Error('community: api not bound') + return currentApi +} + +const giveTip = buildGiveTip({ + tipVotes: BigInt(config.TIP_VOTES), + basePerDay: config.TIP_BASE_PER_DAY, + repPerExtra: config.TIP_REP_PER_EXTRA, + now: () => new Date(), + findUserById, + findOrCreateUser, + countTipsSince, + recordTip, + addPoints, + markJoinedViaChat, + markCommunityNudged, + react: (chatId, messageId) => composerApi().setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji: '🔥' }]).then(() => {}), + reply: (chatId, messageId, text) => composerApi().sendMessage(chatId, text, { reply_parameters: { message_id: messageId } }).then(() => {}), + translate: (locale, key, vars) => i18n.t(locale, key, vars), + logError: message => logger.error(message), +}) + +const indexMessage = buildIndexerHandler({ indexChatMessage }) +const handleReaction = buildReactionTipHandler({ tipEmoji: config.TIP_EMOJI, findChatMessageAuthor, giveTip }) +const handleTipCommand = buildCommandTipHandler({ + giveTip, + reply: (chatId, replyTo, text) => composerApi().sendMessage(chatId, text, replyTo ? { reply_parameters: { message_id: replyTo } } : {}).then(() => {}), + translate: (locale, key) => i18n.t(locale, key), + logError: message => logger.error(message), +}) +const handleJoin = buildJoinHandler({ findOrCreateUser, setChatReferral, logInfo: message => logger.info(message) }) + +community.command('tip', logHandle('community-tip-command'), async (ctx) => { + // /tip can only be a normal message in these (super)group chats, never a + // channel post, but community's own filter doesn't narrow that at the type + // level — guard so ctx.message is non-optional for handleTipCommand. + if (!ctx.message) return + currentApi = ctx.api + const locale = ctx.from?.language_code === 'ru' ? 'ru' : 'en' + await handleTipCommand(ctx.message, locale) +}) + +community.on('message', logHandle('community-index'), async (ctx) => { + await indexMessage(ctx.message) +}) + +community.on('message_reaction', logHandle('community-reaction'), async (ctx) => { + currentApi = ctx.api + await handleReaction(ctx.messageReaction) +}) + +community.on('chat_member', logHandle('community-join'), async (ctx) => { + await handleJoin(ctx.chatMember) +}) + +// Anything else in a community chat (edits, service messages, my_chat_member) +// is swallowed here so it never reaches attachUser. +community.use(() => {}) + +export { composer as communityFeature } diff --git a/src/bot/features/index.ts b/src/bot/features/index.ts index f71c6677..679811df 100644 --- a/src/bot/features/index.ts +++ b/src/bot/features/index.ts @@ -4,6 +4,7 @@ export * from './admin/queue' export * from './admin/resolve' export * from './admin/transaction' export * from './admin/treasury' +export * from './community/index' export * from './help' export * from './line' export * from './removed-commands' diff --git a/src/bot/index.ts b/src/bot/index.ts index 80032d2e..d7cc441c 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -7,6 +7,7 @@ import { session, Bot as TelegramBot } from 'grammy' import { createContextConstructor } from '#root/bot/context' import { collectionFeature, + communityFeature, helpFeature, lineFeature, parametersFeature, @@ -61,6 +62,7 @@ export function createBot(token: string, options: Options) { ) protectedBot.use(slapReaction) protectedBot.use(i18n) + protectedBot.use(communityFeature) // group chats: must precede attachUser protectedBot.use(attachUser) protectedBot.use(queueMenu) From 9f5ca7b0c8ccbe0eda4fe3d3f78ed0d2a2896637 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Thu, 17 Sep 2026 11:32:10 +0800 Subject: [PATCH 08/13] feat(community): stamp firstLoginAt on login and pay the chat inviter once --- src/backend/auth-handler.test.ts | 52 ++++++++++++++++++++++++++++++++ src/backend/auth-handler.ts | 25 ++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/backend/auth-handler.test.ts b/src/backend/auth-handler.test.ts index 5be30350..c73a6fdd 100644 --- a/src/backend/auth-handler.test.ts +++ b/src/backend/auth-handler.test.ts @@ -16,6 +16,8 @@ interface StubUser { name?: string wallet?: string referalId?: number + joinedViaChat?: boolean + firstLoginAt?: Date votes: bigint minted: boolean state: string @@ -33,6 +35,8 @@ interface AuthTestContext { verifyCalls: Array<{ passAddress: string, ownerAddress: string }> setPassCalls: Array<{ userId: number, index: number, verifiedAt: Date }> clearPassCalls: number[] + firstLogins: number[] + inviterCredits: number[] } function toResolvedUser(user: StubUser): ResolvedUser { @@ -68,6 +72,8 @@ async function createAuthTestContext( const verifyCalls: Array<{ passAddress: string, ownerAddress: string }> = [] const setPassCalls: Array<{ userId: number, index: number, verifiedAt: Date }> = [] const clearPassCalls: number[] = [] + const firstLogins: number[] = [] + const inviterCredits: number[] = [] const dependencies: AuthHandlerDependencies = { validateInitData: () => {}, @@ -104,6 +110,14 @@ async function createAuthTestContext( clearUserPass: async (userId) => { clearPassCalls.push(userId) }, + setFirstLoginAt: async (userId: number) => { + const user = users.get(userId) + if (!user || user.firstLoginAt) return false + user.firstLoginAt = new Date() + firstLogins.push(userId) + return true + }, + creditInviter: async (inviterId: number) => { inviterCredits.push(inviterId) }, ...overrides, } @@ -119,6 +133,8 @@ async function createAuthTestContext( verifyCalls, setPassCalls, clearPassCalls, + firstLogins, + inviterCredits, } } @@ -473,6 +489,42 @@ test('login with a stale pass that left the wallet clears it', async (t) => { assert.deepEqual(ctx.clearPassCalls, [1001]) }) +test('first login stamps firstLoginAt and pays the inviter for chat-invited users', async (t) => { + const ctx = await createAuthTestContext() + t.after(() => ctx.app.close()) + ctx.users.set(1001, createStubUser({ joinedViaChat: true, referalId: 2002 })) + await ctx.app.inject({ method: 'POST', url: '/api/auth/login', payload: { initData: 'x' } }) + assert.deepEqual(ctx.firstLogins, [1001]) + assert.deepEqual(ctx.inviterCredits, [2002]) +}) + +test('a second login pays nothing', async (t) => { + const ctx = await createAuthTestContext() + t.after(() => ctx.app.close()) + ctx.users.set(1001, createStubUser({ joinedViaChat: true, referalId: 2002, firstLoginAt: new Date() })) + await ctx.app.inject({ method: 'POST', url: '/api/auth/login', payload: { initData: 'x' } }) + assert.deepEqual(ctx.firstLogins, []) + assert.deepEqual(ctx.inviterCredits, []) +}) + +test('app deep-link referrals stamp firstLoginAt but pay no login drop', async (t) => { + const ctx = await createAuthTestContext() + t.after(() => ctx.app.close()) + ctx.users.set(1001, createStubUser({ referalId: 2002 })) + await ctx.app.inject({ method: 'POST', url: '/api/auth/login', payload: { initData: 'x' } }) + assert.deepEqual(ctx.firstLogins, [1001]) + assert.deepEqual(ctx.inviterCredits, []) +}) + +test('inviter credit failure is logged and does not fail the login', async (t) => { + const ctx = await createAuthTestContext({ creditInviter: async () => { throw new Error('mongo down') } }) + t.after(() => ctx.app.close()) + ctx.users.set(1001, createStubUser({ joinedViaChat: true, referalId: 2002 })) + const response = await ctx.app.inject({ method: 'POST', url: '/api/auth/login', payload: { initData: 'x' } }) + assert.equal(response.statusCode, 200) + assert.ok(ctx.errorLogs.some(m => m.includes('Invite credit failed'))) +}) + test('login keeps a stale pass when the provider fails', async (t) => { const ctx = await createAuthTestContext({ verifyPassOwnership: async () => { throw new Error('toncenter down') }, diff --git a/src/backend/auth-handler.ts b/src/backend/auth-handler.ts index a85ca5df..53334055 100644 --- a/src/backend/auth-handler.ts +++ b/src/backend/auth-handler.ts @@ -1,7 +1,9 @@ import type { InitData } from '@telegram-apps/init-data-node' import type { FastifyInstance } from 'fastify' import type { Pass } from './login-payload' -import { clearUserPass, findOrCreateUser, findUserById, setUserPass } from '#root/common/models/User' +import { BalanceChangeType } from '#root/common/models/Balance' +import { addPoints, clearUserPass, findOrCreateUser, findUserById, setFirstLoginAt, setUserPass } from '#root/common/models/User' +import { config } from '#root/config' import { logger } from '#root/logger' import { defaultParseInitData, defaultValidateInitData } from './init-data' import { loginPayload } from './login-payload' @@ -30,6 +32,10 @@ export interface AuthHandlerDependencies { verifyPassOwnership: (passAddress: string, ownerAddress: string) => Promise setUserPass: (userId: number, pass: Pass, verifiedAt: Date) => Promise clearUserPass: (userId: number) => Promise + // Community: CAS stamp of the first app login (true ⇒ this call was first). + setFirstLoginAt: (userId: number, now: Date) => Promise + // Pays INVITE_LOGIN_REWARD_VOTES to the inviter of a chat-invited user. + creditInviter: (inviterId: number) => Promise } function createDefaultDependencies(): AuthHandlerDependencies { @@ -44,6 +50,12 @@ function createDefaultDependencies(): AuthHandlerDependencies { verifyPassOwnership, setUserPass, clearUserPass, + setFirstLoginAt, + creditInviter: async (inviterId) => { + const votes = BigInt(config.INVITE_LOGIN_REWARD_VOTES) + if (votes <= 0n) return + await addPoints(inviterId, votes, BalanceChangeType.Invite) + }, } } @@ -108,6 +120,17 @@ export function buildAuthHandler( } } + // Community: first app login. The CAS makes a double login pay once; + // only chat-invited shells earn their inviter the login drop. + const firstLogin = await dependencies.setFirstLoginAt(user.id, new Date()) + if (firstLogin && user.joinedViaChat && user.referalId) { + try { + await dependencies.creditInviter(user.referalId) + } catch (err) { + dependencies.error(`Invite credit failed for inviter ${user.referalId} of ${user.id}: ${(err as Error).message}`) + } + } + // Hourly ownership revalidation. Provider failure keeps the pass — // never lock a holder out on a toncenter outage. const pass = user.pass From cac1a93e169bb47b7b98ef51ef2d629fde252aff Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Thu, 17 Sep 2026 11:39:23 +0800 Subject: [PATCH 09/13] feat(community): /api/users/community route, public config numbers, smoke step --- scripts/smoke-api.ts | 7 ++ src/backend/community-api-handler.test.ts | 79 +++++++++++++++++++++++ src/backend/community-api-handler.ts | 77 ++++++++++++++++++++++ src/backend/community-api.ts | 35 ++++++++++ src/server.ts | 5 ++ 5 files changed, 203 insertions(+) create mode 100644 src/backend/community-api-handler.test.ts create mode 100644 src/backend/community-api-handler.ts create mode 100644 src/backend/community-api.ts diff --git a/scripts/smoke-api.ts b/scripts/smoke-api.ts index 4b7991a8..42b95ee8 100644 --- a/scripts/smoke-api.ts +++ b/scripts/smoke-api.ts @@ -314,6 +314,13 @@ async function run() { expect(body.error !== 'API route not found', `route not registered: ${body.error}`) }) + await step('POST /api/users/community answers with the envelope (no chats configured)', async () => { + const body = await post>('/api/users/community', { initData }) + expect(body.tips === null, `tips should be null for a non-holder, got ${JSON.stringify(body.tips)}`) + expect(Array.isArray(body.invites) && (body.invites as unknown[]).length === 0, 'invites should be empty with COMMUNITY_CHAT_IDS unset') + expect(body.invitedLoggedIn === 0, 'invitedLoggedIn should be 0') + }) + await step('POST /api/pass/scan without a bound wallet returns 400 wallet_required', async () => { const response = await fetch(`${BASE}/api/pass/scan`, { method: 'POST', diff --git a/src/backend/community-api-handler.test.ts b/src/backend/community-api-handler.test.ts new file mode 100644 index 00000000..d7da9f9e --- /dev/null +++ b/src/backend/community-api-handler.test.ts @@ -0,0 +1,79 @@ +/* eslint-disable test/no-import-node-test */ +import type { InitData } from '@telegram-apps/init-data-node' +import type { CommunityApiDependencies, CommunityUser } from '#root/backend/community-api-handler' +import assert from 'node:assert/strict' +import { test } from 'node:test' +import fastify from 'fastify' +import { buildCommunityApiHandler } from '#root/backend/community-api-handler' + +interface Ctx { + app: ReturnType + user: CommunityUser + createdLinks: Array<[number, string]> + savedLinks: Array<[number, number, string]> + errors: string[] +} + +async function createCtx(overrides: Partial = {}, userOverrides: Partial = {}): Promise { + const user: CommunityUser = { id: 1001, pass: { index: 7 }, rep: { helped: 120, gave: 0 }, inviteLinks: { '-100': 'https://t.me/+cached' }, ...userOverrides } + const ctx: Ctx = { app: fastify(), user, createdLinks: [], savedLinks: [], errors: [] } + const deps: CommunityApiDependencies = { + validateInitData: () => {}, + parseInitData: () => ({ user: { id: 1001 } } as InitData), + findUser: async id => (id === 1001 ? user : null), + chatIds: [-100, -200], + tipVotes: 50n, + basePerDay: 3, + repPerExtra: 100, + now: () => new Date('2026-09-17T10:00:00Z'), + countTipsSince: async () => 1, + chatTitle: async chatId => (chatId === -100 ? 'EN' : 'RU'), + createInviteLink: async (chatId, name) => { ctx.createdLinks.push([chatId, name]); return `https://t.me/+new${chatId}` }, + saveInviteLink: async (userId, chatId, url) => { ctx.savedLinks.push([userId, chatId, url]) }, + countInvitedLoggedIn: async () => 3, + logError: m => { ctx.errors.push(m) }, + ...overrides, + } + await ctx.app.register(buildCommunityApiHandler(deps)) + return ctx +} + +async function call(ctx: Ctx) { + const res = await ctx.app.inject({ method: 'POST', url: '/community', payload: { initData: 'x' } }) + return { status: res.statusCode, body: res.json() } +} + +test('holder sees allowance, cached + newly created links, invited count', async () => { + const ctx = await createCtx() + const { status, body } = await call(ctx) + assert.equal(status, 200) + assert.deepEqual(body.tips, { perDay: 4, left: 3, votes: '50' }) + assert.deepEqual(body.invites, [ + { chatId: '-100', title: 'EN', url: 'https://t.me/+cached' }, + { chatId: '-200', title: 'RU', url: 'https://t.me/+new-200' }, + ]) + assert.deepEqual(ctx.createdLinks, [[-200, '1001']]) + assert.deepEqual(ctx.savedLinks, [[1001, -200, 'https://t.me/+new-200']]) + assert.equal(body.invitedLoggedIn, 3) +}) + +test('non-holder gets tips: null but still gets invite links', async () => { + const ctx = await createCtx({}, { pass: undefined }) + const { body } = await call(ctx) + assert.equal(body.tips, null) + assert.equal(body.invites.length, 2) +}) + +test('a Telegram failure drops that chat and logs, response still 200', async () => { + const ctx = await createCtx({ createInviteLink: async () => { throw new Error('not admin') } }) + const { status, body } = await call(ctx) + assert.equal(status, 200) + assert.deepEqual(body.invites.map((i: { chatId: string }) => i.chatId), ['-100']) + assert.equal(ctx.errors.length, 1) +}) + +test('no community chats configured ⇒ empty invites', async () => { + const ctx = await createCtx({ chatIds: [] }) + const { body } = await call(ctx) + assert.deepEqual(body.invites, []) +}) diff --git a/src/backend/community-api-handler.ts b/src/backend/community-api-handler.ts new file mode 100644 index 00000000..d45b9395 --- /dev/null +++ b/src/backend/community-api-handler.ts @@ -0,0 +1,77 @@ +import type { InitData } from '@telegram-apps/init-data-node' +import type { FastifyInstance } from 'fastify' +import { tipsPerDay, utcMidnight } from '#root/bot/features/community/give-tip' +import { safeErrorResponse } from './safe-error' + +interface Body { initData: string } + +export interface CommunityUser { + id: number + pass?: unknown + rep?: { helped: number, gave: number } + inviteLinks?: Record +} + +export interface CommunityApiDependencies { + validateInitData: (initData: string) => void + parseInitData: (initData: string) => InitData + findUser: (id: number) => Promise + chatIds: number[] + tipVotes: bigint + basePerDay: number + repPerExtra: number + now: () => Date + countTipsSince: (tipperId: number, since: Date) => Promise + chatTitle: (chatId: number) => Promise + // Link name = the user's id; join-handler reads it back as the inviter. + createInviteLink: (chatId: number, name: string) => Promise + saveInviteLink: (userId: number, chatId: number, url: string) => Promise + countInvitedLoggedIn: (userId: number) => Promise + logError: (message: string) => void +} + +export function buildCommunityApiHandler(deps: CommunityApiDependencies) { + return async function communityApiHandler(fastify: FastifyInstance) { + fastify.post<{ Body: Body }>( + '/community', + { schema: { body: { type: 'object', properties: { initData: { type: 'string', maxLength: 8192 } } } }, attachValidation: true }, + async (request) => { + try { + if (request.validationError) return { error: 'Invalid request body' } + const { initData } = request.body + if (!initData) return { error: 'No initData or hash provided' } + deps.validateInitData(initData) + const userId = deps.parseInitData(initData)?.user?.id + if (!userId) return { error: 'Invalid telegram user id' } + const user = await deps.findUser(userId) + if (!user) return { error: 'User not found' } + + let tips: { perDay: number, left: number, votes: string } | null = null + if (user.pass) { + const perDay = tipsPerDay(user.rep, deps.basePerDay, deps.repPerExtra) + const used = await deps.countTipsSince(userId, utcMidnight(deps.now())) + tips = { perDay, left: Math.max(0, perDay - used), votes: deps.tipVotes.toString() } + } + + const invites: Array<{ chatId: string, title: string, url: string }> = [] + for (const chatId of deps.chatIds) { + try { + let url = user.inviteLinks?.[String(chatId)] + if (!url) { + url = await deps.createInviteLink(chatId, String(userId)) + await deps.saveInviteLink(userId, chatId, url) + } + invites.push({ chatId: String(chatId), title: await deps.chatTitle(chatId), url }) + } catch (err) { + deps.logError(`Community invite link for chat ${chatId} failed: ${(err as Error).message}`) + } + } + + return { tips, invites, invitedLoggedIn: await deps.countInvitedLoggedIn(userId) } + } catch (err) { + return safeErrorResponse(err, deps.logError) + } + }, + ) + } +} diff --git a/src/backend/community-api.ts b/src/backend/community-api.ts new file mode 100644 index 00000000..4dedc51a --- /dev/null +++ b/src/backend/community-api.ts @@ -0,0 +1,35 @@ +import type { Api } from 'grammy' +import { buildCommunityApiHandler } from '#root/backend/community-api-handler' +import { countTipsSince } from '#root/common/models/Tip' +import { countInvitedLoggedIn, findUserById, setInviteLink } from '#root/common/models/User' +import { config } from '#root/config' +import { logger } from '#root/logger' +import { defaultParseInitData, defaultValidateInitData } from './init-data' + +export function createCommunityApiHandler(api: Api) { + // Chat titles never change in practice; one getChat per chat per process. + const titles = new Map() + return buildCommunityApiHandler({ + validateInitData: defaultValidateInitData, + parseInitData: defaultParseInitData, + findUser: findUserById, + chatIds: [...config.COMMUNITY_CHAT_IDS], + tipVotes: BigInt(config.TIP_VOTES), + basePerDay: config.TIP_BASE_PER_DAY, + repPerExtra: config.TIP_REP_PER_EXTRA, + now: () => new Date(), + countTipsSince, + chatTitle: async (chatId) => { + const cached = titles.get(chatId) + if (cached) return cached + const chat = await api.getChat(chatId) + const title = ('title' in chat && chat.title) || String(chatId) + titles.set(chatId, title) + return title + }, + createInviteLink: async (chatId, name) => (await api.createChatInviteLink(chatId, { name })).invite_link, + saveInviteLink: setInviteLink, + countInvitedLoggedIn, + logError: message => logger.error(message), + }) +} diff --git a/src/server.ts b/src/server.ts index 28f1b6cb..9996f0f3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,6 +14,7 @@ import authHandler from './backend/auth-handler' import { createAvatarHandler } from './backend/avatar' import balancesHandler from './backend/balances-handler' import claimHandler from './backend/claim-handler' +import { createCommunityApiHandler } from './backend/community-api' import leaderboardHandler from './backend/leaderboard-handler' import { createMintHandler } from './backend/mint' import nftHandler from './backend/nft-handler' @@ -34,6 +35,7 @@ const ROUTE_RATE_LIMITS: Record = { '/api/users/leaderboard': { max: 60, timeWindow: '1 minute' }, '/api/users/balances': { max: 60, timeWindow: '1 minute' }, '/api/users/topup/invoice': { max: 10, timeWindow: '1 minute' }, + '/api/users/community': { max: 30, timeWindow: '1 minute' }, // Mint flow. /generate is the expensive one (paid Stability call). '/api/mint/quote': { max: 60, timeWindow: '1 minute' }, '/api/mint/status': { max: 60, timeWindow: '1 minute' }, @@ -133,6 +135,7 @@ export async function createServer(bot: Bot) { await server.register(leaderboardHandler, { prefix: '/api/users' }) await server.register(claimHandler, { prefix: '/api/users' }) await server.register(createTopupInvoiceHandler(bot.api), { prefix: '/api/users' }) + await server.register(createCommunityApiHandler(bot.api), { prefix: '/api/users' }) await server.register(publicMetricsHandler, { prefix: '/api/public' }) @@ -146,6 +149,8 @@ export async function createServer(bot: Bot) { generationTryCostVotes: config.GENERATION_TRY_COST_VOTES, referralMintRewardVotes: config.REFERRAL_MINT_REWARD_VOTES, starsTopupVotesPerStar: config.STARS_TOPUP_VOTES_PER_STAR, + tipVotes: config.TIP_VOTES, + inviteLoginRewardVotes: config.INVITE_LOGIN_REWARD_VOTES, })) // TON Connect manifest. Wallets sign ton_proof over this url's host and From b2619b2131aeb1f55073894b45113f5981be8bee Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Thu, 17 Sep 2026 11:39:33 +0800 Subject: [PATCH 10/13] fix(user): pass updatePipeline option for the first-login migration's pipeline update Mongoose 9 rejects an array update (aggregation pipeline syntax) unless updatePipeline is set, so ensureFirstLoginMigration crashed the app at boot in real Mongo (smoke:api). No test exercised it against a live driver before now. --- src/common/models/User.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/common/models/User.ts b/src/common/models/User.ts index b02a2781..f6a1fc99 100644 --- a/src/common/models/User.ts +++ b/src/common/models/User.ts @@ -214,6 +214,7 @@ export async function ensureFirstLoginMigration(): Promise { const res = await UserModel.updateMany( { firstLoginAt: { $exists: false }, joinedViaChat: { $ne: true } }, [{ $set: { firstLoginAt: '$createdAt' } }], + { updatePipeline: true }, ) return res.modifiedCount } From 62de08f90bfdc196530f3f88e20c0a850504b937 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Thu, 17 Sep 2026 11:43:51 +0800 Subject: [PATCH 11/13] feat(community): EARN tab community card (tips left, chat invite links) --- src/frontend/src/api.ts | 12 +++++ src/frontend/src/components/EarnPanel.tsx | 58 +++++++++++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/api.ts b/src/frontend/src/api.ts index ea3577f7..8e12d575 100644 --- a/src/frontend/src/api.ts +++ b/src/frontend/src/api.ts @@ -53,6 +53,8 @@ export interface PublicConfig { generationTryCostVotes: number referralMintRewardVotes: number starsTopupVotesPerStar: number + tipVotes: number + inviteLoginRewardVotes: number } export async function publicConfig(): Promise { @@ -281,3 +283,13 @@ export async function worldPass(index: number): Promise { if (json === null) throw new Error(`HTTP ${response.status}`) return json as PassPublic } + +export interface CommunityInfo extends ApiError { + tips: { perDay: number, left: number, votes: string } | null + invites: Array<{ chatId: string, title: string, url: string }> + invitedLoggedIn: number +} + +export function communityInfo(): Promise { + return post('/api/users/community') +} diff --git a/src/frontend/src/components/EarnPanel.tsx b/src/frontend/src/components/EarnPanel.tsx index 27031f18..ecbe9ad6 100644 --- a/src/frontend/src/components/EarnPanel.tsx +++ b/src/frontend/src/components/EarnPanel.tsx @@ -1,7 +1,7 @@ -import type { PublicConfig } from '../api' +import type { CommunityInfo, PublicConfig } from '../api' import { useTonConnectUI } from '@tonconnect/ui-react' -import { useState } from 'react' -import { topupInvoice } from '../api' +import { useEffect, useState } from 'react' +import { communityInfo, topupInvoice } from '../api' import { useWalletBind } from '../hooks/useWalletBind' import { haptic, openInvoice, openShare } from '../telegram' import { DailyClaim } from './DailyClaim' @@ -33,6 +33,23 @@ export function EarnPanel({ }: EarnPanelProps) { const [busy, setBusy] = useState(false) const [notice, setNotice] = useState(null) + const [community, setCommunity] = useState(null) + useEffect(() => { + let alive = true + communityInfo().then((info) => { if (alive && !info.error) setCommunity(info) }).catch(() => {}) + return () => { alive = false } + }, []) + + async function copy(url: string) { + try { + await navigator.clipboard.writeText(url) + haptic('success') + setNotice('Link copied') + } catch { + openShare(url, 'Join the Cube Worlds chat') + } + } + const [tonConnectUI] = useTonConnectUI() const { bindWallet } = useWalletBind(onWalletBound) @@ -112,6 +129,41 @@ export function EarnPanel({ + {community && (community.tips || community.invites.length > 0) && ( + + {community.tips && ( +
+ {`TIPS TODAY ${community.tips.left}/${community.tips.perDay} · react 🧊 in the chat to tip ${community.tips.votes} $CUBE`} +
+ )} + {community.invites.length > 0 && ( + <> +
+ {config + ? `+${config.inviteLoginRewardVotes} $CUBE when a friend joins the chat via your link and opens the app` + : 'Friends who join the chat via your link earn you $CUBE'} +
+
+ {community.invites.map(invite => ( + + ))} +
+
+ {`${community.invitedLoggedIn} friends joined via your link`} +
+ + )} +
+ )} +
{STARS_PACKS.map((stars) => ( From 43ed6adee615cb6dba9ed0093fe44c3aaed88d4a Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Thu, 17 Sep 2026 11:47:15 +0800 Subject: [PATCH 12/13] docs: community tips and chat invites --- CLAUDE.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6fd3dd89..6cd5c6ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,8 +19,10 @@ Every place is one game-theory engine reading the pass's 120 on-chain traits (`U - **Referrals** — `/start` param → `referId` on `/api/auth/login` stores `referalId`; the referrer is credited `REFERRAL_MINT_REWARD_VOTES` (default 200) **when the invitee's pass is minted** — human-gated ⇒ unfarmable. Wired inside the approve path (`rewardReferrer` dep, non-fatal, at-most-once via the mint CAS). - **TON donations** — watcher (`subscription-core.ts`/`subscription.ts`) credits votes for TON sent **from the bound wallet** (`findUserByAddress` → `addPoints(..., Donation)`). Donation target address = `COLLECTION_OWNER`, exposed via `GET /api/public/config`. - **Telegram Stars top-up** — `POST /api/users/topup/invoice` (pure `topup-invoice-handler.ts` + composer `topup-invoice.ts`, `createInvoiceLink` currency `XTR`); bot `topup` feature handles `pre_checkout_query` + `successful_payment`; payload `cube-topup:::` round-trips through Telegram (tamper-proof, honors the quoted rate `STARS_TOPUP_VOTES_PER_STAR`, default 10/⭐). Idempotent on `telegram_payment_charge_id` via unique `StarsPurchase.chargeId` — record-then-credit, replay is a no-op. +- **Community tips** — pass holders react `TIP_EMOJI` (default 🧊) or reply `/tip` in `COMMUNITY_CHAT_IDS`; the author gets `TIP_VOTES` (50) as `BalanceChangeType.Tip`. Allowance per holder per UTC day = `TIP_BASE_PER_DAY` (3) + floor((rep.helped+rep.gave)/`TIP_REP_PER_EXTRA` (100)); computed from the `Tip` ledger (unique on chat+message+tipper ⇒ re-reactions are no-ops, record-then-credit). Reactions need the bot as chat admin, a `ChatMessage` index (7-day TTL) supplies the author. Strangers get a `User` shell (`joinedViaChat`) and one chat nudge (`communityNudgedAt`). `src/bot/features/community/*`, pure `give-tip.ts`. +- **Chat invites** — `POST /api/users/community` mints a personal `createChatInviteLink` per chat (name = user id, cached in `User.inviteLinks`); `chat_member` join sets `referalId` + `joinedViaChat` (first link wins, wallet-bound users untouched). `/api/auth/login` CAS-stamps `firstLoginAt`; on the first login of a `joinedViaChat` user the inviter gets `INVITE_LOGIN_REWARD_VOTES` (20) as `Invite`. Boot migration `ensureFirstLoginMigration` back-fills `firstLoginAt` for pre-existing users. -`BalanceChangeType`: Initial, Deposit, Withdraw, Dice/Task/Trade (legacy), Referral, Donation, Claim, **Generation** (sink), **StarsTopup** (faucet). +`BalanceChangeType`: Initial, Deposit, Withdraw, Dice/Task/Trade (legacy), Referral, Donation, Claim, **Generation** (sink), **StarsTopup** (faucet), **Stake**/**Payout** (Bali), **Tip**, **Invite**. ## Commands ```bash @@ -75,9 +77,9 @@ All authenticated endpoints validate Telegram's `initData` (HMAC + 24h expiry) 4. `set-wallet` answers **409 `wallet_taken`** when the address belongs to another account; the frontend `post()` helper returns JSON envelopes for non-2xx so `code` reaches the UI. ## Bot -Middleware order: `autoRetry → updateLogger (dev) → autoChatAction → hydrate → session → slapReaction → i18n → attachUser → queueMenu → [features]`. +Middleware order: `autoRetry → updateLogger (dev) → autoChatAction → hydrate → session → slapReaction → i18n → community (group chats, swallows) → attachUser → queueMenu → [features]`. -Features (`src/bot/index.ts`): start, help, queue (admin: `/queue` browser + Approve/Decline callbacks), parameters (admin), collection (admin), stats, whales, line, transaction (admin), user (admin), resolve (admin: `/resolve` forces the current Bali window on staging/dev), **topup** (Stars `pre_checkout_query` + `successful_payment`), then `removedCommandsFeature` (points `/dice`, `/mint`, `/play`… to the Mini App) and `unhandledFeature` last. +Features (`src/bot/index.ts`): start, help, queue (admin: `/queue` browser + Approve/Decline callbacks), parameters (admin), collection (admin), stats, whales, line, transaction (admin), user (admin), resolve (admin: `/resolve` forces the current Bali window on staging/dev), **topup** (Stars `pre_checkout_query` + `successful_payment`), **community** (mounted before attachUser: message indexer, 🧊 reaction / `/tip` tips, invite-link joins — only in `COMMUNITY_CHAT_IDS`), then `removedCommandsFeature` (points `/dice`, `/mint`, `/play`… to the Mini App) and `unhandledFeature` last. ## Security - Leaderboard pagination: limit 1–100, skip ≥ 0. @@ -86,12 +88,14 @@ Features (`src/bot/index.ts`): start, help, queue (admin: `/queue` browser + App - Rate limits per route in `server.ts` (`/api/mint/generate` 6/min — it's a paid Stability call). - Rate limits: `/api/pass/scan` and `/api/pass/select` 10/min (toncenter call). - Rate limits: `/api/world/*` — `state` 60/min, `visit` 10/min, `history` 30/min, `pass/:index` (public) 60/min. +- Rate limits: `/api/users/community` 30/min (Telegram getChat/createChatInviteLink). ## Deploy notes - **Prod deploys are fenced**: `.kamal/hooks/pre-deploy` aborts any non-staging deploy unless `PROD_CUTOVER=yes` is in the environment (`PROD_CUTOVER=yes kamal deploy`). Never set it on the user's behalf — the cutover is their call. `-d staging` passes untouched. - Production still runs **v1**; this tree replaces it wholesale on cutover. Before deploy: `CHECK_MONGO_URI= npx tsx scripts/check-prod-users.ts` — read-only; blocks on duplicate wallets/ids (v3 unique indexes), non-BigInt-castable votes, unknown states; warns on v1 `WaitWallet`/`WaitDescription` (reset to `WaitNothing` at boot by `ensureLegacyStateMigration`), stuck mint claims, missing names, old-host `data/` paths. - `STAGING=true` boots API-only (no tx loop, no Telegram engagement). -- Stale env keys from v2 (`XROCKET_*`, `ADSGRAM_*`, `SEASON_PASS_*`, `MINT_FLOOR_*`, …) are ignored by the config schema; new optional keys: `GENERATION_TRY_COST_VOTES`, `STARS_TOPUP_VOTES_PER_STAR`, `REFERRAL_MINT_REWARD_VOTES`. +- Stale env keys from v2 (`XROCKET_*`, `ADSGRAM_*`, `SEASON_PASS_*`, `MINT_FLOOR_*`, …) are ignored by the config schema; new optional keys: `GENERATION_TRY_COST_VOTES`, `STARS_TOPUP_VOTES_PER_STAR`, `REFERRAL_MINT_REWARD_VOTES`, `TIP_EMOJI`, `TIP_VOTES`, `TIP_BASE_PER_DAY`, `TIP_REP_PER_EXTRA`, `COMMUNITY_CHAT_IDS`, `INVITE_LOGIN_REWARD_VOTES`. +- **Community chats**: the bot must be admin in every `COMMUNITY_CHAT_IDS` chat with the "invite users" right, and `BOT_ALLOWED_UPDATES` must include `message`, `message_reaction`, `chat_member` (Telegram sends reactions/joins only to admin bots that ask). Deploy dark (`COMMUNITY_CHAT_IDS=[]`), then set the ids. Holders with anonymous reactions produce no update — `/tip` is the fallback. - Pre-deploy: `npm run smoke:api` (all secrets overridden with fakes — it can never hit a paid API or the live bot). - **Staging bot**: `kamal deploy -d staging` → @cubeworldsbot at https://staging.cubeworlds.club (`config/deploy.staging.yml` + `.kamal/secrets.staging`; own service name/DB `cube-worlds-bot-staging`/volume `/srv/cube_worlds_staging/data`). Shares chain keys + admins with prod, so an admin Approve there mints a real NFT. - `/tonconnect-manifest.json` is generated from `WEB_APP_URL` (ton_proof domain check), not a static landing file. From aafdeda05df88d0ecf031382cd8ddd688690cb6e Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Thu, 17 Sep 2026 12:04:39 +0800 Subject: [PATCH 13/13] fix(community): correct allowed-updates docs/polling, chat-invite payout gating, and reaction-tip mention --- CLAUDE.md | 2 +- src/backend/auth-handler.test.ts | 32 +++++++++++++++++++ src/backend/auth-handler.ts | 12 +++++-- src/bot/bot-wiring.test.ts | 7 +++- .../community/indexer-handler.test.ts | 16 +++++++--- src/bot/features/community/indexer-handler.ts | 6 ++-- .../community/reaction-tip-handler.test.ts | 9 +++++- .../community/reaction-tip-handler.ts | 18 +++++++---- src/common/models/ChatMessage.ts | 22 ++++++++++--- src/main.ts | 2 +- 10 files changed, 99 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6cd5c6ba..f2915f42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +95,7 @@ Features (`src/bot/index.ts`): start, help, queue (admin: `/queue` browser + App - Production still runs **v1**; this tree replaces it wholesale on cutover. Before deploy: `CHECK_MONGO_URI= npx tsx scripts/check-prod-users.ts` — read-only; blocks on duplicate wallets/ids (v3 unique indexes), non-BigInt-castable votes, unknown states; warns on v1 `WaitWallet`/`WaitDescription` (reset to `WaitNothing` at boot by `ensureLegacyStateMigration`), stuck mint claims, missing names, old-host `data/` paths. - `STAGING=true` boots API-only (no tx loop, no Telegram engagement). - Stale env keys from v2 (`XROCKET_*`, `ADSGRAM_*`, `SEASON_PASS_*`, `MINT_FLOOR_*`, …) are ignored by the config schema; new optional keys: `GENERATION_TRY_COST_VOTES`, `STARS_TOPUP_VOTES_PER_STAR`, `REFERRAL_MINT_REWARD_VOTES`, `TIP_EMOJI`, `TIP_VOTES`, `TIP_BASE_PER_DAY`, `TIP_REP_PER_EXTRA`, `COMMUNITY_CHAT_IDS`, `INVITE_LOGIN_REWARD_VOTES`. -- **Community chats**: the bot must be admin in every `COMMUNITY_CHAT_IDS` chat with the "invite users" right, and `BOT_ALLOWED_UPDATES` must include `message`, `message_reaction`, `chat_member` (Telegram sends reactions/joins only to admin bots that ask). Deploy dark (`COMMUNITY_CHAT_IDS=[]`), then set the ids. Holders with anonymous reactions produce no update — `/tip` is the fallback. +- **Community chats**: the bot must be admin in every `COMMUNITY_CHAT_IDS` chat with the "invite users" right, and `BOT_ALLOWED_UPDATES` is a **replacement allowlist, not an addition** — set it to the full array `["message","edited_message","callback_query","pre_checkout_query","my_chat_member","chat_member","message_reaction"]` (dropping any of the non-community entries silently breaks admin mint-approval buttons, Stars top-up, or bot join tracking). Deploy dark (`COMMUNITY_CHAT_IDS=[]`), then set the ids. Holders with anonymous reactions produce no update — `/tip` is the fallback. - Pre-deploy: `npm run smoke:api` (all secrets overridden with fakes — it can never hit a paid API or the live bot). - **Staging bot**: `kamal deploy -d staging` → @cubeworldsbot at https://staging.cubeworlds.club (`config/deploy.staging.yml` + `.kamal/secrets.staging`; own service name/DB `cube-worlds-bot-staging`/volume `/srv/cube_worlds_staging/data`). Shares chain keys + admins with prod, so an admin Approve there mints a real NFT. - `/tonconnect-manifest.json` is generated from `WEB_APP_URL` (ton_proof domain check), not a static landing file. diff --git a/src/backend/auth-handler.test.ts b/src/backend/auth-handler.test.ts index c73a6fdd..78bd50ea 100644 --- a/src/backend/auth-handler.test.ts +++ b/src/backend/auth-handler.test.ts @@ -516,6 +516,38 @@ test('app deep-link referrals stamp firstLoginAt but pay no login drop', async ( assert.deepEqual(ctx.inviterCredits, []) }) +test('a tipped stranger who later opens an app deep-link does not pay the deep-link referrer a chat-invite drop', async (t) => { + // markJoinedViaChat (give-tip.ts) sets joinedViaChat without ever touching + // referalId — unlike setChatReferral (a real invite-link join), which sets + // both together. So this user has joinedViaChat=true but no referalId yet. + const taggedStranger = createStubUser({ joinedViaChat: true, referalId: undefined, wallet: undefined }) + const deepLinkReferrer = createStubUser({ id: 9009 }) + const users = new Map([ + [1001, taggedStranger], + [9009, deepLinkReferrer], + ]) + const lookup = async (id: number) => { + const user = users.get(id) + return user ? toResolvedUser(user) : null + } + const ctx = await createAuthTestContext({ findUserById: lookup, findOrCreateUser: lookup }) + t.after(() => ctx.app.close()) + + const response = await ctx.app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { initData: 'x', referId: '9009' }, + }) + + // The unrelated deep-link referral still gets recorded normally... + assert.equal(response.json().referalId, 9009) + assert.equal(taggedStranger.referalId, 9009) + // ...but the chat-invite login drop must never go to it: nobody actually + // sent this user a chat invite link. + assert.deepEqual(ctx.firstLogins, [1001]) + assert.deepEqual(ctx.inviterCredits, []) +}) + test('inviter credit failure is logged and does not fail the login', async (t) => { const ctx = await createAuthTestContext({ creditInviter: async () => { throw new Error('mongo down') } }) t.after(() => ctx.app.close()) diff --git a/src/backend/auth-handler.ts b/src/backend/auth-handler.ts index 53334055..a30db651 100644 --- a/src/backend/auth-handler.ts +++ b/src/backend/auth-handler.ts @@ -107,6 +107,12 @@ export function buildAuthHandler( await user.save() } + // Snapshot before the referrer block below can write user.referalId + // from an unrelated app deep-link — the chat-invite payout must only + // ever credit whoever sent the real invite-link join (Task 4), never + // a deep-link referrer who happens to land in the same field. + const chatInviterId = user.joinedViaChat ? user.referalId : undefined + const userAlreadyInvited = user.wallet || user.referalId if (referId && !userAlreadyInvited) { const receiverId = Number(referId) @@ -123,11 +129,11 @@ export function buildAuthHandler( // Community: first app login. The CAS makes a double login pay once; // only chat-invited shells earn their inviter the login drop. const firstLogin = await dependencies.setFirstLoginAt(user.id, new Date()) - if (firstLogin && user.joinedViaChat && user.referalId) { + if (firstLogin && chatInviterId) { try { - await dependencies.creditInviter(user.referalId) + await dependencies.creditInviter(chatInviterId) } catch (err) { - dependencies.error(`Invite credit failed for inviter ${user.referalId} of ${user.id}: ${(err as Error).message}`) + dependencies.error(`Invite credit failed for inviter ${chatInviterId} of ${user.id}: ${(err as Error).message}`) } } diff --git a/src/bot/bot-wiring.test.ts b/src/bot/bot-wiring.test.ts index a40ebfcc..fdcccbb5 100644 --- a/src/bot/bot-wiring.test.ts +++ b/src/bot/bot-wiring.test.ts @@ -165,7 +165,7 @@ test('a plain message in a community chat is indexed and does not upsert the sen ;(update.message as { from: { id: number } }).from.id = 6161 await bot.handleUpdate(update) - assert.equal(await findChatMessageAuthor(-100777, 10), 6161, 'indexer wrote the row') + assert.deepEqual(await findChatMessageAuthor(-100777, 10), { id: 6161, name: 'Tester' }, 'indexer wrote the row') assert.equal(await findUserById(6161), null, 'attachUser did not run for group traffic') }) @@ -202,6 +202,11 @@ test('a 🧊 reaction from a holder in a community chat credits the author', asy assert.ok(await findUserById(6262), 'stranger shell created') assert.ok(calls.some(c => c.method === 'setMessageReaction'), 'bot reacted 🔥') assert.ok(calls.some(c => c.method === 'sendMessage' && String(c.payload.text).includes('50 $CUBE')), 'newcomer nudged') + // The stranger's indexed message carries from.first_name 'Tester' (no + // username) — the nudge must mention them by that real name, never the old + // synthesized "@id6262". + assert.ok(calls.some(c => c.method === 'sendMessage' && String(c.payload.text).includes('@Tester')), 'nudge mentions the real name') + assert.ok(!calls.some(c => c.method === 'sendMessage' && String(c.payload.text).includes('id6262')), 'nudge never uses the synthesized id fallback') }) test.after(async () => { diff --git a/src/bot/features/community/indexer-handler.test.ts b/src/bot/features/community/indexer-handler.test.ts index 890d1a1a..379d20d4 100644 --- a/src/bot/features/community/indexer-handler.test.ts +++ b/src/bot/features/community/indexer-handler.test.ts @@ -4,15 +4,21 @@ import { test } from 'node:test' import { buildIndexerHandler } from '#root/bot/features/community/indexer-handler' function ctx() { - const rows: Array<[number, number, number]> = [] - const handle = buildIndexerHandler({ indexChatMessage: async (c, m, a) => { rows.push([c, m, a]) } }) + const rows: Array<[number, number, number, string | undefined]> = [] + const handle = buildIndexerHandler({ indexChatMessage: async (c, m, a, n) => { rows.push([c, m, a, n]) } }) return { rows, handle } } -test('indexes a human message', async () => { +test('indexes a human message with its author name (username over first_name)', async () => { const { rows, handle } = ctx() - await handle({ chat: { id: -100 }, message_id: 7, from: { id: 42, is_bot: false } }) - assert.deepEqual(rows, [[-100, 7, 42]]) + await handle({ chat: { id: -100 }, message_id: 7, from: { id: 42, is_bot: false, username: 'alice', first_name: 'Alice' } }) + assert.deepEqual(rows, [[-100, 7, 42, 'alice']]) +}) + +test('falls back to first_name when there is no username', async () => { + const { rows, handle } = ctx() + await handle({ chat: { id: -100 }, message_id: 8, from: { id: 43, is_bot: false, first_name: 'Bob' } }) + assert.deepEqual(rows, [[-100, 8, 43, 'Bob']]) }) test('skips bots, anonymous admins and missing senders', async () => { diff --git a/src/bot/features/community/indexer-handler.ts b/src/bot/features/community/indexer-handler.ts index 6982e32c..4215ac2d 100644 --- a/src/bot/features/community/indexer-handler.ts +++ b/src/bot/features/community/indexer-handler.ts @@ -2,18 +2,18 @@ export interface IndexerMessage { chat: { id: number } message_id: number - from?: { id: number, is_bot: boolean } + from?: { id: number, is_bot: boolean, username?: string, first_name?: string } sender_chat?: unknown } export interface IndexerDependencies { - indexChatMessage: (chatId: number, messageId: number, authorId: number) => Promise + indexChatMessage: (chatId: number, messageId: number, authorId: number, authorName?: string) => Promise } export function buildIndexerHandler(deps: IndexerDependencies) { return async function indexMessage(message: IndexerMessage): Promise { const from = message.from if (!from || from.is_bot || message.sender_chat) return - await deps.indexChatMessage(message.chat.id, message.message_id, from.id) + await deps.indexChatMessage(message.chat.id, message.message_id, from.id, from.username ?? from.first_name) } } diff --git a/src/bot/features/community/reaction-tip-handler.test.ts b/src/bot/features/community/reaction-tip-handler.test.ts index f0f09892..e9c5febb 100644 --- a/src/bot/features/community/reaction-tip-handler.test.ts +++ b/src/bot/features/community/reaction-tip-handler.test.ts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { buildReactionTipHandler } from '#root/bot/features/community/reaction-tip-handler' -function ctx(author: number | null = 2) { +function ctx(author: { id: number, name?: string } | null = { id: 2, name: 'bob' }) { const tips: Array> = [] const handle = buildReactionTipHandler({ tipEmoji: '🧊', @@ -32,6 +32,13 @@ test('a newly added tip emoji tips the indexed author', async () => { assert.equal(tips[0].tipperId, 1) assert.equal(tips[0].recipientId, 2) assert.equal(tips[0].messageId, 55) + assert.equal(tips[0].recipientName, 'bob') +}) + +test('falls back to the bare id (never "id") when the indexed row has no author name', async () => { + const { tips, handle } = ctx({ id: 2 }) + await handle(update()) + assert.equal(tips[0].recipientName, '2') }) test('ignores other emojis, already-present emoji, anonymous reactors and unknown messages', async () => { diff --git a/src/bot/features/community/reaction-tip-handler.ts b/src/bot/features/community/reaction-tip-handler.ts index 3c2d75b2..225c04a3 100644 --- a/src/bot/features/community/reaction-tip-handler.ts +++ b/src/bot/features/community/reaction-tip-handler.ts @@ -1,3 +1,4 @@ +import type { ChatMessageAuthor } from '#root/common/models/ChatMessage' import type { buildGiveTip } from './give-tip' export type GiveTipFn = ReturnType @@ -12,7 +13,7 @@ export interface ReactionUpdate { export interface ReactionTipDependencies { tipEmoji: string - findChatMessageAuthor: (chatId: number, messageId: number) => Promise + findChatMessageAuthor: (chatId: number, messageId: number) => Promise giveTip: GiveTipFn } @@ -25,16 +26,19 @@ export function buildReactionTipHandler(deps: ReactionTipDependencies) { // Anonymous reactions carry actor_chat instead of user — nothing to attribute. if (!update.user) return if (!has(update.new_reaction, deps.tipEmoji) || has(update.old_reaction, deps.tipEmoji)) return - const authorId = await deps.findChatMessageAuthor(update.chat.id, update.message_id) - if (authorId === null) return - // The reaction update has no author name; giveTip only needs it for the - // one-time nudge, where a mention by id is enough. + const author = await deps.findChatMessageAuthor(update.chat.id, update.message_id) + if (author === null) return + // The reaction update itself carries no author name — pull the one the + // indexer captured (username, else first_name). Rows indexed before that + // field existed (pre-deploy, aging out within the 7-day TTL) have none; + // ponytail: fall back to the bare id rather than the old "id" + // string so the nudge is at least not a literal broken-looking mention. await deps.giveTip({ tipperId: update.user.id, - recipientId: authorId, + recipientId: author.id, chatId: update.chat.id, messageId: update.message_id, - recipientName: `id${authorId}`, + recipientName: author.name ?? String(author.id), }) } } diff --git a/src/common/models/ChatMessage.ts b/src/common/models/ChatMessage.ts index c6ddc8ef..8f1d8dd0 100644 --- a/src/common/models/ChatMessage.ts +++ b/src/common/models/ChatMessage.ts @@ -20,21 +20,33 @@ class ChatMessage { @prop({ type: Number, required: true }) authorId!: number + // username if the author has one, else first_name — captured at index time + // so a reaction (no `from` on the update) can still produce a real mention + // instead of a synthesized "id" fallback. Absent on rows indexed + // before this field existed; those age out within the 7-day TTL above. + @prop({ type: String }) + authorName?: string + @prop({ type: Date }) createdAt?: Date } const ChatMessageModel = getModelForClass(ChatMessage) -export async function indexChatMessage(chatId: number, messageId: number, authorId: number): Promise { +export interface ChatMessageAuthor { + id: number + name?: string +} + +export async function indexChatMessage(chatId: number, messageId: number, authorId: number, authorName?: string): Promise { await ChatMessageModel.updateOne( { chatId, messageId }, - { $setOnInsert: { chatId, messageId, authorId, createdAt: new Date() } }, + { $setOnInsert: { chatId, messageId, authorId, authorName, createdAt: new Date() } }, { upsert: true }, ) } -export async function findChatMessageAuthor(chatId: number, messageId: number): Promise { - const row = await ChatMessageModel.findOne({ chatId, messageId }, { authorId: 1 }).lean() - return row?.authorId ?? null +export async function findChatMessageAuthor(chatId: number, messageId: number): Promise { + const row = await ChatMessageModel.findOne({ chatId, messageId }, { authorId: 1, authorName: 1 }).lean() + return row ? { id: row.authorId, name: row.authorName } : null } diff --git a/src/main.ts b/src/main.ts index 622fb14b..920e9879 100644 --- a/src/main.ts +++ b/src/main.ts @@ -112,7 +112,7 @@ try { host: config.BOT_SERVER_HOST, port: config.BOT_SERVER_PORT, }) - await bot.start() + await bot.start({ allowed_updates: config.BOT_ALLOWED_UPDATES }) } } } catch (error) {