Rimas Kavaliauskas commited on
Commit
9e3a0e2
·
1 Parent(s): 06ed89e

Add Telegram MVP UX flow and HF health compatibility

Browse files
.env.example CHANGED
@@ -45,9 +45,11 @@ LLM_MODEL=gpt-4o-mini
45
  # Optional: YouTube URL for real-world archive smoke run
46
  POCKET_ARCHIVE_SMOKE_YOUTUBE_URL=https://www.youtube.com/watch?v=o053pv_qJVE&t=4274s
47
 
48
- # Telegram placeholders (deferred in this patch)
49
  TELEGRAM_BOT_TOKEN=
50
  TELEGRAM_WEBHOOK_SECRET=
 
 
51
 
52
  # Server
53
  PORT=8787
 
45
  # Optional: YouTube URL for real-world archive smoke run
46
  POCKET_ARCHIVE_SMOKE_YOUTUBE_URL=https://www.youtube.com/watch?v=o053pv_qJVE&t=4274s
47
 
48
+ # Telegram bot integration
49
  TELEGRAM_BOT_TOKEN=
50
  TELEGRAM_WEBHOOK_SECRET=
51
+ # Optional: lock bot to one private owner chat
52
+ TELEGRAM_ALLOWED_CHAT_ID=
53
 
54
  # Server
55
  PORT=8787
docs/pocket-archive-telegram-mvp.md ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pocket Archive Telegram MVP
2
+
3
+ ## Kur veikia Telegram backend
4
+
5
+ Šis MVP Telegram sluoksnis skirtas **HF backend runtime** (`server.js`), ne Vercel serverless funkcijoms.
6
+
7
+ Nauji endpointai:
8
+
9
+ - `GET /api/telegram/health`
10
+ - `POST /api/telegram/webhook/:secret`
11
+
12
+ Webhook apsauga:
13
+
14
+ - URL path secret turi sutapti su `TELEGRAM_WEBHOOK_SECRET`
15
+ - jei Telegram siunčia `x-telegram-bot-api-secret-token`, jis irgi turi sutapti
16
+
17
+ ## Reikalingi env kintamieji
18
+
19
+ - `TELEGRAM_BOT_TOKEN` - Telegram bot token
20
+ - `TELEGRAM_WEBHOOK_SECRET` - webhook secret
21
+ - `TELEGRAM_ALLOWED_CHAT_ID` - pasirenkama; jei nurodyta, tik šis chat gali naudoti botą
22
+ - `MONGODB_URI`, `MONGODB_DB_NAME` - MongoDB Atlas/lokalus Mongo
23
+ - `RAPIDAPI_KEY` - fallback transkripcijai, jei nėra YouTube captions
24
+ - `LLM_PROVIDER`, `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL` - normalize + Q&A
25
+
26
+ ## Webhook nustatymas
27
+
28
+ 1. Įsitikink, kad HF backend pasiekiamas per HTTPS (`HF_BACKEND_BASE_URL`).
29
+ 2. Paleisk:
30
+
31
+ ```bash
32
+ npm run telegram:set-webhook
33
+ ```
34
+
35
+ Skriptas sukonfigūruoja Telegram webhook į:
36
+
37
+ `<HF_BACKEND_BASE_URL>/api/telegram/webhook/<TELEGRAM_WEBHOOK_SECRET>`
38
+
39
+ ## Ką moka botas
40
+
41
+ - Priima YouTube URL (be komandos) ir paleidžia:
42
+ - `ingest -> normalize -> segment -> get`
43
+ - Rodo vieną redaguojamą job-status žinutę:
44
+ - `Queued`
45
+ - `Transcribing`
46
+ - `Editing transcript`
47
+ - `Building summary and outline`
48
+ - `Saving artifacts`
49
+ - `Done`
50
+ - Po rezultato rodo inline mygtukus:
51
+ - `Outline`
52
+ - `Edited`
53
+ - `Ask`
54
+ - `Save`
55
+ - `Topics`
56
+ - Komandos:
57
+ - `/help`
58
+ - `/library`
59
+ - `/inbox`
60
+ - `/list [state]`
61
+ - `/get <video_key>`
62
+ - `/summary [video_key]`
63
+ - `/outline [video_key]`
64
+ - `/topics [video_key]`
65
+ - `/edited [video_key]`
66
+ - `/md [video_key]`
67
+ - `/ask [video_key] <klausimas>`
68
+ - `/save [video_key] [category]`
69
+ - `/discard [video_key]`
70
+ - `/category [video_key] <category>`
71
+ - `/categories`
72
+ - `/note [video_key] <tekstas>`
73
+ - `/rebuild <video_key|youtube_url>`
74
+
75
+ ## UX taisyklės
76
+
77
+ - Idempotency pagal URL:
78
+ - jei video jau pilnai apdorotas ir `rebuild` neprašytas, botas grąžina esamą rezultatą ir nepaleidžia pilno pipeline iš naujo.
79
+ - Kontekstas per user/chat session:
80
+ - botas prisimena `last_video_key`, paskutinį artifact rinkinį ir paskutinę kategoriją.
81
+ - Dalinis rezultatas:
82
+ - jei dalis etapų nulūžta, bet yra bent `summary` ar `edited`, botas grąžina turimą rezultatą su aiškia klaidos pastaba.
83
+
84
+ ## Pastabos
85
+
86
+ - Botas naudoja esamus `services/archive/*` modulius, todėl Telegram ir Web dirba su tais pačiais Mongo įrašais.
87
+ - `raw` transcript lieka source-of-truth.
88
+ - `summary/outline/edited` gaunami iš `video_texts` artefaktų.
89
+ - Ilgi tekstai Telegram’e siunčiami dalimis arba `.md` dokumentu.
lib/config.js CHANGED
@@ -50,5 +50,6 @@ export function getConfig() {
50
  ownerLoginPassword: String(process.env.OWNER_LOGIN_PASSWORD || '').trim(),
51
  telegramBotToken: String(process.env.TELEGRAM_BOT_TOKEN || '').trim(),
52
  telegramWebhookSecret: String(process.env.TELEGRAM_WEBHOOK_SECRET || '').trim(),
 
53
  };
54
  }
 
50
  ownerLoginPassword: String(process.env.OWNER_LOGIN_PASSWORD || '').trim(),
51
  telegramBotToken: String(process.env.TELEGRAM_BOT_TOKEN || '').trim(),
52
  telegramWebhookSecret: String(process.env.TELEGRAM_WEBHOOK_SECRET || '').trim(),
53
+ telegramAllowedChatId: String(process.env.TELEGRAM_ALLOWED_CHAT_ID || '').trim(),
54
  };
55
  }
package.json CHANGED
@@ -10,6 +10,7 @@
10
  "test": "playwright test",
11
  "test:archive-api": "node --test tests/api/archive-api.contract.test.js",
12
  "smoke:youtube": "node scripts/pocket-archive-youtube-smoke.js",
 
13
  "archive:export-md": "node scripts/export-archive-markdown.js",
14
  "mongo:init": "node scripts/init-mongo.js",
15
  "mongo:up": "docker compose -f docker/docker-compose.mongo.yml up -d",
 
10
  "test": "playwright test",
11
  "test:archive-api": "node --test tests/api/archive-api.contract.test.js",
12
  "smoke:youtube": "node scripts/pocket-archive-youtube-smoke.js",
13
+ "telegram:set-webhook": "node scripts/telegram-set-webhook.js",
14
  "archive:export-md": "node scripts/export-archive-markdown.js",
15
  "mongo:init": "node scripts/init-mongo.js",
16
  "mongo:up": "docker compose -f docker/docker-compose.mongo.yml up -d",
scripts/telegram-set-webhook.js ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'dotenv/config';
2
+
3
+ function required(name) {
4
+ const value = String(process.env[name] || '').trim();
5
+ if (!value) {
6
+ throw new Error(`${name} is required`);
7
+ }
8
+ return value;
9
+ }
10
+
11
+ async function main() {
12
+ const token = required('TELEGRAM_BOT_TOKEN');
13
+ const secret = required('TELEGRAM_WEBHOOK_SECRET');
14
+ const baseUrl = required('HF_BACKEND_BASE_URL').replace(/\/+$/, '');
15
+ const webhookUrl = `${baseUrl}/api/telegram/webhook/${secret}`;
16
+
17
+ const response = await fetch(`https://api.telegram.org/bot${token}/setWebhook`, {
18
+ method: 'POST',
19
+ headers: {
20
+ 'Content-Type': 'application/json',
21
+ },
22
+ body: JSON.stringify({
23
+ url: webhookUrl,
24
+ secret_token: secret,
25
+ drop_pending_updates: false,
26
+ allowed_updates: ['message', 'edited_message'],
27
+ }),
28
+ });
29
+
30
+ const payload = await response.json().catch(() => ({}));
31
+ if (!response.ok || payload?.ok === false) {
32
+ throw new Error(payload?.description || `setWebhook failed (${response.status})`);
33
+ }
34
+
35
+ console.log(
36
+ JSON.stringify(
37
+ {
38
+ ok: true,
39
+ webhook_url: webhookUrl,
40
+ telegram_result: payload.result || true,
41
+ },
42
+ null,
43
+ 2
44
+ )
45
+ );
46
+ }
47
+
48
+ main().catch((error) => {
49
+ console.error('Failed to set Telegram webhook:', error?.message || String(error));
50
+ process.exit(1);
51
+ });
server.js CHANGED
@@ -6,6 +6,8 @@ import { put } from '@vercel/blob';
6
  import { getSubtitles } from 'youtube-caption-extractor';
7
  import blobUploadTokenHandler from './api/blob-upload-token.js';
8
  import hfPostTranscribeHandler from './api/hf/post-transcribe.js';
 
 
9
 
10
  dotenv.config();
11
 
@@ -24,6 +26,32 @@ app.get('/healthz', (_req, res) => {
24
  return res.status(200).json({ ok: true });
25
  });
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  // Supabase configuration
28
  const SUPABASE_URL = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL;
29
  const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY;
