Skip to content
Merged
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
146 changes: 133 additions & 13 deletions apps/web-app/server/services/telegram/wasabi-bot.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Ticket, User } from '@roll-stack/database'
import type { Context } from 'grammy'
import { repository } from '@roll-stack/database'
import { Bot } from 'grammy'
Expand All @@ -9,12 +10,12 @@ const { telegram } = useRuntimeConfig()
let bot: Bot | null = null

export async function useCreateWasabiBot() {
const botInDb = await repository.telegram.findBot(telegram.wasabiBotId)
if (!botInDb?.token) {
const token = await getBotToken()
if (!token) {
throw new Error('Wasabi bot is not configured')
}

bot = new Bot(botInDb.token)
bot = new Bot(token)

Comment on lines 12 to 19
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Make bot initialization idempotent to prevent duplicate handlers and multiple start() calls.
If useCreateWasabiBot() can be called more than once (hot reload, startup race, health-checks), you'll re-register handlers and call start() again. Guard and early-return if already initialized.

Apply this diff:

 export async function useCreateWasabiBot() {
+  if (bot) {
+    logger.info('Wasabi bot is already initialized; skipping re-init')
+    return
+  }
   const token = await getBotToken()
   if (!token) {
     throw new Error('Wasabi bot is not configured')
   }

   bot = new Bot(token)
+  bot.catch((err) => logger.error('Unhandled Wasabi bot error:', err))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function useCreateWasabiBot() {
const botInDb = await repository.telegram.findBot(telegram.wasabiBotId)
if (!botInDb?.token) {
const token = await getBotToken()
if (!token) {
throw new Error('Wasabi bot is not configured')
}
bot = new Bot(botInDb.token)
bot = new Bot(token)
export async function useCreateWasabiBot() {
if (bot) {
logger.info('Wasabi bot is already initialized; skipping re-init')
return
}
const token = await getBotToken()
if (!token) {
throw new Error('Wasabi bot is not configured')
}
bot = new Bot(token)
bot.catch((err) => logger.error('Unhandled Wasabi bot error:', err))
🤖 Prompt for AI Agents
In apps/web-app/server/services/telegram/wasabi-bot.ts around lines 12 to 19,
the code always constructs a new Bot(token) and registers handlers/start which
can run multiple times on hot reloads; make initialization idempotent by
checking an existing bot instance and returning early if already initialized
(optionally verify the existing bot was created with the same token), only
create new Bot and register handlers/start when no bot exists, and ensure any
exported/shared state (bot variable) is set once so duplicate handlers and
start() calls are avoided.

bot.on('message:text', async (ctx) => {
if (ctx.hasCommand('start')) {
Expand All @@ -24,6 +25,22 @@ export async function useCreateWasabiBot() {
return handleMessage(ctx)
})

bot.on('message:photo', async (ctx) => {
return handlePhoto(ctx)
})

bot.on('message:video', async (ctx) => {
return handleVideo(ctx)
})

bot.on('message:document', async (ctx) => {
return handleFile(ctx)
})

bot.on('message:file', async (ctx) => {
return handleFile(ctx)
})

// Somebody invited bot to a group
bot.on('my_chat_member', async (ctx) => {
logger.log('my_chat_member', ctx.update)
Expand Down Expand Up @@ -84,11 +101,102 @@ async function handleMessage(ctx: Context) {
return
}

const telegramUser = await repository.telegram.findUserByTelegramIdAndBotId(ctx.message.from.id.toString(), telegram.wasabiBotId)
if (!telegramUser?.user) {
const data = await getUserAndTicket(ctx.message.from.id.toString())
if (!data) {
return
}

await repository.ticket.createMessage({
ticketId: data.ticket.id,
userId: data.user.id,
text: ctx.message.text,
})

logger.log('message', data.user.id, ctx.message.from.id, ctx.message.text)
ctx.reply('Сообщение передано в службу поддержки.')
}

async function handlePhoto(ctx: Context) {
if (!ctx.message?.photo?.length) {
return
}

const data = await getUserAndTicket(ctx.message.from.id.toString())
if (!data) {
return
}

const bestQuality = ctx.message.photo.pop()
if (!bestQuality) {
return
}

const botToken = await getBotToken()
if (!botToken) {
return null
}

const downloadUrl = await getFileDownloadUrl({ ctx, fileId: bestQuality.file_id, botToken })

await repository.ticket.createMessage({
ticketId: data.ticket.id,
userId: data.user.id,
text: JSON.stringify({ downloadUrl, photo: ctx.message.photo }),
})

// Save photo?
logger.log('photo', data.user.id, ctx.message.from.id, ctx.message.text, ctx.message.photo, downloadUrl)
ctx.reply('Фото передано в службу поддержки.')
}

Comment on lines +119 to +151
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Do not persist or log Telegram download URLs (they embed the bot token).
Storing/logging downloadUrl leaks the bot token (a secret). Also, using Array.pop() mutates ctx.message.photo and drops the highest-quality size from the array you later persist. Fix both.

Apply this diff:

 async function handlePhoto(ctx: Context) {
   if (!ctx.message?.photo?.length) {
     return
   }

   const data = await getUserAndTicket(ctx.message.from.id.toString())
   if (!data) {
     return
   }

-  const bestQuality = ctx.message.photo.pop()
+  const bestQuality = ctx.message.photo[ctx.message.photo.length - 1]
   if (!bestQuality) {
     return
   }
-
-  const botToken = await getBotToken()
-  if (!botToken) {
-    return null
-  }
-
-  const downloadUrl = await getFileDownloadUrl({ ctx, fileId: bestQuality.file_id, botToken })
+  // Fetch file metadata without creating a URL that embeds the bot token
+  const file = await ctx.api.getFile(bestQuality.file_id)
+  if (!file?.file_path) {
+    logger.warn('photo:file_path missing for file_id', bestQuality.file_id)
+    return
+  }

   await repository.ticket.createMessage({
     ticketId: data.ticket.id,
     userId: data.user.id,
-    text: JSON.stringify({ downloadUrl, photo: ctx.message.photo }),
+    text: JSON.stringify({
+      fileId: bestQuality.file_id,
+      filePath: file.file_path,
+      sizes: ctx.message.photo,
+      caption: ctx.message.caption,
+    }),
   })

   // Save photo?
-  logger.log('photo', data.user.id, ctx.message.from.id, ctx.message.text, ctx.message.photo, downloadUrl)
-  ctx.reply('Фото передано в службу поддержки.')
+  logger.log('photo', {
+    userId: data.user.id,
+    tgUserId: ctx.message.from.id,
+    fileId: bestQuality.file_id,
+    filePath: file.file_path,
+  })
+  await ctx.reply('Фото передано в службу поддержки.')
 }

Follow-ups:

  • If you need a usable link for agents, generate it on-demand server-side (proxy endpoint) so the token never leaves the backend.
  • Scrub past logs/DB entries created by this branch that may already contain the token. Rotate the bot token after deploying the fix.

async function handleVideo(ctx: Context) {
if (!ctx.message?.video) {
return
}

const data = await getUserAndTicket(ctx.message.from.id.toString())
if (!data) {
return
}

await repository.ticket.createMessage({
ticketId: data.ticket.id,
userId: data.user.id,
text: JSON.stringify(ctx.message.video),
})

// Save video?
logger.log('video', data.user.id, ctx.message.from.id, ctx.message.text, ctx.message.video)
ctx.reply('Видео передано в службу поддержки.')
}

async function handleFile(ctx: Context) {
if (!ctx.message?.document) {
return
}

const data = await getUserAndTicket(ctx.message.from.id.toString())
if (!data) {
return
}

await repository.ticket.createMessage({
ticketId: data.ticket.id,
userId: data.user.id,
text: JSON.stringify(ctx.message.document),
})

// Save file?
logger.log('file', data.user.id, ctx.message.from.id, ctx.message.text, ctx.message.document)
ctx.reply('Файл передан в службу поддержки.')
}

async function getUserAndTicket(telegramId: string): Promise<{ user: User, ticket: Ticket } | null> {
const telegramUser = await repository.telegram.findUserByTelegramIdAndBotId(telegramId, telegram.wasabiBotId)
if (!telegramUser?.user) {
return null
}

// Get last ticket
const tickets = await repository.ticket.listOpenedByUser(telegramUser.user.id)
let ticket = tickets?.[0]
Expand All @@ -102,17 +210,29 @@ async function handleMessage(ctx: Context) {
})
}
if (!ticket) {
return
return null
}

await repository.ticket.createMessage({
ticketId: ticket.id,
userId: telegramUser.user.id,
text: ctx.message.text,
})
return { user: telegramUser.user, ticket }
}

logger.log('message', telegramUser.user.id, ctx.message.from.id, ctx.message.text)
ctx.reply('Сообщение передано в службу поддержки.')
async function getFileDownloadUrl(data: { ctx: Context, fileId: string, botToken: string }) {
// https://api.telegram.org/file/bot<token>/<file_path>
const file = await data.ctx.api.getFile(data.fileId)
if (!file) {
return null
}

return `https://api.telegram.org/file/bot${data.botToken}/${file.file_path}`
}

async function getBotToken(): Promise<string | null> {
const botInDb = await repository.telegram.findBot(telegram.wasabiBotId)
if (!botInDb?.token) {
return null
}

return botInDb.token
}

export function useWasabiBot(): Bot {
Expand Down