thepopebot 1.0.0

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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -0
  3. package/api/index.js +357 -0
  4. package/bin/cli.js +278 -0
  5. package/config/index.js +29 -0
  6. package/config/instrumentation.js +29 -0
  7. package/docker/Dockerfile +51 -0
  8. package/docker/entrypoint.sh +100 -0
  9. package/lib/actions.js +40 -0
  10. package/lib/claude/conversation.js +76 -0
  11. package/lib/claude/index.js +142 -0
  12. package/lib/claude/tools.js +54 -0
  13. package/lib/cron.js +60 -0
  14. package/lib/paths.js +30 -0
  15. package/lib/tools/create-job.js +40 -0
  16. package/lib/tools/github.js +122 -0
  17. package/lib/tools/openai.js +35 -0
  18. package/lib/tools/telegram.js +222 -0
  19. package/lib/triggers.js +105 -0
  20. package/lib/utils/render-md.js +39 -0
  21. package/package.json +57 -0
  22. package/pi/extensions/env-sanitizer/index.ts +48 -0
  23. package/pi/extensions/env-sanitizer/package.json +5 -0
  24. package/pi/skills/llm-secrets/SKILL.md +34 -0
  25. package/pi/skills/llm-secrets/llm-secrets.js +34 -0
  26. package/setup/lib/auth.mjs +160 -0
  27. package/setup/lib/github.mjs +148 -0
  28. package/setup/lib/prerequisites.mjs +135 -0
  29. package/setup/lib/prompts.mjs +268 -0
  30. package/setup/lib/telegram-verify.mjs +66 -0
  31. package/setup/lib/telegram.mjs +76 -0
  32. package/setup/package.json +6 -0
  33. package/setup/setup-telegram.mjs +236 -0
  34. package/setup/setup.mjs +540 -0
  35. package/templates/.env.example +38 -0
  36. package/templates/.github/workflows/auto-merge.yml +117 -0
  37. package/templates/.github/workflows/docker-build.yml +34 -0
  38. package/templates/.github/workflows/run-job.yml +40 -0
  39. package/templates/.github/workflows/update-event-handler.yml +126 -0
  40. package/templates/.pi/skills/modify-self/SKILL.md +12 -0
  41. package/templates/CLAUDE.md +52 -0
  42. package/templates/app/api/[...thepopebot]/route.js +1 -0
  43. package/templates/app/layout.js +12 -0
  44. package/templates/app/page.js +8 -0
  45. package/templates/instrumentation.js +1 -0
  46. package/templates/next.config.mjs +3 -0
  47. package/templates/operating_system/AGENT.md +32 -0
  48. package/templates/operating_system/CHATBOT.md +74 -0
  49. package/templates/operating_system/CRONS.json +16 -0
  50. package/templates/operating_system/HEARTBEAT.md +3 -0
  51. package/templates/operating_system/JOB_SUMMARY.md +36 -0
  52. package/templates/operating_system/SOUL.md +17 -0
  53. package/templates/operating_system/TELEGRAM.md +21 -0
  54. package/templates/operating_system/TRIGGERS.json +18 -0
