Skip to content

Commit d51d7d0

Browse files
committed
feat(app): token info and Vercel API structure\n\n- Move internal modules out of app/api so Vercel only deploys 5 route files\n (ai.ts, bot.ts, blockchain.ts, ping.ts, telegram.ts), avoiding 12-function limit\n- Add app/ai, app/blockchain, app/bot, app/shared, app/telegram, app/db;\n api routes import from ../ai, ../bot, etc. with .js extensions for Node\n- Bot: treat single-word ticker-like messages (e.g. DOGS, TON) as token_info;\n fall back to chat when token service is temporarily unavailable\n- Swap.Coffee: use tokens API with or without COFFEE key; fix verification\n params (multiple query keys instead of comma-separated) so token lookups work\n- Update scripts (migrate-db, run-bot-local) and Telegram.tsx to import from\n new app/ paths; add PULL_REQUEST.md
Made-with: Cursor
1 parent 26a50e0 commit d51d7d0

22 files changed

Lines changed: 994 additions & 34 deletions

PULL_REQUEST.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# refactor: Unify API structure, user helpers, Vercel config, and remove SELF_URL
2+
3+
## Summary
4+
5+
This PR groups four related changes: shared user helpers for bot and API, simplified Vercel config and TypeScript ping, unified bot/telegram structure with legacy cleanup, and removal of the `SELF_URL` env in favor of Vercel URL variables. Together they simplify the app layout, reduce duplication, and make deployment configuration lighter.
6+
7+
## Commits
8+
9+
1. **`16996a6` — refactor(app): unify user helpers for bot and api**
10+
Single shared `app/api/users.ts` with `normalizeUsername`, `upsertUserFromTma`, and `upsertUserFromBot` backed by `api/db.ts`. Bot webhook, telegram POST handler, and local grammy bot use this module; removed duplicated `app/api/_users.ts` and `app/server/users.ts` so local polling and Vercel serverless share the same user logic.
11+
12+
2. **`cb15afa` — chore(api): simplify vercel function config and move ping to TypeScript**
13+
Dropped explicit function overrides from `app/vercel.json` so API routes use Vercel defaults. Replaced `api/ping.js` with `api/ping.ts` that supports Request/Response and legacy `(req, res)` and returns `{ ok: true, ping: true }`.
14+
15+
3. **`3e303ac` — refactor(api): unify bot and telegram structure and remove legacy scripts**
16+
Split `api/bot` into a thin route plus `bot/webhook` and shared `bot/grammy` used by both the Vercel webhook and local polling. Moved Telegram POST logic into `api/telegram/post`, added `api/shared/users` and centralized DB access in `api/db`. Removed duplicate webhooks, old JS set-webhook and build-output scripts, and duplicated user helpers to simplify the API layout while keeping behavior the same.
17+
18+
4. **`26a50e0` — chore: remove SELF_URL env, use only Vercel URL vars**
19+
Dropped `SELF_URL` from webhook base URL logic. Base URL is now built from `VERCEL_PROJECT_PRODUCTION_URL` or `VERCEL_URL` only. Updated `webhook.ts`, `set-webhook.ts`, `.env.example`, README, and PR doc. Also removed obsolete files: `app/bot/grammy-bot.ts`, `app/bot/webhook.ts`, `app/security.md`, `app/security_plan.md`.
20+
21+
## Changes Made (by area)
22+
23+
### User helpers and DB (commit 1)
24+
- **`app/api/users.ts`**: New shared module with `normalizeUsername`, `upsertUserFromTma`, `upsertUserFromBot` using `api/db.ts`.
25+
- **Removed**: `app/api/_users.ts`, `app/server/users.ts`.
26+
- Bot webhook, telegram POST, and local grammy bot now import from `app/api/users.ts`.
27+
28+
### Vercel and ping (commit 2)
29+
- **`app/vercel.json`**: Removed explicit function overrides; rely on Vercel defaults.
30+
- **`app/api/ping.ts`**: New TypeScript handler (replaces `api/ping.js`); supports Request/Response and legacy (req, res); returns `{ ok: true, ping: true }`.
31+
32+
### Bot and telegram structure (commit 3)
33+
- **`app/api/bot`**: Thin route with shared `bot/webhook` and `bot/grammy`; used by Vercel webhook and local polling.
34+
- **`app/api/telegram/post`**: Heavy Telegram POST logic moved here; **`api/shared/users`** and **`api/db`** for centralized access.
35+
- **Removed**: Duplicate webhooks, old JS set-webhook and build-output scripts, duplicated user helpers.
36+
37+
### SELF_URL removal and cleanup (commit 4)
38+
- **`app/api/bot/webhook.ts`**: Base URL from `VERCEL_PROJECT_PRODUCTION_URL` or `VERCEL_URL` only; no `SELF_URL`; comments and `expected_url` fallback updated.
39+
- **`app/scripts/set-webhook.ts`**: Same Vercel-only base URL; comment and env log no longer mention SELF_URL.
40+
- **`app/.env.example`**: Removed `SELF_URL` entry.
41+
- **Docs**: `app/README.md` and `docs/PULL_REQUEST_Dereal1.md` updated for Vercel-only URL and `BOT_TOKEN`-only deploy.
42+
- **Removed**: `app/bot/grammy-bot.ts`, `app/bot/webhook.ts`, `app/security.md`, `app/security_plan.md`.
43+
44+
## Impact
45+
46+
- **Single user/DB path**: Bot and API use one user helper and DB layer.
47+
- **Simpler Vercel config**: No custom function overrides; ping in TypeScript.
48+
- **Clearer API layout**: Bot vs telegram vs shared code is separated; legacy scripts removed.
49+
- **Simpler deployment**: No `SELF_URL`; webhook URL from `VERCEL_PROJECT_PRODUCTION_URL` or `VERCEL_URL` only.

