thepopebot 1.2.0 → 1.2.4

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.
Files changed (139) hide show
  1. package/README.md +1 -1
  2. package/api/index.js +50 -30
  3. package/bin/cli.js +36 -6
  4. package/bin/{dev.sh → local.sh} +2 -1
  5. package/bin/postinstall.js +6 -2
  6. package/config/index.js +1 -15
  7. package/config/instrumentation.js +17 -5
  8. package/lib/actions.js +7 -6
  9. package/lib/ai/agent.js +11 -13
  10. package/lib/ai/index.js +153 -26
  11. package/lib/ai/model.js +35 -12
  12. package/lib/ai/tools.js +5 -5
  13. package/lib/auth/actions.js +28 -0
  14. package/lib/auth/config.js +45 -0
  15. package/lib/auth/index.js +27 -0
  16. package/lib/auth/middleware.js +30 -0
  17. package/lib/channels/base.js +1 -1
  18. package/lib/channels/index.js +2 -4
  19. package/lib/channels/telegram.js +5 -5
  20. package/lib/chat/actions.js +239 -0
  21. package/lib/chat/api.js +103 -0
  22. package/lib/chat/components/app-sidebar.js +161 -0
  23. package/lib/chat/components/app-sidebar.jsx +214 -0
  24. package/lib/chat/components/chat-header.js +9 -0
  25. package/lib/chat/components/chat-header.jsx +14 -0
  26. package/lib/chat/components/chat-input.js +230 -0
  27. package/lib/chat/components/chat-input.jsx +232 -0
  28. package/lib/chat/components/chat-nav-context.js +11 -0
  29. package/lib/chat/components/chat-nav-context.jsx +11 -0
  30. package/lib/chat/components/chat-page.js +70 -0
  31. package/lib/chat/components/chat-page.jsx +89 -0
  32. package/lib/chat/components/chat.js +78 -0
  33. package/lib/chat/components/chat.jsx +91 -0
  34. package/lib/chat/components/chats-page.js +170 -0
  35. package/lib/chat/components/chats-page.jsx +203 -0
  36. package/lib/chat/components/crons-page.js +144 -0
  37. package/lib/chat/components/crons-page.jsx +204 -0
  38. package/lib/chat/components/greeting.js +11 -0
  39. package/lib/chat/components/greeting.jsx +14 -0
  40. package/lib/chat/components/icons.js +518 -0
  41. package/lib/chat/components/icons.jsx +482 -0
  42. package/lib/chat/components/index.js +19 -0
  43. package/lib/chat/components/message.js +66 -0
  44. package/lib/chat/components/message.jsx +92 -0
  45. package/lib/chat/components/messages.js +63 -0
  46. package/lib/chat/components/messages.jsx +72 -0
  47. package/lib/chat/components/notifications-page.js +54 -0
  48. package/lib/chat/components/notifications-page.jsx +83 -0
  49. package/lib/chat/components/page-layout.js +21 -0
  50. package/lib/chat/components/page-layout.jsx +28 -0
  51. package/lib/chat/components/settings-layout.js +37 -0
  52. package/lib/chat/components/settings-layout.jsx +51 -0
  53. package/lib/chat/components/settings-secrets-page.js +216 -0
  54. package/lib/chat/components/settings-secrets-page.jsx +264 -0
  55. package/lib/chat/components/sidebar-history-item.js +54 -0
  56. package/lib/chat/components/sidebar-history-item.jsx +50 -0
  57. package/lib/chat/components/sidebar-history.js +92 -0
  58. package/lib/chat/components/sidebar-history.jsx +132 -0
  59. package/lib/chat/components/sidebar-user-nav.js +59 -0
  60. package/lib/chat/components/sidebar-user-nav.jsx +69 -0
  61. package/lib/chat/components/swarm-page.js +250 -0
  62. package/lib/chat/components/swarm-page.jsx +356 -0
  63. package/lib/chat/components/triggers-page.js +121 -0
  64. package/lib/chat/components/triggers-page.jsx +177 -0
  65. package/lib/chat/components/ui/dropdown-menu.js +98 -0
  66. package/lib/chat/components/ui/dropdown-menu.jsx +116 -0
  67. package/lib/chat/components/ui/scroll-area.js +13 -0
  68. package/lib/chat/components/ui/scroll-area.jsx +17 -0
  69. package/lib/chat/components/ui/separator.js +21 -0
  70. package/lib/chat/components/ui/separator.jsx +18 -0
  71. package/lib/chat/components/ui/sheet.js +75 -0
  72. package/lib/chat/components/ui/sheet.jsx +95 -0
  73. package/lib/chat/components/ui/sidebar.js +227 -0
  74. package/lib/chat/components/ui/sidebar.jsx +245 -0
  75. package/lib/chat/components/ui/tooltip.js +56 -0
  76. package/lib/chat/components/ui/tooltip.jsx +66 -0
  77. package/lib/chat/utils.js +11 -0
  78. package/lib/cron.js +7 -8
  79. package/lib/db/api-keys.js +160 -0
  80. package/lib/db/chats.js +129 -0
  81. package/lib/db/index.js +106 -0
  82. package/lib/db/notifications.js +99 -0
  83. package/lib/db/schema.js +51 -0
  84. package/lib/db/users.js +89 -0
  85. package/lib/paths.js +22 -19
  86. package/lib/tools/create-job.js +3 -3
  87. package/lib/tools/github.js +145 -1
  88. package/lib/tools/openai.js +1 -1
  89. package/lib/tools/telegram.js +4 -3
  90. package/lib/triggers.js +6 -7
  91. package/lib/utils/render-md.js +6 -6
  92. package/package.json +33 -3
  93. package/setup/lib/auth.mjs +22 -9
  94. package/setup/lib/prerequisites.mjs +10 -3
  95. package/setup/lib/telegram-verify.mjs +3 -16
  96. package/setup/setup-telegram.mjs +31 -62
  97. package/setup/setup.mjs +58 -98
  98. package/templates/.dockerignore +5 -0
  99. package/templates/.env.example +18 -2
  100. package/templates/.github/workflows/auto-merge.yml +1 -1
  101. package/templates/.github/workflows/build-image.yml +6 -4
  102. package/templates/.github/workflows/notify-job-failed.yml +2 -2
  103. package/templates/.github/workflows/notify-pr-complete.yml +2 -2
  104. package/templates/.github/workflows/run-job.yml +24 -10
  105. package/templates/CLAUDE.md +5 -3
  106. package/templates/app/api/auth/[...nextauth]/route.js +1 -0
  107. package/templates/app/api/chat/route.js +1 -0
  108. package/templates/app/chat/[chatId]/page.js +8 -0
  109. package/templates/app/chats/page.js +7 -0
  110. package/templates/app/components/ascii-logo.jsx +10 -0
  111. package/templates/app/components/login-form.jsx +81 -0
  112. package/templates/app/components/setup-form.jsx +82 -0
  113. package/templates/app/components/theme-provider.jsx +11 -0
  114. package/templates/app/components/theme-toggle.jsx +38 -0
  115. package/templates/app/components/ui/button.jsx +21 -0
  116. package/templates/app/components/ui/card.jsx +23 -0
  117. package/templates/app/components/ui/input.jsx +10 -0
  118. package/templates/app/components/ui/label.jsx +10 -0
  119. package/templates/app/crons/page.js +7 -0
  120. package/templates/app/globals.css +66 -0
  121. package/templates/app/layout.js +9 -2
  122. package/templates/app/login/page.js +15 -0
  123. package/templates/app/notifications/page.js +7 -0
  124. package/templates/app/page.js +6 -30
  125. package/templates/app/settings/layout.js +7 -0
  126. package/templates/app/settings/page.js +5 -0
  127. package/templates/app/settings/secrets/page.js +5 -0
  128. package/templates/app/swarm/page.js +7 -0
  129. package/templates/app/triggers/page.js +7 -0
  130. package/templates/config/CRONS.json +2 -2
  131. package/templates/config/TRIGGERS.json +2 -2
  132. package/templates/docker/event-handler/Dockerfile +19 -0
  133. package/templates/docker/{entrypoint.sh → job/entrypoint.sh} +4 -4
  134. package/templates/docker-compose.yml +52 -0
  135. package/templates/instrumentation.js +6 -1
  136. package/templates/middleware.js +1 -0
  137. package/templates/postcss.config.mjs +5 -0
  138. package/lib/ai/memory.js +0 -39
  139. /package/templates/docker/{Dockerfile → job/Dockerfile} +0 -0
