thepopebot 1.1.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +1 -1
  2. package/api/index.js +72 -165
  3. package/bin/cli.js +36 -6
  4. package/bin/local.sh +31 -0
  5. package/bin/postinstall.js +6 -2
  6. package/config/index.js +2 -11
  7. package/config/instrumentation.js +17 -5
  8. package/lib/actions.js +7 -6
  9. package/lib/ai/agent.js +36 -0
  10. package/lib/ai/index.js +274 -0
  11. package/lib/ai/model.js +67 -0
  12. package/lib/ai/tools.js +49 -0
  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 +56 -0
  18. package/lib/channels/index.js +15 -0
  19. package/lib/channels/telegram.js +146 -0
  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 +23 -17
  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 +43 -6
  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/runner/Dockerfile +38 -0
  135. package/templates/docker/runner/entrypoint.sh +41 -0
  136. package/templates/docker-compose.yml +52 -0
  137. package/templates/instrumentation.js +6 -1
  138. package/templates/middleware.js +1 -0
  139. package/templates/postcss.config.mjs +5 -0
  140. package/lib/claude/conversation.js +0 -76
  141. package/lib/claude/index.js +0 -142
  142. package/lib/claude/tools.js +0 -54
  143. /package/templates/docker/{Dockerfile → job/Dockerfile} +0 -0