app/.env.example

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
1-
# Copy to .env and fill in. Used by bot (npm run bot:local) and optionally by Expo.
2-
# Do not commit .env.
3-
4-
# Telegram bot (required for local bot; optional for Expo app)
5-
# Get token from @BotFather on Telegram
6-
BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz-EXAMPLE
7-
# Neon/Postgres connection string (from Neon dashboard)
8-
DATABASE_URL=postgresql://user:password@ep-xxx-xxx.region.aws.neon.tech/neondb?sslmode=require
1+
# Copy to .env and fill in. Used by bot (npm run bot:local) and optionally by Expo.
2+
# Do not commit .env.
3+
4+
# Telegram bot (required for local bot; optional for Expo app)
5+
# Get token from @BotFather on Telegram
6+
BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz-EXAMPLE
7+
# Neon/Postgres connection string (from Neon dashboard)
8+
DATABASE_URL=postgresql://user:password@ep-xxx-xxx.region.aws.neon.tech/neondb?sslmode=require
9+
# OpenAI API key for /api/ai (required for AI responses). Get from https://platform.openai.com/api-keys
10+
OPENAI=sk-proj-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
11+
12+
# Optional: base URL for calling APIs in dev (used by app and bot).
13+
# Example for local Vercel dev: http://localhost:3000
14+
# EXPO_PUBLIC_API_BASE_URL=http://localhost:3000
15+
16+
# Optional: Swap.Coffee API key for TON token info and routing.
17+
# COFFEE=your-swap-coffee-api-key
18+
# COFFEE_BASE_URL=https://api.swap.coffee
19+
# COFFEE_TOKENS_BASE_URL=https://tokens.swap.coffee

app/ai/openai.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import OpenAI from "openai";
2+
3+
export type AiMode = "chat" | "token_info";
4+
5+
export type AiRequestBase = {
6+
input: string;
7+
userId?: string;
8+
context?: Record<string, unknown>;
9+
};
10+
11+
export type AiResponseBase = {
12+
ok: boolean;
13+
provider: "openai";
14+
output_text?: string;
15+
error?: string;
16+
mode: AiMode;
17+
usage?: {
18+
prompt_tokens?: number;
19+
completion_tokens?: number;
20+
total_tokens?: number;
21+
};
22+
meta?: Record<string, unknown>;
23+
};
24+
25+
const OPENAI = process.env.OPENAI?.trim() || "";
26+
27+
const client = OPENAI ? new OpenAI({ apiKey: OPENAI }) : null;
28+
29+
export async function callOpenAiChat(
30+
mode: AiMode,
31+
params: AiRequestBase,
32+
): Promise<AiResponseBase> {
33+
if (!client) {
34+
return {
35+
ok: false,
36+
provider: "openai",
37+
mode,
38+
error: "OPENAI env is not configured on the server.",
39+
};
40+
}
41+
42+
const trimmed = params.input?.trim();
43+
if (!trimmed) {
44+
return {
45+
ok: false,
46+
provider: "openai",
47+
mode,
48+
error: "input is required.",
49+
};
50+
}
51+
52+
const prefix =
53+
mode === "token_info"
54+
? "You are a blockchain and token analyst. Answer clearly and briefly.\n\n"
55+
: "";
56+
57+
try {
58+
const response = await client.responses.create({
59+
model: "gpt-5.2",
60+
input: `${prefix}${trimmed}`,
61+
});
62+
63+
return {
64+
ok: true,
65+
provider: "openai",
66+
mode,
67+
output_text: (response as any).output_text ?? undefined,
68+
usage: (response as any).usage ?? undefined,
69+
};
70+
} catch (e: any) {
71+
const message =
72+
e?.message ?? "Failed to call OpenAI. Check OPENAI env and network.";
73+
return {
74+
ok: false,
75+
provider: "openai",
76+
mode,
77+
error: message,
78+
};
79+
}
80+
}

