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
@@ -0,0 +1,222 @@
1
+ const { Bot } = require('grammy');
2
+ const { hydrateReply } = require('@grammyjs/parse-mode');
3
+
4
+ const MAX_LENGTH = 4096;
5
+
6
+ let bot = null;
7
+ let currentToken = null;
8
+
9
+ /**
10
+ * Get or create bot instance
11
+ * @param {string} token - Bot token from @BotFather
12
+ * @returns {Bot} grammY Bot instance
13
+ */
14
+ function getBot(token) {
15
+ if (!bot || currentToken !== token) {
16
+ bot = new Bot(token);
17
+ bot.use(hydrateReply);
18
+ currentToken = token;
19
+ }
20
+ return bot;
21
+ }
22
+
23
+ /**
24
+ * Set webhook for a Telegram bot
25
+ * @param {string} botToken - Bot token from @BotFather
26
+ * @param {string} webhookUrl - HTTPS URL to receive updates
27
+ * @param {string} [secretToken] - Optional secret token for verification
28
+ * @returns {Promise<boolean>} - Success status
29
+ */
30
+ async function setWebhook(botToken, webhookUrl, secretToken) {
31
+ const b = getBot(botToken);
32
+ const options = {};
33
+ if (secretToken) {
34
+ options.secret_token = secretToken;
35
+ }
36
+ return b.api.setWebhook(webhookUrl, options);
37
+ }
38
+
39
+ /**
40
+ * Smart split text into chunks that fit Telegram's limit
41
+ * Prefers splitting at paragraph > newline > sentence > space
42
+ * @param {string} text - Text to split
43
+ * @param {number} maxLength - Maximum chunk length
44
+ * @returns {string[]} Array of chunks
45
+ */
46
+ function smartSplit(text, maxLength = MAX_LENGTH) {
47
+ if (text.length <= maxLength) return [text];
48
+
49
+ const chunks = [];
50
+ let remaining = text;
51
+
52
+ while (remaining.length > 0) {
53
+ if (remaining.length <= maxLength) {
54
+ chunks.push(remaining);
55
+ break;
56
+ }
57
+
58
+ const chunk = remaining.slice(0, maxLength);
59
+ let splitAt = -1;
60
+
61
+ // Try to split at natural boundaries (prefer earlier ones)
62
+ for (const delim of ['\n\n', '\n', '. ', ' ']) {
63
+ const idx = chunk.lastIndexOf(delim);
64
+ if (idx > maxLength * 0.3) {
65
+ splitAt = idx + delim.length;
66
+ break;
67
+ }
68
+ }
69
+
70
+ if (splitAt === -1) splitAt = maxLength;
71
+
72
+ chunks.push(remaining.slice(0, splitAt).trimEnd());
73
+ remaining = remaining.slice(splitAt).trimStart();
74
+ }
75
+
76
+ return chunks;
77
+ }
78
+
79
+ /**
80
+ * Escape HTML special characters
81
+ * @param {string} text - Text to escape
82
+ * @returns {string} Escaped text
83
+ */
84
+ function escapeHtml(text) {
85
+ if (!text) return '';
86
+ return text
87
+ .replace(/&/g, '&amp;')
88
+ .replace(/</g, '&lt;')
89
+ .replace(/>/g, '&gt;');
90
+ }
91
+
92
+ /**
93
+ * Send a message to a Telegram chat with HTML formatting
94
+ * Automatically splits long messages
95
+ * @param {string} botToken - Bot token from @BotFather
96
+ * @param {number|string} chatId - Chat ID to send message to
97
+ * @param {string} text - Message text (HTML formatted)
98
+ * @param {Object} [options] - Additional options
99
+ * @param {boolean} [options.disablePreview] - Disable link previews
100
+ * @returns {Promise<Object>} - Last message sent
101
+ */
102
+ async function sendMessage(botToken, chatId, text, options = {}) {
103
+ const b = getBot(botToken);
104
+ // Strip HTML comments — Telegram's HTML parser doesn't support them
105
+ text = text.replace(/<!--[\s\S]*?-->/g, '');
106
+ const chunks = smartSplit(text, MAX_LENGTH);
107
+
108
+ let lastMessage;
109
+ for (const chunk of chunks) {
110
+ lastMessage = await b.api.sendMessage(chatId, chunk, {
111
+ parse_mode: 'HTML',
112
+ link_preview_options: { is_disabled: options.disablePreview ?? false },
113
+ });
114
+ }
115
+
116
+ return lastMessage;
117
+ }
118
+
119
+ /**
120
+ * Format a job notification message
121
+ * @param {Object} params - Notification parameters
122
+ * @param {string} params.jobId - Full job ID
123
+ * @param {boolean} params.success - Whether job succeeded
124
+ * @param {string} params.summary - Job summary text
125
+ * @param {string} params.prUrl - PR URL
126
+ * @returns {string} Formatted HTML message
127
+ */
128
+ function formatJobNotification({ jobId, success, summary, prUrl }) {
129
+ const emoji = success ? '\u2705' : '\u26a0\ufe0f';
130
+ const status = success ? 'complete' : 'had issues';
131
+ const shortId = jobId.slice(0, 8);
132
+
133
+ return `${emoji} <b>Job ${shortId}</b> ${status}
134
+
135
+ ${escapeHtml(summary)}
136
+
137
+ <a href="${prUrl}">View PR</a>`;
138
+ }
139
+
140
+ /**
141
+ * Download a file from Telegram servers
142
+ * @param {string} botToken - Bot token from @BotFather
143
+ * @param {string} fileId - Telegram file_id
144
+ * @returns {Promise<{buffer: Buffer, filename: string}>}
145
+ */
146
+ async function downloadFile(botToken, fileId) {
147
+ // Get file path from Telegram
148
+ const fileInfoRes = await fetch(
149
+ `https://api.telegram.org/bot${botToken}/getFile?file_id=${fileId}`
150
+ );
151
+ const fileInfo = await fileInfoRes.json();
152
+ if (!fileInfo.ok) {
153
+ throw new Error(`Telegram API error: ${fileInfo.description}`);
154
+ }
155
+
156
+ const filePath = fileInfo.result.file_path;
157
+
158
+ // Download file
159
+ const fileRes = await fetch(
160
+ `https://api.telegram.org/file/bot${botToken}/${filePath}`
161
+ );
162
+ const buffer = Buffer.from(await fileRes.arrayBuffer());
163
+ const filename = filePath.split('/').pop();
164
+
165
+ return { buffer, filename };
166
+ }
167
+
168
+ /**
169
+ * React to a message with an emoji
170
+ * @param {string} botToken - Bot token from @BotFather
171
+ * @param {number|string} chatId - Chat ID
172
+ * @param {number} messageId - Message ID to react to
173
+ * @param {string} [emoji='\ud83d\udc4d'] - Emoji to react with
174
+ */
175
+ async function reactToMessage(botToken, chatId, messageId, emoji = '\ud83d\udc4d') {
176
+ const b = getBot(botToken);
177
+ await b.api.setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }]);
178
+ }
179
+
180
+ /**
181
+ * Start a repeating typing indicator for a chat.
182
+ * Returns a stop function. The indicator naturally expires after 5s,
183
+ * so we re-send with random gaps (5.5-8s) to look human.
184
+ * @param {string} botToken - Bot token from @BotFather
185
+ * @param {number|string} chatId - Chat ID
186
+ * @returns {Function} Call to stop the typing indicator
187
+ */
188
+ function startTypingIndicator(botToken, chatId) {
189
+ const b = getBot(botToken);
190
+ let timeout;
191
+ let stopped = false;
192
+
193
+ function scheduleNext() {
194
+ if (stopped) return;
195
+ const delay = 5500 + Math.random() * 2500;
196
+ timeout = setTimeout(() => {
197
+ if (stopped) return;
198
+ b.api.sendChatAction(chatId, 'typing').catch(() => {});
199
+ scheduleNext();
200
+ }, delay);
201
+ }
202
+
203
+ b.api.sendChatAction(chatId, 'typing').catch(() => {});
204
+ scheduleNext();
205
+
206
+ return () => {
207
+ stopped = true;
208
+ clearTimeout(timeout);
209
+ };
210
+ }
211
+
212
+ module.exports = {
213
+ getBot,
214
+ setWebhook,
215
+ sendMessage,
216
+ smartSplit,
217
+ escapeHtml,
218
+ formatJobNotification,
219
+ downloadFile,
220
+ reactToMessage,
221
+ startTypingIndicator,
222
+ };
@@ -0,0 +1,105 @@
1
+ const fs = require('fs');
2
+ const paths = require('./paths');
3
+
4
+ const { executeAction } = require('./actions');
5
+
6
+ /**
7
+ * Replace {{body.field}} templates with values from request context
8
+ * @param {string} template - String with {{body.field}} placeholders
9
+ * @param {Object} context - { body, query, headers }
10
+ * @returns {string}
11
+ */
12
+ function resolveTemplate(template, context) {
13
+ return template.replace(/\{\{(\w+)(?:\.(\w+))?\}\}/g, (match, source, field) => {
14
+ const data = context[source];
15
+ if (data === undefined) return match;
16
+ if (!field) return typeof data === 'string' ? data : JSON.stringify(data, null, 2);
17
+ if (data[field] !== undefined) return String(data[field]);
18
+ return match;
19
+ });
20
+ }
21
+
22
+ /**
23
+ * Execute all actions for a trigger (fire-and-forget)
24
+ * @param {Object} trigger - Trigger config object
25
+ * @param {Object} context - { body, query, headers }
26
+ */
27
+ async function executeActions(trigger, context) {
28
+ for (const action of trigger.actions) {
29
+ try {
30
+ const resolved = { ...action };
31
+ if (resolved.command) resolved.command = resolveTemplate(resolved.command, context);
32
+ if (resolved.job) resolved.job = resolveTemplate(resolved.job, context);
33
+ const result = await executeAction(resolved, { cwd: paths.triggersDir, data: context.body });
34
+ console.log(`[TRIGGER] ${trigger.name}: ${result || 'ran'}`);
35
+ } catch (err) {
36
+ console.error(`[TRIGGER] ${trigger.name}: error - ${err.message}`);
37
+ }
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Load triggers from TRIGGERS.json and return trigger map + fire function
43
+ * @returns {{ triggerMap: Map, fireTriggers: Function }}
44
+ */
45
+ function loadTriggers() {
46
+ const triggerFile = paths.triggersFile;
47
+ const triggerMap = new Map();
48
+
49
+ console.log('\n--- Triggers ---');
50
+
51
+ if (!fs.existsSync(triggerFile)) {
52
+ console.log('No TRIGGERS.json found');
53
+ console.log('----------------\n');
54
+ return { triggerMap, fireTriggers: () => {} };
55
+ }
56
+
57
+ const triggers = JSON.parse(fs.readFileSync(triggerFile, 'utf8'));
58
+
59
+ for (const trigger of triggers) {
60
+ if (trigger.enabled === false) continue;
61
+
62
+ if (!triggerMap.has(trigger.watch_path)) {
63
+ triggerMap.set(trigger.watch_path, []);
64
+ }
65
+ triggerMap.get(trigger.watch_path).push(trigger);
66
+ }
67
+
68
+ const activeCount = [...triggerMap.values()].reduce((sum, arr) => sum + arr.length, 0);
69
+
70
+ if (activeCount === 0) {
71
+ console.log('No active triggers');
72
+ } else {
73
+ for (const [watchPath, pathTriggers] of triggerMap) {
74
+ for (const t of pathTriggers) {
75
+ const actionTypes = t.actions.map(a => a.type || 'agent').join(', ');
76
+ console.log(` ${t.name}: ${watchPath} (${actionTypes})`);
77
+ }
78
+ }
79
+ }
80
+
81
+ console.log('----------------\n');
82
+
83
+ /**
84
+ * Fire matching triggers for a given path (non-blocking)
85
+ * @param {string} path - Request path (e.g., '/webhook')
86
+ * @param {Object} body - Request body
87
+ * @param {Object} [query={}] - Query parameters
88
+ * @param {Object} [headers={}] - Request headers
89
+ */
90
+ function fireTriggers(path, body, query = {}, headers = {}) {
91
+ const matched = triggerMap.get(path);
92
+ if (matched) {
93
+ const context = { body, query, headers };
94
+ for (const trigger of matched) {
95
+ executeActions(trigger, context).catch(err => {
96
+ console.error(`[TRIGGER] ${trigger.name}: unhandled error - ${err.message}`);
97
+ });
98
+ }
99
+ }
100
+ }
101
+
102
+ return { triggerMap, fireTriggers };
103
+ }
104
+
105
+ module.exports = { loadTriggers };
@@ -0,0 +1,39 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const paths = require('../paths');
4
+
5
+ const INCLUDE_PATTERN = /\{\{([^}]+\.md)\}\}/g;
6
+
7
+ /**
8
+ * Render a markdown file, resolving {{filepath}} includes recursively.
9
+ * Referenced file paths resolve relative to the project root.
10
+ * @param {string} filePath - Absolute path to the markdown file
11
+ * @param {string[]} [chain=[]] - Already-resolved file paths (for circular detection)
12
+ * @returns {string} Rendered markdown content
13
+ */
14
+ function render_md(filePath, chain = []) {
15
+ const resolved = path.resolve(filePath);
16
+
17
+ if (chain.includes(resolved)) {
18
+ const cycle = [...chain, resolved].map((p) => path.relative(paths.PROJECT_ROOT, p)).join(' -> ');
19
+ console.log(`[render_md] Circular include detected: ${cycle}`);
20
+ return '';
21
+ }
22
+
23
+ if (!fs.existsSync(resolved)) {
24
+ return '';
25
+ }
26
+
27
+ const content = fs.readFileSync(resolved, 'utf8');
28
+ const currentChain = [...chain, resolved];
29
+
30
+ return content.replace(INCLUDE_PATTERN, (match, includePath) => {
31
+ const includeResolved = path.resolve(paths.PROJECT_ROOT, includePath.trim());
32
+ if (!fs.existsSync(includeResolved)) {
33
+ return match;
34
+ }
35
+ return render_md(includeResolved, currentChain);
36
+ });
37
+ }
38
+
39
+ module.exports = { render_md };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "thepopebot",
3
+ "version": "1.0.0",
4
+ "description": "Create autonomous AI agents with a two-layer architecture: Next.js Event Handler + Docker Agent.",
5
+ "bin": {
6
+ "thepopebot": "./bin/cli.js"
7
+ },
8
+ "main": "api/index.js",
9
+ "exports": {
10
+ "./api": "./api/index.js",
11
+ "./config": "./config/index.js",
12
+ "./instrumentation": "./config/instrumentation.js"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "lib/",
17
+ "api/",
18
+ "config/",
19
+ "setup/",
20
+ "docker/",
21
+ "pi/",
22
+ "templates/"
23
+ ],
24
+ "scripts": {
25
+ "test": "echo \"No tests yet\" && exit 0"
26
+ },
27
+ "keywords": [
28
+ "ai",
29
+ "agent",
30
+ "autonomous",
31
+ "telegram",
32
+ "bot",
33
+ "claude",
34
+ "nextjs"
35
+ ],
36
+ "author": "Stephen Pope",
37
+ "license": "MIT",
38
+ "dependencies": {
39
+ "@grammyjs/parse-mode": "^2.2.0",
40
+ "dotenv": "^16.3.1",
41
+ "grammy": "^1.39.3",
42
+ "node-cron": "^3.0.3",
43
+ "uuid": "^9.0.0",
44
+ "chalk": "^5.3.0",
45
+ "inquirer": "^9.2.12",
46
+ "open": "^10.0.0",
47
+ "ora": "^8.0.1"
48
+ },
49
+ "peerDependencies": {
50
+ "next": ">=15.0.0",
51
+ "react": ">=19.0.0",
52
+ "react-dom": ">=19.0.0"
53
+ },
54
+ "engines": {
55
+ "node": ">=18.0.0"
56
+ }
57
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Env Sanitizer Extension - Protects credentials from AI agent access
3
+ *
4
+ * Uses Pi's spawnHook to filter sensitive env vars from bash subprocess calls
5
+ * while keeping them available in the main process for:
6
+ * - Anthropic SDK (needs ANTHROPIC_API_KEY at init)
7
+ * - GitHub CLI (needs GH_TOKEN)
8
+ * - Other extensions that may need credentials
9
+ *
10
+ * Dynamically filters all keys defined in the SECRETS JSON env var.
11
+ */
12
+
13
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
14
+ import { createBashTool } from "@mariozechner/pi-coding-agent";
15
+
16
+ // Parse SECRETS JSON to get list of keys to filter
17
+ function getSecretKeys(): string[] {
18
+ const keys: string[] = [];
19
+ if (process.env.SECRETS) {
20
+ try {
21
+ const secrets = JSON.parse(process.env.SECRETS);
22
+ keys.push(...Object.keys(secrets));
23
+ } catch {
24
+ // Invalid JSON, ignore
25
+ }
26
+ }
27
+ // Always filter SECRETS itself
28
+ keys.push("SECRETS");
29
+ return [...new Set(keys)]; // Dedupe
30
+ }
31
+
32
+ export default function (pi: ExtensionAPI) {
33
+ const secretKeys = getSecretKeys();
34
+
35
+ // Override bash tool with filtered environment for subprocesses
36
+ const bashTool = createBashTool(process.cwd(), {
37
+ spawnHook: ({ command, cwd, env }) => {
38
+ // Filter all secret keys from subprocess environment
39
+ const filteredEnv = { ...env };
40
+ for (const key of secretKeys) {
41
+ delete filteredEnv[key];
42
+ }
43
+ return { command, cwd, env: filteredEnv };
44
+ },
45
+ });
46
+
47
+ pi.registerTool(bashTool);
48
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "env-sanitizer",
3
+ "private": true,
4
+ "type": "module"
5
+ }
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: llm-secrets
3
+ description: List available LLM-accessible credentials. Use when you need API keys, passwords, or other secrets that have been made available to you.
4
+ ---
5
+
6
+ # List Available Secrets
7
+
8
+ ```bash
9
+ /job/.pi/skills/llm-secrets/llm-secrets.js
10
+ ```
11
+
12
+ Shows the names of available secret keys (not values). Output example:
13
+
14
+ ```
15
+ Available secrets:
16
+ - BROWSER_PASSWORD
17
+ - SOME_API_KEY
18
+
19
+ To get a value: echo $KEY_NAME
20
+ ```
21
+
22
+ ## Get a Secret Value
23
+
24
+ ```bash
25
+ echo $KEY_NAME
26
+ ```
27
+
28
+ Replace `KEY_NAME` with one of the available secret names.
29
+
30
+ ## When to Use
31
+
32
+ - When a skill or tool needs authentication credentials
33
+ - When logging into a website via browser tools
34
+ - When calling an external API that requires a key
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * llm-secrets.js - List available LLM-accessible secret keys
5
+ *
6
+ * Usage: llm-secrets.js
7
+ *
8
+ * Lists the key names from LLM_SECRETS (not the values).
9
+ * To get a value, use: echo $KEY_NAME
10
+ */
11
+
12
+ const secretsBase64 = process.env.LLM_SECRETS;
13
+
14
+ if (!secretsBase64) {
15
+ console.log('No LLM_SECRETS configured.');
16
+ process.exit(0);
17
+ }
18
+
19
+ try {
20
+ const decoded = Buffer.from(secretsBase64, 'base64').toString('utf-8');
21
+ const parsed = JSON.parse(decoded);
22
+ const keys = Object.keys(parsed);
23
+
24
+ if (keys.length === 0) {
25
+ console.log('LLM_SECRETS is empty.');
26
+ } else {
27
+ console.log('Available secrets:');
28
+ keys.forEach(key => console.log(` - ${key}`));
29
+ console.log('\nTo get a value: echo $KEY_NAME');
30
+ }
31
+ } catch (e) {
32
+ console.error('Error parsing LLM_SECRETS:', e.message);
33
+ process.exit(1);
34
+ }