@@ -208,6 +236,36 @@ app.post('/api/hf/post-transcribe', async (req, res) => {
208
  return hfPostTranscribeHandler(req, res);
209
  });
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  // GET /api/me/remaining - returns { used, remaining }
212
  app.get('/api/me/remaining', requireAuth, async (req, res) => {
213
  try {
@@ -461,6 +519,6 @@ app.put('/api/blob-upload', express.raw({ type: '*/*', limit: '200mb' }), async
461
  }
462
  });
463
 
464
- app.listen(PORT, () => {
465
- console.log(`Backend listening on http://localhost:${PORT}`);
466
  });
 
6
  import { getSubtitles } from 'youtube-caption-extractor';
7
  import blobUploadTokenHandler from './api/blob-upload-token.js';
8
  import hfPostTranscribeHandler from './api/hf/post-transcribe.js';
9
+ import { getConfig } from './lib/config.js';
10
+ import { handleTelegramUpdate } from './services/telegram/bot.js';
11
 
12
  dotenv.config();
13
 
 
26
  return res.status(200).json({ ok: true });
27
  });
28
 
29
+ // Compatibility health route for platforms that probe /health.
30
+ app.get('/health', (_req, res) => {
31
+ return res.status(200).json({ ok: true });
32
+ });
33
+
34
+ function isTelegramRequestAuthorized(req) {
35
+ const cfg = getConfig();
36
+ const expectedSecret = String(cfg.telegramWebhookSecret || '').trim();
37
+ if (!expectedSecret) {
38
+ return { ok: false, reason: 'TELEGRAM_WEBHOOK_SECRET is not configured' };
39
+ }
40
+
41
+ const pathSecret = String(req.params?.secret || '').trim();
42
+ const headerSecret = String(req.headers['x-telegram-bot-api-secret-token'] || '').trim();
43
+
44
+ if (pathSecret !== expectedSecret) {
45
+ return { ok: false, reason: 'webhook path secret mismatch' };
46
+ }
47
+
48
+ if (headerSecret && headerSecret !== expectedSecret) {
49
+ return { ok: false, reason: 'telegram secret header mismatch' };
50
+ }
51
+
52
+ return { ok: true };
53
+ }
54
+
55
  // Supabase configuration
56
  const SUPABASE_URL = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL;
57
  const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY;
 
236
  return hfPostTranscribeHandler(req, res);
237
  });
238
 
239
+ // Telegram webhook is intended for HF/container runtime.
240
+ // It acknowledges immediately and processes in background because
241
+ // archive workflow can run longer than a normal webhook request.
242
+ app.get('/api/telegram/health', (_req, res) => {
243
+ const cfg = getConfig();
244
+ return res.status(200).json({
245
+ ok: true,
246
+ telegram_configured: Boolean(cfg.telegramBotToken && cfg.telegramWebhookSecret),
247
+ });
248
+ });
249
+
250
+ app.post('/api/telegram/webhook/:secret', async (req, res) => {
251
+ const auth = isTelegramRequestAuthorized(req);
252
+ if (!auth.ok) {
253
+ return res.status(401).json({
254
+ message: 'Unauthorized Telegram webhook request',
255
+ reason: auth.reason,
256
+ });
257
+ }
258
+
259
+ const update = req.body || {};
260
+ res.status(200).json({ ok: true });
261
+
262
+ setImmediate(() => {
263
+ handleTelegramUpdate(update).catch((error) => {
264
+ console.error('[telegram-webhook] update handling failed:', error);
265
+ });
266
+ });
267
+ });
268
+
269
  // GET /api/me/remaining - returns { used, remaining }
270
  app.get('/api/me/remaining', requireAuth, async (req, res) => {
271
  try {
 
519
  }
520
  });
521
 
522
+ app.listen(PORT, '0.0.0.0', () => {
523
+ console.log(`Backend listening on http://0.0.0.0:${PORT}`);
524
  });
services/archive/list-categories.js ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getMongoDb } from '../../lib/mongo.js';
2
+ import { ensureArchiveDb } from '../../db/indexes.js';
3
+ import { COLLECTIONS } from '../../db/collections.js';
4
+
5
+ export async function listArchiveCategories() {
6
+ const db = await getMongoDb();
7
+ await ensureArchiveDb(db);
8
+
9
+ const videos = db.collection(COLLECTIONS.videos);
10
+ const rows = await videos
11
+ .aggregate([
12
+ {
13
+ $match: {
14
+ subscription_topic: { $type: 'string' },
15
+ },
16
+ },
17
+ {
18
+ $project: {
19
+ subscription_topic: { $trim: { input: '$subscription_topic' } },
20
+ },
21
+ },
22
+ {
23
+ $match: {
24
+ subscription_topic: { $ne: '' },
25
+ },
26
+ },
27
+ {
28
+ $group: {
29
+ _id: '$subscription_topic',
30
+ count: { $sum: 1 },
31
+ },
32
+ },
33
+ {
34
+ $project: {
35
+ _id: 0,
36
+ category: '$_id',
37
+ count: 1,
38
+ },
39
+ },
40
+ {
41
+ $sort: {
42
+ count: -1,
43
+ category: 1,
44
+ },
45
+ },
46
+ ])
47
+ .toArray();
48
+
49
+ return rows;
50
+ }
services/archive/normalize-video.js CHANGED
@@ -14,7 +14,15 @@ function appendRun(existingRuns, nextRun) {
14
  return [...rows, nextRun].slice(-10);
15
  }
16
 