app/ai/transmitter.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import type { AiMode, AiRequestBase, AiResponseBase } from "./openai.js";
2+
import { callOpenAiChat } from "./openai.js";
3+
import {
4+
getTokenBySymbol,
5+
normalizeSymbol,
6+
type TokenSearchResult,
7+
} from "../blockchain/coffee.js";
8+
9+
export type AiRequest = AiRequestBase & {
10+
mode?: AiMode;
11+
};
12+
13+
export type AiResponse = AiResponseBase;
14+
15+
function extractSymbolCandidate(input: string): string | null {
16+
const raw = input.trim();
17+
if (!raw) return null;
18+
19+
// Simple patterns like "USDT", "$USDT", "USDT on TON".
20+
const parts = raw.split(/\s+/);
21+
const first = parts[0]?.replace(/^\$/g, "") ?? "";
22+
const normalized = normalizeSymbol(first);
23+
return normalized || null;
24+
}
25+
26+
function buildTokenFactsBlock(symbol: string, token: any): string {
27+
const lines: string[] = [];
28+
29+
const sym = token?.symbol ?? symbol;
30+
const name = token?.name ?? null;
31+
const address = token?.id ?? token?.address ?? null;
32+
const type = token?.type ?? "token";
33+
const decimals = token?.decimals ?? token?.metadata?.decimals ?? null;
34+
const verification =
35+
token?.verification ?? token?.metadata?.verification ?? null;
36+
37+
const market = token?.market_stats ?? {};
38+
const holders =
39+
market?.holders_count ?? token?.holders ?? market?.holders ?? null;
40+
const priceUsd = market?.price_usd ?? null;
41+
const mcap = market?.mcap ?? market?.fdmc ?? null;
42+
const volume24h = market?.volume_usd_24h ?? null;
43+
44+
lines.push(`Symbol: ${sym}`);
45+
if (name) {
46+
lines.push(`Name: ${name}`);
47+
}
48+
lines.push(`Type: ${type}`);
49+
lines.push(`Blockchain: TON`);
50+
if (address) {
51+
lines.push(`Address: ${address}`);
52+
}
53+
if (decimals != null) {
54+
lines.push(`Decimals: ${decimals}`);
55+
}
56+
if (verification) {
57+
lines.push(`Verification: ${verification}`);
58+
}
59+
if (holders != null) {
60+
lines.push(`Holders: ${holders}`);
61+
}
62+
if (priceUsd != null) {
63+
lines.push(`Price (USD): ${priceUsd}`);
64+
}
65+
if (mcap != null) {
66+
lines.push(`Market cap (USD): ${mcap}`);
67+
}
68+
if (volume24h != null) {
69+
lines.push(`24h volume (USD): ${volume24h}`);
70+
}
71+
72+
return lines.join("\n");
73+
}
74+
75+
async function handleTokenInfo(
76+
request: AiRequest,
77+
): Promise<AiResponse> {
78+
const trimmed = request.input?.trim() ?? "";
79+
const symbolCandidate = extractSymbolCandidate(trimmed);
80+
81+
if (!symbolCandidate) {
82+
return {
83+
ok: false,
84+
provider: "openai",
85+
mode: "token_info",
86+
error: "Could not detect a token symbol. Try sending something like USDT.",
87+
};
88+
}
89+
90+
const tokenResult: TokenSearchResult = await getTokenBySymbol(
91+
symbolCandidate,
92+
);
93+
94+
if (!tokenResult.ok) {
95+
return {
96+
ok: false,
97+
provider: "openai",
98+
mode: "token_info",
99+
error:
100+
tokenResult.error === "not_found"
101+
? `Token ${symbolCandidate} was not found on TON.`
102+
: "Token service is temporarily unavailable.",
103+
meta: {
104+
symbol: symbolCandidate,
105+
reason: tokenResult.reason,
106+
status_code: tokenResult.status_code,
107+
},
108+
};
109+
}
110+
111+
const token = tokenResult.data;
112+
const facts = buildTokenFactsBlock(symbolCandidate, token);
113+
114+
const promptParts = [
115+
"You are a concise TON token analyst.",
116+
"",
117+
"Facts about the token:",
118+
facts,
119+
"",
120+
"User question or context:",
121+
trimmed,
122+
];
123+
124+
const composedInput = promptParts.join("\n");
125+
126+
const result = await callOpenAiChat("token_info", {
127+
input: composedInput,
128+
userId: request.userId,
129+
context: {
130+
...request.context,
131+
symbol: symbolCandidate,
132+
token,
133+
source: "swap.coffee",
134+
},
135+
});
136+
137+
return {
138+
...result,
139+
mode: "token_info",
140+
meta: {
141+
...(result.meta ?? {}),
142+
symbol: symbolCandidate,
143+
token,
144+
},
145+
};
146+
}
147+
148+
export async function transmit(request: AiRequest): Promise<AiResponse> {
149+
const mode: AiMode = request.mode ?? "chat";
150+
151+
if (mode === "token_info") {
152+
return handleTokenInfo(request);
153+
}
154+
155+
return callOpenAiChat(mode, {
156+
input: request.input,
157+
userId: request.userId,
158+
context: request.context,
159+
});
160+
}

0 commit comments

Comments
 (0)