this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit.

alice 5d3db0fb

+796
+1
.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc
··· 1 + ../../CLAUDE.md
+2
.env.example
··· 1 + BLUESKY_IDENTIFIER=your.handle.bsky.social 2 + BLUESKY_PASSWORD=your-app-password
+44
.gitignore
··· 1 + # dependencies (bun install) 2 + node_modules 3 + 4 + # output 5 + out 6 + dist 7 + *.tgz 8 + 9 + # code coverage 10 + coverage 11 + *.lcov 12 + 13 + # logs 14 + logs 15 + _.log 16 + report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 + 18 + # dotenv environment variable files 19 + .env 20 + .env.development.local 21 + .env.test.local 22 + .env.production.local 23 + .env.local 24 + 25 + # caches 26 + .eslintcache 27 + .cache 28 + *.tsbuildinfo 29 + 30 + # IntelliJ based IDEs 31 + .idea 32 + 33 + # Finder (MacOS) folder config 34 + .DS_Store 35 + 36 + # Data 37 + data/bible-corpus.json 38 + data/bible-raw-text.cache 39 + 40 + # Bun 41 + .bun/ 42 + 43 + # OS 44 + Thumbs.db
+111
CLAUDE.md
··· 1 + --- 2 + description: Use Bun instead of Node.js, npm, pnpm, or vite. 3 + globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" 4 + alwaysApply: false 5 + --- 6 + 7 + Default to using Bun instead of Node.js. 8 + 9 + - Use `bun <file>` instead of `node <file>` or `ts-node <file>` 10 + - Use `bun test` instead of `jest` or `vitest` 11 + - Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` 12 + - Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` 13 + - Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>` 14 + - Bun automatically loads .env, so don't use dotenv. 15 + 16 + ## APIs 17 + 18 + - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. 19 + - `bun:sqlite` for SQLite. Don't use `better-sqlite3`. 20 + - `Bun.redis` for Redis. Don't use `ioredis`. 21 + - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. 22 + - `WebSocket` is built-in. Don't use `ws`. 23 + - Prefer `Bun.file` over `node:fs`'s readFile/writeFile 24 + - Bun.$`ls` instead of execa. 25 + 26 + ## Testing 27 + 28 + Use `bun test` to run tests. 29 + 30 + ```ts#index.test.ts 31 + import { test, expect } from "bun:test"; 32 + 33 + test("hello world", () => { 34 + expect(1).toBe(1); 35 + }); 36 + ``` 37 + 38 + ## Frontend 39 + 40 + Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind. 41 + 42 + Server: 43 + 44 + ```ts#index.ts 45 + import index from "./index.html" 46 + 47 + Bun.serve({ 48 + routes: { 49 + "/": index, 50 + "/api/users/:id": { 51 + GET: (req) => { 52 + return new Response(JSON.stringify({ id: req.params.id })); 53 + }, 54 + }, 55 + }, 56 + // optional websocket support 57 + websocket: { 58 + open: (ws) => { 59 + ws.send("Hello, world!"); 60 + }, 61 + message: (ws, message) => { 62 + ws.send(message); 63 + }, 64 + close: (ws) => { 65 + // handle close 66 + } 67 + }, 68 + development: { 69 + hmr: true, 70 + console: true, 71 + } 72 + }) 73 + ``` 74 + 75 + HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle. 76 + 77 + ```html#index.html 78 + <html> 79 + <body> 80 + <h1>Hello, world!</h1> 81 + <script type="module" src="./frontend.tsx"></script> 82 + </body> 83 + </html> 84 + ``` 85 + 86 + With the following `frontend.tsx`: 87 + 88 + ```tsx#frontend.tsx 89 + import React from "react"; 90 + 91 + // import .css files directly and it works 92 + import './index.css'; 93 + 94 + import { createRoot } from "react-dom/client"; 95 + 96 + const root = createRoot(document.body); 97 + 98 + export default function Frontend() { 99 + return <h1>Hello, world!</h1>; 100 + } 101 + 102 + root.render(<Frontend />); 103 + ``` 104 + 105 + Then, run index.ts 106 + 107 + ```sh 108 + bun --hot ./index.ts 109 + ``` 110 + 111 + For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
+13
Dockerfile
··· 1 + FROM oven/bun:1-alpine 2 + 3 + WORKDIR /app 4 + 5 + COPY package.json bun.lockb ./ 6 + RUN bun install --production 7 + 8 + COPY src ./src 9 + COPY data ./data 10 + 11 + ENV NODE_ENV=production 12 + 13 + CMD ["bun", "run", "src/bot.ts"]
+76
README.md
··· 1 + # None Of These Words Are In The Bible 2 + 3 + A Bluesky bot that analyzes posts and reports what percentage of words appear in the Bible. 4 + 5 + ## Setup 6 + 7 + 1. Install dependencies: 8 + ```bash 9 + bun install 10 + ``` 11 + 12 + 2. Create a `.env` file with your Bluesky credentials: 13 + ```bash 14 + cp .env.example .env 15 + # Edit .env with your credentials 16 + ``` 17 + 18 + 3. Build the Bible corpus: 19 + ```bash 20 + bun run build-corpus 21 + ``` 22 + 23 + 4. Run the bot: 24 + ```bash 25 + bun start 26 + ``` 27 + 28 + ## How it works 29 + 30 + The bot supports two modes based on your mention text: 31 + 32 + ### Mode 1: "how many" 33 + When you reply to any post with "@bot how many", it analyzes that post. 34 + 35 + Example: 36 + ``` 37 + Post: "there is no such thing as a coincidence" 38 + └─ You: "@noneofthesewords how many" 39 + └─ Bot: "actually, 71% of these words are in the Bible" 40 + ``` 41 + 42 + ### Mode 2: "really?" 43 + When you reply to the bot's analysis with "@bot really?", it re-analyzes the original post (useful for double-checking). 44 + 45 + Example: 46 + ``` 47 + Post: "there is no such thing as a coincidence" 48 + └─ You: "@noneofthesewords how many" 49 + └─ Bot: "actually, 71% of these words are in the Bible" 50 + └─ You: "@noneofthesewords really?" 51 + └─ Bot: "actually, 71% of these words are in the Bible" (re-analyzes the original post) 52 + ``` 53 + 54 + The bot: 55 + 1. Checks each word against the World English Bible corpus 56 + 2. Replies with the percentage of words found in the Bible 57 + 3. Handles contractions and common word variations properly 58 + 59 + ## Development 60 + 61 + Run in watch mode: 62 + ```bash 63 + bun dev 64 + ``` 65 + 66 + ## Deployment 67 + 68 + The bot can be deployed to: 69 + - VPS with PM2 or systemd 70 + - Docker container 71 + - Deno Deploy (with minor modifications) 72 + - Cloud functions (AWS Lambda, Vercel, etc.) 73 + 74 + Environment variables needed: 75 + - `BLUESKY_IDENTIFIER`: Your Bluesky handle 76 + - `BLUESKY_PASSWORD`: Your app password (not main password)
+64
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "workspaces": { 4 + "": { 5 + "name": "none-of-these-words", 6 + "dependencies": { 7 + "@skyware/bot": "^0.3.11", 8 + }, 9 + "devDependencies": { 10 + "@types/bun": "latest", 11 + }, 12 + "peerDependencies": { 13 + "typescript": "^5", 14 + }, 15 + }, 16 + }, 17 + "packages": { 18 + "@atcute/bluesky": ["@atcute/bluesky@1.0.15", "", { "peerDependencies": { "@atcute/client": "^1.0.0 || ^2.0.0" } }, "sha512-+EFiybmKQ97aBAgtaD+cKRJER5AMn3cZMkEwEg/pDdWyzxYJ9m1UgemmLdTgI8VrxPufKqdXS2nl7uO7TY6BPA=="], 19 + 20 + "@atcute/bluesky-richtext-builder": ["@atcute/bluesky-richtext-builder@1.0.2", "", { "peerDependencies": { "@atcute/bluesky": "^1.0.0", "@atcute/client": "^1.0.0 || ^2.0.0" } }, "sha512-sa+9B5Ygb1GcWeMpav9RVBRdFLL5snZEoFFF2RkTaNr61m/cLd5lk97QJs+t9LXUEl5cfHS3jXujywFrGXZj9w=="], 21 + 22 + "@atcute/car": ["@atcute/car@1.1.1", "", { "dependencies": { "@atcute/cbor": "^1.0.6", "@atcute/cid": "^1.0.2", "@atcute/varint": "^1.0.1" } }, "sha512-j6HY//ttIFCbOioDlEowKn2WOGeNavJenZkAP+wWIhsbRlK+V4+TpnJ38IX/VYfMpQHrKweh3W94wRCYp6L5Zg=="], 23 + 24 + "@atcute/cbor": ["@atcute/cbor@1.0.7", "", { "dependencies": { "@atcute/cid": "^1.0.3", "@atcute/multibase": "^1.0.0" } }, "sha512-z3chucgCqjAN36ySvUVl1VSwtGME4CDS173eaaEfiTSpRIQ6ewKpKlkzapLUNqtLU9iBx884b9c2j6kjEyn1XA=="], 25 + 26 + "@atcute/cid": ["@atcute/cid@1.0.3", "", { "dependencies": { "@atcute/multibase": "^1.0.0", "@atcute/varint": "^1.0.1" } }, "sha512-BZbs+Xt0yMci0I2dLqqYsN76ua8lkMk/HQfEIKr7g2XMBlSc0XNCXfZdbAWPwiCK/NuGaPBocYMRwApd4dF2Qg=="], 27 + 28 + "@atcute/client": ["@atcute/client@2.0.9", "", {}, "sha512-QNDm9gMP6x9LY77ArwY+urQOBtQW74/onEAz42c40JxRm6Rl9K9cU4ROvNKJ+5cpVmEm1sthEWVRmDr5CSZENA=="], 29 + 30 + "@atcute/multibase": ["@atcute/multibase@1.1.4", "", { "dependencies": { "@atcute/uint8array": "^1.0.2" } }, "sha512-NUf5AeeSOmuZHGU+4GAaMtISJoG+ZHtW/vUVA4lK/YDt/7LODAW0Fd0NNIIUPVUoW0xJS6zSEIWvwLLuxmEHhA=="], 31 + 32 + "@atcute/ozone": ["@atcute/ozone@1.0.12", "", { "peerDependencies": { "@atcute/bluesky": "^1.0.0", "@atcute/client": "^1.0.0 || ^2.0.0" } }, "sha512-eogx/FCF6X3WTwAPxgG8RcrziuOUcJvMu+qHodeVcLSQ7QJvw2H/Q5V0HpnZegUOY5aRGKb5RvLk2SeZq3LCeA=="], 33 + 34 + "@atcute/uint8array": ["@atcute/uint8array@1.0.3", "", {}, "sha512-M/K+ihiVW8Pl2PFLzaC4E3l4JaZ1IH05Q0AbPWUC4cVHnd/gZ/1kAF5ngdtGvJeDMirHZ2VAy7OmAsPwR/2nlA=="], 35 + 36 + "@atcute/varint": ["@atcute/varint@1.0.2", "", {}, "sha512-0O31hePzzr4O3NGWHUKKOyta6CGSL+AtN8iir8grGxu9jXyI7DBARlw6PbgKA6uTAvsXdpmRmF8MX+p0TsLnNg=="], 37 + 38 + "@skyware/bot": ["@skyware/bot@0.3.11", "", { "dependencies": { "@atcute/bluesky": "^1.0.7", "@atcute/bluesky-richtext-builder": "^1.0.1", "@atcute/client": "^2.0.3", "@atcute/ozone": "^1.0.5", "quick-lru": "^7.0.0", "rate-limit-threshold": "^0.1.5" }, "optionalDependencies": { "@skyware/firehose": "^0.3.2", "@skyware/jetstream": "^0.1.9" } }, "sha512-fG4uvD/3+ynlQPRHv6pz4kk+5fbDNJYTORbLXwmlwWAzJ7FSBkahG1NeK2M+hqIYBVqdzAI5QqcM17fwJXeZeQ=="], 39 + 40 + "@skyware/firehose": ["@skyware/firehose@0.3.2", "", { "dependencies": { "@atcute/car": "^1.1.0", "@atcute/cbor": "^1.0.3", "ws": "^8.16.0" } }, "sha512-CmRaw3lFPEd9euFGV+K/n/TF/o0Rre87oJP5pswC8IExj/qQnWVoncIulAJbL3keUCm5mlt49jCiiqfQXVjigg=="], 41 + 42 + "@skyware/jetstream": ["@skyware/jetstream@0.1.9", "", { "dependencies": { "@atcute/bluesky": "^1.0.6", "partysocket": "^1.0.2" } }, "sha512-87NGqRSmEYmvEuVY0DKZ2ZRbL72tExtDuV+PDvywYqukSp3F/it+9wnU5Xb/Is/pf+KSbd6lWvrnE31HgxJpJg=="], 43 + 44 + "@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="], 45 + 46 + "@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="], 47 + 48 + "bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="], 49 + 50 + "event-target-polyfill": ["event-target-polyfill@0.0.4", "", {}, "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ=="], 51 + 52 + "partysocket": ["partysocket@1.1.4", "", { "dependencies": { "event-target-polyfill": "^0.0.4" } }, "sha512-jXP7PFj2h5/v4UjDS8P7MZy6NJUQ7sspiFyxL4uc/+oKOL+KdtXzHnTV8INPGxBrLTXgalyG3kd12Qm7WrYc3A=="], 53 + 54 + "quick-lru": ["quick-lru@7.0.1", "", {}, "sha512-kLjThirJMkWKutUKbZ8ViqFc09tDQhlbQo2MNuVeLWbRauqYP96Sm6nzlQ24F0HFjUNZ4i9+AgldJ9H6DZXi7g=="], 55 + 56 + "rate-limit-threshold": ["rate-limit-threshold@0.1.5", "", {}, "sha512-75vpvXC/ZqQJrFDp0dVtfoXZi8kxQP2eBuxVYFvGDfnHhcgE+ZG870u4ItQhWQh54Y6nNwOaaq5g3AL9n27lTg=="], 57 + 58 + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], 59 + 60 + "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], 61 + 62 + "ws": ["ws@8.18.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ=="], 63 + } 64 + }
+10
docker-compose.yml
··· 1 + version: '3.8' 2 + 3 + services: 4 + bot: 5 + build: . 6 + restart: unless-stopped 7 + env_file: 8 + - .env 9 + volumes: 10 + - ./data:/app/data:ro
+1
index.ts
··· 1 + console.log("Hello via Bun!");
+20
package.json
··· 1 + { 2 + "name": "none-of-these-words", 3 + "module": "index.ts", 4 + "type": "module", 5 + "private": true, 6 + "scripts": { 7 + "start": "bun run src/bot.ts", 8 + "dev": "bun --watch src/bot.ts", 9 + "build-corpus": "bun run src/build-corpus.ts" 10 + }, 11 + "devDependencies": { 12 + "@types/bun": "latest" 13 + }, 14 + "peerDependencies": { 15 + "typescript": "^5" 16 + }, 17 + "dependencies": { 18 + "@skyware/bot": "^0.3.11" 19 + } 20 + }
+77
src/analyzer.ts
··· 1 + import type { BibleCorpus, WordAnalysis } from './types'; 2 + 3 + export class BibleWordAnalyzer { 4 + private wordSet: Set<string>; 5 + 6 + constructor(corpus: BibleCorpus) { 7 + this.wordSet = new Set(corpus.words); 8 + } 9 + 10 + analyze(text: string): WordAnalysis { 11 + const words = this.extractWords(text); 12 + 13 + // Count unique words only 14 + const uniqueWords = new Set(words); 15 + const foundWords: string[] = []; 16 + const notFoundWords: string[] = []; 17 + 18 + for (const word of uniqueWords) { 19 + if (this.wordSet.has(word)) { 20 + foundWords.push(word); 21 + } else { 22 + notFoundWords.push(word); 23 + } 24 + } 25 + 26 + const totalWords = uniqueWords.size; 27 + const wordsInBible = foundWords.length; 28 + const percentage = totalWords > 0 ? Math.round((wordsInBible / totalWords) * 100) : 0; 29 + 30 + return { 31 + totalWords, 32 + wordsInBible, 33 + percentage, 34 + foundWords: foundWords.sort(), 35 + notFoundWords: notFoundWords.sort() 36 + }; 37 + } 38 + 39 + private extractWords(text: string): string[] { 40 + // Match the same word extraction logic as build-corpus.ts 41 + const cleanedText = text 42 + .toLowerCase() 43 + .replace(/\b\d+:\d+\b/g, ' ') 44 + .replace(/chapter\s+\d+/gi, ' ') 45 + .replace(/\b\d+\b/g, ' '); 46 + 47 + return cleanedText 48 + .replace(/--/g, ' ') 49 + .replace(/[''`]/g, "'") 50 + .replace(/[^a-z\s'-]/g, ' ') 51 + .split(/\s+/) 52 + .map(word => word.trim()) 53 + .filter(word => { 54 + if (word.length === 0 || word === "'" || word === '-') return false; 55 + word = word.replace(/^['"-]+|['"-]+$/g, ''); 56 + if (word.length === 0) return false; 57 + if (/^[-']|[-']$/.test(word) && !this.isValidContraction(word)) return false; 58 + return true; 59 + }) 60 + .map(word => word.replace(/^['"-]+|['"-]+$/g, '')) 61 + .filter(word => word.length > 0); 62 + } 63 + 64 + private isValidContraction(word: string): boolean { 65 + const validContractions = [ 66 + "don't", "doesn't", "didn't", "won't", "wouldn't", "shouldn't", "couldn't", 67 + "can't", "isn't", "aren't", "wasn't", "weren't", "hasn't", "haven't", "hadn't", 68 + "i'm", "i've", "i'll", "i'd", "you're", "you've", "you'll", "you'd", 69 + "he's", "he'll", "he'd", "she's", "she'll", "she'd", "it's", "it'll", 70 + "we're", "we've", "we'll", "we'd", "they're", "they've", "they'll", "they'd", 71 + "that's", "that'll", "that'd", "who's", "who'll", "who'd", "what's", "what'll", 72 + "where's", "where'll", "when's", "how's", "let's", "here's", "there's" 73 + ]; 74 + 75 + return validContractions.includes(word.toLowerCase()); 76 + } 77 + }
+150
src/bot.ts
··· 1 + import { Bot } from '@skyware/bot'; 2 + import { BibleWordAnalyzer } from './analyzer'; 3 + import type { BibleCorpus } from './types'; 4 + 5 + const IDENTIFIER = process.env.BLUESKY_IDENTIFIER!; 6 + const PASSWORD = process.env.BLUESKY_PASSWORD!; 7 + 8 + if (!IDENTIFIER || !PASSWORD) { 9 + console.error('Please set BLUESKY_IDENTIFIER and BLUESKY_PASSWORD environment variables'); 10 + process.exit(1); 11 + } 12 + 13 + async function loadCorpus(): Promise<BibleCorpus> { 14 + try { 15 + const corpusFile = await Bun.file('data/bible-corpus.json').text(); 16 + return JSON.parse(corpusFile); 17 + } catch (error) { 18 + console.error('Failed to load corpus. Run "bun run build-corpus" first.'); 19 + process.exit(1); 20 + } 21 + } 22 + 23 + async function main() { 24 + console.log('Loading Bible corpus...'); 25 + const corpus = await loadCorpus(); 26 + const analyzer = new BibleWordAnalyzer(corpus); 27 + console.log(`Loaded ${corpus.wordCount} unique words from ${corpus.source}`); 28 + 29 + const bot = new Bot(); 30 + 31 + await bot.login({ 32 + identifier: IDENTIFIER, 33 + password: PASSWORD 34 + }); 35 + 36 + console.log('Bot logged in successfully!'); 37 + 38 + bot.on('mention', async (mention) => { 39 + try { 40 + console.log(`Received mention from @${mention.author.handle}: "${mention.text}"`); 41 + 42 + // Debug logging 43 + console.log('Mention object:', { 44 + text: mention.text, 45 + hasParent: !!mention.parent, 46 + parentType: mention.parent ? typeof mention.parent : 'none' 47 + }); 48 + 49 + // Determine which post to analyze based on mention text 50 + let postToAnalyze; 51 + const mentionTextLower = mention.text.toLowerCase(); 52 + 53 + if (mentionTextLower.includes("how many")) { 54 + // "how many" - analyze the post this mention is replying to 55 + postToAnalyze = mention.parent; 56 + } else if (mentionTextLower.includes("really?") || mentionTextLower.includes("really")) { 57 + // "really?" - user is questioning our analysis 58 + // Structure: original post -> bot reply -> "really?" 59 + // We need to find what the bot originally analyzed 60 + console.log('Really path - checking parent chain...'); 61 + 62 + try { 63 + const parent = mention.parent; // This should be bot's reply 64 + console.log('Parent (bot reply):', { 65 + exists: !!parent, 66 + text: parent && 'text' in parent ? (parent.text as string).substring(0, 100) + '...' : 'no text' 67 + }); 68 + 69 + if (parent && 'fetchParent' in parent) { 70 + // Fetch the post that the bot was replying to (the original post) 71 + const originalPost = await parent.fetchParent({ parentHeight: 1 }); 72 + console.log('Fetched original post:', { 73 + exists: !!originalPost, 74 + text: originalPost && 'text' in originalPost ? (originalPost.text as string) : 'no text' 75 + }); 76 + 77 + postToAnalyze = originalPost; 78 + } 79 + } catch (error) { 80 + console.error('Error fetching parent chain:', error); 81 + } 82 + } else { 83 + // Default: analyze the parent post 84 + postToAnalyze = mention.parent; 85 + } 86 + 87 + // More debug logging 88 + if (postToAnalyze) { 89 + console.log('Post to analyze:', { 90 + hasText: 'text' in postToAnalyze, 91 + textPreview: 'text' in postToAnalyze ? (postToAnalyze.text as string).substring(0, 50) : 'no text' 92 + }); 93 + } 94 + 95 + if (!postToAnalyze || typeof postToAnalyze !== 'object' || !('text' in postToAnalyze)) { 96 + await mention.reply({ 97 + text: "I need a post to analyze! Reply to any post with '@bot how many' and I'll check how many of its words are in the Bible." 98 + }); 99 + return; 100 + } 101 + 102 + const textToAnalyze = postToAnalyze.text; 103 + const analysis = analyzer.analyze(textToAnalyze); 104 + 105 + // Detailed logging of analysis 106 + console.log('\n=== ANALYSIS RESULTS ==='); 107 + console.log(`Text: "${textToAnalyze}"`); 108 + console.log(`Total words: ${analysis.totalWords}`); 109 + console.log(`Words in Bible: ${analysis.wordsInBible} (${analysis.percentage}%)`); 110 + console.log(`\nWords FOUND in Bible: ${analysis.foundWords.join(', ') || '(none)'}`); 111 + console.log(`Words NOT found in Bible: ${analysis.notFoundWords.join(', ') || '(none)'}`); 112 + console.log('========================\n'); 113 + 114 + let replyText; 115 + if (mentionTextLower.includes("really?") || mentionTextLower.includes("really")) { 116 + // For "really?" responses, use "actually" 117 + replyText = analysis.percentage === 0 118 + ? "none of these words are in the Bible" 119 + : `actually, ${analysis.percentage}% of these words are in the Bible`; 120 + } else { 121 + // For "how many" and other mentions, don't use "actually" 122 + replyText = analysis.percentage === 0 123 + ? "none of these words are in the Bible" 124 + : `${analysis.percentage}% of these words are in the Bible`; 125 + } 126 + 127 + await mention.reply({ 128 + text: replyText 129 + }); 130 + 131 + } catch (error) { 132 + console.error('Error handling mention:', error); 133 + try { 134 + await mention.reply({ 135 + text: "Sorry, I encountered an error analyzing that post." 136 + }); 137 + } catch (replyError) { 138 + console.error('Failed to send error reply:', replyError); 139 + } 140 + } 141 + }); 142 + 143 + bot.on('error', (error) => { 144 + console.error('Bot error:', error); 145 + }); 146 + 147 + console.log('Bot is running! Waiting for mentions...'); 148 + } 149 + 150 + main().catch(console.error);
+158
src/build-corpus.ts
··· 1 + #!/usr/bin/env bun 2 + 3 + const WEB_BIBLE_URL = "https://www.sacred-texts.com/bib/web/"; 4 + 5 + async function downloadBible() { 6 + console.log("Downloading World English Bible..."); 7 + 8 + const books = [ 9 + "gen", "exo", "lev", "num", "deu", "jos", "jdg", "rut", "sa1", "sa2", 10 + "kg1", "kg2", "ch1", "ch2", "ezr", "neh", "est", "job", "psa", "pro", 11 + "ecc", "sol", "isa", "jer", "lam", "eze", "dan", "hos", "joe", "amo", 12 + "oba", "jon", "mic", "nah", "hab", "zep", "hag", "zac", "mal", 13 + "mat", "mar", "luk", "joh", "act", "rom", "co1", "co2", "gal", "eph", 14 + "phi", "col", "th1", "th2", "ti1", "ti2", "tit", "plm", "heb", "jam", 15 + "pe1", "pe2", "jo1", "jo2", "jo3", "jde", "rev" 16 + ]; 17 + 18 + // Download all books in parallel with concurrency limit 19 + const BATCH_SIZE = 10; 20 + const results: string[] = []; 21 + 22 + for (let i = 0; i < books.length; i += BATCH_SIZE) { 23 + const batch = books.slice(i, i + BATCH_SIZE); 24 + const batchPromises = batch.map(async (book) => { 25 + try { 26 + const response = await fetch(`${WEB_BIBLE_URL}${book}.htm`); 27 + if (!response.ok) { 28 + console.warn(`Failed to fetch ${book}: ${response.status}`); 29 + return ''; 30 + } 31 + 32 + const html = await response.text(); 33 + const textMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i); 34 + if (textMatch) { 35 + const bodyText = textMatch[1] 36 + .replace(/<[^>]*>/g, ' ') 37 + .replace(/&[^;]+;/g, ' ') 38 + .replace(/\s+/g, ' ') 39 + .trim(); 40 + 41 + console.log(`Downloaded ${book}`); 42 + return bodyText; 43 + } 44 + return ''; 45 + } catch (error) { 46 + console.error(`Error downloading ${book}:`, error); 47 + return ''; 48 + } 49 + }); 50 + 51 + const batchResults = await Promise.all(batchPromises); 52 + results.push(...batchResults.filter(text => text.length > 0)); 53 + 54 + // Small delay between batches to avoid rate limiting 55 + if (i + BATCH_SIZE < books.length) { 56 + await new Promise(resolve => setTimeout(resolve, 100)); 57 + } 58 + } 59 + 60 + return results.join(' '); 61 + } 62 + 63 + function buildWordSet(text: string): Set<string> { 64 + // First, remove verse numbers and chapter headings (e.g., "1:1", "Chapter 1") 65 + const cleanedText = text 66 + .toLowerCase() 67 + .replace(/\b\d+:\d+\b/g, ' ') // Remove verse numbers like "3:16" 68 + .replace(/chapter\s+\d+/gi, ' ') // Remove chapter headings 69 + .replace(/\b\d+\b/g, ' '); // Remove standalone numbers 70 + 71 + // Extract words, handling contractions properly 72 + const words = cleanedText 73 + .replace(/--/g, ' ') // Replace double dashes with space 74 + .replace(/[''`]/g, "'") // Normalize apostrophes 75 + .replace(/[^a-z\s'-]/g, ' ') // Keep only letters, spaces, apostrophes, and hyphens 76 + .split(/\s+/) 77 + .map(word => word.trim()) 78 + .filter(word => { 79 + // Filter out empty strings, single quotes, and single hyphens 80 + if (word.length === 0 || word === "'" || word === '-') return false; 81 + 82 + // Remove leading/trailing quotes and hyphens 83 + word = word.replace(/^['"-]+|['"-]+$/g, ''); 84 + 85 + // Skip if it's just punctuation or too short 86 + if (word.length === 0) return false; 87 + 88 + // Skip if it starts or ends with punctuation (except valid contractions) 89 + if (/^[-']|[-']$/.test(word) && !isValidContraction(word)) return false; 90 + 91 + return true; 92 + }) 93 + .map(word => { 94 + // Clean up the word one more time 95 + return word.replace(/^['"-]+|['"-]+$/g, ''); 96 + }) 97 + .filter(word => word.length > 0); 98 + 99 + return new Set(words); 100 + } 101 + 102 + function isValidContraction(word: string): boolean { 103 + // Common valid contractions 104 + const validContractions = [ 105 + "don't", "doesn't", "didn't", "won't", "wouldn't", "shouldn't", "couldn't", 106 + "can't", "isn't", "aren't", "wasn't", "weren't", "hasn't", "haven't", "hadn't", 107 + "i'm", "i've", "i'll", "i'd", "you're", "you've", "you'll", "you'd", 108 + "he's", "he'll", "he'd", "she's", "she'll", "she'd", "it's", "it'll", 109 + "we're", "we've", "we'll", "we'd", "they're", "they've", "they'll", "they'd", 110 + "that's", "that'll", "that'd", "who's", "who'll", "who'd", "what's", "what'll", 111 + "where's", "where'll", "when's", "how's", "let's", "here's", "there's" 112 + ]; 113 + 114 + return validContractions.includes(word.toLowerCase()); 115 + } 116 + 117 + async function main() { 118 + try { 119 + // Check if we have cached raw text 120 + const cacheFile = "data/bible-raw-text.cache"; 121 + let bibleText: string; 122 + 123 + try { 124 + const cached = await Bun.file(cacheFile).text(); 125 + console.log("Using cached Bible text"); 126 + bibleText = cached; 127 + } catch { 128 + // No cache, download fresh 129 + const startTime = Date.now(); 130 + bibleText = await downloadBible(); 131 + const downloadTime = Date.now() - startTime; 132 + console.log(`Downloaded ${bibleText.length} characters in ${(downloadTime / 1000).toFixed(1)}s`); 133 + 134 + // Cache the raw text for future runs 135 + await Bun.write(cacheFile, bibleText); 136 + console.log("Cached raw text for future use"); 137 + } 138 + 139 + console.log("Building word corpus..."); 140 + const wordSet = buildWordSet(bibleText); 141 + console.log(`Found ${wordSet.size} unique words`); 142 + 143 + const corpus = { 144 + source: "World English Bible", 145 + wordCount: wordSet.size, 146 + words: Array.from(wordSet).sort() 147 + }; 148 + 149 + await Bun.write("data/bible-corpus.json", JSON.stringify(corpus)); 150 + console.log("Corpus saved to data/bible-corpus.json"); 151 + 152 + } catch (error) { 153 + console.error("Error building corpus:", error); 154 + process.exit(1); 155 + } 156 + } 157 + 158 + main();
+13
src/types.ts
··· 1 + export interface BibleCorpus { 2 + source: string; 3 + wordCount: number; 4 + words: string[]; 5 + } 6 + 7 + export interface WordAnalysis { 8 + totalWords: number; 9 + wordsInBible: number; 10 + percentage: number; 11 + foundWords: string[]; 12 + notFoundWords: string[]; 13 + }
+27
test-analysis.ts
··· 1 + #!/usr/bin/env bun 2 + 3 + import { BibleWordAnalyzer } from './src/analyzer'; 4 + import type { BibleCorpus } from './src/types'; 5 + 6 + async function test() { 7 + const corpusFile = await Bun.file('data/bible-corpus.json').text(); 8 + const corpus: BibleCorpus = JSON.parse(corpusFile); 9 + const analyzer = new BibleWordAnalyzer(corpus); 10 + 11 + const testPhrases = [ 12 + "there is no such thing as a coincidence", 13 + "how many", 14 + "the quick brown fox jumps over the lazy dog" 15 + ]; 16 + 17 + for (const phrase of testPhrases) { 18 + const analysis = analyzer.analyze(phrase); 19 + console.log(`\nPhrase: "${phrase}"`); 20 + console.log(`Total words: ${analysis.totalWords}`); 21 + console.log(`Words in Bible: ${analysis.wordsInBible} (${analysis.percentage}%)`); 22 + console.log(`Found: ${analysis.foundWords.join(', ')}`); 23 + console.log(`Not found: ${analysis.notFoundWords.join(', ')}`); 24 + } 25 + } 26 + 27 + test();
+29
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + // Environment setup & latest features 4 + "lib": ["ESNext"], 5 + "target": "ESNext", 6 + "module": "Preserve", 7 + "moduleDetection": "force", 8 + "jsx": "react-jsx", 9 + "allowJs": true, 10 + 11 + // Bundler mode 12 + "moduleResolution": "bundler", 13 + "allowImportingTsExtensions": true, 14 + "verbatimModuleSyntax": true, 15 + "noEmit": true, 16 + 17 + // Best practices 18 + "strict": true, 19 + "skipLibCheck": true, 20 + "noFallthroughCasesInSwitch": true, 21 + "noUncheckedIndexedAccess": true, 22 + "noImplicitOverride": true, 23 + 24 + // Some stricter flags (disabled by default) 25 + "noUnusedLocals": false, 26 + "noUnusedParameters": false, 27 + "noPropertyAccessFromIndexSignature": false 28 + } 29 + }