17
- export async function normalizeVideoByKey(videoKey, { targetLanguage = null, outlineProvider = null } = {}) {
 
 
 
 
 
 
 
 
18
  const key = String(videoKey || '').trim();
19
  if (!key) {
20
  const error = new Error('video_key is required');
@@ -46,12 +54,19 @@ export async function normalizeVideoByKey(videoKey, { targetLanguage = null, out
46
  const cleaned = cleanupTranscript(textDoc.content_raw);
47
 
48
  try {
 
 
 
49
  const literary = await literaryNormalize(cleaned);
50
  const inferredLanguage = inferLanguageFromText(literary.content, {
51
  fallback: textDoc.language || video.language_original || 'en',
52
  });
53
  const resolvedLanguage = normalizeLanguageCode(targetLanguage) || inferredLanguage;
54
 
 
 
 
 
55
  const topicMemory = await extractTopicMap({
56
  editedTranscript: literary.content,
57
  video: {
@@ -160,6 +175,9 @@ export async function normalizeVideoByKey(videoKey, { targetLanguage = null, out
160
  }
161
  );
162
 
 
 
 
163
  await texts.updateOne(
164
  { _id: textDoc._id },
165
  {
 
14
  return [...rows, nextRun].slice(-10);
15
  }
16
 
17
+ async function reportProgress(onProgress, stage, meta = {}) {
18
+ if (typeof onProgress !== 'function') return;
19
+ await onProgress(stage, meta);
20
+ }
21
+
22
+ export async function normalizeVideoByKey(
23
+ videoKey,
24
+ { targetLanguage = null, outlineProvider = null, onProgress = null } = {}
25
+ ) {
26
  const key = String(videoKey || '').trim();
27
  if (!key) {
28
  const error = new Error('video_key is required');
 
54
  const cleaned = cleanupTranscript(textDoc.content_raw);
55
 
56
  try {
57
+ await reportProgress(onProgress, 'editing_transcript', {
58
+ video_key: key,
59
+ });
60
  const literary = await literaryNormalize(cleaned);
61
  const inferredLanguage = inferLanguageFromText(literary.content, {
62
  fallback: textDoc.language || video.language_original || 'en',
63
  });
64
  const resolvedLanguage = normalizeLanguageCode(targetLanguage) || inferredLanguage;
65
 
66
+ await reportProgress(onProgress, 'building_summary_outline', {
67
+ video_key: key,
68
+ language: resolvedLanguage,
69
+ });
70
  const topicMemory = await extractTopicMap({
71
  editedTranscript: literary.content,
72
  video: {
 
175
  }
176
  );
177
 
178
+ await reportProgress(onProgress, 'saving_artifacts', {
179
+ video_key: key,
180
+ });
181
  await texts.updateOne(
182
  { _id: textDoc._id },
183
  {
services/archive/set-video-category.js ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ObjectId } from 'mongodb';
2
+ import { getMongoDb } from '../../lib/mongo.js';
3
+ import { ensureArchiveDb } from '../../db/indexes.js';
4
+ import { COLLECTIONS } from '../../db/collections.js';
5
+
6
+ export async function setVideoCategory({ videoKey, category = null, actor = 'telegram-owner' } = {}) {
7
+ const key = String(videoKey || '').trim();
8
+ if (!key) {
9
+ const error = new Error('video_key is required');
10
+ error.status = 400;
11
+ throw error;
12
+ }
13
+
14
+ const normalizedCategory = category == null ? null : String(category).trim();
15
+
16
+ const db = await getMongoDb();
17
+ await ensureArchiveDb(db);
18
+
19
+ const videos = db.collection(COLLECTIONS.videos);
20
+ const actions = db.collection(COLLECTIONS.reviewActions);
21
+
22
+ const video = await videos.findOne({ video_key: key });
23
+ if (!video) {
24
+ const error = new Error('Video not found');
25
+ error.status = 404;
26
+ throw error;
27
+ }
28
+
29
+ const now = new Date();
30
+ await videos.updateOne(
31
+ { _id: video._id },
32
+ {
33
+ $set: {
34
+ subscription_topic: normalizedCategory || null,
35
+ updated_at: now,
36
+ },
37
+ }
38
+ );
39
+
40
+ await actions.insertOne({
41
+ _id: new ObjectId(),
42
+ video_id: video._id,
43
+ video_key: key,
44
+ action: normalizedCategory ? 'set-category' : 'clear-category',
45
+ actor: String(actor || 'telegram-owner'),
46
+ note: normalizedCategory || null,
47
+ created_at: now,
48
+ });
49
+
50
+ return {
51
+ video_key: key,
52
+ subscription_topic: normalizedCategory || null,
53
+ };
54
+ }
services/telegram/bot.js ADDED
@@ -0,0 +1,1514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getConfig } from '../../lib/config.js';
2
+ import { logger } from '../../lib/logger.js';
3
+ import { generateText } from '../../lib/llm-provider.js';
4
+ import { ingestVideo } from '../archive/ingest-video.js';
5
+ import { normalizeVideoByKey } from '../archive/normalize-video.js';
6
+ import { segmentVideoByKey } from '../archive/segment-video.js';
7
+ import { getVideoByKey } from '../archive/get-video.js';
8
+ import { listVideos } from '../archive/list-videos.js';
9
+ import { setReviewState } from '../archive/review-video.js';
10
+ import { addEditorialNote } from '../archive/add-note.js';
11
+ import { setVideoCategory } from '../archive/set-video-category.js';
12
+ import { listArchiveCategories } from '../archive/list-categories.js';
13
+ import {
14
+ buildTelegramResponsePayload,
15
+ buildUserFacingArtifact,
16
+ renderUserFacingMarkdown,
17
+ } from '../archive/user-facing-artifacts.js';
18
+ import {
19
+ answerTelegramCallbackQuery,
20
+ editTelegramText,
21
+ sendTelegramChatAction,
22
+ sendTelegramDocument,
23
+ sendTelegramText,
24
+ } from './telegram-client.js';
25
+
26
+ const REVIEW_STATES = new Set(['inbox', 'reviewed', 'saved', 'discarded', 'publication-candidate']);
27
+ const sessionStore = new Map();
28
+
29
+ const STATUS_STEPS = [
30
+ 'queued',
31
+ 'transcribing',
32
+ 'editing_transcript',
33
+ 'building_summary_outline',
34
+ 'saving_artifacts',
35
+ 'done',
36
+ ];
37
+
38
+ function sessionKey(chatId, userId) {
39
+ return `${String(chatId)}:${String(userId || chatId)}`;
40
+ }
41
+
42
+ function getSession(chatId, userId) {
43
+ const key = sessionKey(chatId, userId);
44
+ if (!sessionStore.has(key)) {
45
+ sessionStore.set(key, {
46
+ lastVideoKey: '',
47
+ lastArtifacts: null,
48
+ lastCategory: null,
49
+ recentCategories: [],
50
+ categoryOptionsByVideo: {},
51
+ topicOptionsByVideo: {},
52
+ });
53
+ }
54
+
55
+ if (sessionStore.size > 5000) {
56
+ const first = sessionStore.keys().next().value;
57
+ if (first) sessionStore.delete(first);
58
+ }
59
+
60
+ return sessionStore.get(key);
61
+ }
62
+
63
+ function extractMessage(update) {
64
+ return update?.message || update?.edited_message || null;
65
+ }
66
+
67
+ function extractCallbackQuery(update) {
68
+ return update?.callback_query || null;
69
+ }
70
+
71
+ function extractYouTubeUrl(text) {
72
+ const value = String(text || '');
73
+ const match = value.match(/https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?[^\s]+|youtu\.be\/[^\s]+)/i);
74
+ return match ? match[0] : null;
75
+ }
76
+
77
+ function parseCommand(text) {
78
+ const value = String(text || '').trim();
79
+ if (!value.startsWith('/')) return null;
80
+
81
+ const parts = value.split(/\s+/);
82
+ const first = parts.shift() || '';
83
+ const [commandRaw] = first.slice(1).split('@');
84
+ const command = String(commandRaw || '').trim().toLowerCase();
85
+ return {
86
+ command,
87
+ args: parts,
88
+ argsText: parts.join(' ').trim(),
89
+ };
90
+ }
91
+
92
+ function truncateText(text, max = 120) {
93
+ const value = String(text || '').replace(/\s+/g, ' ').trim();
94
+ if (value.length <= max) return value;
95
+ return `${value.slice(0, Math.max(0, max - 1)).trimEnd()}...`;
96
+ }
97
+
98
+ function resolveVideoKey(session, explicitKey) {
99
+ const key = String(explicitKey || '').trim();
100
+ if (key) return key;
101
+ return String(session?.lastVideoKey || '').trim();
102
+ }
103
+
104
+ function rememberCategory(session, category) {
105
+ const normalized = String(category || '').trim();
106
+ if (!normalized) return;
107
+ session.lastCategory = normalized;
108
+ session.recentCategories = [normalized, ...(session.recentCategories || []).filter((item) => item !== normalized)].slice(
109
+ 0,
110
+ 8
111
+ );
112
+ }
113
+
114
+ function rememberVideo(session, archiveItem) {
115
+ const videoKey = String(archiveItem?.video?.video_key || '').trim();
116
+ if (!videoKey) return;
117
+
118
+ session.lastVideoKey = videoKey;
119
+ if (archiveItem?.video?.subscription_topic) {
120
+ rememberCategory(session, archiveItem.video.subscription_topic);
121
+ }
122
+
123
+ const text = archiveItem?.video_text || {};
124
+ session.lastArtifacts = {
125
+ video_key: videoKey,
126
+ has_summary: Boolean(String(text.content_summary_1p || '').trim()),
127
+ has_outline: Boolean(String(text.content_outline_thematic || '').trim()),
128
+ has_edited: Boolean(String(text.content_literary || '').trim()),
129
+ has_topic_map: Array.isArray(text?.content_topic_map?.topics) && text.content_topic_map.topics.length > 0,
130
+ updated_at: new Date().toISOString(),
131
+ };
132
+ }
133
+
134
+ function buildHelpText() {
135
+ return [
136
+ 'Pocket Archive Telegram komandos:',
137
+ '/library - vienas vaizdas: recent + categories',
138
+ '/inbox - naujausi inbox irasai',
139
+ '/list [state] - sarasas (pvz. /list saved)',
140
+ '/get <video_key> - summary + veiksmai',
141
+ '/summary [video_key] - tik summary',
142
+ '/outline [video_key] - thematic outline',
143
+ '/topics [video_key] - temos su mygtukais',
144
+ '/edited [video_key] - edited transcript MD failas',
145
+ '/md [video_key] - master MD failas',
146
+ '/ask [video_key] <klausimas> - klausimai apie irasa',
147
+ '/save [video_key] [category] - save + kategorija',
148
+ '/discard [video_key] - discard',
149
+ '/category [video_key] <category> - nustatyti kategorija',
150
+ '/categories - kategoriju sarasas',
151
+ '/note [video_key] <tekstas> - prideti pastaba',
152
+ '/rebuild <video_key|youtube_url> - priverstinai pergen.',
153
+ '',
154
+ 'Taip pat gali tiesiog atsiusti YouTube URL.',
155
+ ].join('\n');
156
+ }
157
+
158
+ function buildMainActionsKeyboard(videoKey) {
159
+ return {
160
+ inline_keyboard: [
161
+ [
162
+ { text: 'Outline', callback_data: `a|out|${videoKey}` },
163
+ { text: 'Edited', callback_data: `a|ed|${videoKey}` },
164
+ ],
165
+ [
166
+ { text: 'Ask', callback_data: `a|ask|${videoKey}` },
167
+ { text: 'Save', callback_data: `a|save|${videoKey}` },
168
+ { text: 'Topics', callback_data: `a|topics|${videoKey}` },
169
+ ],
170
+ ],
171
+ };
172
+ }
173
+
174
+ function composeJobStatusText(currentStage, note = '') {
175
+ const currentIndex = STATUS_STEPS.indexOf(currentStage);
176
+ const effectiveIndex = currentIndex === -1 ? 0 : currentIndex;
177
+
178
+ const lines = ['Job status'];
179
+ const labels = [
180
+ ['queued', 'Queued'],
181
+ ['transcribing', 'Transcribing'],
182
+ ['editing_transcript', 'Editing transcript'],
183
+ ['building_summary_outline', 'Building summary and outline'],
184
+ ['saving_artifacts', 'Saving artifacts'],
185
+ ['done', 'Done'],
186
+ ];
187
+
188
+ for (let index = 0; index < labels.length; index += 1) {
189
+ const [stepKey, stepLabel] = labels[index];
190
+ const statusMark = index < effectiveIndex ? 'x' : index === effectiveIndex ? '...' : '-';
191
+ lines.push(`${statusMark} ${stepLabel}`);
192
+ if (stepKey === currentStage && note) {
193
+ lines.push(` ${note}`);
194
+ }
195
+ }
196
+
197
+ return lines.join('\n');
198
+ }
199
+
200
+ async function startJobStatus({ cfg, chatId, replyToMessageId = null }) {
201
+ const sent = await sendTelegramText({
202
+ botToken: cfg.telegramBotToken,
203
+ chatId,
204
+ replyToMessageId,
205
+ text: composeJobStatusText('queued'),
206
+ });
207
+
208
+ const first = Array.isArray(sent) && sent.length > 0 ? sent[0] : null;
209
+ return {
210
+ chatId,
211
+ messageId: first?.message_id || null,
212
+ currentStage: 'queued',
213
+ };
214
+ }
215
+
216
+ async function updateJobStatus({ cfg, jobStatus, stage, note = '' }) {
217
+ if (!jobStatus?.messageId) return;
218
+ if (jobStatus.currentStage === stage && !note) return;
219
+
220
+ const text = composeJobStatusText(stage, note);
221
+ await editTelegramText({
222
+ botToken: cfg.telegramBotToken,
223
+ chatId: jobStatus.chatId,
224
+ messageId: jobStatus.messageId,
225
+ text,
226
+ });
227
+
228
+ jobStatus.currentStage = stage;
229
+ }
230
+
231
+ function buildEditedMd(archiveItem) {
232
+ const artifact = buildUserFacingArtifact(
233
+ {
234
+ video: archiveItem.video,
235
+ videoText: archiveItem.video_text,
236
+ },
237
+ { includeEdited: true }
238
+ );
239
+ const title = archiveItem.video?.title_original || archiveItem.video?.video_key || 'Archive Item';
240
+ return `# ${title}\n\n## Edited Transcript\n\n${artifact.content_edited || '_Edited transcript not available yet._'}\n`;
241
+ }
242
+
243
+ function collectTopicButtons(session, videoKey, archiveItem) {
244
+ const topics = Array.isArray(archiveItem?.video_text?.content_topic_map?.topics)
245
+ ? archiveItem.video_text.content_topic_map.topics
246
+ : [];
247
+
248
+ if (topics.length === 0) {
249
+ session.topicOptionsByVideo[videoKey] = [];
250
+ return {
251
+ keyboard: null,
252
+ count: 0,
253
+ };
254
+ }
255
+
256
+ const maxTopics = Math.min(10, topics.length);
257
+ const options = [];
258
+ const rows = [];
259
+
260
+ for (let index = 0; index < maxTopics; index += 1) {
261
+ const topic = topics[index];
262
+ options.push({
263
+ topic_id: topic?.topic_id || `t${index + 1}`,
264
+ label: topic?.label || `Topic ${index + 1}`,
265
+ });
266
+ rows.push([
267
+ {
268
+ text: truncateText(topic?.label || `Topic ${index + 1}`, 36),
269
+ callback_data: `t|${videoKey}|${index}`,
270
+ },
271
+ ]);
272
+ }
273
+
274
+ session.topicOptionsByVideo[videoKey] = options;
275
+ return {
276
+ keyboard: {
277
+ inline_keyboard: rows,
278
+ },
279
+ count: options.length,
280
+ };
281
+ }
282
+
283
+ function buildCategoryKeyboard(session, videoKey, globalCategories = []) {
284
+ const fromSession = Array.isArray(session.recentCategories) ? session.recentCategories : [];
285
+ const fromGlobal = Array.isArray(globalCategories)
286
+ ? globalCategories.map((item) => String(item?.category || '').trim()).filter(Boolean)
287
+ : [];
288
+
289
+ const merged = [...new Set([...fromSession, ...fromGlobal])].slice(0, 6);
290
+ session.categoryOptionsByVideo[videoKey] = merged;
291
+
292
+ const rows = merged.map((category, index) => [
293
+ {
294
+ text: truncateText(category, 30),
295
+ callback_data: `c|${videoKey}|${index}`,
296
+ },
297
+ ]);
298
+
299
+ rows.push([
300
+ {
301
+ text: 'New category',
302
+ callback_data: `c|${videoKey}|new`,
303
+ },
304
+ ]);
305
+
306
+ return {
307
+ inline_keyboard: rows,
308
+ };
309
+ }
310
+
311
+ async function sendSummaryResult({
312
+ cfg,
313
+ chatId,
314
+ archiveItem,
315
+ session,
316
+ replyToMessageId = null,
317
+ note = '',
318
+ includeKeyboard = true,
319
+ }) {
320
+ rememberVideo(session, archiveItem);
321
+
322
+ const payload = buildTelegramResponsePayload(
323
+ {
324
+ video: archiveItem.video,
325
+ videoText: archiveItem.video_text,
326
+ },
327
+ {
328
+ includeEdited: false,
329
+ editedCommand: `/edited ${archiveItem.video.video_key}`,
330
+ }
331
+ );
332
+
333
+ const title = payload?.title || archiveItem.video.video_key;
334
+ const summary = payload?.content_summary_1p || 'Summary dar neparuostas. Pabandyk /rebuild.';
335
+
336
+ const lines = [title, '', 'Summary:', summary];
337
+ if (note) {
338
+ lines.push('', note);
339
+ }
340
+
341
+ await sendTelegramText({
342
+ botToken: cfg.telegramBotToken,
343
+ chatId,
344
+ replyToMessageId,
345
+ text: lines.join('\n'),
346
+ replyMarkup: includeKeyboard ? buildMainActionsKeyboard(archiveItem.video.video_key) : null,
347
+ });
348
+ }
349
+
350
+ async function sendOutlineText({ cfg, chatId, archiveItem }) {
351
+ const outline = buildUserFacingArtifact({
352
+ video: archiveItem.video,
353
+ videoText: archiveItem.video_text,
354
+ }).content_outline_thematic;
355
+
356
+ await sendTelegramText({
357
+ botToken: cfg.telegramBotToken,
358
+ chatId,
359
+ text: outline || 'Outline dar nera. Paleisk /rebuild.',
360
+ });
361
+ }
362
+
363
+ function formatTopicDetail(topic) {
364
+ const lines = [topic?.label || 'Theme'];
365
+ if (topic?.thesis) {
366
+ lines.push('', `Thesis: ${String(topic.thesis).trim()}`);
367
+ }
368
+ if (topic?.micro_summary) {
369
+ lines.push('', String(topic.micro_summary).trim());
370
+ }
371
+ const points = Array.isArray(topic?.key_points) ? topic.key_points.filter(Boolean).slice(0, 6) : [];
372
+ if (points.length > 0) {
373
+ lines.push('', 'Key points:');
374
+ for (const point of points) {
375
+ lines.push(`- ${String(point).trim()}`);
376
+ }
377
+ }
378
+ return lines.join('\n');
379
+ }
380
+
381
+ function resolveTopicByIndex(session, archiveItem, videoKey, indexValue) {
382
+ const index = Number(indexValue);
383
+ if (!Number.isInteger(index) || index < 0) return null;
384
+
385
+ const topics = Array.isArray(archiveItem?.video_text?.content_topic_map?.topics)
386
+ ? archiveItem.video_text.content_topic_map.topics
387
+ : [];
388
+
389
+ const sessionOptions = Array.isArray(session.topicOptionsByVideo[videoKey]) ? session.topicOptionsByVideo[videoKey] : [];
390
+ if (sessionOptions[index]?.topic_id) {
391
+ const found = topics.find((topic) => String(topic?.topic_id || '') === String(sessionOptions[index].topic_id));
392
+ if (found) return found;
393
+ }
394
+
395
+ return topics[index] || null;
396
+ }
397
+
398
+ function extractSeqFromRef(ref) {
399
+ const value = String(ref || '');
400
+ const match = value.match(/(\d+)/);
401
+ if (!match) return null;
402
+ const seq = Number(match[1]);
403
+ return Number.isInteger(seq) ? seq : null;
404
+ }
405
+
406
+ function buildTopicEvidence(topic, segments) {
407
+ const refs = Array.isArray(topic?.segment_refs) ? topic.segment_refs : [];
408
+ if (refs.length === 0) {
409
+ return 'Evidence: segment refs not available for this topic.';
410
+ }
411
+
412
+ const wantedSeq = refs.map(extractSeqFromRef).filter((value) => Number.isInteger(value));
413
+ if (wantedSeq.length === 0) {
414
+ return 'Evidence: segment refs are not mappable to segment sequence.';
415
+ }
416
+
417
+ const segmentMap = new Map((Array.isArray(segments) ? segments : []).map((segment) => [segment.seq, segment]));
418
+ const picked = wantedSeq
419
+ .map((seq) => segmentMap.get(seq))
420
+ .filter(Boolean)
421
+ .slice(0, 4)
422
+ .map((segment) => `- [${segment.seq}] ${truncateText(segment.text, 240)}`);
423
+
424
+ if (picked.length === 0) {
425
+ return 'Evidence: referenced segments were not found in current segment set.';
426
+ }
427
+
428
+ return ['Evidence:', ...picked].join('\n');
429
+ }
430
+
431
+ function describePipelineError(stage, error, archiveItem = null) {
432
+ const message = String(error?.message || '').trim();
433
+ const lower = message.toLowerCase();
434
+
435
+ if (stage === 'transcribing') {
436
+ if (/youtube|caption|transcript|rapidapi|unavailable|forbidden|blocked|failed to fetch media/i.test(lower)) {
437
+ return 'Nepavyko pasiekti transkripcijos saltinio.';
438
+ }
439
+ return `Nepavyko gauti transkripcijos: ${message || 'nezinoma klaida'}`;
440
+ }
441
+
442
+ if (stage === 'building_summary_outline' || stage === 'editing_transcript') {
443
+ const hasSummary = Boolean(String(archiveItem?.video_text?.content_summary_1p || '').trim());
444
+ const hasOutline = Boolean(String(archiveItem?.video_text?.content_outline_thematic || '').trim());
445
+
446
+ if (hasSummary && !hasOutline) {
447
+ return 'Transkriptas gautas, bet outline sugeneruoti nepavyko.';
448
+ }
449
+ if (!hasSummary && hasOutline) {
450
+ return 'Transkriptas gautas, bet summary sugeneruoti nepavyko.';
451
+ }
452
+ return `Normalize etapas nepavyko: ${message || 'nezinoma klaida'}`;
453
+ }
454
+
455
+ if (stage === 'saving_artifacts') {
456
+ return `Artefaktu issaugojimas nepavyko: ${message || 'nezinoma klaida'}`;
457
+ }
458
+
459
+ return message || 'Nepavyko apdoroti uzduoties.';
460
+ }
461
+
462
+ async function answerQuestionAboutVideo({ question, archiveItem }) {
463
+ const video = archiveItem.video || {};
464
+ const text = archiveItem.video_text || {};
465
+ const promptContext = [
466
+ `Video key: ${video.video_key || ''}`,
467
+ `Title: ${video.title_original || ''}`,
468
+ `Source URL: ${video.source_url || ''}`,
469
+ '',
470
+ 'Summary:',
471
+ text.content_summary_1p || '',
472
+ '',
473
+ 'Thematic outline:',
474
+ text.content_outline_thematic || '',
475
+ '',
476
+ 'Edited transcript excerpt:',
477
+ String(text.content_literary || '').slice(0, 14000),
478
+ ].join('\n');
479
+
480
+ const response = await generateText({
481
+ systemPrompt:
482
+ 'You answer questions about one transcript source. ' +
483
+ 'Use only provided context. Keep answer concise, plain language, and factual. ' +
484
+ 'If context is insufficient, explicitly say what is missing.',
485
+ userPrompt: `Question: ${String(question || '').trim()}\n\nContext:\n${promptContext}`,
486
+ temperature: 0.15,
487
+ maxTokens: 900,
488
+ });
489
+
490
+ return response.text;
491
+ }
492
+
493
+ function hasCompletedArtifacts(archiveItem) {
494
+ const text = archiveItem?.video_text || {};
495
+ return Boolean(String(text.content_summary_1p || '').trim()) &&
496
+ Boolean(String(text.content_outline_thematic || '').trim()) &&
497
+ Boolean(String(text.content_literary || '').trim());
498
+ }
499
+
500
+ function hasSegments(archiveItem) {
501
+ return Array.isArray(archiveItem?.segments) && archiveItem.segments.length > 0;
502
+ }
503
+
504
+ async function runRebuildByVideoKey({ cfg, chatId, session, videoKey, replyToMessageId = null }) {
505
+ const key = String(videoKey || '').trim();
506
+ if (!key) {
507
+ await sendTelegramText({
508
+ botToken: cfg.telegramBotToken,
509
+ chatId,
510
+ text: 'Nurodyk video_key: /rebuild vid_xxx',
511
+ });
512
+ return true;
513
+ }
514
+
515
+ const jobStatus = await startJobStatus({ cfg, chatId, replyToMessageId });
516
+
517
+ try {
518
+ await normalizeVideoByKey(key, {
519
+ onProgress: async (stage) => {
520
+ await updateJobStatus({ cfg, jobStatus, stage });
521
+ },
522
+ });
523
+
524
+ await updateJobStatus({ cfg, jobStatus, stage: 'saving_artifacts' });
525
+ await segmentVideoByKey(key);
526
+
527
+ const archiveItem = await getVideoByKey(key);
528
+ if (!archiveItem) {
529
+ throw new Error('Archive item not found after rebuild');
530
+ }
531
+
532
+ rememberVideo(session, archiveItem);
533
+ await updateJobStatus({ cfg, jobStatus, stage: 'done' });
534
+ await sendSummaryResult({ cfg, chatId, session, archiveItem, note: 'Rebuild baigtas.' });
535
+ return true;
536
+ } catch (error) {
537
+ const archiveItem = await getVideoByKey(key).catch(() => null);
538
+ const note = describePipelineError(jobStatus.currentStage, error, archiveItem);
539
+ await updateJobStatus({ cfg, jobStatus, stage: 'done', note: 'Completed with warnings' });
540
+
541
+ if (archiveItem && (archiveItem.video_text?.content_summary_1p || archiveItem.video_text?.content_literary)) {
542
+ rememberVideo(session, archiveItem);
543
+ await sendSummaryResult({
544
+ cfg,
545
+ chatId,
546
+ session,
547
+ archiveItem,
548
+ note,
549
+ });
550
+ } else {
551
+ await sendTelegramText({
552
+ botToken: cfg.telegramBotToken,
553
+ chatId,
554
+ text: note,
555
+ });
556
+ }
557
+
558
+ logger.error('Telegram rebuild failed', {
559
+ stage: jobStatus.currentStage,
560
+ videoKey: key,
561
+ chatId: String(chatId),
562
+ message: error?.message || String(error),
563
+ });
564
+
565
+ return true;
566
+ }
567
+ }
568
+
569
+ async function runYoutubeWorkflow({
570
+ cfg,
571
+ chatId,
572
+ session,
573
+ sourceText,
574
+ replyToMessageId = null,
575
+ forceRebuild = false,
576
+ }) {
577
+ const url = extractYouTubeUrl(sourceText);
578
+ if (!url) return false;
579
+
580
+ const jobStatus = await startJobStatus({ cfg, chatId, replyToMessageId });
581
+ let videoKey = '';
582
+
583
+ try {
584
+ await updateJobStatus({ cfg, jobStatus, stage: 'transcribing' });
585
+ await sendTelegramChatAction({ botToken: cfg.telegramBotToken, chatId, action: 'typing' });
586
+
587
+ const ingest = await ingestVideo({ url });
588
+ videoKey = ingest.video_key;
589
+
590
+ let archiveItem = await getVideoByKey(videoKey);
591
+ if (!archiveItem) {
592
+ throw new Error('Archive item not found after ingest');
593
+ }
594
+
595
+ rememberVideo(session, archiveItem);
596
+
597
+ const reusedAndComplete = Boolean(ingest.reused) && hasCompletedArtifacts(archiveItem) && hasSegments(archiveItem);
598
+ if (reusedAndComplete && !forceRebuild) {
599
+ await updateJobStatus({
600
+ cfg,
601
+ jobStatus,
602
+ stage: 'done',
603
+ note: 'Video already in library. Returned existing artifacts.',
604
+ });
605
+ await sendSummaryResult({
606
+ cfg,
607
+ chatId,
608
+ session,
609
+ archiveItem,
610
+ note: 'Video jau yra bibliotekoje. Grąžintas esamas rezultatas.',
611
+ });
612
+ return true;
613
+ }
614
+
615
+ const needNormalize =
616
+ forceRebuild ||
617
+ !Boolean(String(archiveItem?.video_text?.content_literary || '').trim()) ||
618
+ !Boolean(String(archiveItem?.video_text?.content_summary_1p || '').trim()) ||
619
+ !Boolean(String(archiveItem?.video_text?.content_outline_thematic || '').trim());
620
+
621
+ if (needNormalize) {
622
+ await normalizeVideoByKey(videoKey, {
623
+ onProgress: async (stage) => {
624
+ await updateJobStatus({ cfg, jobStatus, stage });
625
+ },
626
+ });
627
+ }
628
+
629
+ await updateJobStatus({ cfg, jobStatus, stage: 'saving_artifacts' });
630
+
631
+ const archiveAfterNormalize = await getVideoByKey(videoKey);
632
+ if (!archiveAfterNormalize) {
633
+ throw new Error('Archive item not found after normalize');
634
+ }
635
+
636
+ const needSegments = forceRebuild || !hasSegments(archiveAfterNormalize);
637
+ if (needSegments) {
638
+ await segmentVideoByKey(videoKey);
639
+ }
640
+
641
+ archiveItem = await getVideoByKey(videoKey);
642
+ if (!archiveItem) {
643
+ throw new Error('Archive item not found after segments');
644
+ }
645
+
646
+ rememberVideo(session, archiveItem);
647
+ await updateJobStatus({ cfg, jobStatus, stage: 'done' });
648
+
649
+ const note = ingest.reused ? 'Video jau buvo bibliotekoje, artefaktai atnaujinti.' : '';
650
+ await sendSummaryResult({ cfg, chatId, session, archiveItem, note });
651
+ return true;
652
+ } catch (error) {
653
+ const archiveItem = videoKey ? await getVideoByKey(videoKey).catch(() => null) : null;
654
+ const errorText = describePipelineError(jobStatus.currentStage, error, archiveItem);
655
+
656
+ await updateJobStatus({
657
+ cfg,
658
+ jobStatus,
659
+ stage: 'done',
660
+ note: 'Completed with warnings',
661
+ });
662
+
663
+ if (archiveItem && (archiveItem.video_text?.content_summary_1p || archiveItem.video_text?.content_literary)) {
664
+ rememberVideo(session, archiveItem);
665
+ await sendSummaryResult({
666
+ cfg,
667
+ chatId,
668
+ session,
669
+ archiveItem,
670
+ note: errorText,
671
+ });
672
+ } else {
673
+ await sendTelegramText({
674
+ botToken: cfg.telegramBotToken,
675
+ chatId,
676
+ text: errorText,
677
+ });
678
+ }
679
+
680
+ logger.error('Telegram YouTube workflow failed', {
681
+ stage: jobStatus.currentStage,
682
+ chatId: String(chatId),
683
+ videoKey,
684
+ message: error?.message || String(error),
685
+ });
686
+
687
+ return true;
688
+ }
689
+ }
690
+
691
+ function ensureAllowedChat(cfg, chatId) {
692
+ const allowed = String(cfg.telegramAllowedChatId || '').trim();
693
+ if (!allowed) return true;
694
+ return String(chatId) === allowed;
695
+ }
696
+
697
+ async function sendLibraryView({ cfg, chatId }) {
698
+ const recent = await listVideos({ limit: 12 });
699
+ const categories = await listArchiveCategories();
700
+
701
+ const lines = ['Library'];
702
+ lines.push('');
703
+ lines.push('Recent:');
704
+ if (recent.items.length === 0) {
705
+ lines.push('- no items');
706
+ } else {
707
+ for (const item of recent.items.slice(0, 8)) {
708
+ const cat = item.subscription_topic ? ` | ${item.subscription_topic}` : '';
709
+ lines.push(`- ${item.video_key} | ${item.review_state}${cat} | ${truncateText(item.title_original || item.youtube_video_id, 52)}`);
710
+ }
711
+ }
712
+
713
+ lines.push('');
714
+ lines.push('By category:');
715
+ if (categories.length === 0) {
716
+ lines.push('- no categories');
717
+ } else {
718
+ for (const row of categories.slice(0, 10)) {
719
+ lines.push(`- ${row.category} (${row.count})`);
720
+ }
721
+ }
722
+
723
+ const languageCounts = new Map();
724
+ for (const item of recent.items) {
725
+ const lang = String(item.language_original || '').trim();
726
+ if (!lang) continue;
727
+ languageCounts.set(lang, (languageCounts.get(lang) || 0) + 1);
728
+ }
729
+
730
+ if (languageCounts.size > 0) {
731
+ lines.push('');
732
+ lines.push('By language (recent sample):');
733
+ for (const [lang, count] of [...languageCounts.entries()].sort((a, b) => b[1] - a[1])) {
734
+ lines.push(`- ${lang} (${count})`);
735
+ }
736
+ }
737
+
738
+ await sendTelegramText({
739
+ botToken: cfg.telegramBotToken,
740
+ chatId,
741
+ text: lines.join('\n'),
742
+ });
743
+ }
744
+
745
+ async function sendSaveWithCategoryHints({ cfg, chatId, session, videoKey, baseMessage }) {
746
+ const globalCategories = await listArchiveCategories().catch(() => []);
747
+ const keyboard = buildCategoryKeyboard(session, videoKey, globalCategories);
748
+
749
+ await sendTelegramText({
750
+ botToken: cfg.telegramBotToken,
751
+ chatId,
752
+ text: baseMessage,
753
+ replyMarkup: keyboard,
754
+ });
755
+ }
756
+
757
+ async function handleTopicListRequest({ cfg, chatId, session, videoKey }) {
758
+ const archiveItem = await getVideoByKey(videoKey);
759
+ if (!archiveItem) {
760
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Irasas nerastas.' });
761
+ return;
762
+ }
763
+
764
+ rememberVideo(session, archiveItem);
765
+ const { keyboard, count } = collectTopicButtons(session, videoKey, archiveItem);
766
+ if (!keyboard || count === 0) {
767
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Topic map dar neparuostas.' });
768
+ return;
769
+ }
770
+
771
+ await sendTelegramText({
772
+ botToken: cfg.telegramBotToken,
773
+ chatId,
774
+ text: `Temos (${count}):`,
775
+ replyMarkup: keyboard,
776
+ });
777
+ }
778
+
779
+ async function handleCommand({ cfg, chatId, userId, messageId, command, args, argsText, session }) {
780
+ if (command === 'start' || command === 'help') {
781
+ await sendTelegramText({
782
+ botToken: cfg.telegramBotToken,
783
+ chatId,
784
+ replyToMessageId: messageId,
785
+ text: buildHelpText(),
786
+ });
787
+ return;
788
+ }
789
+
790
+ if (command === 'library') {
791
+ await sendLibraryView({ cfg, chatId });
792
+ return;
793
+ }
794
+
795
+ if (command === 'inbox') {
796
+ const result = await listVideos({ reviewState: 'inbox', limit: 15 });
797
+ if (result.items.length === 0) {
798
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Inbox tuscias.' });
799
+ return;
800
+ }
801
+
802
+ const lines = ['Inbox:'];
803
+ for (const item of result.items) {
804
+ const category = item.subscription_topic ? ` | ${item.subscription_topic}` : '';
805
+ lines.push(`- ${item.video_key} | ${truncateText(item.title_original || item.youtube_video_id, 70)}${category}`);
806
+ }
807
+
808
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: lines.join('\n') });
809
+ return;
810
+ }
811
+
812
+ if (command === 'list') {
813
+ const stateArg = args[0] && REVIEW_STATES.has(args[0]) ? args[0] : null;
814
+ const result = await listVideos({ reviewState: stateArg, limit: 20 });
815
+ if (result.items.length === 0) {
816
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Irasu nerasta.' });
817
+ return;
818
+ }
819
+
820
+ const lines = [`Irasai${stateArg ? ` (${stateArg})` : ''}:`];
821
+ for (const item of result.items) {
822
+ lines.push(`- ${item.video_key} | ${item.review_state} | ${truncateText(item.title_original || item.youtube_video_id, 64)}`);
823
+ }
824
+
825
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: lines.join('\n') });
826
+ return;
827
+ }
828
+
829
+ if (command === 'categories') {
830
+ const categories = await listArchiveCategories();
831
+ if (categories.length === 0) {
832
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Kategoriju dar nera.' });
833
+ return;
834
+ }
835
+
836
+ const lines = ['Kategorijos:'];
837
+ for (const category of categories) {
838
+ lines.push(`- ${category.category} (${category.count})`);
839
+ }
840
+
841
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: lines.join('\n') });
842
+ return;
843
+ }
844
+
845
+ if (command === 'rebuild') {
846
+ const target = String(argsText || '').trim();
847
+ if (!target) {
848
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Naudojimas: /rebuild <video_key|youtube_url>' });
849
+ return;
850
+ }
851
+
852
+ const maybeUrl = extractYouTubeUrl(target);
853
+ if (maybeUrl) {
854
+ await runYoutubeWorkflow({
855
+ cfg,
856
+ chatId,
857
+ session,
858
+ sourceText: maybeUrl,
859
+ replyToMessageId: messageId,
860
+ forceRebuild: true,
861
+ });
862
+ return;
863
+ }
864
+
865
+ await runRebuildByVideoKey({
866
+ cfg,
867
+ chatId,
868
+ session,
869
+ videoKey: target,
870
+ replyToMessageId: messageId,
871
+ });
872
+ return;
873
+ }
874
+
875
+ if (command === 'get' || command === 'summary') {
876
+ const videoKey = resolveVideoKey(session, args[0]);
877
+ if (!videoKey) {
878
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Nurodyk video_key. Pvz: /get vid_xxx' });
879
+ return;
880
+ }
881
+
882
+ const archiveItem = await getVideoByKey(videoKey);
883
+ if (!archiveItem) {
884
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Irasas nerastas.' });
885
+ return;
886
+ }
887
+
888
+ rememberVideo(session, archiveItem);
889
+ await sendSummaryResult({ cfg, chatId, session, archiveItem, replyToMessageId: messageId });
890
+ return;
891
+ }
892
+
893
+ if (command === 'outline') {
894
+ const videoKey = resolveVideoKey(session, args[0]);
895
+ if (!videoKey) {
896
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Nurodyk video_key: /outline vid_xxx' });
897
+ return;
898
+ }
899
+
900
+ const archiveItem = await getVideoByKey(videoKey);
901
+ if (!archiveItem) {
902
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Irasas nerastas.' });
903
+ return;
904
+ }
905
+
906
+ rememberVideo(session, archiveItem);
907
+ await sendOutlineText({ cfg, chatId, archiveItem });
908
+ return;
909
+ }
910
+
911
+ if (command === 'topics') {
912
+ const videoKey = resolveVideoKey(session, args[0]);
913
+ if (!videoKey) {
914
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Nurodyk video_key: /topics vid_xxx' });
915
+ return;
916
+ }
917
+
918
+ await handleTopicListRequest({ cfg, chatId, session, videoKey });
919
+ return;
920
+ }
921
+
922
+ if (command === 'edited') {
923
+ const videoKey = resolveVideoKey(session, args[0]);
924
+ if (!videoKey) {
925
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Nurodyk video_key: /edited vid_xxx' });
926
+ return;
927
+ }
928
+
929
+ const archiveItem = await getVideoByKey(videoKey);
930
+ if (!archiveItem) {
931
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Irasas nerastas.' });
932
+ return;
933
+ }
934
+
935
+ rememberVideo(session, archiveItem);
936
+ await sendTelegramDocument({
937
+ botToken: cfg.telegramBotToken,
938
+ chatId,
939
+ filename: `${videoKey}.edited.md`,
940
+ caption: `Edited transcript: ${videoKey}`,
941
+ content: buildEditedMd(archiveItem),
942
+ });
943
+ return;
944
+ }
945
+
946
+ if (command === 'md') {
947
+ const videoKey = resolveVideoKey(session, args[0]);
948
+ if (!videoKey) {
949
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Nurodyk video_key: /md vid_xxx' });
950
+ return;
951
+ }
952
+
953
+ const archiveItem = await getVideoByKey(videoKey);
954
+ if (!archiveItem) {
955
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Irasas nerastas.' });
956
+ return;
957
+ }
958
+
959
+ rememberVideo(session, archiveItem);
960
+ const content = renderUserFacingMarkdown(
961
+ {
962
+ video: archiveItem.video,
963
+ videoText: archiveItem.video_text,
964
+ segments: archiveItem.segments || [],
965
+ },
966
+ {
967
+ includeEdited: true,
968
+ debug: false,
969
+ }
970
+ );
971
+
972
+ await sendTelegramDocument({
973
+ botToken: cfg.telegramBotToken,
974
+ chatId,
975
+ filename: `${videoKey}.md`,
976
+ caption: `Master markdown: ${videoKey}`,
977
+ content,
978
+ });
979
+ return;
980
+ }
981
+
982
+ if (command === 'save') {
983
+ const explicitKey = args[0] && String(args[0]).startsWith('vid_') ? args[0] : '';
984
+ const videoKey = resolveVideoKey(session, explicitKey);
985
+ const categoryText = explicitKey ? args.slice(1).join(' ').trim() : args.join(' ').trim();
986
+
987
+ if (!videoKey) {
988
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Naudojimas: /save [video_key] [category]' });
989
+ return;
990
+ }
991
+
992
+ await setReviewState({ videoKey, reviewState: 'saved', note: 'telegram-save' });
993
+
994
+ if (categoryText) {
995
+ const updated = await setVideoCategory({ videoKey, category: categoryText, actor: 'telegram-owner' });
996
+ if (updated.subscription_topic) rememberCategory(session, updated.subscription_topic);
997
+ await sendTelegramText({
998
+ botToken: cfg.telegramBotToken,
999
+ chatId,
1000
+ text: `Issaugota: ${videoKey} | category: ${categoryText}`,
1001
+ });
1002
+ return;
1003
+ }
1004
+
1005
+ await sendSaveWithCategoryHints({
1006
+ cfg,
1007
+ chatId,
1008
+ session,
1009
+ videoKey,
1010
+ baseMessage: `Issaugota: ${videoKey}. Pasirink kategorija:`,
1011
+ });
1012
+ return;
1013
+ }
1014
+
1015
+ if (command === 'discard') {
1016
+ const videoKey = resolveVideoKey(session, args[0]);
1017
+ if (!videoKey) {
1018
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Naudojimas: /discard [video_key]' });
1019
+ return;
1020
+ }
1021
+
1022
+ await setReviewState({ videoKey, reviewState: 'discarded', note: 'telegram-discard' });
1023
+ session.lastVideoKey = videoKey;
1024
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: `Discarded: ${videoKey}` });
1025
+ return;
1026
+ }
1027
+
1028
+ if (command === 'category' || command === 'dir') {
1029
+ const explicitKey = args[0] && String(args[0]).startsWith('vid_') ? args[0] : '';
1030
+ const videoKey = resolveVideoKey(session, explicitKey);
1031
+ const category = explicitKey ? args.slice(1).join(' ').trim() : args.join(' ').trim();
1032
+
1033
+ if (!videoKey || !category) {
1034
+ await sendTelegramText({
1035
+ botToken: cfg.telegramBotToken,
1036
+ chatId,
1037
+ text: 'Naudojimas: /category [video_key] <category>',
1038
+ });
1039
+ return;
1040
+ }
1041
+
1042
+ const nextCategory = category === '-' ? null : category;
1043
+ const updated = await setVideoCategory({ videoKey, category: nextCategory, actor: 'telegram-owner' });
1044
+ if (updated.subscription_topic) rememberCategory(session, updated.subscription_topic);
1045
+ session.lastVideoKey = videoKey;
1046
+
1047
+ await sendTelegramText({
1048
+ botToken: cfg.telegramBotToken,
1049
+ chatId,
1050
+ text: updated.subscription_topic
1051
+ ? `Kategorija nustatyta: ${videoKey} -> ${updated.subscription_topic}`
1052
+ : `Kategorija isvalyta: ${videoKey}`,
1053
+ });
1054
+ return;
1055
+ }
1056
+
1057
+ if (command === 'note') {
1058
+ const explicitKey = args[0] && String(args[0]).startsWith('vid_') ? args[0] : '';
1059
+ const videoKey = resolveVideoKey(session, explicitKey);
1060
+ const noteBody = explicitKey ? args.slice(1).join(' ').trim() : args.join(' ').trim();
1061
+
1062
+ if (!videoKey || !noteBody) {
1063
+ await sendTelegramText({
1064
+ botToken: cfg.telegramBotToken,
1065
+ chatId,
1066
+ text: 'Naudojimas: /note [video_key] <tekstas>',
1067
+ });
1068
+ return;
1069
+ }
1070
+
1071
+ const note = await addEditorialNote({
1072
+ videoKey,
1073
+ noteType: 'telegram-note',
1074
+ body: noteBody,
1075
+ });
1076
+
1077
+ session.lastVideoKey = videoKey;
1078
+ await sendTelegramText({
1079
+ botToken: cfg.telegramBotToken,
1080
+ chatId,
1081
+ text: `Pastaba issaugota: ${note.note_key}`,
1082
+ });
1083
+ return;
1084
+ }
1085
+
1086
+ if (command === 'ask') {
1087
+ let videoKey = '';
1088
+ let question = '';
1089
+
1090
+ if (args[0] && String(args[0]).startsWith('vid_')) {
1091
+ videoKey = args[0];
1092
+ question = args.slice(1).join(' ').trim();
1093
+ } else {
1094
+ videoKey = resolveVideoKey(session, '');
1095
+ question = argsText;
1096
+ }
1097
+
1098
+ if (!videoKey || !question) {
1099
+ await sendTelegramText({
1100
+ botToken: cfg.telegramBotToken,
1101
+ chatId,
1102
+ text: 'Naudojimas: /ask [video_key] <klausimas>',
1103
+ });
1104
+ return;
1105
+ }
1106
+
1107
+ const archiveItem = await getVideoByKey(videoKey);
1108
+ if (!archiveItem) {
1109
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Irasas nerastas.' });
1110
+ return;
1111
+ }
1112
+
1113
+ rememberVideo(session, archiveItem);
1114
+ await sendTelegramChatAction({ botToken: cfg.telegramBotToken, chatId, action: 'typing' });
1115
+ const answer = await answerQuestionAboutVideo({ question, archiveItem });
1116
+
1117
+ await sendTelegramText({
1118
+ botToken: cfg.telegramBotToken,
1119
+ chatId,
1120
+ text: answer,
1121
+ });
1122
+ return;
1123
+ }
1124
+
1125
+ await sendTelegramText({
1126
+ botToken: cfg.telegramBotToken,
1127
+ chatId,
1128
+ text: `Neatpazinta komanda: /${command}\n\n${buildHelpText()}`,
1129
+ });
1130
+ }
1131
+
1132
+ async function handleNaturalLanguageFallback({ cfg, chatId, text, session }) {
1133
+ const key = resolveVideoKey(session, '');
1134
+ if (!key) {
1135
+ await sendTelegramText({
1136
+ botToken: cfg.telegramBotToken,
1137
+ chatId,
1138
+ text: 'Atsiusk YouTube URL arba /help.',
1139
+ });
1140
+ return;
1141
+ }
1142
+
1143
+ if (/\boutline\b/i.test(text)) {
1144
+ const archiveItem = await getVideoByKey(key);
1145
+ if (!archiveItem) {
1146
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Irasas nerastas.' });
1147
+ return;
1148
+ }
1149
+ rememberVideo(session, archiveItem);
1150
+ await sendOutlineText({ cfg, chatId, archiveItem });
1151
+ return;
1152
+ }
1153
+
1154
+ if (/\bsummary\b|santrau/i.test(text)) {
1155
+ const archiveItem = await getVideoByKey(key);
1156
+ if (!archiveItem) {
1157
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Irasas nerastas.' });
1158
+ return;
1159
+ }
1160
+ rememberVideo(session, archiveItem);
1161
+ await sendSummaryResult({ cfg, chatId, session, archiveItem });
1162
+ return;
1163
+ }
1164
+
1165
+ if (/\bsave\b|issaug|išsaug/i.test(text)) {
1166
+ await setReviewState({ videoKey: key, reviewState: 'saved', note: 'telegram-save-natural' });
1167
+
1168
+ const categoryMatch = text.match(/\b(?:i|į)\s+([^.,!?\n]{2,60})$/i);
1169
+ if (categoryMatch?.[1]) {
1170
+ const category = categoryMatch[1].trim();
1171
+ const updated = await setVideoCategory({ videoKey: key, category, actor: 'telegram-owner' });
1172
+ if (updated.subscription_topic) rememberCategory(session, updated.subscription_topic);
1173
+ await sendTelegramText({
1174
+ botToken: cfg.telegramBotToken,
1175
+ chatId,
1176
+ text: `Issaugota i kategorija: ${category}`,
1177
+ });
1178
+ return;
1179
+ }
1180
+
1181
+ await sendSaveWithCategoryHints({
1182
+ cfg,
1183
+ chatId,
1184
+ session,
1185
+ videoKey: key,
1186
+ baseMessage: `Issaugota: ${key}. Pasirink kategorija:`,
1187
+ });
1188
+ return;
1189
+ }
1190
+
1191
+ const archiveItem = await getVideoByKey(key);
1192
+ if (!archiveItem) {
1193
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: 'Irasas nerastas.' });
1194
+ return;
1195
+ }
1196
+
1197
+ rememberVideo(session, archiveItem);
1198
+ await sendTelegramChatAction({ botToken: cfg.telegramBotToken, chatId, action: 'typing' });
1199
+ const answer = await answerQuestionAboutVideo({ question: text, archiveItem });
1200
+ await sendTelegramText({ botToken: cfg.telegramBotToken, chatId, text: answer });
1201
+ }
1202
+
1203
+ async function handleCallback({ cfg, callback }) {
1204
+ const callbackId = callback?.id;
1205
+ const data = String(callback?.data || '').trim();
1206
+ const chatId = callback?.message?.chat?.id;
1207
+ const userId = callback?.from?.id || chatId;
1208
+
1209
+ if (!chatId || !data) {
1210
+ if (callbackId) {
1211
+ await answerTelegramCallbackQuery({
1212
+ botToken: cfg.telegramBotToken,
1213
+ callbackQueryId: callbackId,
1214
+ }).catch(() => {});
1215
+ }
1216
+ return;
1217
+ }
1218
+
1219
+ const session = getSession(chatId, userId);
1220
+ const parts = data.split('|');
1221
+ const type = parts[0] || '';
1222
+
1223
+ const ack = async (text = '') => {
1224
+ if (!callbackId) return;
1225
+ await answerTelegramCallbackQuery({
1226
+ botToken: cfg.telegramBotToken,
1227
+ callbackQueryId: callbackId,
1228
+ text,
1229
+ showAlert: false,
1230
+ }).catch(() => {});
1231
+ };
1232
+
1233
+ if (type === 'a') {
1234
+ const action = parts[1] || '';
1235
+ const videoKey = parts[2] || '';
1236
+ const archiveItem = await getVideoByKey(videoKey);
1237
+
1238
+ if (!archiveItem) {
1239
+ await ack('Irasas nerastas');
1240
+ return;
1241
+ }
1242
+
1243
+ rememberVideo(session, archiveItem);
1244
+
1245
+ if (action === 'out') {
1246
+ await ack('Outline');
1247
+ await sendOutlineText({ cfg, chatId, archiveItem });
1248
+ return;
1249
+ }
1250
+
1251
+ if (action === 'ed') {
1252
+ await ack('Edited transcript');
1253
+ await sendTelegramDocument({
1254
+ botToken: cfg.telegramBotToken,
1255
+ chatId,
1256
+ filename: `${videoKey}.edited.md`,
1257
+ caption: `Edited transcript: ${videoKey}`,
1258
+ content: buildEditedMd(archiveItem),
1259
+ });
1260
+ return;
1261
+ }
1262
+
1263
+ if (action === 'ask') {
1264
+ await ack('Ask mode');
1265
+ await sendTelegramText({
1266
+ botToken: cfg.telegramBotToken,
1267
+ chatId,
1268
+ text: `Užduok klausimą apie ${videoKey}. Gali rašyti tiesiog tekstą arba /ask <klausimas>.`,
1269
+ });
1270
+ return;
1271
+ }
1272
+
1273
+ if (action === 'save') {
1274
+ await setReviewState({ videoKey, reviewState: 'saved', note: 'telegram-save-button' });
1275
+ await ack('Saved');
1276
+ await sendSaveWithCategoryHints({
1277
+ cfg,
1278
+ chatId,
1279
+ session,
1280
+ videoKey,
1281
+ baseMessage: `Issaugota: ${videoKey}. Pasirink kategorija:`,
1282
+ });
1283
+ return;
1284
+ }
1285
+
1286
+ if (action === 'topics') {
1287
+ await ack('Topics');
1288
+ await handleTopicListRequest({ cfg, chatId, session, videoKey });
1289
+ return;
1290
+ }
1291
+
1292
+ await ack('Unknown action');
1293
+ return;
1294
+ }
1295
+
1296
+ if (type === 'c') {
1297
+ const videoKey = parts[1] || '';
1298
+ const indexRaw = parts[2] || '';
1299
+
1300
+ if (indexRaw === 'new') {
1301
+ await ack('New category');
1302
+ await sendTelegramText({
1303
+ botToken: cfg.telegramBotToken,
1304
+ chatId,
1305
+ text: `Nauja kategorija: /category ${videoKey} <category>`,
1306
+ });
1307
+ return;
1308
+ }
1309
+
1310
+ const idx = Number(indexRaw);
1311
+ const options = Array.isArray(session.categoryOptionsByVideo[videoKey]) ? session.categoryOptionsByVideo[videoKey] : [];
1312
+ const category = Number.isInteger(idx) && idx >= 0 ? options[idx] : null;
1313
+
1314
+ if (!category) {
1315
+ await ack('Category not found');
1316
+ await sendTelegramText({
1317
+ botToken: cfg.telegramBotToken,
1318
+ chatId,
1319
+ text: 'Kategorijos pasirinkimas nebegalioja. Pakartok /save.',
1320
+ });
1321
+ return;
1322
+ }
1323
+
1324
+ const updated = await setVideoCategory({ videoKey, category, actor: 'telegram-owner' });
1325
+ if (updated.subscription_topic) rememberCategory(session, updated.subscription_topic);
1326
+
1327
+ await ack('Category saved');
1328
+ await sendTelegramText({
1329
+ botToken: cfg.telegramBotToken,
1330
+ chatId,
1331
+ text: `Kategorija nustatyta: ${videoKey} -> ${category}`,
1332
+ });
1333
+ return;
1334
+ }
1335
+
1336
+ if (type === 't') {
1337
+ const videoKey = parts[1] || '';
1338
+ const indexRaw = parts[2] || '';
1339
+ const archiveItem = await getVideoByKey(videoKey);
1340
+
1341
+ if (!archiveItem) {
1342
+ await ack('Irasas nerastas');
1343
+ return;
1344
+ }
1345
+
1346
+ rememberVideo(session, archiveItem);
1347
+ const topic = resolveTopicByIndex(session, archiveItem, videoKey, indexRaw);
1348
+ if (!topic) {
1349
+ await ack('Topic not found');
1350
+ return;
1351
+ }
1352
+
1353
+ await ack('Topic');
1354
+ const idx = Number(indexRaw);
1355
+ const detailKeyboard = {
1356
+ inline_keyboard: [[{ text: 'Show evidence', callback_data: `e|${videoKey}|${idx}` }]],
1357
+ };
1358
+
1359
+ await sendTelegramText({
1360
+ botToken: cfg.telegramBotToken,
1361
+ chatId,
1362
+ text: formatTopicDetail(topic),
1363
+ replyMarkup: detailKeyboard,
1364
+ });
1365
+ return;
1366
+ }
1367
+
1368
+ if (type === 'e') {
1369
+ const videoKey = parts[1] || '';
1370
+ const indexRaw = parts[2] || '';
1371
+ const archiveItem = await getVideoByKey(videoKey);
1372
+
1373
+ if (!archiveItem) {
1374
+ await ack('Irasas nerastas');
1375
+ return;
1376
+ }
1377
+
1378
+ const topic = resolveTopicByIndex(session, archiveItem, videoKey, indexRaw);
1379
+ if (!topic) {
1380
+ await ack('Topic not found');
1381
+ return;
1382
+ }
1383
+
1384
+ await ack('Evidence');
1385
+ await sendTelegramText({
1386
+ botToken: cfg.telegramBotToken,
1387
+ chatId,
1388
+ text: buildTopicEvidence(topic, archiveItem.segments || []),
1389
+ });
1390
+ return;
1391
+ }
1392
+
1393
+ await ack('Unsupported action');
1394
+ }
1395
+
1396
+ export async function handleTelegramUpdate(update) {
1397
+ const cfg = getConfig();
1398
+ if (!cfg.telegramBotToken) {
1399
+ logger.warn('Telegram update skipped: TELEGRAM_BOT_TOKEN missing');
1400
+ return;
1401
+ }
1402
+
1403
+ const callback = extractCallbackQuery(update);
1404
+ if (callback) {
1405
+ const chatId = callback?.message?.chat?.id;
1406
+ if (chatId && !ensureAllowedChat(cfg, chatId)) {
1407
+ await answerTelegramCallbackQuery({
1408
+ botToken: cfg.telegramBotToken,
1409
+ callbackQueryId: callback.id,
1410
+ text: 'Unauthorized chat',
1411
+ showAlert: true,
1412
+ }).catch(() => {});
1413
+ return;
1414
+ }
1415
+
1416
+ try {
1417
+ await handleCallback({ cfg, callback });
1418
+ } catch (error) {
1419
+ logger.error('Telegram callback failed', {
1420
+ chatId: String(chatId || ''),
1421
+ message: error?.message || String(error),
1422
+ });
1423
+ if (callback?.id) {
1424
+ await answerTelegramCallbackQuery({
1425
+ botToken: cfg.telegramBotToken,
1426
+ callbackQueryId: callback.id,
1427
+ text: 'Klaida apdorojant veiksmą',
1428
+ showAlert: true,
1429
+ }).catch(() => {});
1430
+ }
1431
+ }
1432
+ return;
1433
+ }
1434
+
1435
+ const message = extractMessage(update);
1436
+ if (!message || !message.chat?.id) {
1437
+ return;
1438
+ }
1439
+
1440
+ const chatId = message.chat.id;
1441
+ const userId = message.from?.id || chatId;
1442
+ const messageId = message.message_id || null;
1443
+ const text = String(message.text || '').trim();
1444
+ if (!text) return;
1445
+
1446
+ if (!ensureAllowedChat(cfg, chatId)) {
1447
+ await sendTelegramText({
1448
+ botToken: cfg.telegramBotToken,
1449
+ chatId,
1450
+ text: 'Sis botas konfiguruotas tik savininko chat ID.',
1451
+ replyToMessageId: messageId,
1452
+ });
1453
+ return;
1454
+ }
1455
+
1456
+ const session = getSession(chatId, userId);
1457
+
1458
+ const handledByUrl = await runYoutubeWorkflow({
1459
+ cfg,
1460
+ chatId,
1461
+ session,
1462
+ sourceText: text,
1463
+ replyToMessageId: messageId,
1464
+ });
1465
+ if (handledByUrl) return;
1466
+
1467
+ const parsed = parseCommand(text);
1468
+ if (!parsed) {
1469
+ try {
1470
+ await handleNaturalLanguageFallback({
1471
+ cfg,
1472
+ chatId,
1473
+ text,
1474
+ session,
1475
+ });
1476
+ } catch (error) {
1477
+ logger.error('Telegram natural message failed', {
1478
+ chatId: String(chatId),
1479
+ userId: String(userId),
1480
+ message: error?.message || String(error),
1481
+ });
1482
+ await sendTelegramText({
1483
+ botToken: cfg.telegramBotToken,
1484
+ chatId,
1485
+ text: `Klaida: ${error?.message || 'nezinoma klaida'}`,
1486
+ });
1487
+ }
1488
+ return;
1489
+ }
1490
+
1491
+ try {
1492
+ await handleCommand({
1493
+ cfg,
1494
+ chatId,
1495
+ userId,
1496
+ messageId,
1497
+ command: parsed.command,
1498
+ args: parsed.args,
1499
+ argsText: parsed.argsText,
1500
+ session,
1501
+ });
1502
+ } catch (error) {
1503
+ logger.error('Telegram command failed', {
1504
+ command: parsed.command,
1505
+ chatId: String(chatId),
1506
+ message: error?.message || String(error),
1507
+ });
1508
+ await sendTelegramText({
1509
+ botToken: cfg.telegramBotToken,
1510
+ chatId,
1511
+ text: `Klaida: ${error?.message || 'nezinoma klaida'}`,
1512
+ });
1513
+ }
1514
+ }
services/telegram/telegram-client.js ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { logger } from '../../lib/logger.js';
2
+
3
+ const TELEGRAM_API_ROOT = 'https://api.telegram.org';
4
+ const TELEGRAM_MAX_TEXT = 3800;
5
+
6
+ function assertToken(botToken) {
7
+ const token = String(botToken || '').trim();
8
+ if (!token) {
9
+ throw new Error('TELEGRAM_BOT_TOKEN is not configured');
10
+ }
11
+ return token;
12
+ }
13
+
14
+ function buildUrl(botToken, method) {
15
+ return `${TELEGRAM_API_ROOT}/bot${botToken}/${method}`;
16
+ }
17
+
18
+ async function callTelegram(botToken, method, body, { isFormData = false } = {}) {
19
+ const token = assertToken(botToken);
20
+ const response = await fetch(buildUrl(token, method), {
21
+ method: 'POST',
22
+ headers: isFormData ? undefined : { 'Content-Type': 'application/json' },
23
+ body: isFormData ? body : JSON.stringify(body),
24
+ });
25
+
26
+ const payload = await response.json().catch(() => ({}));
27
+ if (!response.ok || payload?.ok === false) {
28
+ const description = payload?.description || `Telegram API error (${response.status})`;
29
+ throw new Error(description);
30
+ }
31
+ return payload.result;
32
+ }
33
+
34
+ export function splitTelegramText(input, maxLength = TELEGRAM_MAX_TEXT) {
35
+ const text = String(input || '').trim();
36
+ if (!text) return [];
37
+ if (text.length <= maxLength) return [text];
38
+
39
+ const lines = text.split('\n');
40
+ const chunks = [];
41
+ let current = '';
42
+
43
+ for (const line of lines) {
44
+ if (!current) {
45
+ if (line.length <= maxLength) {
46
+ current = line;
47
+ continue;
48
+ }
49
+
50
+ const words = line.split(' ');
51
+ let wordChunk = '';
52
+ for (const word of words) {
53
+ const candidate = wordChunk ? `${wordChunk} ${word}` : word;
54
+ if (candidate.length > maxLength) {
55
+ if (wordChunk) chunks.push(wordChunk);
56
+ wordChunk = word;
57
+ } else {
58
+ wordChunk = candidate;
59
+ }
60
+ }
61
+ if (wordChunk) {
62
+ current = wordChunk;
63
+ }
64
+ continue;
65
+ }
66
+
67
+ const candidate = `${current}\n${line}`;
68
+ if (candidate.length > maxLength) {
69
+ chunks.push(current);
70
+ current = line;
71
+ } else {
72
+ current = candidate;
73
+ }
74
+ }
75
+
76
+ if (current) chunks.push(current);
77
+ return chunks;
78
+ }
79
+
80
+ export async function sendTelegramText({
81
+ botToken,
82
+ chatId,
83
+ text,
84
+ disableWebPagePreview = true,
85
+ replyToMessageId = null,
86
+ replyMarkup = null,
87
+ } = {}) {
88
+ const chunks = splitTelegramText(text);
89
+ if (chunks.length === 0) return [];
90
+
91
+ const results = [];
92
+ for (let index = 0; index < chunks.length; index += 1) {
93
+ const chunk = chunks[index];
94
+ const payload = {
95
+ chat_id: chatId,
96
+ text: chunk,
97
+ disable_web_page_preview: disableWebPagePreview,
98
+ };
99
+ if (replyToMessageId) {
100
+ payload.reply_to_message_id = replyToMessageId;
101
+ }
102
+ if (replyMarkup && index === chunks.length - 1) {
103
+ payload.reply_markup = replyMarkup;
104
+ }
105
+ const sent = await callTelegram(botToken, 'sendMessage', payload);
106
+ results.push(sent);
107
+ }
108
+ return results;
109
+ }
110
+
111
+ export async function editTelegramText({
112
+ botToken,
113
+ chatId,
114
+ messageId,
115
+ text,
116
+ disableWebPagePreview = true,
117
+ replyMarkup = null,
118
+ } = {}) {
119
+ const payload = {
120
+ chat_id: chatId,
121
+ message_id: messageId,
122
+ text: String(text || '').trim(),
123
+ disable_web_page_preview: disableWebPagePreview,
124
+ };
125
+ if (replyMarkup) {
126
+ payload.reply_markup = replyMarkup;
127
+ }
128
+
129
+ return callTelegram(botToken, 'editMessageText', payload);
130
+ }
131
+
132
+ export async function sendTelegramChatAction({ botToken, chatId, action = 'typing' } = {}) {
133
+ try {
134
+ await callTelegram(botToken, 'sendChatAction', {
135
+ chat_id: chatId,
136
+ action,
137
+ });
138
+ } catch (error) {
139
+ logger.debug('Telegram chat action failed', {
140
+ action,
141
+ message: error?.message || String(error),
142
+ });
143
+ }
144
+ }
145
+
146
+ export async function sendTelegramDocument({
147
+ botToken,
148
+ chatId,
149
+ filename,
150
+ content,
151
+ caption = '',
152
+ } = {}) {
153
+ const data = new FormData();
154
+ data.append('chat_id', String(chatId));
155
+ if (caption) data.append('caption', String(caption));
156
+ data.append(
157
+ 'document',
158
+ new Blob([String(content || '')], { type: 'text/markdown;charset=utf-8' }),
159
+ String(filename || 'artifact.md')
160
+ );
161
+
162
+ return callTelegram(botToken, 'sendDocument', data, { isFormData: true });
163
+ }
164
+
165
+ export async function answerTelegramCallbackQuery({
166
+ botToken,
167
+ callbackQueryId,
168
+ text = '',
169
+ showAlert = false,
170
+ } = {}) {
171
+ return callTelegram(botToken, 'answerCallbackQuery', {
172
+ callback_query_id: callbackQueryId,
173
+ text: String(text || ''),
174
+ show_alert: Boolean(showAlert),
175
+ });
176
+ }