@@ -0,0 +1,274 @@
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
+ }
28
+
29
+ /**
30
+ * Process a chat message through the LangGraph agent.
31
+ * Saves user and assistant messages to the DB automatically.
32
+ *
33
+ * @param {string} threadId - Conversation thread ID (from channel adapter)
34
+ * @param {string} message - User's message text
35
+ * @param {Array} [attachments=[]] - Normalized attachments from adapter
36
+ * @param {object} [options] - { userId, chatTitle } for DB persistence
37
+ * @returns {Promise<string>} AI response text
38
+ */
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);
44
+
45
+ // Build content blocks: text + any image attachments as base64 vision
46
+ const content = [];
47
+
48
+ if (message) {
49
+ content.push({ type: 'text', text: message });
50
+ }
51
+
52
+ for (const att of attachments) {
53
+ if (att.category === 'image') {
54
+ content.push({
55
+ type: 'image_url',
56
+ image_url: {
57
+ url: `data:${att.mimeType};base64,${att.data.toString('base64')}`,
58
+ },
59
+ });
60
+ }
61
+ // Documents: future handling
62
+ }
63
+
64
+ // If only text and no attachments, simplify to a string
65
+ const messageContent = content.length === 1 && content[0].type === 'text'
66
+ ? content[0].text
67
+ : content;
68
+
69
+ const result = await agent.invoke(
70
+ { messages: [new HumanMessage({ content: messageContent })] },
71
+ { configurable: { thread_id: threadId } }
72
+ );
73
+
74
+ const lastMessage = result.messages[result.messages.length - 1];
75
+
76
+ // LangChain message content can be a string or an array of content blocks
77
+ let response;
78
+ if (typeof lastMessage.content === 'string') {
79
+ response = lastMessage.content;
80
+ } else {
81
+ response = lastMessage.content
82
+ .filter((block) => block.type === 'text')
83
+ .map((block) => block.text)
84
+ .join('\n');
85
+ }
86
+
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;
96
+ }
97
+
98
+ /**
99
+ * Process a chat message with streaming (for channels that support it).
100
+ * Saves user and assistant messages to the DB automatically.
101
+ *
102
+ * @param {string} threadId - Conversation thread ID
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
106
+ * @returns {AsyncIterableIterator<string>} Stream of text chunks
107
+ */
108
+ async function* chatStream(threadId, message, attachments = [], options = {}) {
109
+ const agent = await getAgent();
110
+
111
+ // Save user message to DB
112
+ persistMessage(threadId, 'user', message || '[attachment]', options);
113
+
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
+ });
131
+ }
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
+ }
208
+ }
209
+
210
+ /**
211
+ * One-shot summarization with a different system prompt and no memory.
212
+ * Used for job completion summaries sent via GitHub webhook.
213
+ *
214
+ * @param {object} results - Job results from webhook payload
215
+ * @returns {Promise<string>} Summary text
216
+ */
217
+ async function summarizeJob(results) {
218
+ try {
219
+ const model = await createModel({ maxTokens: 1024 });
220
+ const systemPrompt = render_md(jobSummaryMd);
221
+
222
+ const userMessage = [
223
+ results.job ? `## Task\n${results.job}` : '',
224
+ results.commit_message ? `## Commit Message\n${results.commit_message}` : '',
225
+ results.changed_files?.length ? `## Changed Files\n${results.changed_files.join('\n')}` : '',
226
+ results.status ? `## Status\n${results.status}` : '',
227
+ results.merge_result ? `## Merge Result\n${results.merge_result}` : '',
228
+ results.pr_url ? `## PR URL\n${results.pr_url}` : '',
229
+ results.run_url ? `## Run URL\n${results.run_url}` : '',
230
+ results.log ? `## Agent Log\n${results.log}` : '',
231
+ ]
232
+ .filter(Boolean)
233
+ .join('\n\n');
234
+
235
+ const response = await model.invoke([
236
+ ['system', systemPrompt],
237
+ ['human', userMessage],
238
+ ]);
239
+
240
+ const text =
241
+ typeof response.content === 'string'
242
+ ? response.content
243
+ : response.content
244
+ .filter((block) => block.type === 'text')
245
+ .map((block) => block.text)
246
+ .join('\n');
247
+
248
+ return text.trim() || 'Job finished.';
249
+ } catch (err) {
250
+ console.error('Failed to summarize job:', err);
251
+ return 'Job finished.';
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Inject a message into a thread's memory so the agent has context
257
+ * for future conversations (e.g., job completion summaries).
258
+ *
259
+ * @param {string} threadId - Conversation thread ID
260
+ * @param {string} text - Message text to inject as an assistant message
261
+ */
262
+ async function addToThread(threadId, text) {
263
+ try {
264
+ const agent = await getAgent();
265
+ await agent.updateState(
266
+ { configurable: { thread_id: threadId } },
267
+ { messages: [new AIMessage(text)] }
268
+ );
269
+ } catch (err) {
270
+ console.error('Failed to add message to thread:', err);
271
+ }
272
+ }
273
+
274
+ export { chat, chatStream, summarizeJob, addToThread, persistMessage };
@@ -0,0 +1,67 @@
1
+ import { ChatAnthropic } from '@langchain/anthropic';
2
+
3
+ const DEFAULT_MODELS = {
4
+ anthropic: 'claude-sonnet-4-20250514',
5
+ openai: 'gpt-4o',
6
+ google: 'gemini-2.5-pro',
7
+ };
8
+
9
+ /**
10
+ * Create a LangChain chat model based on environment configuration.
11
+ *
12
+ * Config env vars:
13
+ * LLM_PROVIDER — "anthropic" (default), "openai", "google"
14
+ * LLM_MODEL — Model name override (e.g. "claude-sonnet-4-20250514")
15
+ * ANTHROPIC_API_KEY — Required for anthropic provider
16
+ * OPENAI_API_KEY — Required for openai provider
17
+ * GOOGLE_API_KEY — Required for google provider
18
+ *
19
+ * @param {object} [options]
20
+ * @param {number} [options.maxTokens=4096] - Max tokens for the response
21
+ * @returns {import('@langchain/core/language_models/chat_models').BaseChatModel}
22
+ */
23
+ export async function createModel(options = {}) {
24
+ const provider = process.env.LLM_PROVIDER || 'anthropic';
25
+ const modelName = process.env.LLM_MODEL || DEFAULT_MODELS[provider] || DEFAULT_MODELS.anthropic;
26
+ const maxTokens = options.maxTokens || 4096;
27
+
28
+ switch (provider) {
29
+ case 'anthropic': {
30
+ const apiKey = process.env.ANTHROPIC_API_KEY;
31
+ if (!apiKey) {
32
+ throw new Error('ANTHROPIC_API_KEY environment variable is required');
33
+ }
34
+ return new ChatAnthropic({
35
+ modelName,
36
+ maxTokens,
37
+ anthropicApiKey: apiKey,
38
+ });
39
+ }
40
+ case 'openai': {
41
+ const { ChatOpenAI } = await import('@langchain/openai');
42
+ const apiKey = process.env.OPENAI_API_KEY;
43
+ if (!apiKey) {
44
+ throw new Error('OPENAI_API_KEY environment variable is required');
45
+ }
46
+ return new ChatOpenAI({
47
+ modelName,
48
+ maxTokens,
49
+ openAIApiKey: apiKey,
50
+ });
51
+ }
52
+ case 'google': {
53
+ const { ChatGoogleGenerativeAI } = await import('@langchain/google-genai');
54
+ const apiKey = process.env.GOOGLE_API_KEY;
55
+ if (!apiKey) {
56
+ throw new Error('GOOGLE_API_KEY environment variable is required');
57
+ }
58
+ return new ChatGoogleGenerativeAI({
59
+ modelName,
60
+ maxOutputTokens: maxTokens,
61
+ apiKey,
62
+ });
63
+ }
64
+ default:
65
+ throw new Error(`Unknown LLM provider: ${provider}`);
66
+ }
67
+ }
@@ -0,0 +1,49 @@
1
+ import { tool } from '@langchain/core/tools';
2
+ import { z } from 'zod';
3
+ import { createJob } from '../tools/create-job.js';
4
+ import { getJobStatus } from '../tools/github.js';
5
+
6
+ const createJobTool = tool(
7
+ async ({ job_description }) => {
8
+ const result = await createJob(job_description);
9
+ return JSON.stringify({
10
+ success: true,
11
+ job_id: result.job_id,
12
+ branch: result.branch,
13
+ });
14
+ },
15
+ {
16
+ name: 'create_job',
17
+ description:
18
+ 'Create an autonomous job for thepopebot to execute. Use this tool liberally - if the user asks for ANY task to be done, create a job. Jobs can handle code changes, file updates, research tasks, web scraping, data analysis, or anything requiring autonomous work. When the user explicitly asks for a job, ALWAYS use this tool. Returns the job ID and branch name.',
19
+ schema: z.object({
20
+ job_description: z
21
+ .string()
22
+ .describe(
23
+ 'Detailed job description including context and requirements. Be specific about what needs to be done.'
24
+ ),
25
+ }),
26
+ }
27
+ );
28
+
29
+ const getJobStatusTool = tool(
30
+ async ({ job_id }) => {
31
+ const result = await getJobStatus(job_id);
32
+ return JSON.stringify(result);
33
+ },
34
+ {
35
+ name: 'get_job_status',
36
+ description:
37
+ 'Check status of running jobs. Returns list of active workflow runs with timing and current step. Use when user asks about job progress, running jobs, or job status.',
38
+ schema: z.object({
39
+ job_id: z
40
+ .string()
41
+ .optional()
42
+ .describe(
43
+ 'Optional: specific job ID to check. If omitted, returns all running jobs.'
44
+ ),
45
+ }),
46
+ }
47
+ );
48
+
49
+ export { createJobTool, getJobStatusTool };
@@ -0,0 +1,28 @@
1
+ 'use server';
2
+
3
+ import { createFirstUser } from '../db/users.js';
4
+
5
+ /**
6
+ * Create the first admin user (setup action).
7
+ * Uses atomic createFirstUser() to prevent race conditions.
8
+ * No session/token is created — the admin must log in through the normal auth flow.
9
+ *
10
+ * @param {string} email
11
+ * @param {string} password
12
+ * @returns {Promise<{ success?: boolean, error?: string }>}
13
+ */
14
+ export async function setupAdmin(email, password) {
15
+ if (!email || !password) {
16
+ return { error: 'Email and password are required.' };
17
+ }
18
+ if (password.length < 8) {
19
+ return { error: 'Password must be at least 8 characters.' };
20
+ }
21
+
22
+ const created = createFirstUser(email, password);
23
+ if (!created) {
24
+ return { error: 'Setup already completed.' };
25
+ }
26
+
27
+ return { success: true };
28
+ }
@@ -0,0 +1,45 @@
1
+ import NextAuth from 'next-auth';
2
+ import Credentials from 'next-auth/providers/credentials';
3
+
4
+ // DB imports are dynamic inside authorize() so they don't get pulled in
5
+ // at module level — middleware runs on Edge which has no fs/better-sqlite3.
6
+
7
+ export const { handlers, signIn, signOut, auth } = NextAuth({
8
+ providers: [
9
+ Credentials({
10
+ credentials: {
11
+ email: { label: 'Email', type: 'email' },
12
+ password: { label: 'Password', type: 'password' },
13
+ },
14
+ async authorize(credentials) {
15
+ if (!credentials?.email || !credentials?.password) return null;
16
+
17
+ const { getUserByEmail, verifyPassword } = await import('../db/users.js');
18
+ const user = getUserByEmail(credentials.email);
19
+ if (!user) return null;
20
+
21
+ const valid = await verifyPassword(user, credentials.password);
22
+ if (!valid) return null;
23
+
24
+ return { id: user.id, email: user.email, role: user.role };
25
+ },
26
+ }),
27
+ ],
28
+ session: { strategy: 'jwt' },
29
+ pages: { signIn: '/login' },
30
+ callbacks: {
31
+ jwt({ token, user }) {
32
+ if (user) {
33
+ token.role = user.role;
34
+ }
35
+ return token;
36
+ },
37
+ session({ session, token }) {
38
+ if (session.user) {
39
+ session.user.id = token.sub;
40
+ session.user.role = token.role;
41
+ }
42
+ return session;
43
+ },
44
+ },
45
+ });
@@ -0,0 +1,27 @@
1
+ import { handlers, auth } from './config.js';
2
+
3
+ // Re-export Auth.js route handlers (GET + POST for [...nextauth])
4
+ export const { GET, POST } = handlers;
5
+
6
+ // Re-export auth for session checking
7
+ export { auth };
8
+
9
+ /**
10
+ * Get the auth state for the main page (server component).
11
+ * Returns both the session and whether setup is needed, in one call.
12
+ * DB import is dynamic so it doesn't get pulled in at module level.
13
+ *
14
+ * @returns {Promise<{ session: object|null, needsSetup: boolean }>}
15
+ */
16
+ export async function getPageAuthState() {
17
+ const { getUserCount } = await import('../db/users.js');
18
+ const [session, userCount] = await Promise.all([
19
+ auth(),
20
+ Promise.resolve(getUserCount()),
21
+ ]);
22
+
23
+ return {
24
+ session,
25
+ needsSetup: userCount === 0,
26
+ };
27
+ }
@@ -0,0 +1,30 @@
1
+ import { auth } from './config.js';
2
+ import { NextResponse } from 'next/server';
3
+
4
+ export const middleware = auth((req) => {
5
+ const { pathname } = req.nextUrl;
6
+
7
+ // API routes use their own centralized auth (checkAuth in api/index.js)
8
+ if (pathname.startsWith('/api')) return;
9
+
10
+ // Static assets from public/ — skip auth for common file extensions
11
+ if (/\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|woff2?|ttf|eot|mp4|webm)$/i.test(pathname)) {
12
+ return;
13
+ }
14
+
15
+ // /login is the only unprotected page (login + first-user setup)
16
+ if (pathname === '/login') {
17
+ if (req.auth) return NextResponse.redirect(new URL('/', req.url));
18
+ return;
19
+ }
20
+
21
+ // Everything else requires auth
22
+ if (!req.auth) {
23
+ return NextResponse.redirect(new URL('/login', req.url));
24
+ }
25
+ });
26
+
27
+ export const config = {
28
+ // Exclude all _next internal paths (static chunks, HMR, images, Turbopack dev assets)
29
+ matcher: ['/((?!_next|favicon.ico).*)'],
30
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Base channel adapter interface.
3
+ * Every chat channel (Telegram, Slack, web, etc.) implements this contract.
4
+ */
5
+ class ChannelAdapter {
6
+ /**
7
+ * Handle an incoming webhook request from this channel.
8
+ * Returns normalized message data or null if no action needed.
9
+ *
10
+ * @param {Request} request - Incoming HTTP request
11
+ * @returns {Promise<{ threadId: string, text: string, attachments: Array, metadata: object } | null>}
12
+ *
13
+ * Attachments array (may be empty) — only non-text content that the LLM needs to see:
14
+ * { category: "image", mimeType: "image/png", data: Buffer } — send to LLM as vision
15
+ * { category: "document", mimeType: "application/pdf", data: Buffer } — future: extract/attach
16
+ *
17
+ * The adapter downloads authenticated files and normalizes them.
18
+ * Voice/audio messages are fully resolved by the adapter — transcribed to text
19
+ * and included in the `text` field. They are NOT passed as attachments.
20
+ */
21
+ async receive(request) {
22
+ throw new Error('Not implemented');
23
+ }
24
+
25
+ /**
26
+ * Called when message is received — adapter shows acknowledgment.
27
+ * Telegram: thumbs up reaction. Slack: emoji reaction. Web: no-op.
28
+ */
29
+ async acknowledge(metadata) {}
30
+
31
+ /**
32
+ * Called while AI is processing — adapter shows activity.
33
+ * Telegram: typing indicator. Slack: typing indicator. Web: no-op (streaming handles this).
34
+ * Returns a stop function.
35
+ */
36
+ startProcessingIndicator(metadata) {
37
+ return () => {};
38
+ }
39
+
40
+ /**
41
+ * Send a complete (non-streaming) response back to the channel.
42
+ */
43
+ async sendResponse(threadId, text, metadata) {
44
+ throw new Error('Not implemented');
45
+ }
46
+
47
+ /**
48
+ * Whether this channel supports real streaming (e.g., web chat via Vercel AI SDK).
49
+ * If true, the AI layer provides a stream instead of a complete response.
50
+ */
51
+ get supportsStreaming() {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ export { ChannelAdapter };
@@ -0,0 +1,15 @@
1
+ import { TelegramAdapter } from './telegram.js';
2
+
3
+ let _telegramAdapter = null;
4
+
5
+ /**
6
+ * Get the Telegram channel adapter (lazy singleton).
7
+ * @param {string} botToken - Telegram bot token
8
+ * @returns {TelegramAdapter}
9
+ */
10
+ export function getTelegramAdapter(botToken) {
11
+ if (!_telegramAdapter || _telegramAdapter.botToken !== botToken) {
12
+ _telegramAdapter = new TelegramAdapter(botToken);
13
+ }
14
+ return _telegramAdapter;
15
+ }