@www.hyperlinks.space/program-kit 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/api/ai.ts +111 -0
- package/api/base.ts +117 -0
- package/api/blockchain.ts +58 -0
- package/api/bot.ts +19 -0
- package/api/ping.ts +41 -0
- package/api/releases.ts +162 -0
- package/api/telegram.ts +65 -0
- package/api/tsconfig.json +17 -0
- package/app/_layout.tsx +135 -0
- package/app/ai.tsx +39 -0
- package/app/components/GlobalBottomBar.tsx +447 -0
- package/app/components/GlobalBottomBarWeb.tsx +362 -0
- package/app/components/GlobalLogoBar.tsx +108 -0
- package/app/components/GlobalLogoBarFallback.tsx +66 -0
- package/app/components/GlobalLogoBarWithFallback.tsx +24 -0
- package/app/components/HyperlinksSpaceLogo.tsx +29 -0
- package/app/components/Telegram.tsx +648 -0
- package/app/components/telegramWebApp.ts +359 -0
- package/app/fonts.ts +12 -0
- package/app/index.tsx +102 -0
- package/app/theme.ts +117 -0
- package/app.json +60 -0
- package/assets/icon.ico +0 -0
- package/assets/images/favicon.png +0 -0
- package/blockchain/coffee.ts +217 -0
- package/blockchain/router.ts +44 -0
- package/bot/format.ts +143 -0
- package/bot/grammy.ts +52 -0
- package/bot/responder.ts +620 -0
- package/bot/webhook.ts +262 -0
- package/database/messages.ts +128 -0
- package/database/start.ts +133 -0
- package/database/users.ts +46 -0
- package/docs/ai_and_search_bar_input.md +94 -0
- package/docs/ai_bot_messages.md +124 -0
- package/docs/backlogs/medium_term_backlog.md +26 -0
- package/docs/backlogs/short_term_backlog.md +39 -0
- package/docs/blue_bar_tackling.md +143 -0
- package/docs/bot_async_streaming.md +174 -0
- package/docs/build_and_install.md +129 -0
- package/docs/database_messages.md +34 -0
- package/docs/fonts.md +18 -0
- package/docs/releases.md +201 -0
- package/docs/releases_github_actions.md +188 -0
- package/docs/scalability.md +34 -0
- package/docs/security_plan_raw.md +244 -0
- package/docs/security_raw.md +345 -0
- package/docs/timing_raw.md +63 -0
- package/docs/tma_logo_bar_jump_investigation.md +69 -0
- package/docs/update.md +205 -0
- package/docs/wallets_hosting_architecture.md +257 -0
- package/eas.json +47 -0
- package/eslint.config.js +10 -0
- package/fullREADME.md +159 -0
- package/global.css +67 -0
- package/npmReadMe.md +53 -0
- package/package.json +214 -0
- package/scripts/load-env.ts +17 -0
- package/scripts/migrate-db.ts +16 -0
- package/scripts/program-kit-init.cjs +58 -0
- package/scripts/run-bot-local.ts +30 -0
- package/scripts/set-webhook.ts +67 -0
- package/scripts/test-api-base.ts +12 -0
- package/telegram/post.ts +328 -0
- package/tsconfig.json +17 -0
- package/vercel.json +7 -0
- package/windows/after-sign-windows-icon.cjs +13 -0
- package/windows/build-layout.cjs +72 -0
- package/windows/build-with-progress.cjs +88 -0
- package/windows/build.cjs +2247 -0
- package/windows/cleanup-legacy-appdata-installs.ps1 +91 -0
- package/windows/cleanup-legacy-windows-shortcuts.ps1 +46 -0
- package/windows/cleanup.cjs +200 -0
- package/windows/embed-windows-exe-icon.cjs +55 -0
- package/windows/extractAppPackage.nsh +150 -0
- package/windows/forge/README.md +41 -0
- package/windows/forge/forge.config.js +138 -0
- package/windows/forge/make-with-stamp.cjs +65 -0
- package/windows/forge-cleanup.cjs +255 -0
- package/windows/hsp-app-process.ps1 +63 -0
- package/windows/installer-hooks.nsi +373 -0
- package/windows/product-brand.cjs +42 -0
- package/windows/remove-orphan-uninstall-registry.ps1 +67 -0
- package/windows/run-installed-with-icon-debug.cmd +20 -0
- package/windows/run-win-electron-builder.cjs +46 -0
- package/windows/updater-dialog.html +143 -0
package/bot/webhook.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram webhook handler for /api/bot (serverless).
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* - User sends a message in Telegram → Telegram POSTs that update here.
|
|
6
|
+
* - We return 200 OK immediately so Telegram does not retry or hide the user's message.
|
|
7
|
+
* - We process the update in the background (waitUntil).
|
|
8
|
+
*
|
|
9
|
+
* Per-chat serialization: we process one update per chat at a time. When a new update arrives
|
|
10
|
+
* for a chat, we wait for the previous handler for that chat to finish, then run the new one.
|
|
11
|
+
* So Reply A is always sent before we start processing Prompt B — no reorder flash.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { waitUntil } from '@vercel/functions';
|
|
15
|
+
import { createBot } from './grammy.js';
|
|
16
|
+
|
|
17
|
+
interface TelegramUpdate {
|
|
18
|
+
update_id: number;
|
|
19
|
+
message?: { chat?: { id?: number }; [key: string]: unknown };
|
|
20
|
+
edited_message?: { chat?: { id?: number }; [key: string]: unknown };
|
|
21
|
+
channel_post?: { chat?: { id?: number }; [key: string]: unknown };
|
|
22
|
+
callback_query?: { message?: { chat?: { id?: number } }; [key: string]: unknown };
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Extract chat id from update so we can serialize processing per chat and avoid reply order flash. */
|
|
27
|
+
function getChatIdFromUpdate(update: TelegramUpdate): number | undefined {
|
|
28
|
+
const msg = update.message ?? update.edited_message ?? update.channel_post;
|
|
29
|
+
if (msg && typeof msg === 'object' && msg.chat && typeof (msg.chat as { id?: number }).id === 'number') {
|
|
30
|
+
return (msg.chat as { id: number }).id;
|
|
31
|
+
}
|
|
32
|
+
const cq = update.callback_query;
|
|
33
|
+
if (cq && typeof cq === 'object' && cq.message && typeof (cq.message as { chat?: { id?: number } }).chat?.id === 'number') {
|
|
34
|
+
return (cq.message as { chat: { id: number } }).chat.id;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Per-chat tail promise: next update for this chat waits for the previous handler to finish. */
|
|
40
|
+
const chatQueue = new Map<number, Promise<void>>();
|
|
41
|
+
|
|
42
|
+
const BOT_TOKEN = process.env.BOT_TOKEN || process.env.TELEGRAM_BOT_TOKEN;
|
|
43
|
+
/** Single bot instance for all webhook requests; created once at module load. */
|
|
44
|
+
const bot = BOT_TOKEN ? createBot(BOT_TOKEN) : null;
|
|
45
|
+
/** Lazy init: run bot.init() once on first use; later requests reuse the same promise. */
|
|
46
|
+
let botInitPromise: Promise<void> | null = null;
|
|
47
|
+
function ensureBotInit(): Promise<void> {
|
|
48
|
+
if (!bot) return Promise.resolve();
|
|
49
|
+
if (!botInitPromise) botInitPromise = bot.init();
|
|
50
|
+
return botInitPromise;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Vercel production alias (VERCEL_PROJECT_PRODUCTION_URL) or deployment URL (VERCEL_URL).
|
|
54
|
+
const BASE_URL =
|
|
55
|
+
process.env.VERCEL_PROJECT_PRODUCTION_URL
|
|
56
|
+
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
|
|
57
|
+
: process.env.VERCEL_URL
|
|
58
|
+
? `https://${process.env.VERCEL_URL}`
|
|
59
|
+
: '';
|
|
60
|
+
|
|
61
|
+
function jsonResponse(data: object, status = 200): Response {
|
|
62
|
+
return new Response(JSON.stringify(data), {
|
|
63
|
+
status,
|
|
64
|
+
headers: {
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
'Access-Control-Allow-Origin': '*',
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function setWebhook(): Promise<{ ok?: boolean } | null> {
|
|
72
|
+
if (!BOT_TOKEN || !BASE_URL) return null;
|
|
73
|
+
const url = `${BASE_URL}/api/bot`;
|
|
74
|
+
const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/setWebhook`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ url }),
|
|
78
|
+
});
|
|
79
|
+
const data = (await res.json()) as { ok?: boolean; description?: string };
|
|
80
|
+
return data;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function getWebhookInfo(): Promise<{ url?: string }> {
|
|
84
|
+
if (!BOT_TOKEN) return {};
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/getWebhookInfo`);
|
|
87
|
+
const data = (await res.json()) as { ok?: boolean; result?: { url?: string } };
|
|
88
|
+
return data?.result ?? {};
|
|
89
|
+
} catch {
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function handleRequest(request: Request): Promise<Response> {
|
|
95
|
+
const method = request.method;
|
|
96
|
+
console.log('[webhook]', method, new Date().toISOString());
|
|
97
|
+
|
|
98
|
+
if (method === 'OPTIONS') return jsonResponse({}, 200);
|
|
99
|
+
|
|
100
|
+
if (method === 'GET') {
|
|
101
|
+
const expectedUrl = BASE_URL ? `${BASE_URL}/api/bot` : '';
|
|
102
|
+
const current = await getWebhookInfo();
|
|
103
|
+
if (BASE_URL && BOT_TOKEN) {
|
|
104
|
+
const result = await setWebhook();
|
|
105
|
+
return jsonResponse({
|
|
106
|
+
ok: true,
|
|
107
|
+
webhook_set: result?.ok === true,
|
|
108
|
+
url: expectedUrl,
|
|
109
|
+
telegram_has: current.url || '(none)',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return jsonResponse({
|
|
113
|
+
ok: true,
|
|
114
|
+
service: 'telegram-bot',
|
|
115
|
+
bot: !!BOT_TOKEN,
|
|
116
|
+
vercel_url_set: !!BASE_URL,
|
|
117
|
+
expected_url: expectedUrl || '(set VERCEL_URL / VERCEL_PROJECT_PRODUCTION_URL)',
|
|
118
|
+
telegram_has: current.url || '(none)',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (method !== 'POST') {
|
|
123
|
+
return jsonResponse({ ok: false, error: 'method_not_allowed' }, 405);
|
|
124
|
+
}
|
|
125
|
+
if (!BOT_TOKEN || !bot) {
|
|
126
|
+
return jsonResponse({ ok: false, error: 'BOT_TOKEN not set' }, 500);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Read body before returning 200. Once we return, the request may be closed and body
|
|
130
|
+
// unavailable, so reading it inside waitUntil can fail and the message is lost.
|
|
131
|
+
let update: TelegramUpdate;
|
|
132
|
+
try {
|
|
133
|
+
const body =
|
|
134
|
+
typeof request.json === 'function'
|
|
135
|
+
? await request.json()
|
|
136
|
+
: (request as unknown as { body?: unknown }).body;
|
|
137
|
+
update =
|
|
138
|
+
typeof body === 'string'
|
|
139
|
+
? (JSON.parse(body) as TelegramUpdate)
|
|
140
|
+
: (body as TelegramUpdate);
|
|
141
|
+
} catch {
|
|
142
|
+
console.error('[webhook] invalid_body');
|
|
143
|
+
return jsonResponse({ ok: false, error: 'invalid_body' }, 400);
|
|
144
|
+
}
|
|
145
|
+
if (!update || typeof update !== 'object') {
|
|
146
|
+
console.error('[webhook] invalid update');
|
|
147
|
+
return jsonResponse({ ok: false, error: 'invalid_update' }, 400);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Return 200 OK immediately so Telegram applies the message to the chat. Process update
|
|
151
|
+
// in waitUntil so we don't block the response on AI/DB.
|
|
152
|
+
// Serialize per chat so Reply A is always sent before we start processing Prompt B.
|
|
153
|
+
const updateId = update.update_id;
|
|
154
|
+
const chatId = getChatIdFromUpdate(update);
|
|
155
|
+
const prev = chatId !== undefined ? chatQueue.get(chatId) : undefined;
|
|
156
|
+
const work = (prev ?? Promise.resolve())
|
|
157
|
+
.then(() => ensureBotInit())
|
|
158
|
+
.then(() => bot!.handleUpdate(update as Parameters<typeof bot.handleUpdate>[0]))
|
|
159
|
+
.then(() => {
|
|
160
|
+
console.log('[webhook] handled update', updateId);
|
|
161
|
+
})
|
|
162
|
+
.catch((err) => {
|
|
163
|
+
console.error('[bot]', err);
|
|
164
|
+
});
|
|
165
|
+
const tail = work.then(() => {}, () => {});
|
|
166
|
+
if (chatId !== undefined) {
|
|
167
|
+
chatQueue.set(chatId, tail);
|
|
168
|
+
}
|
|
169
|
+
waitUntil(work);
|
|
170
|
+
return jsonResponse({ ok: true });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Legacy (req, res) handler; also used when Vercel passes (request, context). */
|
|
174
|
+
export interface NodeRes {
|
|
175
|
+
setHeader(name: string, value: string): void;
|
|
176
|
+
status(code: number): { json(data: unknown): void; end(): void };
|
|
177
|
+
end(): void;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface NodeReq {
|
|
181
|
+
method: string;
|
|
182
|
+
body?: unknown;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function legacyHandler(req: NodeReq, res: NodeRes): Promise<void> {
|
|
186
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
187
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
188
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
189
|
+
if (req.method === 'OPTIONS') {
|
|
190
|
+
res.status(200).end();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (req.method === 'GET') {
|
|
194
|
+
const expectedUrl = BASE_URL ? `${BASE_URL}/api/bot` : '';
|
|
195
|
+
const current = await getWebhookInfo();
|
|
196
|
+
if (BASE_URL && BOT_TOKEN) {
|
|
197
|
+
const result = await setWebhook();
|
|
198
|
+
res.status(200).json({
|
|
199
|
+
ok: true,
|
|
200
|
+
webhook_set: result?.ok === true,
|
|
201
|
+
url: expectedUrl,
|
|
202
|
+
telegram_has: current.url || '(none)',
|
|
203
|
+
});
|
|
204
|
+
} else {
|
|
205
|
+
res.status(200).json({
|
|
206
|
+
ok: true,
|
|
207
|
+
service: 'telegram-bot',
|
|
208
|
+
bot: !!BOT_TOKEN,
|
|
209
|
+
vercel_url_set: !!BASE_URL,
|
|
210
|
+
expected_url: expectedUrl || '(set VERCEL_URL / VERCEL_PROJECT_PRODUCTION_URL)',
|
|
211
|
+
telegram_has: current.url || '(none)',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (req.method !== 'POST') {
|
|
217
|
+
res.status(405).json({ ok: false, error: 'method_not_allowed' });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (!BOT_TOKEN || !bot) {
|
|
221
|
+
res.status(500).json({ ok: false, error: 'BOT_TOKEN not set' });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
let update: TelegramUpdate = req.body as TelegramUpdate;
|
|
225
|
+
if (typeof update === 'string') {
|
|
226
|
+
try {
|
|
227
|
+
update = JSON.parse(update) as TelegramUpdate;
|
|
228
|
+
} catch {
|
|
229
|
+
res.status(400).json({ ok: false, error: 'invalid_body' });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!update || typeof update !== 'object') {
|
|
234
|
+
res.status(400).json({ ok: false, error: 'invalid_body' });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
await ensureBotInit();
|
|
239
|
+
const chatIdLegacy = getChatIdFromUpdate(update);
|
|
240
|
+
const prevLegacy = chatIdLegacy !== undefined ? chatQueue.get(chatIdLegacy) : undefined;
|
|
241
|
+
await (prevLegacy ?? Promise.resolve());
|
|
242
|
+
await bot.handleUpdate(update as Parameters<typeof bot.handleUpdate>[0]);
|
|
243
|
+
if (chatIdLegacy !== undefined) {
|
|
244
|
+
chatQueue.set(chatIdLegacy, Promise.resolve());
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error('[bot]', err);
|
|
248
|
+
res.status(500).json({ ok: false, error: 'handler_error' });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
res.status(200).json({ ok: true });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export default async function handler(
|
|
255
|
+
request: Request | NodeReq,
|
|
256
|
+
context?: NodeRes,
|
|
257
|
+
): Promise<Response | void> {
|
|
258
|
+
if (request && typeof (request as Request).json === 'function') {
|
|
259
|
+
return handleRequest(request as Request);
|
|
260
|
+
}
|
|
261
|
+
return legacyHandler(request as NodeReq, context!);
|
|
262
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message helpers for the AI messages table (bot + TMA).
|
|
3
|
+
* Shared by API routes and bot. Import from ../database/messages.js.
|
|
4
|
+
*/
|
|
5
|
+
import { sql } from './start.js';
|
|
6
|
+
|
|
7
|
+
export type MessageType = 'bot' | 'app';
|
|
8
|
+
export type MessageRole = 'user' | 'assistant' | 'system';
|
|
9
|
+
|
|
10
|
+
export interface Message {
|
|
11
|
+
id: number;
|
|
12
|
+
created_at: Date;
|
|
13
|
+
user_telegram: string;
|
|
14
|
+
thread_id: number;
|
|
15
|
+
type: MessageType;
|
|
16
|
+
role: MessageRole;
|
|
17
|
+
content: string | null;
|
|
18
|
+
telegram_update_id: number | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface InsertMessageOpts {
|
|
22
|
+
user_telegram: string;
|
|
23
|
+
thread_id: number;
|
|
24
|
+
type: MessageType;
|
|
25
|
+
role: MessageRole;
|
|
26
|
+
content: string | null;
|
|
27
|
+
telegram_update_id?: number | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Insert a message. For bot user messages with telegram_update_id, the unique
|
|
32
|
+
* constraint may conflict (duplicate webhook or another instance). Returns the
|
|
33
|
+
* new row id, or null if insert was skipped due to unique violation.
|
|
34
|
+
*/
|
|
35
|
+
export async function insertMessage(
|
|
36
|
+
opts: InsertMessageOpts,
|
|
37
|
+
): Promise<{ id: number } | null> {
|
|
38
|
+
const {
|
|
39
|
+
user_telegram,
|
|
40
|
+
thread_id,
|
|
41
|
+
type,
|
|
42
|
+
role,
|
|
43
|
+
content,
|
|
44
|
+
telegram_update_id = null,
|
|
45
|
+
} = opts;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const rows = await sql`
|
|
49
|
+
INSERT INTO messages (user_telegram, thread_id, type, role, content, telegram_update_id)
|
|
50
|
+
VALUES (${user_telegram}, ${thread_id}, ${type}, ${role}, ${content}, ${telegram_update_id})
|
|
51
|
+
RETURNING id;
|
|
52
|
+
`;
|
|
53
|
+
const row = rows[0] as { id: string } | undefined;
|
|
54
|
+
if (!row) return null;
|
|
55
|
+
return { id: Number(row.id) };
|
|
56
|
+
} catch (err: unknown) {
|
|
57
|
+
const code = err && typeof err === 'object' && 'code' in err ? (err as { code: string }).code : '';
|
|
58
|
+
if (code === '23505') return null; // unique_violation (bot dedupe)
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Messages for a thread, ordered by created_at ascending (oldest first, for AI history).
|
|
65
|
+
*/
|
|
66
|
+
export async function getThreadHistory(
|
|
67
|
+
opts: {
|
|
68
|
+
user_telegram: string;
|
|
69
|
+
thread_id: number;
|
|
70
|
+
type: MessageType;
|
|
71
|
+
limit?: number;
|
|
72
|
+
},
|
|
73
|
+
): Promise<Message[]> {
|
|
74
|
+
const { user_telegram, thread_id, type, limit = 100 } = opts;
|
|
75
|
+
const rows = await sql`
|
|
76
|
+
SELECT id, created_at, user_telegram, thread_id, type, role, content, telegram_update_id
|
|
77
|
+
FROM messages
|
|
78
|
+
WHERE user_telegram = ${user_telegram} AND thread_id = ${thread_id} AND type = ${type}
|
|
79
|
+
ORDER BY created_at ASC
|
|
80
|
+
LIMIT ${limit};
|
|
81
|
+
`;
|
|
82
|
+
return (rows as RawMessageRow[]).map(rowToMessage);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Max telegram_update_id for user messages in the thread (bot only). Used to check
|
|
87
|
+
* "is the latest user message still the one I inserted?" before sending a reply.
|
|
88
|
+
* Returns null if no user messages with telegram_update_id in the thread.
|
|
89
|
+
*/
|
|
90
|
+
export async function getMaxTelegramUpdateIdForThread(
|
|
91
|
+
user_telegram: string,
|
|
92
|
+
thread_id: number,
|
|
93
|
+
type: MessageType,
|
|
94
|
+
): Promise<number | null> {
|
|
95
|
+
const rows = await sql`
|
|
96
|
+
SELECT MAX(telegram_update_id) AS max_id
|
|
97
|
+
FROM messages
|
|
98
|
+
WHERE user_telegram = ${user_telegram} AND thread_id = ${thread_id} AND type = ${type}
|
|
99
|
+
AND role = 'user' AND telegram_update_id IS NOT NULL;
|
|
100
|
+
`;
|
|
101
|
+
const row = rows[0] as { max_id: string | null } | undefined;
|
|
102
|
+
if (!row || row.max_id == null) return null;
|
|
103
|
+
return Number(row.max_id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface RawMessageRow {
|
|
107
|
+
id: string;
|
|
108
|
+
created_at: Date;
|
|
109
|
+
user_telegram: string;
|
|
110
|
+
thread_id: string;
|
|
111
|
+
type: string;
|
|
112
|
+
role: string;
|
|
113
|
+
content: string | null;
|
|
114
|
+
telegram_update_id: string | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function rowToMessage(row: RawMessageRow): Message {
|
|
118
|
+
return {
|
|
119
|
+
id: Number(row.id),
|
|
120
|
+
created_at: row.created_at,
|
|
121
|
+
user_telegram: row.user_telegram,
|
|
122
|
+
thread_id: Number(row.thread_id),
|
|
123
|
+
type: row.type as MessageType,
|
|
124
|
+
role: row.role as MessageRole,
|
|
125
|
+
content: row.content,
|
|
126
|
+
telegram_update_id: row.telegram_update_id != null ? Number(row.telegram_update_id) : null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import dotenv from "dotenv";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
// Local-only: try to load .env / .env.local when running tools like db:migrate.
|
|
5
|
+
// In Vercel Lambdas these files don't exist, so these calls are harmless no-ops.
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
dotenv.config({ path: path.join(cwd, ".env") });
|
|
8
|
+
dotenv.config({ path: path.join(cwd, "app", ".env") });
|
|
9
|
+
dotenv.config({ path: path.join(cwd, ".env.local") });
|
|
10
|
+
dotenv.config({ path: path.join(cwd, "app", ".env.local") });
|
|
11
|
+
import { neon } from "@neondatabase/serverless";
|
|
12
|
+
|
|
13
|
+
const connectionString = process.env.DATABASE_URL;
|
|
14
|
+
|
|
15
|
+
if (!connectionString) {
|
|
16
|
+
// Fail fast on the server side so misconfiguration is obvious in logs.
|
|
17
|
+
throw new Error(
|
|
18
|
+
"DATABASE_URL is not set. Configure it in ./app/.env for the current Neon branch.",
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const sql = neon(connectionString);
|
|
23
|
+
|
|
24
|
+
async function runSchemaMigrations() {
|
|
25
|
+
// users table
|
|
26
|
+
await sql`
|
|
27
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
28
|
+
telegram_username TEXT PRIMARY KEY,
|
|
29
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
30
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
31
|
+
last_login_at TIMESTAMPTZ,
|
|
32
|
+
last_tma_seen_at TIMESTAMPTZ,
|
|
33
|
+
locale TEXT,
|
|
34
|
+
time_zone TEXT,
|
|
35
|
+
number_of_wallets INTEGER NOT NULL DEFAULT 0,
|
|
36
|
+
default_wallet BIGINT
|
|
37
|
+
);
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
// wallets table
|
|
41
|
+
await sql`
|
|
42
|
+
CREATE TABLE IF NOT EXISTS wallets (
|
|
43
|
+
id BIGSERIAL PRIMARY KEY,
|
|
44
|
+
telegram_username TEXT NOT NULL REFERENCES users(telegram_username),
|
|
45
|
+
wallet_address TEXT NOT NULL,
|
|
46
|
+
wallet_blockchain TEXT NOT NULL,
|
|
47
|
+
wallet_net TEXT NOT NULL,
|
|
48
|
+
type TEXT NOT NULL,
|
|
49
|
+
label TEXT,
|
|
50
|
+
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
|
51
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
52
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
53
|
+
last_used_at TIMESTAMPTZ,
|
|
54
|
+
last_seen_balance_at TIMESTAMPTZ,
|
|
55
|
+
source TEXT,
|
|
56
|
+
notes TEXT,
|
|
57
|
+
UNIQUE (telegram_username, wallet_address, wallet_blockchain, wallet_net)
|
|
58
|
+
);
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
// pending_transactions table
|
|
62
|
+
await sql`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS pending_transactions (
|
|
64
|
+
id TEXT PRIMARY KEY,
|
|
65
|
+
telegram_username TEXT NOT NULL REFERENCES users(telegram_username),
|
|
66
|
+
wallet_address TEXT NOT NULL,
|
|
67
|
+
wallet_blockchain TEXT NOT NULL,
|
|
68
|
+
wallet_net TEXT NOT NULL,
|
|
69
|
+
payload JSONB NOT NULL,
|
|
70
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'confirmed', 'rejected', 'failed')),
|
|
71
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
72
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
73
|
+
);
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
// Helpful indexes
|
|
77
|
+
await sql`
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_wallets_user
|
|
79
|
+
ON wallets(telegram_username);
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
await sql`
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_pending_tx_user
|
|
84
|
+
ON pending_transactions(telegram_username);
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
await sql`
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_pending_tx_status
|
|
89
|
+
ON pending_transactions(status);
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
// messages table (AI: bot + TMA)
|
|
93
|
+
await sql`
|
|
94
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
95
|
+
id BIGSERIAL PRIMARY KEY,
|
|
96
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
97
|
+
user_telegram TEXT NOT NULL REFERENCES users(telegram_username),
|
|
98
|
+
thread_id BIGINT NOT NULL,
|
|
99
|
+
type TEXT NOT NULL CHECK (type IN ('bot', 'app')),
|
|
100
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
|
|
101
|
+
content TEXT,
|
|
102
|
+
telegram_update_id BIGINT
|
|
103
|
+
);
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
await sql`
|
|
107
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread
|
|
108
|
+
ON messages(user_telegram, thread_id, type, created_at);
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
await sql`
|
|
112
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_bot_update_id
|
|
113
|
+
ON messages(user_telegram, thread_id, type, telegram_update_id)
|
|
114
|
+
WHERE telegram_update_id IS NOT NULL;
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let schemaInitPromise: Promise<void> | null = null;
|
|
119
|
+
|
|
120
|
+
export function ensureSchema(): Promise<void> {
|
|
121
|
+
if (!schemaInitPromise) {
|
|
122
|
+
schemaInitPromise = runSchemaMigrations().catch((err) => {
|
|
123
|
+
console.error("[db] schema init failed", err);
|
|
124
|
+
schemaInitPromise = null;
|
|
125
|
+
throw err;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return schemaInitPromise;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Schema runs at deploy via `npm run db:migrate` in buildCommand. No schema work
|
|
132
|
+
// in the request path — keeps /api/telegram and other routes fast (no 504).
|
|
133
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User helpers for the users table. Shared by API routes and bot.
|
|
3
|
+
* Import from ../database/users.js (e.g. from api/, telegram/, bot/).
|
|
4
|
+
*/
|
|
5
|
+
import { sql } from './start.js';
|
|
6
|
+
|
|
7
|
+
export function normalizeUsername(raw: unknown): string {
|
|
8
|
+
if (typeof raw !== 'string') return '';
|
|
9
|
+
let s = raw.trim();
|
|
10
|
+
if (s.startsWith('@')) s = s.slice(1);
|
|
11
|
+
return s.toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function upsertUserFromTma(opts: {
|
|
15
|
+
telegramUsername: string;
|
|
16
|
+
locale: string | null;
|
|
17
|
+
}): Promise<void> {
|
|
18
|
+
const { telegramUsername, locale } = opts;
|
|
19
|
+
if (!telegramUsername) return;
|
|
20
|
+
|
|
21
|
+
await sql`
|
|
22
|
+
INSERT INTO users (telegram_username, locale, created_at, updated_at, last_tma_seen_at)
|
|
23
|
+
VALUES (${telegramUsername}, ${locale}, NOW(), NOW(), NOW())
|
|
24
|
+
ON CONFLICT (telegram_username) DO UPDATE
|
|
25
|
+
SET locale = EXCLUDED.locale,
|
|
26
|
+
last_tma_seen_at = NOW(),
|
|
27
|
+
updated_at = NOW();
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function upsertUserFromBot(opts: {
|
|
32
|
+
telegramUsername: string;
|
|
33
|
+
locale: string | null;
|
|
34
|
+
}): Promise<void> {
|
|
35
|
+
const { telegramUsername, locale } = opts;
|
|
36
|
+
if (!telegramUsername) return;
|
|
37
|
+
|
|
38
|
+
await sql`
|
|
39
|
+
INSERT INTO users (telegram_username, locale, created_at, updated_at, last_login_at)
|
|
40
|
+
VALUES (${telegramUsername}, ${locale}, NOW(), NOW(), NOW())
|
|
41
|
+
ON CONFLICT (telegram_username) DO UPDATE
|
|
42
|
+
SET locale = EXCLUDED.locale,
|
|
43
|
+
last_login_at = NOW(),
|
|
44
|
+
updated_at = NOW();
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
## AI & Search bar input behaviour
|
|
2
|
+
|
|
3
|
+
This document describes how the text in the global AI & Search bottom bar should behave as the user types, matching the Flutter implementation.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
### 1. Reference states (pictures sequence)
|
|
8
|
+
|
|
9
|
+
The reference images show a sequence of states for a long line of text; they illustrate how the bar grows and then turns into a scrolling window:
|
|
10
|
+
|
|
11
|
+
1. **Full text, no bar**
|
|
12
|
+
Long multi‑line text fills a tall content area. This is effectively the raw content without the constraints of the bottom bar.
|
|
13
|
+
|
|
14
|
+
2. **Initial bar: single line + arrow**
|
|
15
|
+
- Only a single line of text is visible.
|
|
16
|
+
- The text baseline is horizontally aligned with the apply arrow icon on the right.
|
|
17
|
+
- There is empty space above the line; bar height is at its minimum.
|
|
18
|
+
|
|
19
|
+
3. **Unconstrained multi‑line text**
|
|
20
|
+
- Text has grown to multiple lines in a taller, unbounded view (again, this is the raw content).
|
|
21
|
+
|
|
22
|
+
4. **Growing bar: multiple lines + arrow**
|
|
23
|
+
- The bottom bar has increased in height to show multiple lines.
|
|
24
|
+
- As lines are added, the **space above the text shrinks**, but the **last visible line remains on the same vertical level as the arrow**.
|
|
25
|
+
- Visually, the bar grows upwards while the arrow + last line baseline stays fixed.
|
|
26
|
+
|
|
27
|
+
5. **Very long text, no bar**
|
|
28
|
+
- The entire long text block is visible in a tall area, showing how much total content exists.
|
|
29
|
+
|
|
30
|
+
6. **Capped bar height: scrolling window**
|
|
31
|
+
- The bottom bar height is now capped (e.g. at 180 px).
|
|
32
|
+
- The visible area becomes a **fixed‑height window** into the text:
|
|
33
|
+
- Older lines at the top continue moving up and eventually disappear under the **top edge** of the bar as more text is entered.
|
|
34
|
+
- The **last visible line stays aligned with the arrow baseline** at the bottom of the bar. The typing position does not move vertically once the bar has reached its maximum height.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
### 2. Detailed behaviour by line count
|
|
39
|
+
|
|
40
|
+
#### 1–7 lines: growing bar
|
|
41
|
+
|
|
42
|
+
- For each new line from 1 up to 7:
|
|
43
|
+
- The **bottom bar height increases** by exactly one line height (20 px).
|
|
44
|
+
- The height formula is:
|
|
45
|
+
\[
|
|
46
|
+
\text{height} = 20\text{ (top padding)} + N \times 20\text{ (lines)} + 20\text{ (bottom padding)}, \quad 1 \le N \le 7.
|
|
47
|
+
\]
|
|
48
|
+
- The **last line is always on the same baseline as the arrow** on the right.
|
|
49
|
+
- Visually, the bar grows **upwards**; the arrow + last line stay fixed at the bottom.
|
|
50
|
+
|
|
51
|
+
#### 8 lines: text reaches the top edge
|
|
52
|
+
|
|
53
|
+
- When the **8th line** appears:
|
|
54
|
+
- The text block now reaches the **top edge of the bottom bar**.
|
|
55
|
+
- The bar height is at its **maximum** (e.g. 180 px).
|
|
56
|
+
- All 8 lines are still visible at once, from the top edge down to the arrow.
|
|
57
|
+
|
|
58
|
+
#### 9 lines: full‑height text area, one line hidden
|
|
59
|
+
|
|
60
|
+
- When the **9th line** appears:
|
|
61
|
+
- The **scrollable text area is exactly 180 px high**, the same as the bar.
|
|
62
|
+
- The **last line remains aligned with the arrow** at the bottom.
|
|
63
|
+
- The **topmost line (1st)** is now hidden just above the top edge of the bar.
|
|
64
|
+
- If the user scrolls, they can reveal all 9 lines, because:
|
|
65
|
+
\[
|
|
66
|
+
9 \times 20\text{ px} = 180\text{ px},
|
|
67
|
+
\]
|
|
68
|
+
so all 9 lines can fit into the bar’s full height when scrolled to the appropriate position.
|
|
69
|
+
|
|
70
|
+
#### 9+ lines: fixed bar, 9‑line scrolling window
|
|
71
|
+
|
|
72
|
+
- For **any number of lines ≥ 9**:
|
|
73
|
+
- The bar height stays fixed at its maximum (e.g. 180 px).
|
|
74
|
+
- The **scrollable area always occupies the full bar height** (180 px).
|
|
75
|
+
- At any moment:
|
|
76
|
+
- Up to **9 lines are visible** in the window.
|
|
77
|
+
- The **bottom (last visible) line stays aligned with the arrow** while typing.
|
|
78
|
+
- Older lines scroll upwards and are hidden above the top edge; the user can scroll to reveal them.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
### 3. Implementation‑oriented summary
|
|
83
|
+
|
|
84
|
+
- **Line height & padding**
|
|
85
|
+
- Line height: 20 px.
|
|
86
|
+
- Top padding: 20 px.
|
|
87
|
+
- Bottom padding: 20 px.
|
|
88
|
+
|
|
89
|
+
- **Bar growth vs. scroll mode**
|
|
90
|
+
- For 1–7 lines, bar height grows; arrow + last line baseline are fixed.
|
|
91
|
+
- From the 8th line onward, the bar stays at max height; the input switches to a scrollable window that:
|
|
92
|
+
- Always keeps the caret / last line baseline aligned with the arrow.
|
|
93
|
+
- Hides older lines under the top edge while allowing them to be revealed by scrolling.
|
|
94
|
+
|