From d4d3f4854e6362529f7e511cd8b8e82a51d1bf1e Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Thu, 5 Mar 2026 19:13:25 +0300 Subject: [PATCH 01/24] fix: add Node types for process.env --- .gitignore | 4 ++++ app/.env.example | 25 ++++++++++++++----------- app/.gitignore | 4 ++++ app/package-lock.json | 13 +++++++------ app/package.json | 7 ++++--- app/tsconfig.json | 15 ++++++++------- 6 files changed, 41 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 68aa86c..40957fa 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,7 @@ __pycache__/ ai/backend/main.py.backup + +# Local env files +/.env.local +/.env.production diff --git a/app/.env.example b/app/.env.example index 81e125f..6b3ecb9 100644 --- a/app/.env.example +++ b/app/.env.example @@ -1,11 +1,14 @@ -# Copy to .env and fill in. Used by bot (npm run bot:local) and optionally by Expo. -# Do not commit .env. - -# Telegram bot (required for local bot; optional for Expo app) -# Get token from @BotFather on Telegram -BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz-EXAMPLE -# Neon/Postgres connection string (from Neon dashboard) -DATABASE_URL=postgresql://user:password@ep-xxx-xxx.region.aws.neon.tech/neondb?sslmode=require - -# Optional: production domain so webhook is stable (e.g. https://hsbexpo.vercel.app). If unset, VERCEL_URL is used. -# SELF_URL=https://your-app.vercel.app +# Template only. Keep real secrets in .env/.env.local (gitignored) +# and Vercel Project Settings -> Environment Variables. + +# Local / Dev runtime values +BOT_TOKEN= +DATABASE_URL= + +# Optional: production domain so webhook is stable (e.g. https://hsbexpo.vercel.app). If unset, VERCEL_URL is used. +# SELF_URL=https://your-app.vercel.app + +# Production on Vercel should use the same key names: +# BOT_TOKEN= +# DATABASE_URL= +# SELF_URL=https://your-app.vercel.app diff --git a/app/.gitignore b/app/.gitignore index 9f9fcd7..6653567 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -40,3 +40,7 @@ yarn-error.* app-example .vercel + +# Explicit env variants +.env.local +.env.production diff --git a/app/package-lock.json b/app/package-lock.json index 21bc481..648162f 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -46,6 +46,7 @@ }, "devDependencies": { "@babel/core": "^7.25.2", + "@types/node": "^25.3.3", "@types/react": "~19.1.0", "concurrently": "^9.1.0", "cross-env": "^7.0.3", @@ -4881,9 +4882,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.2.tgz", - "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -15685,9 +15686,9 @@ } }, "node_modules/tar": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", - "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz", + "integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", diff --git a/app/package.json b/app/package.json index 46a2df1..a13db7b 100644 --- a/app/package.json +++ b/app/package.json @@ -63,15 +63,16 @@ "react-native-worklets": "0.5.1" }, "devDependencies": { - "cross-env": "^7.0.3", - "vercel": "^38.0.0", "@babel/core": "^7.25.2", + "@types/node": "^25.3.3", "@types/react": "~19.1.0", "concurrently": "^9.1.0", + "cross-env": "^7.0.3", "dotenv": "^16.4.5", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", "tsx": "^4.19.2", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "vercel": "^38.0.0" } } diff --git a/app/tsconfig.json b/app/tsconfig.json index 032b25f..fa14b08 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -1,11 +1,12 @@ { - "extends": "expo/tsconfig.base", - "compilerOptions": { - "strict": true, - "paths": { - "@/*": [ - "./*" - ] + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "types": ["node"], + "paths": { + "@/*": [ + "./*" + ] } }, "include": [ From 4f35b5657ed6d1a8481b6c2432336267649a7237 Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 10:39:59 +0300 Subject: [PATCH 02/24] fix: enforce node typings for vercel/app build paths --- app/api/tsconfig.json | 1 + app/package-lock.json | 8 ++++---- app/package.json | 2 +- app/tsconfig.json | 1 + app/vercel.json | 1 - bot/coffee.ts | 3 ++- bot/openapi.ts | 2 ++ 7 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/api/tsconfig.json b/app/api/tsconfig.json index d215de2..22fc81e 100644 --- a/app/api/tsconfig.json +++ b/app/api/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2020", "module": "ESNext", "moduleResolution": "node", + "types": ["node"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/app/package-lock.json b/app/package-lock.json index 648162f..2ecaba7 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -46,7 +46,7 @@ }, "devDependencies": { "@babel/core": "^7.25.2", - "@types/node": "^25.3.3", + "@types/node": "^25.3.5", "@types/react": "~19.1.0", "concurrently": "^9.1.0", "cross-env": "^7.0.3", @@ -4882,9 +4882,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", - "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "version": "25.3.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", + "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", "license": "MIT", "dependencies": { "undici-types": "~7.18.0" diff --git a/app/package.json b/app/package.json index a13db7b..9b46118 100644 --- a/app/package.json +++ b/app/package.json @@ -64,7 +64,7 @@ }, "devDependencies": { "@babel/core": "^7.25.2", - "@types/node": "^25.3.3", + "@types/node": "^25.3.5", "@types/react": "~19.1.0", "concurrently": "^9.1.0", "cross-env": "^7.0.3", diff --git a/app/tsconfig.json b/app/tsconfig.json index fa14b08..ecde08e 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -2,6 +2,7 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, + "moduleResolution": "node", "types": ["node"], "paths": { "@/*": [ diff --git a/app/vercel.json b/app/vercel.json index a315cf5..09b4f6f 100644 --- a/app/vercel.json +++ b/app/vercel.json @@ -1,5 +1,4 @@ { - "$schema": "https://openapi.vercel.sh/vercel.json", "buildCommand": "npm run db:migrate && npx expo export -p web && npx tsx scripts/set-webhook.ts", "outputDirectory": "dist", "framework": null, diff --git a/bot/coffee.ts b/bot/coffee.ts index 3e23219..771bebc 100644 --- a/bot/coffee.ts +++ b/bot/coffee.ts @@ -1,3 +1,5 @@ +/// + export type CoffeeTokenContext = { symbol: string; name?: string; @@ -116,4 +118,3 @@ export async function fetchCoffeeContext(symbolInput: string): Promise + export type ChatRole = "system" | "user" | "assistant"; export type ChatMessage = { From cc0ce3ba3245beb0a7c7fbb50d68fdd94286bd2d Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 11:39:44 +0300 Subject: [PATCH 03/24] fix: type narrowing for symbol lookup --- bot/handler.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/handler.ts b/bot/handler.ts index a82f7cf..e6aeda3 100644 --- a/bot/handler.ts +++ b/bot/handler.ts @@ -74,9 +74,10 @@ export async function handleChat(input: HandleChatInput): Promise 0) { try { - const coffee = await fetchCoffeeContext(tickerSymbol); + const coffee = await fetchCoffeeContext(symbolForLookup); if (coffee) { usedCoffee = true; coffeeName = coffee.name; @@ -141,4 +142,3 @@ export async function handleChat(input: HandleChatInput): Promise Date: Fri, 6 Mar 2026 12:04:42 +0300 Subject: [PATCH 04/24] fix: configure bot api function --- app/vercel.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/vercel.json b/app/vercel.json index 09b4f6f..fae1d68 100644 --- a/app/vercel.json +++ b/app/vercel.json @@ -5,6 +5,6 @@ "installCommand": "npm install", "functions": { "api/ping.js": { "maxDuration": 10 }, - "api/telegram.ts": { "maxDuration": 60 } + "api/bot.ts": { "maxDuration": 60 } } } From 8e16521d7b13645d40772506c073e74c581b05b9 Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 12:50:44 +0300 Subject: [PATCH 05/24] connect telegram bot to chat handler --- app/api/bot-webhook.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/api/bot-webhook.ts b/app/api/bot-webhook.ts index fa06390..3c4b82d 100644 --- a/app/api/bot-webhook.ts +++ b/app/api/bot-webhook.ts @@ -8,6 +8,7 @@ import { Bot, type Context } from 'grammy'; import { normalizeUsername, upsertUserFromBot } from './_users.js'; +import { handleChat } from '../../bot/handler.js'; interface TelegramUpdate { update_id: number; @@ -79,7 +80,13 @@ function createBot(token: string): Bot { bot.on('message:text', async (ctx: Context) => { await handleUserUpsert(ctx); - await ctx.reply('Hello'); + const text = ctx.message.text; + + const result = await handleChat({ + messages: [{ role: "user", content: text }] + }); + + await ctx.reply(result.text); }); bot.on('message', async (ctx: Context) => { @@ -245,4 +252,3 @@ export default async function handler( } return legacyHandler(request as NodeReq, context!); } - From 1a285139105bb467e792a83722d044b9968bab59 Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 13:02:57 +0300 Subject: [PATCH 06/24] fix: narrow telegram text message before chat handler --- app/api/bot-webhook.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/api/bot-webhook.ts b/app/api/bot-webhook.ts index 3c4b82d..d2d0732 100644 --- a/app/api/bot-webhook.ts +++ b/app/api/bot-webhook.ts @@ -80,12 +80,17 @@ function createBot(token: string): Bot { bot.on('message:text', async (ctx: Context) => { await handleUserUpsert(ctx); - const text = ctx.message.text; - + + const text = ctx.message?.text; + if (!text) { + await ctx.reply('I could not read that message.'); + return; + } + const result = await handleChat({ - messages: [{ role: "user", content: text }] + messages: [{ role: "user", content: text }], }); - + await ctx.reply(result.text); }); From 7d1ff2a72be6dec12e727c87bd669646a61befe3 Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 13:21:57 +0300 Subject: [PATCH 07/24] fix: use extensionless import for handler --- app/api/bot-webhook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/bot-webhook.ts b/app/api/bot-webhook.ts index d2d0732..3249a34 100644 --- a/app/api/bot-webhook.ts +++ b/app/api/bot-webhook.ts @@ -8,7 +8,7 @@ import { Bot, type Context } from 'grammy'; import { normalizeUsername, upsertUserFromBot } from './_users.js'; -import { handleChat } from '../../bot/handler.js'; +import { handleChat } from '../../bot/handler'; interface TelegramUpdate { update_id: number; From fa4269cba753fc9791d8e466ef04d96036f9e630 Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 13:37:11 +0300 Subject: [PATCH 08/24] fix: correct handler import path --- app/api/bot-webhook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/bot-webhook.ts b/app/api/bot-webhook.ts index 3249a34..7d1f11b 100644 --- a/app/api/bot-webhook.ts +++ b/app/api/bot-webhook.ts @@ -8,7 +8,7 @@ import { Bot, type Context } from 'grammy'; import { normalizeUsername, upsertUserFromBot } from './_users.js'; -import { handleChat } from '../../bot/handler'; +import { handleChat } from '../bot/handler.js'; interface TelegramUpdate { update_id: number; From a0c0a3b601eaba555c59e887b0935b2a990f4427 Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 13:55:23 +0300 Subject: [PATCH 09/24] fix: align tsconfig module resolution --- app/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/tsconfig.json b/app/tsconfig.json index ecde08e..810a1ba 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -2,7 +2,7 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "types": ["node"], "paths": { "@/*": [ From 7688ef6db070fb41639485af6dee77b7a40252b7 Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 14:07:37 +0300 Subject: [PATCH 10/24] fix: correct handler import for typescript --- app/api/bot-webhook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/bot-webhook.ts b/app/api/bot-webhook.ts index 7d1f11b..cddfe6f 100644 --- a/app/api/bot-webhook.ts +++ b/app/api/bot-webhook.ts @@ -8,7 +8,7 @@ import { Bot, type Context } from 'grammy'; import { normalizeUsername, upsertUserFromBot } from './_users.js'; -import { handleChat } from '../bot/handler.js'; +import { handleChat } from '../bot/handler'; interface TelegramUpdate { update_id: number; From 4620e14cc0e7dde5a652d65575a3a15c579f78cc Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 16:29:00 +0300 Subject: [PATCH 11/24] fix: align api tsconfig module resolution --- app/api/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/tsconfig.json b/app/api/tsconfig.json index 22fc81e..85cf998 100644 --- a/app/api/tsconfig.json +++ b/app/api/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2020", "module": "ESNext", - "moduleResolution": "node", + "moduleResolution": "bundler", "types": ["node"], "strict": true, "esModuleInterop": true, From fb8103c78a33bb3dd847d806ee25abba5e2c68d2 Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 16:52:01 +0300 Subject: [PATCH 12/24] fix: correct bot handler import --- app/api/bot-webhook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/bot-webhook.ts b/app/api/bot-webhook.ts index cddfe6f..f0acd9d 100644 --- a/app/api/bot-webhook.ts +++ b/app/api/bot-webhook.ts @@ -8,7 +8,7 @@ import { Bot, type Context } from 'grammy'; import { normalizeUsername, upsertUserFromBot } from './_users.js'; -import { handleChat } from '../bot/handler'; +import { handleChat } from '../bot/webhook'; interface TelegramUpdate { update_id: number; From 756c8e9e5d2845a88166a3c0b72b41824c29e09b Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 17:03:46 +0300 Subject: [PATCH 13/24] fix: import default bot handler --- app/api/bot-webhook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/bot-webhook.ts b/app/api/bot-webhook.ts index f0acd9d..d7e71a8 100644 --- a/app/api/bot-webhook.ts +++ b/app/api/bot-webhook.ts @@ -8,7 +8,7 @@ import { Bot, type Context } from 'grammy'; import { normalizeUsername, upsertUserFromBot } from './_users.js'; -import { handleChat } from '../bot/webhook'; +import handleChat from '../bot/webhook'; interface TelegramUpdate { update_id: number; From d5564fae6b308fc443d01341d1cf882549935a95 Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 17:26:54 +0300 Subject: [PATCH 14/24] connect telegram bot to chat handler --- app/bot/grammy-bot.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/bot/grammy-bot.ts b/app/bot/grammy-bot.ts index 7f56541..4d43a7d 100644 --- a/app/bot/grammy-bot.ts +++ b/app/bot/grammy-bot.ts @@ -4,6 +4,7 @@ */ import { Bot, type Context } from 'grammy'; import { normalizeUsername, upsertUserFromBot } from '../server/users'; +import { handleChat } from './handler'; export function createBot(token: string): Bot { const bot = new Bot(token); @@ -32,7 +33,15 @@ export function createBot(token: string): Bot { bot.on('message:text', async (ctx: Context) => { await handleUserUpsert(ctx); - await ctx.reply('Hello'); + + const text = ctx.message?.text; + if (!text) return; + + const result = await handleChat({ + messages: [{ role: "user", content: text }] + }); + + await ctx.reply(result.text); }); bot.on('message', async (ctx: Context) => { @@ -46,4 +55,3 @@ export function createBot(token: string): Bot { return bot; } - From 6c923b0d6f613e018deecc2dcd83ad3a16b3e226 Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 17:32:39 +0300 Subject: [PATCH 15/24] fix: stabilize app bot handler wiring --- app/api/bot-webhook.ts | 2 +- app/bot/handler.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 app/bot/handler.ts diff --git a/app/api/bot-webhook.ts b/app/api/bot-webhook.ts index d7e71a8..cddfe6f 100644 --- a/app/api/bot-webhook.ts +++ b/app/api/bot-webhook.ts @@ -8,7 +8,7 @@ import { Bot, type Context } from 'grammy'; import { normalizeUsername, upsertUserFromBot } from './_users.js'; -import handleChat from '../bot/webhook'; +import { handleChat } from '../bot/handler'; interface TelegramUpdate { update_id: number; diff --git a/app/bot/handler.ts b/app/bot/handler.ts new file mode 100644 index 0000000..0a7a3d8 --- /dev/null +++ b/app/bot/handler.ts @@ -0,0 +1,30 @@ +export type ChatRole = 'system' | 'user' | 'assistant'; + +export type ChatMessage = { + role: ChatRole; + content: string; +}; + +export type HandleChatInput = { + messages: ChatMessage[]; +}; + +export type HandleChatOutput = { + text: string; +}; + +function lastUserText(messages: ChatMessage[]): string { + return [...messages].reverse().find((message) => message.role === 'user')?.content?.trim() || ''; +} + +export async function handleChat(input: HandleChatInput): Promise { + const text = lastUserText(input.messages); + if (!text) { + return { text: 'I could not read that message.' }; + } + + // Temporary local handler to keep webhook flow stable. + return { text }; +} + +export default handleChat; From 20103792a319638524d17cd0a10889ef4f0579ef Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 17:56:20 +0300 Subject: [PATCH 16/24] fix: resolve bot handler imports for node esm runtime --- app/api/bot-webhook.ts | 2 +- app/bot/grammy-bot.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api/bot-webhook.ts b/app/api/bot-webhook.ts index cddfe6f..7d1f11b 100644 --- a/app/api/bot-webhook.ts +++ b/app/api/bot-webhook.ts @@ -8,7 +8,7 @@ import { Bot, type Context } from 'grammy'; import { normalizeUsername, upsertUserFromBot } from './_users.js'; -import { handleChat } from '../bot/handler'; +import { handleChat } from '../bot/handler.js'; interface TelegramUpdate { update_id: number; diff --git a/app/bot/grammy-bot.ts b/app/bot/grammy-bot.ts index 4d43a7d..64df631 100644 --- a/app/bot/grammy-bot.ts +++ b/app/bot/grammy-bot.ts @@ -4,7 +4,7 @@ */ import { Bot, type Context } from 'grammy'; import { normalizeUsername, upsertUserFromBot } from '../server/users'; -import { handleChat } from './handler'; +import { handleChat } from './handler.js'; export function createBot(token: string): Bot { const bot = new Bot(token); From 57cd5ce0b140a0fe710bd6663ea1336b43cfae7a Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 18:05:30 +0300 Subject: [PATCH 17/24] fix: wire stable chat flow and remove echo bot behavior --- app/api/bot-webhook.ts | 61 ++-------------------------------- app/bot/grammy-bot.ts | 26 ++++++++------- app/bot/handler.ts | 75 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 90 insertions(+), 72 deletions(-) diff --git a/app/api/bot-webhook.ts b/app/api/bot-webhook.ts index 7d1f11b..7c06021 100644 --- a/app/api/bot-webhook.ts +++ b/app/api/bot-webhook.ts @@ -2,13 +2,11 @@ * Telegram webhook handler for /api/bot (serverless). * * This file lives under api/ so Vercel bundles it together with the - * /api/bot function. It contains both the webhook logic and the minimal - * Grammy bot needed to reply "Hello" and upsert users. + * /api/bot function. Telegram update handling is delegated to the shared + * bot implementation in app/bot/grammy-bot.ts. */ -import { Bot, type Context } from 'grammy'; -import { normalizeUsername, upsertUserFromBot } from './_users.js'; -import { handleChat } from '../bot/handler.js'; +import { createBot } from '../bot/grammy-bot.js'; interface TelegramUpdate { update_id: number; @@ -53,59 +51,6 @@ async function getWebhookInfo(): Promise<{ url?: string }> { } } -function createBot(token: string): Bot { - const bot = new Bot(token); - - async function handleUserUpsert(ctx: Context): Promise { - try { - const from = ctx.from; - if (!from) return; - - const telegramUsername = normalizeUsername(from.username); - if (!telegramUsername) return; - - const locale = - typeof from.language_code === 'string' ? from.language_code : null; - - await upsertUserFromBot({ telegramUsername, locale }); - } catch (err) { - console.error('[bot] upsert user failed', err); - } - } - - bot.command('start', async (ctx: Context) => { - await handleUserUpsert(ctx); - await ctx.reply('Hello'); - }); - - bot.on('message:text', async (ctx: Context) => { - await handleUserUpsert(ctx); - - const text = ctx.message?.text; - if (!text) { - await ctx.reply('I could not read that message.'); - return; - } - - const result = await handleChat({ - messages: [{ role: "user", content: text }], - }); - - await ctx.reply(result.text); - }); - - bot.on('message', async (ctx: Context) => { - await handleUserUpsert(ctx); - await ctx.reply('Hello'); - }); - - bot.catch((err) => { - console.error('[bot]', err); - }); - - return bot; -} - export async function handleRequest(request: Request): Promise { const method = request.method; console.log('[webhook]', method, new Date().toISOString()); diff --git a/app/bot/grammy-bot.ts b/app/bot/grammy-bot.ts index 64df631..18c24c5 100644 --- a/app/bot/grammy-bot.ts +++ b/app/bot/grammy-bot.ts @@ -28,25 +28,27 @@ export function createBot(token: string): Bot { bot.command('start', async (ctx: Context) => { await handleUserUpsert(ctx); - await ctx.reply('Hello'); + await ctx.reply('Hi. I am ready. Send any text and I will answer.'); }); bot.on('message:text', async (ctx: Context) => { await handleUserUpsert(ctx); const text = ctx.message?.text; - if (!text) return; - - const result = await handleChat({ - messages: [{ role: "user", content: text }] - }); - - await ctx.reply(result.text); - }); + if (!text) { + await ctx.reply('I could not read that message.'); + return; + } - bot.on('message', async (ctx: Context) => { - await handleUserUpsert(ctx); - await ctx.reply('Hello'); + try { + const result = await handleChat({ + messages: [{ role: 'user', content: text }], + }); + await ctx.reply(result.text); + } catch (err) { + console.error('[bot] handleChat failed', err); + await ctx.reply('AI is temporarily unavailable. Please try again in a moment.'); + } }); bot.catch((err) => { diff --git a/app/bot/handler.ts b/app/bot/handler.ts index 0a7a3d8..39c00a9 100644 --- a/app/bot/handler.ts +++ b/app/bot/handler.ts @@ -17,14 +17,85 @@ function lastUserText(messages: ChatMessage[]): string { return [...messages].reverse().find((message) => message.role === 'user')?.content?.trim() || ''; } +function openAiApiKey(): string { + return ( + process.env.OPENAI_API_KEY || + process.env.OPENAI_KEY || + process.env.API_KEY || + '' + ).trim(); +} + +function toOpenAiMessages(messages: ChatMessage[]): Array<{ role: ChatRole; content: string }> { + return messages + .map((message) => ({ + role: message.role, + content: typeof message.content === 'string' ? message.content.trim() : '', + })) + .filter((message) => message.content.length > 0); +} + +async function completeWithOpenAi(messages: ChatMessage[]): Promise { + const apiKey = openAiApiKey(); + if (!apiKey) return null; + + const baseUrl = (process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1').replace(/\/$/, ''); + const model = (process.env.OPENAI_MODEL || 'gpt-4o-mini').trim(); + const timeoutMs = Number(process.env.OPENAI_TIMEOUT_MS || 20000); + const payloadMessages = toOpenAiMessages(messages); + if (payloadMessages.length === 0) return null; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(`${baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + messages: payloadMessages, + temperature: 0.3, + }), + signal: controller.signal, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`openai_http_${res.status}${body ? `: ${body}` : ''}`); + } + + const data = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = data.choices?.[0]?.message?.content?.trim(); + return content || null; + } finally { + clearTimeout(timeout); + } +} + export async function handleChat(input: HandleChatInput): Promise { const text = lastUserText(input.messages); if (!text) { return { text: 'I could not read that message.' }; } - // Temporary local handler to keep webhook flow stable. - return { text }; + try { + const aiText = await completeWithOpenAi(input.messages); + if (aiText && aiText.length > 0) { + return { text: aiText }; + } + } catch (err) { + console.error('[bot/handler] openai failed', err); + } + + return { + text: 'I am online, but AI is temporarily unavailable. Please try again in a moment.', + }; } export default handleChat; From d966b5badb663987b97089b42e3f58eee8ef5ffc Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 18:48:02 +0300 Subject: [PATCH 18/24] fix: use js extension for server users import --- app/bot/grammy-bot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/bot/grammy-bot.ts b/app/bot/grammy-bot.ts index 18c24c5..4e7bdb2 100644 --- a/app/bot/grammy-bot.ts +++ b/app/bot/grammy-bot.ts @@ -3,7 +3,7 @@ * Used by bot/webhook.ts (Vercel) and scripts/run-bot-local.ts (polling). */ import { Bot, type Context } from 'grammy'; -import { normalizeUsername, upsertUserFromBot } from '../server/users'; +import { normalizeUsername, upsertUserFromBot } from '../server/users.js'; import { handleChat } from './handler.js'; export function createBot(token: string): Bot { From b27a871ad615a54a0f04e65fd98798137e23cde3 Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 19:07:18 +0300 Subject: [PATCH 19/24] fix: harden app bot esm imports and make webhook setup non-fatal --- app/scripts/set-webhook.ts | 5 ++--- app/server/users.ts | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/scripts/set-webhook.ts b/app/scripts/set-webhook.ts index ec2f6e3..79e150c 100644 --- a/app/scripts/set-webhook.ts +++ b/app/scripts/set-webhook.ts @@ -46,12 +46,11 @@ async function setWebhook(): Promise { } console.error('[set-webhook] Telegram setWebhook failed:', data.description ?? data); - process.exit(1); + console.error('[set-webhook] Non-fatal: continuing build without failing deployment.'); } setWebhook() - .then(() => process.exit(0)) .catch((err: Error) => { console.error('[set-webhook] Error:', err.message); - process.exit(1); + console.error('[set-webhook] Non-fatal: continuing build without failing deployment.'); }); diff --git a/app/server/users.ts b/app/server/users.ts index b12f03c..6a8410e 100644 --- a/app/server/users.ts +++ b/app/server/users.ts @@ -1,4 +1,4 @@ -import { sql } from '../api/db'; +import { sql } from '../api/db.js'; export function normalizeUsername(raw: unknown): string { if (typeof raw !== 'string') return ''; @@ -42,4 +42,3 @@ export async function upsertUserFromBot(opts: { updated_at = NOW(); `; } - From 4a314e813e2f195fae6f34ab98de9c4bd63140d2 Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 19:19:47 +0300 Subject: [PATCH 20/24] fix: make db migrate step non-fatal during vercel build --- app/scripts/migrate-db.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/scripts/migrate-db.ts b/app/scripts/migrate-db.ts index c2526ac..4ff7e2e 100644 --- a/app/scripts/migrate-db.ts +++ b/app/scripts/migrate-db.ts @@ -1,16 +1,23 @@ -import { ensureSchema } from '../api/db'; - async function main() { + const databaseUrl = (process.env.DATABASE_URL || '').trim(); + if (!databaseUrl) { + console.log('[db] Skip migrations: DATABASE_URL is not set in build/runtime env.'); + return; + } + try { + const { ensureSchema } = await import('../api/db.js'); console.log('[db] Running schema migrations against DATABASE_URL...'); await ensureSchema(); console.log('[db] Schema is up to date.'); } catch (err) { console.error('[db] Migration failed', err); - process.exitCode = 1; + console.error('[db] Non-fatal: continuing without migration step.'); } } // eslint-disable-next-line @typescript-eslint/no-floating-promises -main(); - +main().catch((err) => { + console.error('[db] Migration script crashed', err); + console.error('[db] Non-fatal: continuing without migration step.'); +}); From 5126000e1c7c4e389b337c4110074aa62c4a64ba Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Fri, 6 Mar 2026 20:02:26 +0300 Subject: [PATCH 21/24] fix: restore robust chat fallback path for bot handler --- app/.env.example | 7 ++ app/bot/handler.ts | 206 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 201 insertions(+), 12 deletions(-) diff --git a/app/.env.example b/app/.env.example index 6b3ecb9..d16990d 100644 --- a/app/.env.example +++ b/app/.env.example @@ -4,6 +4,12 @@ # Local / Dev runtime values BOT_TOKEN= DATABASE_URL= +OPENAI_API_KEY= +# Optional if using a proxy/provider compatible with OpenAI chat/completions +# OPENAI_BASE_URL=https://api.openai.com/v1 +# OPENAI_MODEL=gpt-4o-mini +# Optional token-context provider +# COFFEE_KEY= # Optional: production domain so webhook is stable (e.g. https://hsbexpo.vercel.app). If unset, VERCEL_URL is used. # SELF_URL=https://your-app.vercel.app @@ -11,4 +17,5 @@ DATABASE_URL= # Production on Vercel should use the same key names: # BOT_TOKEN= # DATABASE_URL= +# OPENAI_API_KEY= # SELF_URL=https://your-app.vercel.app diff --git a/app/bot/handler.ts b/app/bot/handler.ts index 39c00a9..5d44fcb 100644 --- a/app/bot/handler.ts +++ b/app/bot/handler.ts @@ -7,23 +7,41 @@ export type ChatMessage = { export type HandleChatInput = { messages: ChatMessage[]; + tokenHint?: string; }; export type HandleChatOutput = { text: string; }; +type CoffeeTokenContext = { + symbol: string; + name?: string; + description?: string; + facts: string[]; + sourceUrls: string[]; +}; + +let loggedMissingOpenAiKey = false; + function lastUserText(messages: ChatMessage[]): string { - return [...messages].reverse().find((message) => message.role === 'user')?.content?.trim() || ''; + return [...messages] + .reverse() + .find((message) => message.role === 'user') + ?.content?.trim() || ''; +} + +function looksLikeOpenAiKey(value: string): boolean { + return /^sk-[A-Za-z0-9\-_]+$/.test(value.trim()); } function openAiApiKey(): string { - return ( - process.env.OPENAI_API_KEY || - process.env.OPENAI_KEY || - process.env.API_KEY || - '' - ).trim(); + const fromOpenAi = (process.env.OPENAI_API_KEY || process.env.OPENAI_KEY || '').trim(); + if (fromOpenAi) return fromOpenAi; + + const genericApiKey = (process.env.API_KEY || '').trim(); + if (looksLikeOpenAiKey(genericApiKey)) return genericApiKey; + return ''; } function toOpenAiMessages(messages: ChatMessage[]): Array<{ role: ChatRole; content: string }> { @@ -35,9 +53,149 @@ function toOpenAiMessages(messages: ChatMessage[]): Array<{ role: ChatRole; cont .filter((message) => message.content.length > 0); } +function normalizeSymbol(value: string): string { + return value.replace('$', '').trim().toUpperCase(); +} + +function extractTickerFromText(text: string): string | undefined { + const fromDollar = text.match(/\$([A-Za-z0-9]{2,15})\b/); + if (fromDollar?.[1]) return normalizeSymbol(fromDollar[1]); + + const fromUpper = text.match(/\b([A-Z0-9]{2,12})\b/); + if (fromUpper?.[1]) return normalizeSymbol(fromUpper[1]); + + return undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter(Boolean); +} + +function asObject(value: unknown): Record { + return typeof value === 'object' && value !== null + ? (value as Record) + : {}; +} + +function extractTokenPayload(payloadObj: Record): Record { + if (typeof payloadObj.token === 'object' && payloadObj.token !== null) { + return payloadObj.token as Record; + } + if (typeof payloadObj.data === 'object' && payloadObj.data !== null) { + return payloadObj.data as Record; + } + return payloadObj; +} + +async function fetchCoffeeContext(symbolInput: string): Promise { + const symbol = normalizeSymbol(symbolInput); + if (!symbol) return null; + + const baseUrl = (process.env.SWAP_COFFEE_BASE_URL || 'https://tokens.swap.coffee').replace(/\/$/, ''); + const coffeeKey = (process.env.COFFEE_KEY || '').trim(); + const timeoutMs = Number(process.env.COFFEE_TIMEOUT_MS || 6000); + const headers = coffeeKey ? ({ 'X-API-Key': coffeeKey } as Record) : undefined; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`${baseUrl}/tokens/${encodeURIComponent(symbol)}`, { + headers, + signal: controller.signal, + }); + if (!response.ok) return null; + + const payload = (await response.json()) as unknown; + const payloadObj = asObject(payload); + const token = extractTokenPayload(payloadObj); + + const normalizedSymbol = normalizeSymbol( + asString(token.symbol) || asString(payloadObj.symbol) || symbol, + ); + const name = asString(token.name) || asString(payloadObj.name); + const description = + asString(token.description) || + asString(payloadObj.description) || + asString(token.summary); + const facts = [ + ...asStringArray(token.facts), + ...asStringArray(payloadObj.facts), + ...asStringArray(payloadObj.context), + ]; + if (facts.length === 0) { + if (name && description) facts.push(`${name}: ${description}`); + else if (description) facts.push(description); + } + + const sourceUrls = [ + asString(token.source_url), + asString(payloadObj.source_url), + asString(token.url), + asString(payloadObj.url), + ].filter((value): value is string => Boolean(value)); + + return { + symbol: normalizedSymbol, + name, + description, + facts, + sourceUrls, + }; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +function withContextMessages( + messages: ChatMessage[], + facts: string[], + sourceUrls: string[], +): ChatMessage[] { + if (facts.length === 0) return messages; + + const contextLines = [ + 'Use this verified token context if relevant to the user question.', + ...facts, + ]; + if (sourceUrls.length > 0) contextLines.push(`Sources: ${sourceUrls.join(', ')}`); + + return [{ role: 'system', content: contextLines.join('\n') }, ...messages]; +} + +function buildTokenFallback( + symbol: string, + name?: string, + description?: string, +): string { + const normalized = symbol.replace('$', '').toUpperCase(); + const title = name?.trim() || `$${normalized}`; + if (description?.trim()) { + return `${title} (${normalized}) currently reads like a narrative-driven token.\n\n${description.trim()}\n\nIf useful, I can break this down into thesis, risk flags, and what to verify before entering.`; + } + return `${title} (${normalized}) looks like a speculative token where narrative and risk management matter most.\n\nI can provide a compact brief with thesis, catalysts, and risk checks.`; +} + async function completeWithOpenAi(messages: ChatMessage[]): Promise { const apiKey = openAiApiKey(); - if (!apiKey) return null; + if (!apiKey) { + if (!loggedMissingOpenAiKey) { + console.error( + '[bot/handler] missing OPENAI_API_KEY/OPENAI_KEY (or API_KEY with OpenAI format).', + ); + loggedMissingOpenAiKey = true; + } + return null; + } const baseUrl = (process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1').replace(/\/$/, ''); const model = (process.env.OPENAI_MODEL || 'gpt-4o-mini').trim(); @@ -64,7 +222,7 @@ async function completeWithOpenAi(messages: ChatMessage[]): Promise ''); + const body = (await res.text().catch(() => '')).slice(0, 320); throw new Error(`openai_http_${res.status}${body ? `: ${body}` : ''}`); } @@ -79,13 +237,27 @@ async function completeWithOpenAi(messages: ChatMessage[]): Promise { - const text = lastUserText(input.messages); - if (!text) { + const userText = lastUserText(input.messages); + if (!userText) { return { text: 'I could not read that message.' }; } + const ticker = + normalizeSymbol(input.tokenHint || '') || + extractTickerFromText(userText); + + let coffee: CoffeeTokenContext | null = null; + if (ticker) { + coffee = await fetchCoffeeContext(ticker); + } + try { - const aiText = await completeWithOpenAi(input.messages); + const messages = withContextMessages( + input.messages, + coffee?.facts || [], + coffee?.sourceUrls || [], + ); + const aiText = await completeWithOpenAi(messages); if (aiText && aiText.length > 0) { return { text: aiText }; } @@ -93,6 +265,16 @@ export async function handleChat(input: HandleChatInput): Promise Date: Fri, 6 Mar 2026 20:11:10 +0300 Subject: [PATCH 22/24] fix: improve bot fallback for quota errors and preserve chat context --- app/bot/grammy-bot.ts | 43 +++++++++++++++++++++++++++++++++++++++++-- app/bot/handler.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/app/bot/grammy-bot.ts b/app/bot/grammy-bot.ts index 4e7bdb2..6192c68 100644 --- a/app/bot/grammy-bot.ts +++ b/app/bot/grammy-bot.ts @@ -4,7 +4,38 @@ */ import { Bot, type Context } from 'grammy'; import { normalizeUsername, upsertUserFromBot } from '../server/users.js'; -import { handleChat } from './handler.js'; +import { handleChat, type ChatMessage } from './handler.js'; + +const chatHistory = new Map(); +const MAX_HISTORY_MESSAGES = 8; +const MAX_CHAT_ROWS = 500; + +function getChatKey(ctx: Context): string | null { + const id = ctx.chat?.id ?? ctx.from?.id; + if (typeof id === 'number' || typeof id === 'bigint') return String(id); + return null; +} + +function pruneHistoryIfNeeded(): void { + if (chatHistory.size < MAX_CHAT_ROWS) return; + let dropped = 0; + for (const key of chatHistory.keys()) { + chatHistory.delete(key); + dropped += 1; + if (dropped >= Math.ceil(MAX_CHAT_ROWS / 5)) break; + } +} + +function getHistory(chatKey: string | null): ChatMessage[] { + if (!chatKey) return []; + return chatHistory.get(chatKey) || []; +} + +function setHistory(chatKey: string | null, history: ChatMessage[]): void { + if (!chatKey) return; + pruneHistoryIfNeeded(); + chatHistory.set(chatKey, history.slice(-MAX_HISTORY_MESSAGES)); +} export function createBot(token: string): Bot { const bot = new Bot(token); @@ -28,6 +59,7 @@ export function createBot(token: string): Bot { bot.command('start', async (ctx: Context) => { await handleUserUpsert(ctx); + setHistory(getChatKey(ctx), []); await ctx.reply('Hi. I am ready. Send any text and I will answer.'); }); @@ -40,11 +72,18 @@ export function createBot(token: string): Bot { return; } + const chatKey = getChatKey(ctx); + const messages: ChatMessage[] = [ + ...getHistory(chatKey), + { role: 'user', content: text }, + ]; + try { const result = await handleChat({ - messages: [{ role: 'user', content: text }], + messages, }); await ctx.reply(result.text); + setHistory(chatKey, [...messages, { role: 'assistant', content: result.text }]); } catch (err) { console.error('[bot] handleChat failed', err); await ctx.reply('AI is temporarily unavailable. Please try again in a moment.'); diff --git a/app/bot/handler.ts b/app/bot/handler.ts index 5d44fcb..4b1e9a1 100644 --- a/app/bot/handler.ts +++ b/app/bot/handler.ts @@ -67,6 +67,15 @@ function extractTickerFromText(text: string): string | undefined { return undefined; } +function extractTickerFromMessages(messages: ChatMessage[]): string | undefined { + for (const message of [...messages].reverse()) { + if (typeof message.content !== 'string') continue; + const symbol = extractTickerFromText(message.content); + if (symbol) return symbol; + } + return undefined; +} + function asString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } @@ -242,9 +251,12 @@ export async function handleChat(input: HandleChatInput): Promise Date: Sun, 15 Mar 2026 05:32:20 +0300 Subject: [PATCH 23/24] Bot: replace static 'Thinking...' with rotating typing indicator and add regression test --- bot/bot.py | 140 +++++++++++++++++++++++------ bot/tests/test_typing_indicator.py | 78 ++++++++++++++++ 2 files changed, 193 insertions(+), 25 deletions(-) create mode 100644 bot/tests/test_typing_indicator.py diff --git a/bot/bot.py b/bot/bot.py index 1b2969c..5883437 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -51,6 +51,7 @@ _lang_switch_last_tap: dict[tuple[int, int], float] = {} _http_runner: web.AppRunner | None = None LANG_SWITCH_DEBOUNCE_SECONDS = 0.5 +DEFAULT_THINKING_TEXT = "Thinking..." def _mask_secret(value: str, visible: int = 4) -> str: @@ -108,6 +109,26 @@ def build_language_keyboard(message_id: int) -> InlineKeyboardMarkup: return InlineKeyboardMarkup(keyboard) +def build_typing_indicator_frames(base_text: str) -> list[str]: + """Convert a static thinking label into a simple rotating dot animation.""" + normalized = (base_text or "").strip() or DEFAULT_THINKING_TEXT + stem = normalized.rstrip() + stem_without_dots = stem.rstrip(".…").rstrip() + if stem_without_dots: + stem = stem_without_dots + return [f"{stem}.", f"{stem}..", f"{stem}..."] + + +def get_initial_typing_indicator_text(lang: str) -> str: + return build_typing_indicator_frames(THINKING_TEXT.get(lang, THINKING_TEXT["en"]))[0] + + +def truncate_telegram_text(text: str, max_length: int = 4096) -> str: + if len(text) > max_length: + return text[:max_length - 3] + "..." + return text + + def build_app_launch_url() -> str | None: """Build a valid Mini App URL with mode=fullscreen when APP_URL is configured.""" raw = (os.getenv("APP_URL") or "").strip() @@ -833,7 +854,14 @@ async def hello(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: ) -async def stream_ai_response(messages: list, bot, chat_id: int, message_id: int, telegram_id: int): +async def stream_ai_response( + messages: list, + bot, + chat_id: int, + message_id: int, + telegram_id: int, + thinking_text: str | None = None, +): """ Stream AI response and edit message as chunks arrive messages: List of message dicts with 'role' and 'content' (AI backend ChatMessage format) @@ -847,15 +875,64 @@ async def stream_ai_response(messages: list, bot, chat_id: int, message_id: int, accumulated_text = "" last_edit_time = asyncio.get_event_loop().time() edit_interval = float(os.getenv("EDIT_INTERVAL_SECONDS", "1")) - last_sent_text = "" # Track last sent text to avoid "message not modified" errors + typing_interval = float(os.getenv("THINKING_ANIMATION_INTERVAL_SECONDS", "0.35")) + typing_frames = build_typing_indicator_frames(thinking_text or DEFAULT_THINKING_TEXT) + last_sent_text = (thinking_text or "").strip() + first_response_sent = False current_message_id = message_id key = (chat_id, message_id) tracked_keys = {key} cancel_event = asyncio.Event() + typing_stop_event = asyncio.Event() _stream_cancel_events[key] = cancel_event current_task = asyncio.current_task() if current_task: _active_stream_tasks.setdefault(key, current_task) + message_edit_lock = asyncio.Lock() + typing_task: asyncio.Task | None = None + + async def stop_typing_indicator(): + nonlocal typing_task + if typing_stop_event.is_set(): + return + typing_stop_event.set() + if typing_task and not typing_task.done(): + typing_task.cancel() + try: + await typing_task + except asyncio.CancelledError: + pass + + async def animate_typing_indicator(): + nonlocal last_sent_text + if not typing_frames or typing_interval <= 0: + return + frame_index = 1 if last_sent_text == typing_frames[0] and len(typing_frames) > 1 else 0 + try: + while not cancel_event.is_set() and not typing_stop_event.is_set(): + await asyncio.sleep(typing_interval) + if cancel_event.is_set() or typing_stop_event.is_set(): + return + next_text = typing_frames[frame_index % len(typing_frames)] + frame_index += 1 + if next_text == last_sent_text: + continue + try: + async with message_edit_lock: + if cancel_event.is_set() or typing_stop_event.is_set(): + return + await bot.edit_message_text( + chat_id=chat_id, + message_id=current_message_id, + text=next_text, + reply_markup=build_language_keyboard(current_message_id), + ) + last_sent_text = next_text + except TelegramError as e: + if "not modified" not in str(e).lower(): + print(f"Warning: Could not animate typing indicator for message {current_message_id}: {e}") + except asyncio.CancelledError: + raise async def edit_or_fallback_send(text: str): nonlocal current_message_id, last_sent_text, tracked_keys @@ -864,15 +941,16 @@ async def edit_or_fallback_send(text: str): if cancel_event.is_set(): return try: - kwargs = { - "chat_id": chat_id, - "message_id": current_message_id, - "text": text, - "reply_markup": build_language_keyboard(current_message_id), - } - await bot.edit_message_text(**kwargs) - last_sent_text = text - return + async with message_edit_lock: + kwargs = { + "chat_id": chat_id, + "message_id": current_message_id, + "text": text, + "reply_markup": build_language_keyboard(current_message_id), + } + await bot.edit_message_text(**kwargs) + last_sent_text = text + return except TelegramError as e: if "not modified" in str(e).lower(): return @@ -898,6 +976,9 @@ async def edit_or_fallback_send(text: str): last_sent_text = text except TelegramError as e: print(f"Warning: Could not send fallback message: {e}") + + if typing_frames and typing_interval > 0: + typing_task = asyncio.create_task(animate_typing_indicator()) try: async with stream_chat(messages=messages, api_key=api_key, timeout_s=60.0) as (ai_backend_url, response): @@ -920,6 +1001,7 @@ async def edit_or_fallback_send(text: str): "[AI_BACKEND_ERROR] " f"ai_backend_url={ai_backend_url} key_source={key_source} key_preview={_mask_secret(api_key)}" ) + await stop_typing_indicator() await edit_or_fallback_send(f"AI backend error (status {status_code}). Please try again.") return @@ -934,6 +1016,7 @@ async def edit_or_fallback_send(text: str): log_timing("First AI chunk received", stream_start) if "error" in data: error_text = f"Error: {data['error']}" + await stop_typing_indicator() await edit_or_fallback_send(error_text) return @@ -942,17 +1025,20 @@ async def edit_or_fallback_send(text: str): accumulated_text += data["token"] elif "response" in data: accumulated_text = data["response"] + + display_text = truncate_telegram_text(accumulated_text) + if display_text and not first_response_sent: + await stop_typing_indicator() + await edit_or_fallback_send(display_text) + last_edit_time = asyncio.get_event_loop().time() + first_response_sent = True + typing_task = None # Edit message periodically to avoid rate limits. current_time = asyncio.get_event_loop().time() if current_time - last_edit_time >= edit_interval: if cancel_event.is_set(): return - max_response_length = 4096 - if len(accumulated_text) > max_response_length: - display_text = accumulated_text[:max_response_length - 3] + "..." - else: - display_text = accumulated_text if display_text and display_text != last_sent_text: await edit_or_fallback_send(display_text) last_edit_time = current_time @@ -963,17 +1049,14 @@ async def edit_or_fallback_send(text: str): continue # Final edit with complete response as-is from backend - max_response_length = 4096 - if len(accumulated_text) > max_response_length: - response_text = accumulated_text[:max_response_length - 3] + "..." - else: - response_text = accumulated_text + response_text = truncate_telegram_text(accumulated_text) if cancel_event.is_set(): return final_text = response_text if cancel_event.is_set(): return + await stop_typing_indicator() await edit_or_fallback_send(final_text) log_timing("Stream complete -> final edit sent", stream_start) @@ -986,20 +1069,24 @@ async def edit_or_fallback_send(text: str): await edit_or_fallback_send(no_response_text) except httpx.TimeoutException: error_text = "Sorry, the AI took too long to respond. Please try again." + await stop_typing_indicator() await edit_or_fallback_send(error_text) except httpx.RequestError as e: error_text = ( f"Sorry, I couldn't connect to the AI service at {ai_backend_url}. " f"Error: {str(e)}" ) + await stop_typing_indicator() await edit_or_fallback_send(error_text) except asyncio.CancelledError: print(f"Stream cancelled for message {message_id}") raise except Exception as e: error_text = f"Sorry, an error occurred: {str(e)}" + await stop_typing_indicator() await edit_or_fallback_send(error_text) finally: + await stop_typing_indicator() for tracked_key in list(tracked_keys): if _stream_cancel_events.get(tracked_key) is cancel_event: _stream_cancel_events.pop(tracked_key, None) @@ -1056,8 +1143,9 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> asyncio.create_task(save_message(telegram_id, "user", message_text)) # Send initial thinking message with immediate keyboard, then bind callback_data to the real message_id. + thinking_text = get_initial_typing_indicator_text(message_lang) sent_message = await update.message.reply_text( - THINKING_TEXT.get(message_lang, THINKING_TEXT["en"]), + thinking_text, reply_markup=build_language_keyboard(0), ) try: @@ -1078,7 +1166,8 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> context.bot, sent_message.chat_id, sent_message.message_id, - telegram_id + telegram_id, + thinking_text=thinking_text, )) _active_stream_tasks[(sent_message.chat_id, sent_message.message_id)] = stream_task log_timing("Stream task created", timing_checkpoint) @@ -1141,7 +1230,7 @@ async def handle_language_callback(update: Update, context: ContextTypes.DEFAULT async with lock: await cancel_stream(chat_id, target_message_id) - thinking_text = THINKING_TEXT.get(lang, THINKING_TEXT["en"]) + thinking_text = get_initial_typing_indicator_text(lang) active_message_id = target_message_id try: await context.bot.edit_message_text( @@ -1207,7 +1296,8 @@ async def handle_language_callback(update: Update, context: ContextTypes.DEFAULT context.bot, chat_id, active_message_id, - telegram_id + telegram_id, + thinking_text=thinking_text, )) _active_stream_tasks[(chat_id, active_message_id)] = stream_task finally: diff --git a/bot/tests/test_typing_indicator.py b/bot/tests/test_typing_indicator.py new file mode 100644 index 0000000..374c3c2 --- /dev/null +++ b/bot/tests/test_typing_indicator.py @@ -0,0 +1,78 @@ +import asyncio +import json + +import pytest + +pytest.importorskip("telegram") +pytest.importorskip("aiohttp") + +from bot import bot as bot_module + + +class FakeResponse: + def raise_for_status(self): + return None + + async def aiter_lines(self): + await asyncio.sleep(0.03) + yield json.dumps({"token": "Hi", "done": True}) + + +class FakeStreamContext: + async def __aenter__(self): + return "http://test", FakeResponse() + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class FakeBot: + def __init__(self): + self.edits: list[str] = [] + + async def edit_message_text(self, **kwargs): + self.edits.append(kwargs["text"]) + + async def send_message(self, **kwargs): + raise AssertionError("stream_ai_response should not need fallback send in this test") + + async def edit_message_reply_markup(self, **kwargs): + return None + + +async def _noop_save_message(_telegram_id: int, _role: str, _text: str): + return None + + +def test_build_typing_indicator_frames_rotates_dots(): + assert bot_module.build_typing_indicator_frames("Thinking...") == [ + "Thinking.", + "Thinking..", + "Thinking...", + ] + + +def test_stream_ai_response_animates_before_first_chunk(monkeypatch): + monkeypatch.setenv("INNER_CALLS_KEY", "test-key") + monkeypatch.setenv("THINKING_ANIMATION_INTERVAL_SECONDS", "0.01") + monkeypatch.setenv("EDIT_INTERVAL_SECONDS", "1") + monkeypatch.setattr(bot_module, "stream_chat", lambda **_kwargs: FakeStreamContext()) + monkeypatch.setattr(bot_module, "save_message", _noop_save_message) + + fake_bot = FakeBot() + + asyncio.run( + bot_module.stream_ai_response( + messages=[{"role": "user", "content": "hello"}], + bot=fake_bot, + chat_id=1, + message_id=10, + telegram_id=123, + thinking_text="Thinking.", + ) + ) + + assert "Hi" in fake_bot.edits + first_response_index = fake_bot.edits.index("Hi") + assert any(text in {"Thinking..", "Thinking..."} for text in fake_bot.edits[:first_response_index]) + assert fake_bot.edits[-1] == "Hi" From 8585ca800172c24aa9baaddc116d9218e5fd975b Mon Sep 17 00:00:00 2001 From: Dhereal1 Date: Sun, 15 Mar 2026 07:46:52 +0300 Subject: [PATCH 24/24] Rotate typing indicator instead of static ellipsis --- app/bot/responder.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/bot/responder.ts b/app/bot/responder.ts index 66d9dc2..2845c0b 100644 --- a/app/bot/responder.ts +++ b/app/bot/responder.ts @@ -329,6 +329,7 @@ export async function handleBotAiResponse(ctx: Context): Promise { }; const sendOrEdit = (accumulated: string): void => { + clearInterval(typingInterval); streamedAccumulated = accumulated; if (isCancelled()) return; const slice = accumulated.length > MAX_MESSAGE_TEXT_LENGTH @@ -372,7 +373,19 @@ export async function handleBotAiResponse(ctx: Context): Promise { interruptedReplyCallback = sendInterruptedReply; - await sendOrEditOnce("…", "…"); + // Start with rotating typing indicator instead of static "…" + const typingFrames = ["\\", "/", "-", "|"]; + let typingIndex = 0; + + await sendOrEditOnce(typingFrames[typingIndex], typingFrames[typingIndex]); + + const typingInterval = setInterval(() => { + if (sentMessageId === null) return; + typingIndex = (typingIndex + 1) % typingFrames.length; + ctx.api + .editMessageText(chatId, sentMessageId, typingFrames[typingIndex]) + .catch(() => {}); + }, 300); result = await transmitStream( { input: text, userId, context, mode, threadContext, instructions: TELEGRAM_BOT_LENGTH_INSTRUCTION }, sendOrEdit,