package/README.md CHANGED
@@ -111,7 +111,7 @@ The wizard walks you through everything:
111
111
  **Step 3** — Start using your agent:
112
112
 
113
113
  - **Telegram**: Message your bot to create jobs conversationally. Ask it to do tasks, check job status, or just chat.
114
- - **Webhook**: Send a POST to `/api/webhook` with your API key to create jobs programmatically.
114
+ - **Webhook**: Send a POST to `/api/create-job` with your API key to create jobs programmatically.
115
115
  - **Cron**: Edit `config/CRONS.json` to schedule recurring jobs.
116
116
 
117
117
  ---
package/api/index.js CHANGED
@@ -1,8 +1,12 @@
1
- const { createJob } = require('../lib/tools/create-job');
2
- const { setWebhook, sendMessage } = require('../lib/tools/telegram');
3
- const { getJobStatus } = require('../lib/tools/github');
4
- const { getTelegramAdapter } = require('../lib/channels');
5
- const { chat, summarizeJob, addToThread } = require('../lib/ai');
1
+ import { createHash, timingSafeEqual } from 'crypto';
2
+ import { createJob } from '../lib/tools/create-job.js';
3
+ import { setWebhook } from '../lib/tools/telegram.js';
4
+ import { getJobStatus } from '../lib/tools/github.js';
5
+ import { getTelegramAdapter } from '../lib/channels/index.js';
6
+ import { chat, summarizeJob } from '../lib/ai/index.js';
7
+ import { createNotification } from '../lib/db/notifications.js';
8
+ import { loadTriggers } from '../lib/triggers.js';
9
+ import { verifyApiKey } from '../lib/db/api-keys.js';
6
10
 