package/bin/cli.js ADDED
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const command = process.argv[2];
8
+ const args = process.argv.slice(3);
9
+
10
+ function printUsage() {
11
+ console.log(`
12
+ Usage: thepopebot <command>
13
+
14
+ Commands:
15
+ init Scaffold a new thepopebot project
16
+ setup Run interactive setup wizard
17
+ setup-telegram Reconfigure Telegram webhook
18
+ reset [file] Restore a template file (or list available templates)
19
+ `);
20
+ }
21
+
22
+ /**
23
+ * Collect all template files as relative paths.
24
+ */
25
+ function getTemplateFiles(templatesDir) {
26
+ const files = [];
27
+ function walk(dir) {
28
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
29
+ for (const entry of entries) {
30
+ const fullPath = path.join(dir, entry.name);
31
+ if (entry.isDirectory()) {
32
+ walk(fullPath);
33
+ } else {
34
+ files.push(path.relative(templatesDir, fullPath));
35
+ }
36
+ }
37
+ }
38
+ walk(templatesDir);
39
+ return files;
40
+ }
41
+
42
+ function init() {
43
+ const cwd = process.cwd();
44
+ const packageDir = path.join(__dirname, '..');
45
+ const templatesDir = path.join(packageDir, 'templates');
46
+
47
+ console.log('\nScaffolding thepopebot project...\n');
48
+
49
+ const templateFiles = getTemplateFiles(templatesDir);
50
+ const created = [];
51
+ const skipped = [];
52
+ const changed = [];
53
+
54
+ for (const relPath of templateFiles) {
55
+ const src = path.join(templatesDir, relPath);
56
+ const dest = path.join(cwd, relPath);
57
+
58
+ if (!fs.existsSync(dest)) {
59
+ // File doesn't exist — create it
60
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
61
+ fs.copyFileSync(src, dest);
62
+ created.push(relPath);
63
+ console.log(` Created ${relPath}`);
64
+ } else {
65
+ // File exists — check if template has changed
66
+ const srcContent = fs.readFileSync(src);
67
+ const destContent = fs.readFileSync(dest);
68
+ if (srcContent.equals(destContent)) {
69
+ skipped.push(relPath);
70
+ } else {
71
+ changed.push(relPath);
72
+ console.log(` Skipped ${relPath} (already exists)`);
73
+ }
74
+ }
75
+ }
76
+
77
+ // Create package.json if it doesn't exist
78
+ const pkgPath = path.join(cwd, 'package.json');
79
+ if (!fs.existsSync(pkgPath)) {
80
+ const dirName = path.basename(cwd);
81
+ const pkg = {
82
+ name: dirName,
83
+ private: true,
84
+ scripts: {
85
+ dev: 'next dev',
86
+ build: 'next build',
87
+ start: 'next start',
88
+ setup: 'thepopebot setup',
89
+ 'setup-telegram': 'thepopebot setup-telegram',
90
+ },
91
+ dependencies: {
92
+ thepopebot: '^1.0.0',
93
+ next: '^16.0.0',
94
+ react: '^19.0.0',
95
+ 'react-dom': '^19.0.0',
96
+ },
97
+ };
98
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
99
+ console.log(' Created package.json');
100
+ } else {
101
+ console.log(' Skipped package.json (already exists)');
102
+ }
103
+
104
+ // Create .gitkeep files for empty dirs
105
+ const gitkeepDirs = ['cron', 'triggers', 'logs'];
106
+ for (const dir of gitkeepDirs) {
107
+ const gitkeep = path.join(cwd, dir, '.gitkeep');
108
+ if (!fs.existsSync(gitkeep)) {
109
+ fs.mkdirSync(path.join(cwd, dir), { recursive: true });
110
+ fs.writeFileSync(gitkeep, '');
111
+ }
112
+ }
113
+
114
+ // Report changed templates
115
+ if (changed.length > 0) {
116
+ console.log('\n Updated templates available:');
117
+ console.log(' These files differ from the current package templates.');
118
+ console.log(' This may be from your edits, or from a thepopebot update.\n');
119
+ for (const file of changed) {
120
+ console.log(` ${file}`);
121
+ }
122
+ console.log('\n To view differences: npx thepopebot diff <file>');
123
+ console.log(' To reset to default: npx thepopebot reset <file>');
124
+ }
125
+
126
+ if (created.length > 0 || !fs.existsSync(path.join(cwd, 'node_modules'))) {
127
+ console.log('\nDone! Next steps:\n');
128
+ console.log(' 1. npm install');
129
+ console.log(' 2. npm run setup');
130
+ console.log(' 3. npm run dev\n');
131
+ } else {
132
+ console.log('\nDone!\n');
133
+ }
134
+ }
135
+
136
+ /**
137
+ * List all available template files, or restore a specific one.
138
+ */
139
+ function reset(filePath) {
140
+ const packageDir = path.join(__dirname, '..');
141
+ const templatesDir = path.join(packageDir, 'templates');
142
+ const cwd = process.cwd();
143
+
144
+ if (!filePath) {
145
+ console.log('\nAvailable template files:\n');
146
+ const files = getTemplateFiles(templatesDir);
147
+ for (const file of files) {
148
+ console.log(` ${file}`);
149
+ }
150
+ console.log('\nUsage: thepopebot reset <file>');
151
+ console.log('Example: thepopebot reset operating_system/SOUL.md\n');
152
+ return;
153
+ }
154
+
155
+ const src = path.join(templatesDir, filePath);
156
+ const dest = path.join(cwd, filePath);
157
+
158
+ if (!fs.existsSync(src)) {
159
+ console.error(`\nTemplate not found: ${filePath}`);
160
+ console.log('Run "thepopebot reset" to see available templates.\n');
161
+ process.exit(1);
162
+ }
163
+
164
+ if (fs.statSync(src).isDirectory()) {
165
+ console.log(`\nRestoring ${filePath}/...\n`);
166
+ copyDirSyncForce(src, dest);
167
+ } else {
168
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
169
+ fs.copyFileSync(src, dest);
170
+ console.log(`\nRestored ${filePath}\n`);
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Show the diff between a user's file and the package template.
176
+ */
177
+ function diff(filePath) {
178
+ const packageDir = path.join(__dirname, '..');
179
+ const templatesDir = path.join(packageDir, 'templates');
180
+ const cwd = process.cwd();
181
+
182
+ if (!filePath) {
183
+ // Show all files that differ
184
+ console.log('\nFiles that differ from package templates:\n');
185
+ const files = getTemplateFiles(templatesDir);
186
+ let anyDiff = false;
187
+ for (const file of files) {
188
+ const src = path.join(templatesDir, file);
189
+ const dest = path.join(cwd, file);
190
+ if (fs.existsSync(dest)) {
191
+ const srcContent = fs.readFileSync(src);
192
+ const destContent = fs.readFileSync(dest);
193
+ if (!srcContent.equals(destContent)) {
194
+ console.log(` ${file}`);
195
+ anyDiff = true;
196
+ }
197
+ } else {
198
+ console.log(` ${file} (missing)`);
199
+ anyDiff = true;
200
+ }
201
+ }
202
+ if (!anyDiff) {
203
+ console.log(' All files match package templates.');
204
+ }
205
+ console.log('\nUsage: thepopebot diff <file>');
206
+ console.log('Example: thepopebot diff operating_system/SOUL.md\n');
207
+ return;
208
+ }
209
+
210
+ const src = path.join(templatesDir, filePath);
211
+ const dest = path.join(cwd, filePath);
212
+
213
+ if (!fs.existsSync(src)) {
214
+ console.error(`\nTemplate not found: ${filePath}`);
215
+ process.exit(1);
216
+ }
217
+
218
+ if (!fs.existsSync(dest)) {
219
+ console.log(`\n${filePath} does not exist in your project.`);
220
+ console.log(`Run "thepopebot reset ${filePath}" to create it.\n`);
221
+ return;
222
+ }
223
+
224
+ try {
225
+ // Use git diff for nice colored output, fall back to plain diff
226
+ execSync(`git diff --no-index -- "${dest}" "${src}"`, { stdio: 'inherit' });
227
+ console.log('\nFiles are identical.\n');
228
+ } catch (e) {
229
+ // git diff exits with 1 when files differ (output already printed)
230
+ console.log(`\n To reset: thepopebot reset ${filePath}\n`);
231
+ }
232
+ }
233
+
234
+ function copyDirSyncForce(src, dest) {
235
+ fs.mkdirSync(dest, { recursive: true });
236
+ const entries = fs.readdirSync(src, { withFileTypes: true });
237
+ for (const entry of entries) {
238
+ const srcPath = path.join(src, entry.name);
239
+ const destPath = path.join(dest, entry.name);
240
+ if (entry.isDirectory()) {
241
+ copyDirSyncForce(srcPath, destPath);
242
+ } else {
243
+ fs.copyFileSync(srcPath, destPath);
244
+ console.log(` Restored ${path.relative(process.cwd(), destPath)}`);
245
+ }
246
+ }
247
+ }
248
+
249
+ function setup() {
250
+ const setupScript = path.join(__dirname, '..', 'setup', 'setup.mjs');
251
+ execSync(`node ${setupScript}`, { stdio: 'inherit', cwd: process.cwd() });
252
+ }
253
+
254
+ function setupTelegram() {
255
+ const setupScript = path.join(__dirname, '..', 'setup', 'setup-telegram.mjs');
256
+ execSync(`node ${setupScript}`, { stdio: 'inherit', cwd: process.cwd() });
257
+ }
258
+
259
+ switch (command) {
260
+ case 'init':
261
+ init();
262
+ break;
263
+ case 'setup':
264
+ setup();
265
+ break;
266
+ case 'setup-telegram':
267
+ setupTelegram();
268
+ break;
269
+ case 'reset':
270
+ reset(args[0]);
271
+ break;
272
+ case 'diff':
273
+ diff(args[0]);
274
+ break;
275
+ default:
276
+ printUsage();
277
+ process.exit(command ? 1 : 0);
278
+ }
@@ -0,0 +1,29 @@
1
+ const path = require('path');
2
+
3
+ /**
4
+ * Next.js config wrapper for thepopebot.
5
+ * Enables instrumentation hook for cron scheduling on server start.
6
+ *
7
+ * Usage in user's next.config.mjs:
8
+ * import { withThepopebot } from 'thepopebot/config';
9
+ * export default withThepopebot({});
10
+ *
11
+ * @param {Object} nextConfig - User's Next.js config
12
+ * @returns {Object} Enhanced Next.js config
13
+ */
14
+ function withThepopebot(nextConfig = {}) {
15
+ return {
16
+ ...nextConfig,
17
+ // Ensure server-only packages aren't bundled for client
18
+ serverExternalPackages: [
19
+ ...(nextConfig.serverExternalPackages || []),
20
+ 'thepopebot',
21
+ 'grammy',
22
+ '@grammyjs/parse-mode',
23
+ 'node-cron',
24
+ 'uuid',
25
+ ],
26
+ };
27
+ }
28
+
29
+ module.exports = { withThepopebot };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Next.js instrumentation hook for thepopebot.
3
+ * This file is loaded by Next.js on server start when instrumentationHook is enabled.
4
+ *
5
+ * Users should create an instrumentation.js in their project root that imports this:
6
+ *
7
+ * export { register } from 'thepopebot/instrumentation';
8
+ *
9
+ * Or they can re-export and add their own logic.
10
+ */
11
+
12
+ let initialized = false;
13
+
14
+ async function register() {
15
+ // Only run on the server, and only once
16
+ if (typeof window !== 'undefined' || initialized) return;
17
+ initialized = true;
18
+
19
+ // Load .env from project root
20
+ require('dotenv').config();
21
+
22
+ // Start cron scheduler
23
+ const { loadCrons } = require('../lib/cron');
24
+ loadCrons();
25
+
26
+ console.log('thepopebot initialized');
27
+ }
28
+
29
+ module.exports = { register };
@@ -0,0 +1,51 @@
1
+ FROM node:22-bookworm-slim
2
+
3
+ RUN apt-get update && apt-get install -y \
4
+ git \
5
+ jq \
6
+ curl \
7
+ procps \
8
+ # Chrome/Chromium dependencies
9
+ libnss3 \
10
+ libnspr4 \
11
+ libatk1.0-0 \
12
+ libatk-bridge2.0-0 \
13
+ libcups2 \
14
+ libdrm2 \
15
+ libdbus-1-3 \
16
+ libxkbcommon0 \
17
+ libatspi2.0-0 \
18
+ libxcomposite1 \
19
+ libxdamage1 \
20
+ libxfixes3 \
21
+ libxrandr2 \
22
+ libgbm1 \
23
+ libasound2 \
24
+ libpango-1.0-0 \
25
+ libcairo2 \
26
+ && rm -rf /var/lib/apt/lists/*
27
+
28
+ # Install GitHub CLI
29
+ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
30
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
31
+ && apt-get update && apt-get install -y gh \
32
+ && rm -rf /var/lib/apt/lists/*
33
+
34
+ RUN npm install -g @mariozechner/pi-coding-agent
35
+
36
+ # Create Pi config directory (extension loaded from repo at runtime)
37
+ RUN mkdir -p /root/.pi/agent
38
+
39
+ # Clone pi-skills and install browser-tools (includes Puppeteer + Chromium)
40
+ RUN git clone https://github.com/badlogic/pi-skills.git /pi-skills
41
+ WORKDIR /pi-skills/browser-tools
42
+ RUN npm install
43
+ WORKDIR /pi-skills/brave-search
44
+ RUN npm install
45
+
46
+ COPY entrypoint.sh /entrypoint.sh
47
+ RUN chmod +x /entrypoint.sh
48
+
49
+ WORKDIR /job
50
+
51
+ ENTRYPOINT ["/entrypoint.sh"]
@@ -0,0 +1,100 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Extract job ID from branch name (job/uuid -> uuid), fallback to random UUID
5
+ if [[ "$BRANCH" == job/* ]]; then
6
+ JOB_ID="${BRANCH#job/}"
7
+ else
8
+ JOB_ID=$(cat /proc/sys/kernel/random/uuid)
9
+ fi
10
+ echo "Job ID: ${JOB_ID}"
11
+
12
+ # Start Chrome (using Puppeteer's chromium from pi-skills browser-tools)
13
+ CHROME_BIN=$(find /root/.cache/puppeteer -name "chrome" -type f | head -1)
14
+ $CHROME_BIN --headless --no-sandbox --disable-gpu --remote-debugging-port=9222 2>/dev/null &
15
+ CHROME_PID=$!
16
+ sleep 2
17
+
18
+ # Export SECRETS (base64 JSON) as flat env vars (GH_TOKEN, ANTHROPIC_API_KEY, etc.)
19
+ # These are filtered from LLM's bash subprocess by env-sanitizer extension
20
+ if [ -n "$SECRETS" ]; then
21
+ SECRETS_JSON=$(echo "$SECRETS" | base64 -d)
22
+ eval $(echo "$SECRETS_JSON" | jq -r 'to_entries | .[] | "export \(.key)=\"\(.value)\""')
23
+ export SECRETS="$SECRETS_JSON" # Keep decoded for extension to parse
24
+ fi
25
+
26
+ # Export LLM_SECRETS (base64 JSON) as flat env vars
27
+ # These are NOT filtered - LLM can access these (browser logins, skill API keys, etc.)
28
+ if [ -n "$LLM_SECRETS" ]; then
29
+ LLM_SECRETS_JSON=$(echo "$LLM_SECRETS" | base64 -d)
30
+ eval $(echo "$LLM_SECRETS_JSON" | jq -r 'to_entries | .[] | "export \(.key)=\"\(.value)\""')
31
+ fi
32
+
33
+ # Git setup - derive identity from GitHub token
34
+ gh auth setup-git
35
+ GH_USER_JSON=$(gh api user -q '{name: .name, login: .login, email: .email, id: .id}')
36
+ GH_USER_NAME=$(echo "$GH_USER_JSON" | jq -r '.name // .login')
37
+ GH_USER_EMAIL=$(echo "$GH_USER_JSON" | jq -r '.email // "\(.id)+\(.login)@users.noreply.github.com"')
38
+ git config --global user.name "$GH_USER_NAME"
39
+ git config --global user.email "$GH_USER_EMAIL"
40
+
41
+ # Clone branch
42
+ if [ -n "$REPO_URL" ]; then
43
+ git clone --single-branch --branch "$BRANCH" --depth 1 "$REPO_URL" /job
44
+ else
45
+ echo "No REPO_URL provided"
46
+ fi
47
+
48
+ cd /job
49
+
50
+ # Create temp directory for agent use (gitignored via tmp/)
51
+ mkdir -p /job/tmp
52
+
53
+ # Symlink pi-skills into .pi/skills/ so Pi discovers them
54
+ ln -sf /pi-skills/brave-search /job/.pi/skills/brave-search
55
+
56
+ # Setup logs
57
+ LOG_DIR="/job/logs/${JOB_ID}"
58
+ mkdir -p "${LOG_DIR}"
59
+
60
+ # 1. Build system prompt from operating_system MD files
61
+ SYSTEM_FILES=("SOUL.md" "AGENT.md")
62
+ > /job/.pi/SYSTEM.md
63
+ for i in "${!SYSTEM_FILES[@]}"; do
64
+ cat "/job/operating_system/${SYSTEM_FILES[$i]}" >> /job/.pi/SYSTEM.md
65
+ if [ "$i" -lt $((${#SYSTEM_FILES[@]} - 1)) ]; then
66
+ echo -e "\n\n" >> /job/.pi/SYSTEM.md
67
+ fi
68
+ done
69
+
70
+ PROMPT="
71
+
72
+ # Your Job
73
+
74
+ $(cat /job/logs/${JOB_ID}/job.md)"
75
+
76
+ MODEL_FLAGS=""
77
+ if [ -n "$MODEL" ]; then
78
+ MODEL_FLAGS="--provider anthropic --model $MODEL"
79
+ fi
80
+
81
+ pi $MODEL_FLAGS -p "$PROMPT" --session-dir "${LOG_DIR}"
82
+
83
+ # 2. Commit changes + logs
84
+ git add -A
85
+ git add -f "${LOG_DIR}"
86
+ git commit -m "thepopebot: job ${JOB_ID}" || true
87
+ git push origin
88
+
89
+ # 3. Merge (pi has memory of job via session)
90
+ #if [ -n "$REPO_URL" ] && [ -f "/job/MERGE_JOB.md" ]; then
91
+ # echo "MERGED"
92
+ # pi -p "$(cat /job/MERGE_JOB.md)" --session-dir "${LOG_DIR}" --continue
93
+ #fi
94
+
95
+ # 5. Create PR (auto-merge handled by GitHub Actions workflow)
96
+ gh pr create --title "thepopebot: job ${JOB_ID}" --body "Automated job" --base main || true
97
+
98
+ # Cleanup
99
+ kill $CHROME_PID 2>/dev/null || true
100
+ echo "Done. Job ID: ${JOB_ID}"
package/lib/actions.js ADDED
@@ -0,0 +1,40 @@
1
+ const { exec } = require('child_process');
2
+ const { promisify } = require('util');
3
+ const execAsync = promisify(exec);
4
+ const { createJob } = require('./tools/create-job');
5
+
6
+ /**
7
+ * Execute a single action
8
+ * @param {Object} action - { type, job, command, url, method, headers, vars }
9
+ * @param {Object} opts - { cwd, data }
10
+ * @returns {Promise<string>} Result description for logging
11
+ */
12
+ async function executeAction(action, opts = {}) {
13
+ const type = action.type || 'agent';
14
+
15
+ if (type === 'command') {
16
+ const { stdout, stderr } = await execAsync(action.command, { cwd: opts.cwd });
17
+ return (stdout || stderr || '').trim();
18
+ }
19
+
20
+ if (type === 'http') {
21
+ const method = (action.method || 'POST').toUpperCase();
22
+ const headers = { 'Content-Type': 'application/json', ...action.headers };
23
+ const fetchOpts = { method, headers };
24
+
25
+ if (method !== 'GET') {
26
+ const body = { ...action.vars };
27
+ if (opts.data) body.data = opts.data;
28
+ fetchOpts.body = JSON.stringify(body);
29
+ }
30
+
31
+ const res = await fetch(action.url, fetchOpts);
32
+ return `${method} ${action.url} → ${res.status}`;
33
+ }
34
+
35
+ // Default: agent
36
+ const result = await createJob(action.job);
37
+ return `job ${result.job_id}`;
38
+ }
39
+
40
+ module.exports = { executeAction };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * In-memory conversation history management per Telegram chat.
3
+ * - Keyed by chat_id
4
+ * - 30-minute TTL per conversation
5
+ * - Max 20 messages per conversation
6
+ */
7
+
8
+ const MAX_MESSAGES = 20;
9
+ const TTL_MS = 30 * 60 * 1000; // 30 minutes
10
+ const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
11
+
12
+ // Map<chatId, { messages: Array, lastAccess: number }>
13
+ const conversations = new Map();
14
+
15
+ /**
16
+ * Get conversation history for a chat
17
+ * @param {string} chatId - Telegram chat ID
18
+ * @returns {Array} - Message history array
19
+ */
20
+ function getHistory(chatId) {
21
+ const entry = conversations.get(chatId);
22
+ if (!entry) return [];
23
+
24
+ // Check if expired
25
+ if (Date.now() - entry.lastAccess > TTL_MS) {
26
+ conversations.delete(chatId);
27
+ return [];
28
+ }
29
+
30
+ entry.lastAccess = Date.now();
31
+ return entry.messages;
32
+ }
33
+
34
+ /**
35
+ * Update conversation history for a chat
36
+ * @param {string} chatId - Telegram chat ID
37
+ * @param {Array} messages - New message history
38
+ */
39
+ function updateHistory(chatId, messages) {
40
+ // Trim to max messages (keep most recent)
41
+ const trimmed = messages.slice(-MAX_MESSAGES);
42
+
43
+ conversations.set(chatId, {
44
+ messages: trimmed,
45
+ lastAccess: Date.now(),
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Clear conversation history for a chat
51
+ * @param {string} chatId - Telegram chat ID
52
+ */
53
+ function clearHistory(chatId) {
54
+ conversations.delete(chatId);
55
+ }
56
+
57
+ /**
58
+ * Clean up expired conversations
59
+ */
60
+ function cleanupExpired() {
61
+ const now = Date.now();
62
+ for (const [chatId, entry] of conversations) {
63
+ if (now - entry.lastAccess > TTL_MS) {
64
+ conversations.delete(chatId);
65
+ }
66
+ }
67
+ }
68
+
69
+ // Start cleanup interval
70
+ setInterval(cleanupExpired, CLEANUP_INTERVAL_MS);
71
+
72
+ module.exports = {
73
+ getHistory,
74
+ updateHistory,
75
+ clearHistory,
76
+ };