Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/bot/features/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ const feature = composer.chatType('private')
feature.command(
'help',
logHandle('command-help'),
buildHelpCommandHandler({ webAppUrl: config.WEB_APP_URL }),
// The Mini App lives under /game (root serves the public landing).
buildHelpCommandHandler({ webAppUrl: `${config.WEB_APP_URL.replace(/\/$/, '')}/game` }),
)

export { composer as helpFeature }
5 changes: 4 additions & 1 deletion src/bot/features/removed-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ const composer = new Composer<Context>()

const feature = composer.chatType('private')

const handle = buildRemovedCommandsHandler({ webAppUrl: config.WEB_APP_URL })
// The Mini App lives under /game (root serves the public landing).
const handle = buildRemovedCommandsHandler({
webAppUrl: `${config.WEB_APP_URL.replace(/\/$/, '')}/game`,
})

feature.on('message:text', logHandle('removed-command'), async (ctx, next) => {
const text = ctx.message?.text ?? ''
Expand Down
68 changes: 61 additions & 7 deletions src/bot/handlers/commands/sync-commands-core.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable test/no-import-node-test */
import type { BotCommandScope } from '@grammyjs/types'
import type { BotApiLike } from '#root/bot/handlers/commands/sync-commands-core'
import type { BotApiLike, ChatMenuButton } from '#root/bot/handlers/commands/sync-commands-core'
import assert from 'node:assert/strict'
import { test } from 'node:test'
import {
Expand All @@ -15,7 +15,9 @@ interface SetMyCommandsCall {
options?: { language_code?: string, scope?: BotCommandScope }
}

function makeApiStub() {
function makeApiStub(
currentMenuButton: ChatMenuButton = { type: 'default' },
) {
const setMyCommandsCalls: SetMyCommandsCall[] = []
const setMyDescriptionCalls: Array<{ description: string, options?: { language_code?: string } }> = []
const setMyShortDescriptionCalls: Array<{ short_description: string, options?: { language_code?: string } }> = []
Expand All @@ -33,6 +35,7 @@ function makeApiStub() {
setChatMenuButton: async (options) => {
setChatMenuButtonCalls.push(options)
},
getChatMenuButton: async () => currentMenuButton,
}
return {
api,
Expand Down Expand Up @@ -160,18 +163,69 @@ test('syncBotCommands fans description and short-description across locales', as
})
})

test('buildSetMenuButton calls setChatMenuButton with a web_app button at the configured URL', async () => {
const stub = makeApiStub()
const set = buildSetMenuButton({ webAppUrl: 'https://app.example', label: 'Open App' })
test('buildSetMenuButton sets the web_app button when none is configured yet', async () => {
const stub = makeApiStub({ type: 'default' })
const set = buildSetMenuButton({ webAppUrl: 'https://app.example/game', label: 'Open App' })

await set(stub.api)
const result = await set(stub.api)

assert.equal(stub.setChatMenuButtonCalls.length, 1)
assert.deepEqual(stub.setChatMenuButtonCalls[0], {
menu_button: {
type: 'web_app',
text: 'Open App',
web_app: { url: 'https://app.example' },
web_app: { url: 'https://app.example/game' },
},
})
assert.deepEqual(result, { changed: true, previousUrl: null, url: 'https://app.example/game' })
})

test('buildSetMenuButton updates when the existing web_app URL differs (/ → /game)', async () => {
const stub = makeApiStub({
type: 'web_app',
text: 'Open App',
web_app: { url: 'https://app.example' },
})
const set = buildSetMenuButton({ webAppUrl: 'https://app.example/game', label: 'Open App' })

const result = await set(stub.api)

assert.equal(stub.setChatMenuButtonCalls.length, 1)
assert.deepEqual(result, {
changed: true,
previousUrl: 'https://app.example',
url: 'https://app.example/game',
})
})

test('buildSetMenuButton is a no-op when the URL and label already match', async () => {
const stub = makeApiStub({
type: 'web_app',
text: 'Open App',
web_app: { url: 'https://app.example/game' },
})
const set = buildSetMenuButton({ webAppUrl: 'https://app.example/game', label: 'Open App' })

const result = await set(stub.api)

assert.equal(stub.setChatMenuButtonCalls.length, 0)
assert.deepEqual(result, {
changed: false,
previousUrl: 'https://app.example/game',
url: 'https://app.example/game',
})
})

test('buildSetMenuButton updates when only the label differs', async () => {
const stub = makeApiStub({
type: 'web_app',
text: 'Old Label',
web_app: { url: 'https://app.example/game' },
})
const set = buildSetMenuButton({ webAppUrl: 'https://app.example/game', label: 'Open App' })

const result = await set(stub.api)

assert.equal(stub.setChatMenuButtonCalls.length, 1)
assert.equal(result.changed, true)
})
35 changes: 34 additions & 1 deletion src/bot/handlers/commands/sync-commands-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,17 @@ export interface BotApiLike {
setChatMenuButton: (options: {
menu_button: { type: 'web_app', text: string, web_app: { url: string } }
}) => Promise<unknown>
getChatMenuButton: () => Promise<ChatMenuButton>
}

// The subset of Telegram's MenuButton union we need to read back. A web_app
// button carries the URL/label we compare against; the other variants (default
// button, commands) carry no URL, so any of them means "not yet pointing at us".
export type ChatMenuButton =
| { type: 'web_app', text: string, web_app: { url: string } }
| { type: 'default' }
| { type: 'commands' }

export type TranslateFn = (locale: string, key: string) => string

export interface SyncCommandsDependencies {
Expand Down Expand Up @@ -114,14 +123,38 @@ export interface SetMenuButtonDependencies {
label: string
}

export interface MenuButtonResult {
/** Whether Telegram was actually updated (false = already correct). */
changed: boolean
/** The web_app URL before this run, or null if no web_app button was set. */
previousUrl: string | null
/** The desired web_app URL. */
url: string
}

export function buildSetMenuButton(deps: SetMenuButtonDependencies) {
return async function setMenuButton(api: Pick<BotApiLike, 'setChatMenuButton'>): Promise<void> {
return async function setMenuButton(
api: Pick<BotApiLike, 'setChatMenuButton' | 'getChatMenuButton'>,
): Promise<MenuButtonResult> {
const current = await api.getChatMenuButton()
const previous
= current.type === 'web_app'
? { url: current.web_app.url, text: current.text }
: null

// Only touch Telegram when the URL or label actually differs — keeps deploys
// idempotent and lets the caller log the / → /game migration when it happens.
if (previous && previous.url === deps.webAppUrl && previous.text === deps.label) {
return { changed: false, previousUrl: previous.url, url: deps.webAppUrl }
}

await api.setChatMenuButton({
menu_button: {
type: 'web_app',
text: deps.label,
web_app: { url: deps.webAppUrl },
},
})
return { changed: true, previousUrl: previous?.url ?? null, url: deps.webAppUrl }
}
}
3 changes: 2 additions & 1 deletion src/bot/handlers/commands/sync-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export const syncBotCommands = buildSyncBotCommands({
})

export const setMenuButton = buildSetMenuButton({
webAppUrl: config.WEB_APP_URL,
// The Mini App lives under /game (root serves the public landing).
webAppUrl: `${config.WEB_APP_URL.replace(/\/$/, '')}/game`,
label: i18n.t(DEFAULT_LOCALE, 'menu_button.label'),
})

Expand Down
4 changes: 3 additions & 1 deletion src/frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import 'element-plus/dist/index.css'
import './style.css'

const router = createRouter({
history: createWebHistory(),
// Base is '/game/' in prod (see vite.config.ts base); route paths like '/mint'
// stay base-relative, so the beforeEach gate comparisons are unaffected.
history: createWebHistory(import.meta.env.BASE_URL),
routes: vueRoutes,
})

Expand Down
4 changes: 4 additions & 0 deletions src/frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { defineConfig } from 'vite'

// https://vitejs.dev/config/
export default defineConfig({
// The game is served under /game (the crawler-friendly landing owns the root).
// This prefixes every emitted asset URL and feeds vue-router's base via
// import.meta.env.BASE_URL. The Telegram Mini App URL must point at /game.
base: '/game/',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
Expand Down
Loading