7
11
  // Bot token from env, can be overridden by /telegram/register
8
12
  let telegramBotToken = null;
@@ -19,7 +23,6 @@ function getTelegramBotToken() {
19
23
 
20
24
  function getFireTriggers() {
21
25
  if (!_fireTriggers) {
22
- const { loadTriggers } = require('../lib/triggers');
23
26
  const result = loadTriggers();
24
27
  _fireTriggers = result.fireTriggers;
25
28
  }
@@ -30,18 +33,39 @@ function getFireTriggers() {
30
33
  const PUBLIC_ROUTES = ['/telegram/webhook', '/github/webhook'];
31
34
 
32
35
  /**
33
- * Check API key authentication
36
+ * Timing-safe string comparison.
37
+ * @param {string} a
38
+ * @param {string} b
39
+ * @returns {boolean}
40
+ */
41
+ function safeCompare(a, b) {
42
+ if (!a || !b) return false;
43
+ const bufA = Buffer.from(a);
44
+ const bufB = Buffer.from(b);
45
+ if (bufA.length !== bufB.length) return false;
46
+ return timingSafeEqual(bufA, bufB);
47
+ }
48
+
49
+ /**
50
+ * Centralized auth gate for all API routes.
51
+ * Public routes pass through; everything else requires a valid API key from the database.
34
52
  * @param {string} routePath - The route path
35
53
  * @param {Request} request - The incoming request
36
- * @returns {Response|null} - Error response if unauthorized, null if OK
54
+ * @returns {Response|null} - Error response or null if authorized
37
55
  */
38
56
  function checkAuth(routePath, request) {
39
57
  if (PUBLIC_ROUTES.includes(routePath)) return null;
40
58
 
41
59
  const apiKey = request.headers.get('x-api-key');
42
- if (apiKey !== process.env.API_KEY) {
60
+ if (!apiKey) {
61
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
62
+ }
63
+
64
+ const record = verifyApiKey(apiKey);
65
+ if (!record) {
43
66
  return Response.json({ error: 'Unauthorized' }, { status: 401 });
44
67
  }
68
+
45
69
  return null;
46
70
  }
47
71
 
@@ -106,13 +130,19 @@ async function handleTelegramWebhook(request) {
106
130
 
107
131
  /**
108
132
  * Process a normalized message through the AI layer with channel UX.
133
+ * Message persistence is handled centrally by the AI layer.
109
134
  */
110
135
  async function processChannelMessage(adapter, normalized) {
111
136
  await adapter.acknowledge(normalized.metadata);
112
137
  const stopIndicator = adapter.startProcessingIndicator(normalized.metadata);
113
138
 
114
139
  try {
115
- const response = await chat(normalized.threadId, normalized.text, normalized.attachments);
140
+ const response = await chat(
141
+ normalized.threadId,
142
+ normalized.text,
143
+ normalized.attachments,
144
+ { userId: 'telegram', chatTitle: 'Telegram' }
145
+ );
116
146
  await adapter.sendResponse(normalized.threadId, response, normalized.metadata);
117
147
  } catch (err) {
118
148
  console.error('Failed to process message with AI:', err);
@@ -129,13 +159,12 @@ async function processChannelMessage(adapter, normalized) {
129
159
  }
130
160
 
131
161
  async function handleGithubWebhook(request) {
132
- const { GH_WEBHOOK_SECRET, TELEGRAM_CHAT_ID } = process.env;
133
- const botToken = getTelegramBotToken();
162
+ const { GH_WEBHOOK_SECRET } = process.env;
134
163
 
135
- // Validate webhook secret
164
+ // Validate webhook secret (timing-safe)
136
165
  if (GH_WEBHOOK_SECRET) {
137
166
  const headerSecret = request.headers.get('x-github-webhook-secret-token');
138
- if (headerSecret !== GH_WEBHOOK_SECRET) {
167
+ if (!safeCompare(headerSecret, GH_WEBHOOK_SECRET)) {
139
168
  return Response.json({ error: 'Unauthorized' }, { status: 401 });
140
169
  }
141
170
  }
@@ -144,11 +173,6 @@ async function handleGithubWebhook(request) {
144
173
  const jobId = payload.job_id || extractJobId(payload.branch);
145
174
  if (!jobId) return Response.json({ ok: true, skipped: true, reason: 'not a job' });
146
175
 
147
- if (!TELEGRAM_CHAT_ID || !botToken) {
148
- console.log(`Job ${jobId} completed but no chat ID to notify`);
149
- return Response.json({ ok: true, skipped: true, reason: 'no chat to notify' });
150
- }
151
-
152
176
  try {
153
177
  const results = {
154
178
  job: payload.job || '',
@@ -162,13 +186,9 @@ async function handleGithubWebhook(request) {
162
186
  };
163
187
 
164
188
  const message = await summarizeJob(results);
189
+ await createNotification(message, payload);
165
190
 
166
- await sendMessage(botToken, TELEGRAM_CHAT_ID, message);
167
-
168
- // Add the summary to chat memory so the agent has context in future conversations
169
- await addToThread(TELEGRAM_CHAT_ID, message);
170
-
171
- console.log(`Notified chat ${TELEGRAM_CHAT_ID} about job ${jobId.slice(0, 8)}`);
191
+ console.log(`Notification saved for job ${jobId.slice(0, 8)}`);
172
192
 
173
193
  return Response.json({ ok: true, notified: true });
174
194
  } catch (err) {
@@ -216,7 +236,7 @@ async function POST(request) {
216
236
 
217
237
  // Route to handler
218
238
  switch (routePath) {
219
- case '/webhook': return handleWebhook(request);
239
+ case '/create-job': return handleWebhook(request);
220
240
  case '/telegram/webhook': return handleTelegramWebhook(request);
221
241
  case '/telegram/register': return handleTelegramRegister(request);
222
242
  case '/github/webhook': return handleGithubWebhook(request);
@@ -233,10 +253,10 @@ async function GET(request) {
233
253
  if (authError) return authError;
234
254
 
235
255
  switch (routePath) {
236
- case '/ping': return Response.json({ message: 'Pong!' });
237
- case '/jobs/status': return handleJobStatus(request);
238
- default: return Response.json({ error: 'Not found' }, { status: 404 });
256
+ case '/ping': return Response.json({ message: 'Pong!' });
257
+ case '/jobs/status': return handleJobStatus(request);
258
+ default: return Response.json({ error: 'Not found' }, { status: 404 });
239
259
  }
240
260
  }
241
261
 
242
- module.exports = { GET, POST };
262
+ export { GET, POST };
package/bin/cli.js CHANGED
@@ -1,8 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { execSync } = require('child_process');
4
- const fs = require('fs');
5
- const path = require('path');
3
+ import { execSync } from 'child_process';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
6
10
 
7
11
  const command = process.argv[2];
8
12
  const args = process.argv.slice(3);
@@ -15,6 +19,7 @@ Commands:
15
19
  init Scaffold a new thepopebot project
16
20
  setup Run interactive setup wizard
17
21
  setup-telegram Reconfigure Telegram webhook
22
+ reset-auth Regenerate AUTH_SECRET (invalidates all sessions)
18
23
  reset [file] Restore a template file (or list available templates)
19
24
  `);
20
25
  }
@@ -82,17 +87,22 @@ function init() {
82
87
  name: dirName,
83
88
  private: true,
84
89
  scripts: {
85
- dev: 'next dev',
90
+ dev: 'next dev --turbopack',
86
91
  build: 'next build',
87
92
  start: 'next start',
88
93
  setup: 'thepopebot setup',
89
94
  'setup-telegram': 'thepopebot setup-telegram',
95
+ 'reset-auth': 'thepopebot reset-auth',
90
96
  },
91
97
  dependencies: {
92
98
  thepopebot: '^1.0.0',
93
- next: '^16.0.0',
99
+ next: '^15.5.12',
100
+ 'next-auth': '5.0.0-beta.30',
101
+ 'next-themes': '^0.4.0',
94
102
  react: '^19.0.0',
95
103
  'react-dom': '^19.0.0',
104
+ tailwindcss: '^4.0.0',
105
+ '@tailwindcss/postcss': '^4.0.0',
96
106
  },
97
107
  };
98
108
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
@@ -102,7 +112,7 @@ function init() {
102
112
  }
103
113
 
104
114
  // Create .gitkeep files for empty dirs
105
- const gitkeepDirs = ['cron', 'triggers', 'logs', 'tmp'];
115
+ const gitkeepDirs = ['cron', 'triggers', 'logs', 'tmp', 'data'];
106
116
  for (const dir of gitkeepDirs) {
107
117
  const gitkeep = path.join(cwd, dir, '.gitkeep');
108
118
  if (!fs.existsSync(gitkeep)) {
@@ -261,6 +271,23 @@ function setupTelegram() {
261
271
  }
262
272
  }
263
273
 
274
+ async function resetAuth() {
275
+ const { randomBytes } = await import('crypto');
276
+ const { updateEnvVariable } = await import(path.join(__dirname, '..', 'setup', 'lib', 'auth.mjs'));
277
+
278
+ const envPath = path.join(process.cwd(), '.env');
279
+ if (!fs.existsSync(envPath)) {
280
+ console.error('\n No .env file found. Run "npm run setup" first.\n');
281
+ process.exit(1);
282
+ }
283
+
284
+ const newSecret = randomBytes(32).toString('base64');
285
+ updateEnvVariable('AUTH_SECRET', newSecret);
286
+ console.log('\n AUTH_SECRET regenerated.');
287
+ console.log(' All existing sessions have been invalidated.');
288
+ console.log(' Restart your server for the change to take effect.\n');
289
+ }
290
+
264
291
  switch (command) {
265
292
  case 'init':
266
293
  init();
@@ -271,6 +298,9 @@ switch (command) {
271
298
  case 'setup-telegram':
272
299
  setupTelegram();
273
300
  break;
301
+ case 'reset-auth':
302
+ await resetAuth();
303
+ break;
274
304
  case 'reset':
275
305
  reset(args[0]);
276
306
  break;
@@ -2,7 +2,7 @@
2
2
  set -e
3
3
 
4
4
  PACKAGE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
5
- DEV_DIR="${1:-/tmp/thepopebot}"
5
+ DEV_DIR="${1:-/tmp/thepopebot.local}"
6
6
  ENV_BACKUP="/tmp/env.$(uuidgen)"
7
7
 
8
8
  HAS_ENV=false
@@ -22,6 +22,7 @@ sed -i '' "s|\"thepopebot\": \".*\"|\"thepopebot\": \"file:$PACKAGE_DIR\"|" pack
22
22
  rm -rf node_modules package-lock.json
23
23
  npm install --install-links
24
24
 
25
+
25
26
  if [ "$HAS_ENV" = true ]; then
26
27
  mv "$ENV_BACKUP" .env
27
28
  echo "Restored .env from previous build"
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const fs = require('fs');
4
- const path = require('path');
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
5
9
 
6
10
  // postinstall runs from the package dir inside node_modules.
7
11
  // The user's project root is two levels up: node_modules/thepopebot/ -> project root
package/config/index.js CHANGED
@@ -1,5 +1,3 @@
1
- const path = require('path');
2
-
3
1
  /**
4
2
  * Next.js config wrapper for thepopebot.
5
3
  * Enables instrumentation hook for cron scheduling on server start.
@@ -11,24 +9,12 @@ const path = require('path');
11
9
  * @param {Object} nextConfig - User's Next.js config
12
10
  * @returns {Object} Enhanced Next.js config
13
11
  */
14
- function withThepopebot(nextConfig = {}) {
12
+ export function withThepopebot(nextConfig = {}) {
15
13
  return {
16
14
  ...nextConfig,
17
- // Ensure server-only packages aren't bundled for client
18
15
  serverExternalPackages: [
19
16
  ...(nextConfig.serverExternalPackages || []),
20
- 'thepopebot',
21
- 'grammy',
22
- '@grammyjs/parse-mode',
23
- 'node-cron',
24
- 'uuid',
25
- '@langchain/langgraph',
26
- '@langchain/anthropic',
27
- '@langchain/core',
28
- 'zod',
29
17
  'better-sqlite3',
30
18
  ],
31
19
  };
32
20
  }
33
-
34
- module.exports = { withThepopebot };
@@ -11,19 +11,31 @@
11
11
 
12
12
  let initialized = false;
13
13
 
14
- async function register() {
14
+ export async function register() {
15
15
  // Only run on the server, and only once
16
16
  if (typeof window !== 'undefined' || initialized) return;
17
17
  initialized = true;
18
18
 
19
19
  // Load .env from project root
20
- require('dotenv').config();
20
+ const dotenv = await import('dotenv');
21
+ dotenv.config();
22
+
23
+ // Validate AUTH_SECRET is set (required by Auth.js for session encryption)
24
+ if (!process.env.AUTH_SECRET) {
25
+ console.error('\n ERROR: AUTH_SECRET is not set in your .env file.');
26
+ console.error(' This is required for session encryption.');
27
+ console.error(' Run "npm run setup" to generate it automatically, or add manually:');
28
+ console.error(' openssl rand -base64 32\n');
29
+ throw new Error('AUTH_SECRET environment variable is required');
30
+ }
31
+
32
+ // Initialize auth database
33
+ const { initDatabase } = await import('../lib/db/index.js');
34
+ initDatabase();
21
35
 
22
36
  // Start cron scheduler
23
- const { loadCrons } = require('../lib/cron');
37
+ const { loadCrons } = await import('../lib/cron.js');
24
38
  loadCrons();
25
39
 
26
40
  console.log('thepopebot initialized');
27
41
  }
28
-
29
- module.exports = { register };
package/lib/actions.js CHANGED
@@ -1,11 +1,12 @@
1
- const { exec } = require('child_process');
2
- const { promisify } = require('util');
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { createJob } from './tools/create-job.js';
4
+
3
5
  const execAsync = promisify(exec);
4
- const { createJob } = require('./tools/create-job');
5
6
 
6
7
  /**
7
8
  * Execute a single action
8
- * @param {Object} action - { type, job, command, url, method, headers, vars }
9
+ * @param {Object} action - { type, job, command, url, method, headers, vars } (type: agent|command|webhook)
9
10
  * @param {Object} opts - { cwd, data }
10
11
  * @returns {Promise<string>} Result description for logging
11
12
  */
@@ -17,7 +18,7 @@ async function executeAction(action, opts = {}) {
17
18
  return (stdout || stderr || '').trim();
18
19
  }
19
20
 
20
- if (type === 'http') {
21
+ if (type === 'webhook') {
21
22
  const method = (action.method || 'POST').toUpperCase();
22
23
  const headers = { 'Content-Type': 'application/json', ...action.headers };
23
24
  const fetchOpts = { method, headers };
@@ -37,4 +38,4 @@ async function executeAction(action, opts = {}) {
37
38
  return `job ${result.job_id}`;
38
39
  }
39
40
 
40
- module.exports = { executeAction };
41
+ export { executeAction };
package/lib/ai/agent.js CHANGED
@@ -1,9 +1,9 @@
1
- const { createReactAgent } = require('@langchain/langgraph/prebuilt');
2
- const { createModel } = require('./model');
3
- const { createJobTool, getJobStatusTool } = require('./tools');
4
- const { createCheckpointer } = require('./memory');
5
- const paths = require('../paths');
6
- const { render_md } = require('../utils/render-md');
1
+ import { createReactAgent } from '@langchain/langgraph/prebuilt';
2
+ import { createModel } from './model.js';
3
+ import { createJobTool, getJobStatusTool } from './tools.js';
4
+ import { SqliteSaver } from '@langchain/langgraph-checkpoint-sqlite';
5
+ import { chatbotMd, thepopebotDb } from '../paths.js';
6
+ import { render_md } from '../utils/render-md.js';
7
7
 
8
8
  let _agent = null;
9
9
 
@@ -11,12 +11,12 @@ let _agent = null;
11
11
  * Get or create the LangGraph agent singleton.
12
12
  * Uses createReactAgent which handles the tool loop automatically.
13
13
  */
14
- function getAgent() {
14
+ export async function getAgent() {
15
15
  if (!_agent) {
16
- const model = createModel();
16
+ const model = await createModel();
17
17
  const tools = [createJobTool, getJobStatusTool];
18
- const checkpointer = createCheckpointer();
19
- const systemPrompt = render_md(paths.chatbotMd);
18
+ const checkpointer = SqliteSaver.fromConnString(thepopebotDb);
19
+ const systemPrompt = render_md(chatbotMd);
20
20
 
21
21
  _agent = createReactAgent({
22
22
  llm: model,
@@ -31,8 +31,6 @@ function getAgent() {
31
31
  /**
32
32
  * Reset the agent singleton (e.g., when config changes).
33
33
  */
34
- function resetAgent() {
34
+ export function resetAgent() {
35
35
  _agent = null;
36
36
  }
37
-
38
- module.exports = { getAgent, resetAgent };
package/lib/ai/index.js CHANGED
@@ -1,19 +1,46 @@
1
- const { HumanMessage, AIMessage } = require('@langchain/core/messages');
2
- const { getAgent } = require('./agent');
3
- const { createModel } = require('./model');
4
- const paths = require('../paths');
5
- const { render_md } = require('../utils/render-md');
1
+ import { HumanMessage, AIMessage } from '@langchain/core/messages';
2
+ import { getAgent } from './agent.js';
3
+ import { createModel } from './model.js';
4
+ import { jobSummaryMd } from '../paths.js';
5
+ import { render_md } from '../utils/render-md.js';
6
+ import { getChatById, createChat, saveMessage, updateChatTitle } from '../db/chats.js';
7
+
8
+ /**
9
+ * Ensure a chat exists in the DB and save a message.
10
+ * Centralized so every channel gets persistence automatically.
11
+ *
12
+ * @param {string} threadId - Chat/thread ID
13
+ * @param {string} role - 'user' or 'assistant'
14
+ * @param {string} text - Message text
15
+ * @param {object} [options] - { userId, chatTitle }
16
+ */
17
+ function persistMessage(threadId, role, text, options = {}) {
18
+ try {
19
+ if (!getChatById(threadId)) {
20
+ createChat(options.userId || 'unknown', options.chatTitle || 'New Chat', threadId);
21
+ }
22
+ saveMessage(threadId, role, text);
23
+ } catch (err) {
24
+ // DB persistence is best-effort — don't break chat if DB fails
25
+ console.error('Failed to persist message:', err);
26
+ }
27
+ }
6
28
 
7
29
  /**
8
30
  * Process a chat message through the LangGraph agent.
31
+ * Saves user and assistant messages to the DB automatically.
9
32
  *
10
33
  * @param {string} threadId - Conversation thread ID (from channel adapter)
11
34
  * @param {string} message - User's message text
12
35
  * @param {Array} [attachments=[]] - Normalized attachments from adapter
36
+ * @param {object} [options] - { userId, chatTitle } for DB persistence
13
37
  * @returns {Promise<string>} AI response text
14
38
  */
15
- async function chat(threadId, message, attachments = []) {
16
- const agent = getAgent();
39
+ async function chat(threadId, message, attachments = [], options = {}) {
40
+ const agent = await getAgent();
41
+
42
+ // Save user message to DB
43
+ persistMessage(threadId, 'user', message || '[attachment]', options);
17
44
 
18
45
  // Build content blocks: text + any image attachments as base64 vision
19
46
  const content = [];
@@ -47,37 +74,137 @@ async function chat(threadId, message, attachments = []) {
47
74
  const lastMessage = result.messages[result.messages.length - 1];
48
75
 
49
76
  // LangChain message content can be a string or an array of content blocks
77
+ let response;
50
78
  if (typeof lastMessage.content === 'string') {
51
- return lastMessage.content;
79
+ response = lastMessage.content;
80
+ } else {
81
+ response = lastMessage.content
82
+ .filter((block) => block.type === 'text')
83
+ .map((block) => block.text)
84
+ .join('\n');
52
85
  }
53
86
 
54
- // Extract text from content blocks
55
- return lastMessage.content
56
- .filter((block) => block.type === 'text')
57
- .map((block) => block.text)
58
- .join('\n');
87
+ // Save assistant response to DB
88
+ persistMessage(threadId, 'assistant', response, options);
89
+
90
+ // Auto-generate title for new chats
91
+ if (options.userId && message) {
92
+ autoTitle(threadId, message).catch(() => {});
93
+ }
94
+
95
+ return response;
59
96
  }
60
97
 
61
98
  /**
62
99
  * Process a chat message with streaming (for channels that support it).
100
+ * Saves user and assistant messages to the DB automatically.
63
101
  *
64
102
  * @param {string} threadId - Conversation thread ID
65
103
  * @param {string} message - User's message text
104
+ * @param {Array} [attachments=[]] - Image/PDF attachments: { category, mimeType, dataUrl }
105
+ * @param {object} [options] - { userId, chatTitle } for DB persistence
66
106
  * @returns {AsyncIterableIterator<string>} Stream of text chunks
67
107
  */
68
- async function* chatStream(threadId, message) {
69
- const agent = getAgent();
108
+ async function* chatStream(threadId, message, attachments = [], options = {}) {
109
+ const agent = await getAgent();
70
110
 
71
- const stream = await agent.stream(
72
- { messages: [new HumanMessage(message)] },
73
- { configurable: { thread_id: threadId }, streamMode: 'messages' }
74
- );
111
+ // Save user message to DB
112
+ persistMessage(threadId, 'user', message || '[attachment]', options);
75
113
 
76
- for await (const [message, metadata] of stream) {
77
- if (message.content && typeof message.content === 'string') {
78
- yield message.content;
114
+ // Build content blocks: text + any image/PDF attachments as vision
115
+ const content = [];
116
+
117
+ if (message) {
118
+ content.push({ type: 'text', text: message });
119
+ }
120
+
121
+ for (const att of attachments) {
122
+ if (att.category === 'image') {
123
+ // Support both dataUrl (web) and Buffer (Telegram) formats
124
+ const url = att.dataUrl
125
+ ? att.dataUrl
126
+ : `data:${att.mimeType};base64,${att.data.toString('base64')}`;
127
+ content.push({
128
+ type: 'image_url',
129
+ image_url: { url },
130
+ });
79
131
  }
80
132
  }
133
+
134
+ // If only text and no attachments, simplify to a string
135
+ const messageContent = content.length === 1 && content[0].type === 'text'
136
+ ? content[0].text
137
+ : content;
138
+
139
+ try {
140
+ const stream = await agent.stream(
141
+ { messages: [new HumanMessage({ content: messageContent })] },
142
+ { configurable: { thread_id: threadId }, streamMode: 'messages' }
143
+ );
144
+
145
+ let fullText = '';
146
+
147
+ for await (const event of stream) {
148
+ // streamMode: 'messages' yields [message, metadata] tuples
149
+ const msg = Array.isArray(event) ? event[0] : event;
150
+ const isAI = msg._getType?.() === 'ai';
151
+ if (!isAI) continue;
152
+
153
+ // Content can be a string or an array of content blocks
154
+ let text = '';
155
+ if (typeof msg.content === 'string') {
156
+ text = msg.content;
157
+ } else if (Array.isArray(msg.content)) {
158
+ text = msg.content
159
+ .filter((b) => b.type === 'text' && b.text)
160
+ .map((b) => b.text)
161
+ .join('');
162
+ }
163
+
164
+ if (text) {
165
+ fullText += text;
166
+ yield text;
167
+ }
168
+ }
169
+
170
+ // Save assistant response to DB
171
+ if (fullText) {
172
+ persistMessage(threadId, 'assistant', fullText, options);
173
+ }
174
+
175
+ // Auto-generate title for new chats
176
+ if (options.userId && message) {
177
+ autoTitle(threadId, message).catch(() => {});
178
+ }
179
+ } catch (err) {
180
+ console.error('[chatStream] error:', err);
181
+ throw err;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Auto-generate a chat title from the first user message (fire-and-forget).
187
+ */
188
+ async function autoTitle(threadId, firstMessage) {
189
+ try {
190
+ const chat = getChatById(threadId);
191
+ if (!chat || chat.title !== 'New Chat') return;
192
+
193
+ const model = await createModel({ maxTokens: 50 });
194
+ const response = await model.invoke([
195
+ ['system', 'Generate a short (3-6 word) title for this chat based on the user\'s first message. Return ONLY the title, nothing else.'],
196
+ ['human', firstMessage],
197
+ ]);
198
+ const title = typeof response.content === 'string'
199
+ ? response.content
200
+ : response.content.filter(b => b.type === 'text').map(b => b.text).join('');
201
+ const cleaned = title.replace(/^["']+|["']+$/g, '').trim();
202
+ if (cleaned) {
203
+ updateChatTitle(threadId, cleaned);
204
+ }
205
+ } catch (err) {
206
+ // Title generation is best-effort
207
+ }
81
208
  }
82
209
 
83
210
  /**
@@ -89,8 +216,8 @@ async function* chatStream(threadId, message) {
89
216
  */
90
217
  async function summarizeJob(results) {
91
218
  try {
92
- const model = createModel({ maxTokens: 1024 });
93
- const systemPrompt = render_md(paths.jobSummaryMd);
219
+ const model = await createModel({ maxTokens: 1024 });
220
+ const systemPrompt = render_md(jobSummaryMd);
94
221
 
95
222
  const userMessage = [
96
223
  results.job ? `## Task\n${results.job}` : '',
@@ -134,7 +261,7 @@ async function summarizeJob(results) {
134
261
  */
135
262
  async function addToThread(threadId, text) {
136
263
  try {
137
- const agent = getAgent();
264
+ const agent = await getAgent();
138
265
  await agent.updateState(
139
266
  { configurable: { thread_id: threadId } },
140
267
  { messages: [new AIMessage(text)] }
@@ -144,4 +271,4 @@ async function addToThread(threadId, text) {
144
271
  }
145
272
  }
146
273
 
147
- module.exports = { chat, chatStream, summarizeJob, addToThread };
274
+ export { chat, chatStream, summarizeJob, addToThread, persistMessage };