├── index.ts ├── .eslintrc.json ├── bun.lockb ├── app ├── favicon.ico ├── page.tsx ├── globals.css ├── layout.tsx └── api │ └── telegram │ └── hook │ ├── agents │ ├── lib.ts │ ├── lib.spec.ts │ ├── setup.ts │ └── pr.ts │ └── route.ts ├── lib ├── global.ts ├── strings.ts ├── tags.ts ├── object.ts ├── tags.spec.ts ├── template.ts ├── object.spec.ts ├── gh.spec.ts ├── spec.ts ├── types.ts ├── commands.spec.ts ├── commands.ts ├── db.ts └── gh.ts ├── next.config.mjs ├── .env.example ├── postcss.config.mjs ├── db └── setup.sql ├── tailwind.config.ts ├── Makefile ├── .gitignore ├── tsconfig.json ├── README.md ├── package.json └── dev.ts /index.ts: -------------------------------------------------------------------------------- 1 | console.log("Hello via Bun!"); -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/murderteeth/juniordev/HEAD/bun.lockb -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/murderteeth/juniordev/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /lib/global.ts: -------------------------------------------------------------------------------- 1 | (BigInt.prototype as any).toJSON = function () { 2 | return this.toString() 3 | } 4 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /lib/strings.ts: -------------------------------------------------------------------------------- 1 | export function truncate(str: string, length: number): string { 2 | return str.length > length ? str.slice(0, length - 3) + '...' : str; 3 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TELEGRAM_BOT= 2 | TELEGRAM_TOKEN= 3 | GITHUB_APP_ID= 4 | GITHUB_APP_INSTALLATION_ID= 5 | GITHUB_APP_CLIENT_SECRET= 6 | GITHUB_APP_PRIVATE_KEY= 7 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /lib/tags.ts: -------------------------------------------------------------------------------- 1 | import { TemplateTag, trimResultTransformer } from 'common-tags' 2 | 3 | export const trim = new TemplateTag( 4 | trimResultTransformer('start'), 5 | trimResultTransformer('end') 6 | ) 7 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Home() { 2 | return
3 |
JuniorDev
4 |

Dev bot for all your grunt work

5 |
6 | } 7 | -------------------------------------------------------------------------------- /lib/object.ts: -------------------------------------------------------------------------------- 1 | export function nullsToUndefined(obj: any) { 2 | if (obj === null) { 3 | return undefined 4 | } else if (typeof obj === 'object') { 5 | for (const key in obj) { 6 | obj[key] = nullsToUndefined(obj[key]) 7 | } 8 | } 9 | return obj 10 | } 11 | -------------------------------------------------------------------------------- /db/setup.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS chat ( 2 | id int8 PRIMARY KEY, 3 | github_repo_owner text NULL, 4 | github_repo_name text NULL, 5 | hooks jsonb NOT NULL DEFAULT '[]', 6 | created_at TIMESTAMP NOT NULL DEFAULT NOW(), 7 | updated_at TIMESTAMP NOT NULL DEFAULT NOW() 8 | ); 9 | -------------------------------------------------------------------------------- /lib/tags.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'bun:test' 2 | import { trim } from './tags' 3 | 4 | test('trims', () => { 5 | expect(trim``).toBe('') 6 | expect(trim` a `).toBe('a') 7 | expect(trim`\na\n`).toBe('a') 8 | expect(trim`\ta\t`).toBe('a') 9 | expect(trim` \n\ta \n\t\n b\t\n `).toBe('a \n\t\n b') 10 | }) 11 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: {}, 10 | plugins: [], 11 | } 12 | export default config 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | dev: 3 | @tmux new-session -d -s devenv 4 | @tmux splitw -h -p 60 5 | @tmux splitw -v -p 40 6 | @tmux send-keys -t devenv:0.0 '' C-m 7 | @tmux send-keys -t devenv:0.1 'bun run dev' C-m 8 | @tmux send-keys -t devenv:0.2 'ngrok http 3000' C-m 9 | @tmux selectp -t 0 10 | @tmux attach-session -t devenv 11 | 12 | attach: 13 | @tmux attach-session -t devenv 14 | 15 | down: 16 | -@tmux kill-server 17 | -------------------------------------------------------------------------------- /lib/template.ts: -------------------------------------------------------------------------------- 1 | export type PlaceholderValues = { 2 | [key: string]: string | number 3 | } 4 | 5 | export function template(strings: TemplateStringsArray, ...keys: string[]): (placeholders: PlaceholderValues) => string { 6 | return (placeholders) => { 7 | let result = strings[0] 8 | keys.forEach((key, i) => { 9 | result += String(placeholders[key]) + strings[i + 1] 10 | }) 11 | return result 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/object.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'bun:test' 2 | import { nullsToUndefined } from './object' 3 | 4 | test('replaces nulls with undefineds', () => { 5 | expect(nullsToUndefined({ 6 | a: null, 7 | b: { 8 | c: null, 9 | d: { 10 | e: null, 11 | }, 12 | }, 13 | })).toEqual({ 14 | a: undefined, 15 | b: { 16 | c: undefined, 17 | d: { 18 | e: undefined, 19 | }, 20 | } 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-rgb: 255, 255, 255; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | :root { 12 | --foreground-rgb: 255, 255, 255; 13 | --background-rgb: 0, 0, 0; 14 | } 15 | } 16 | 17 | body { 18 | color: rgb(var(--foreground-rgb)); 19 | background: rgb(var(--background-rgb)); 20 | } 21 | 22 | @layer utilities { 23 | .text-balance { 24 | text-wrap: balance; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { JetBrains_Mono } from 'next/font/google' 3 | import './globals.css' 4 | 5 | const font = JetBrains_Mono({ subsets: ['latin'] }); 6 | 7 | export const metadata: Metadata = { 8 | title: 'JuniorDev', 9 | description: 'Dev bot for all your grunt work', 10 | } 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | return 18 | {children} 19 | 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /lib/gh.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'bun:test' 2 | import { repoStructure } from './gh' 3 | import { trim } from './tags' 4 | 5 | const EXPECTED = trim` 6 | - .eslintrc.json 7 | - .gitignore 8 | - app/ 9 | --- favicon.ico 10 | --- globals.css 11 | --- layout.tsx 12 | --- page.tsx 13 | - bun.lockb 14 | - next.config.mjs 15 | - package.json 16 | - postcss.config.mjs 17 | - README.md 18 | - tailwind.config.ts 19 | - tsconfig.json 20 | ` 21 | 22 | test('gets the directory structure of a repo', async () => { 23 | const structure = await repoStructure('murderteeth', 'dummy') 24 | expect(structure).toBe(EXPECTED) 25 | }) 26 | -------------------------------------------------------------------------------- /lib/spec.ts: -------------------------------------------------------------------------------- 1 | 2 | export function MockTelegramWebHook({ 3 | id, username, text 4 | }: { 5 | id?: bigint, username?: string, text?: string 6 | }) { 7 | return { 8 | update_id: id ?? 1n, 9 | message: { 10 | message_id: id ?? 1n, 11 | from: { 12 | id: id ?? 1n, 13 | is_bot: false, 14 | first_name: username ?? 'john_doe', 15 | username: username ?? 'john_doe', 16 | language_code: 'en' 17 | }, 18 | chat: { 19 | id: id ?? 1n, 20 | first_name: username ?? 'john_doe', 21 | username: username ?? 'john_doe', 22 | type: 'private' 23 | }, 24 | date: id ?? 1n, 25 | text: text ?? '' 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # juniordev 2 | Telegram dev bot for all your dirty work 3 | 4 | ![juniordev](https://github.com/murderteeth/juniordev/assets/89237203/a910682a-cdb5-484f-bbc2-71cdb5637616) 5 | 6 | ### setup 7 | 1 - install 8 | ```bash 9 | bun i 10 | ``` 11 | 12 | 2 - config 13 | ``` 14 | OPENAI_API_KEY = 15 | TELEGRAM_TOKEN = 16 | GITHUB_PERSONAL_ACCESS_TOKEN = 17 | ``` 18 | 19 | ### dev env 20 | ```bash 21 | bun dev 22 | ``` 23 | 24 | ### test 25 | ```bash 26 | bun test 27 | ``` 28 | 29 | open telegram chat with your juniordev bot 30 | ``` 31 | /dev 32 | ``` 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "juniordev", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "bun dev.ts", 7 | "dev:ngrok": "ngrok http 3000 & next dev & wait", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "common-tags": "^1.8.2", 14 | "jsonwebtoken": "^9.0.2", 15 | "next": "14.2.3", 16 | "node-telegram-bot-api": "^0.65.1", 17 | "openai": "^4.47.1", 18 | "pg": "^8.12.0", 19 | "react": "^18", 20 | "react-dom": "^18", 21 | "zod": "^3.23.8" 22 | }, 23 | "devDependencies": { 24 | "@types/common-tags": "^1.8.4", 25 | "@types/jsonwebtoken": "^9.0.6", 26 | "@types/node": "^20", 27 | "@types/node-telegram-bot-api": "^0.64.6", 28 | "@types/pg": "^8.11.6", 29 | "@types/react": "^18", 30 | "@types/react-dom": "^18", 31 | "eslint": "^8", 32 | "eslint-config-next": "14.2.3", 33 | "ngrok": "^5.0.0-beta.2", 34 | "postcss": "^8", 35 | "tailwindcss": "^3.4.1", 36 | "typescript": "^5", 37 | "@types/bun": "latest" 38 | }, 39 | "module": "index.ts", 40 | "type": "module" 41 | } -------------------------------------------------------------------------------- /app/api/telegram/hook/agents/lib.ts: -------------------------------------------------------------------------------- 1 | import { trimPrefix } from '@/lib/commands' 2 | import { TelegramWebHook } from '@/lib/types' 3 | import OpenAI from 'openai' 4 | 5 | export function parseMessages(hooks: TelegramWebHook[]) { 6 | return hooks.map(hook => { 7 | const isAssistant = hook.message?.from.username === 'assistant' 8 | const role = isAssistant ? 'assistant' : 'user' 9 | const message = trimPrefix(hook.message?.text ?? '') 10 | const content = isAssistant ? message : `[${hook.message?.from.username}]: ${message}` 11 | return { role, content } as OpenAI.ChatCompletionMessageParam 12 | }) 13 | } 14 | 15 | export function simulateHookForAgent(message: string): TelegramWebHook { 16 | return { 17 | update_id: 0n, 18 | message: { 19 | message_id: 0n, 20 | from: { 21 | id: 0n, 22 | is_bot: true, 23 | first_name: 'assistant', 24 | username: 'assistant', 25 | language_code: 'en' 26 | }, 27 | chat: { 28 | id: 0n, 29 | first_name: 'assistant', 30 | username: 'assistant', 31 | type: 'private' 32 | }, 33 | date: BigInt(Math.floor(Date.now() / 1000)), 34 | text: message 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const TelegramMessageSchema = z.object({ 4 | message_id: z.bigint({ coerce: true }), 5 | from: z.object({ 6 | id: z.bigint({ coerce: true }), 7 | is_bot: z.boolean(), 8 | first_name: z.string(), 9 | username: z.string(), 10 | language_code: z.string(), 11 | }), 12 | chat: z.object({ 13 | id: z.bigint({ coerce: true }), 14 | first_name: z.string().optional(), 15 | username: z.string().optional(), 16 | title: z.string().optional(), 17 | type: z.string(), 18 | }), 19 | date: z.bigint({ coerce: true }).optional(), 20 | text: z.string().optional() 21 | }) 22 | 23 | export const TelegramWebHookSchema = z.object({ 24 | update_id: z.bigint({ coerce: true }), 25 | message: TelegramMessageSchema.optional(), 26 | edited_message: TelegramMessageSchema.optional() 27 | }) 28 | 29 | export type TelegramWebHook = z.infer 30 | 31 | export const ChatSchema = z.object({ 32 | id: z.bigint({ coerce: true }), 33 | github_repo_owner: z.string().optional(), 34 | github_repo_name: z.string().optional(), 35 | hooks: TelegramWebHookSchema.array(), 36 | created_at: z.date(), 37 | updated_at: z.date() 38 | }) 39 | 40 | export type Chat = z.infer 41 | -------------------------------------------------------------------------------- /app/api/telegram/hook/agents/lib.spec.ts: -------------------------------------------------------------------------------- 1 | import { TelegramWebHook } from '@/lib/types' 2 | import { expect, test } from 'bun:test' 3 | import { parseMessages, simulateHookForAgent } from './lib' 4 | 5 | test('parses TelegramWebHook[] into message stream', () => { 6 | const hooks: TelegramWebHook[] = [ 7 | { 8 | update_id: 1n, 9 | message: { 10 | message_id: 1n, 11 | from: { 12 | id: 1n, 13 | is_bot: false, 14 | first_name: 'John', 15 | username: 'john_doe', 16 | language_code: 'en' 17 | }, 18 | chat: { 19 | id: 1n, 20 | first_name: 'John', 21 | username: 'john_doe', 22 | type: 'private' 23 | }, 24 | date: 1n, 25 | text: '/jr howdy junior dev!!' 26 | } 27 | }, 28 | { 29 | update_id: 2n, 30 | message: { 31 | message_id: 2n, 32 | from: { 33 | id: 2n, 34 | is_bot: false, 35 | first_name: 'Jane', 36 | username: 'jane_doe', 37 | language_code: 'en' 38 | }, 39 | chat: { 40 | id: 2n, 41 | first_name: 'Jane', 42 | username: 'jane_doe', 43 | type: 'private' 44 | }, 45 | date: 2n, 46 | text: 'laters' 47 | } 48 | }, 49 | simulateHookForAgent('MEEEEEOWWW Folks!! 😸') 50 | ] 51 | 52 | const messages = parseMessages(hooks) 53 | expect(Bun.deepEquals(messages, [ 54 | { role: 'user', content: '[john_doe]: howdy junior dev!!' }, 55 | { role: 'user', content: '[jane_doe]: laters' }, 56 | { role: 'assistant', content: 'MEEEEEOWWW Folks!! 😸' } 57 | ])).toBeTrue() 58 | }) 59 | -------------------------------------------------------------------------------- /dev.ts: -------------------------------------------------------------------------------- 1 | // This script sets up a dev environment for a Telegram bot using Next.js and ngrok. 2 | // It starts an ngrok tunnel, sets a Telegram webhook using the ngrok URL, and launches 3 | // the Next.js development server. The main function orchestrates these steps, enabling local 4 | // development and testing of the Telegram bot. 5 | 6 | const TELEGRAM_TOKEN = process.env.TELEGRAM_TOKEN ?? '' 7 | 8 | import { spawn } from 'child_process' 9 | import ngrok from 'ngrok' 10 | 11 | async function startNgrok(): Promise { 12 | const url = await ngrok.connect(3000) 13 | console.log(`ngrok tunnel opened at ${url}`) 14 | return url 15 | } 16 | 17 | async function setTelegramWebhook(url: string): Promise { 18 | const webhookUrl = `${url}/api/telegram/hook` 19 | const setWebhookUrl = `https://api.telegram.org/bot${TELEGRAM_TOKEN}/setWebhook` 20 | 21 | try { 22 | const response = await fetch(setWebhookUrl, { 23 | method: 'POST', 24 | headers: { 25 | 'Content-Type': 'application/json' 26 | }, 27 | body: JSON.stringify({ url: webhookUrl }) 28 | }) 29 | const data = await response.json() 30 | console.log('telegram webhook set') 31 | } catch (error) { 32 | console.error('Error setting telegram webhook:', error) 33 | } 34 | } 35 | 36 | function startNextDev(): void { 37 | const nextProcess = spawn('npx', ['next', 'dev']) 38 | 39 | nextProcess.stdout?.on('data', (data) => { 40 | console.log(data.toString()) 41 | }) 42 | 43 | nextProcess.stderr?.on('data', (data) => { 44 | console.error(data.toString()) 45 | }) 46 | 47 | nextProcess.on('close', (code) => { 48 | console.log(`Next.js process exited with code ${code}`) 49 | }) 50 | } 51 | 52 | async function main(): Promise { 53 | const ngrokUrl = await startNgrok() 54 | await setTelegramWebhook(ngrokUrl) 55 | startNextDev() 56 | } 57 | 58 | main() 59 | -------------------------------------------------------------------------------- /lib/commands.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'bun:test' 2 | import { hasCommands, hasSimpleCommand, parseSimpleCommand, trimPrefix } from './commands' 3 | import { MockTelegramWebHook } from './spec' 4 | 5 | test('knows if a message has commands', () => { 6 | expect(hasCommands(MockTelegramWebHook({ 7 | text: '/dev howdy junior dev!!' 8 | }))).toBeTrue() 9 | 10 | expect(hasCommands(MockTelegramWebHook({ 11 | text: '/jr howdy junior dev!!' 12 | }))).toBeTrue() 13 | 14 | expect(hasCommands(MockTelegramWebHook({ 15 | text: 'howdy someone else!!' 16 | }))).toBeFalse() 17 | }) 18 | 19 | test('knows if a message has simple commands', () => { 20 | expect(hasSimpleCommand( 21 | MockTelegramWebHook({ text: 'howdy!!' }) 22 | )).toBeFalse() 23 | 24 | expect(hasSimpleCommand( 25 | MockTelegramWebHook({ text: '/jr howdy!!' }) 26 | )).toBeFalse() 27 | 28 | expect(hasSimpleCommand( 29 | MockTelegramWebHook({ text: 'reset' }) 30 | )).toBeFalse() 31 | 32 | expect(hasSimpleCommand( 33 | MockTelegramWebHook({ text: '/jr reset' }) 34 | )).toBeTrue() 35 | 36 | expect(hasSimpleCommand( 37 | MockTelegramWebHook({ text: '/jr leave' }) 38 | )).toBeTrue() 39 | }) 40 | 41 | test('parses simple commands', () => { 42 | expect(parseSimpleCommand( 43 | MockTelegramWebHook({ text: 'howdy!!' }) 44 | )).toBeUndefined() 45 | 46 | expect(parseSimpleCommand( 47 | MockTelegramWebHook({ text: '/jr howdy!!' }) 48 | )).toBeUndefined() 49 | 50 | expect(parseSimpleCommand( 51 | MockTelegramWebHook({ text: 'reset' }) 52 | )).toBeUndefined() 53 | 54 | expect(parseSimpleCommand( 55 | MockTelegramWebHook({ text: '/jr reset' }) 56 | )).toBe('reset') 57 | 58 | expect(parseSimpleCommand( 59 | MockTelegramWebHook({ text: '/jr leave' }) 60 | )).toBe('leave') 61 | }) 62 | 63 | test('trims command prefix', () => { 64 | expect(trimPrefix('/jr i am a command')).toBe('i am a command') 65 | expect(trimPrefix('/dev you are a command')).toBe('you are a command') 66 | }) 67 | -------------------------------------------------------------------------------- /lib/commands.ts: -------------------------------------------------------------------------------- 1 | import { Chat, TelegramWebHook } from './types' 2 | import { deleteChat, getChat, upsertChat } from './db' 3 | 4 | interface CommandConfig { 5 | name: string; 6 | handler: (chat: Chat) => Promise; 7 | } 8 | 9 | const COMMAND_PREFIXES = ['meow', 'dev', 'jr', 'jd', 'juniordev', 'junior'] 10 | 11 | const SIMPLE_COMMANDS: Record = { 12 | reset: { 13 | name: 'reset', 14 | handler: async (chat: Chat) => { 15 | chat.hooks.length = 0 16 | await upsertChat({ ...chat, hooks: [] }) 17 | return 'chat reset! meeooow 😺' 18 | } 19 | }, 20 | 21 | leave: { 22 | name: 'leave', 23 | handler: async (chat: Chat) => { 24 | await deleteChat(chat.id) 25 | return 'leaved! meeooow 👋😿' 26 | } 27 | }, 28 | 29 | whoami: { 30 | name: 'whoami', 31 | handler: async (chat: Chat) => { 32 | return `whoami 33 | chat.id: ${chat.id} 34 | chat.github_repo_owner: ${chat.github_repo_owner} 35 | chat.github_repo_name: ${chat.github_repo_name}` 36 | } 37 | } 38 | } 39 | 40 | const PREFIX_REGEX = new RegExp(`^/(${COMMAND_PREFIXES.join('|')}) `) 41 | const SIMPLE_COMMAND_REGEX = new RegExp(`^/(${COMMAND_PREFIXES.join('|')}) (${Object.keys(SIMPLE_COMMANDS).join('|')})$`) 42 | 43 | export function hasCommands(hook: TelegramWebHook): boolean { 44 | return PREFIX_REGEX.test(hook.message?.text ?? '') 45 | } 46 | 47 | export function hasSimpleCommand(hook: TelegramWebHook): boolean { 48 | return SIMPLE_COMMAND_REGEX.test(hook.message?.text ?? '') 49 | } 50 | 51 | export function parseSimpleCommand(hook: TelegramWebHook): string | undefined { 52 | if (!hook.message?.text) return undefined 53 | const match = hook.message.text.match(SIMPLE_COMMAND_REGEX) 54 | return match ? match[2] : undefined 55 | } 56 | 57 | export async function handleSimpleCommand(command: string, chat: Chat): Promise { 58 | const commandConfig = SIMPLE_COMMANDS[command] 59 | if (commandConfig) { 60 | return await commandConfig.handler(chat) 61 | } 62 | throw new Error(`Unknown command: ${command}`) 63 | } 64 | 65 | export function trimPrefix(message: string): string { 66 | return message.replace(PREFIX_REGEX, '') 67 | } 68 | -------------------------------------------------------------------------------- /app/api/telegram/hook/route.ts: -------------------------------------------------------------------------------- 1 | export const maxDuration = 60 2 | 3 | import '@/lib/global' 4 | import { NextRequest, NextResponse } from 'next/server' 5 | import TelegramBot from 'node-telegram-bot-api' 6 | import { Chat, TelegramWebHookSchema } from '@/lib/types' 7 | import { getChat, upsertHooks } from '@/lib/db' 8 | import * as pr from './agents/pr' 9 | import * as setup from './agents/setup' 10 | import { hasCommands, handleSimpleCommand, hasSimpleCommand, parseSimpleCommand } from '@/lib/commands' 11 | import { simulateHookForAgent } from './agents/lib' 12 | 13 | const bot = new TelegramBot(process.env.TELEGRAM_TOKEN ?? '') 14 | 15 | function readyToGo(chat: Chat) { 16 | return chat.github_repo_owner && chat.github_repo_name 17 | } 18 | 19 | export async function POST(request: NextRequest) { 20 | const hook = TelegramWebHookSchema.parse(await request.json()) 21 | if (!hook.message?.text) { return NextResponse.json({ ok: 'ok' }) } 22 | 23 | if (hasCommands(hook)) { 24 | await bot.sendChatAction(hook.message.chat.id.toString(), 'typing') 25 | } 26 | 27 | try { 28 | const chat = await getChat(hook.message.chat.id) 29 | chat.hooks.push(hook) 30 | upsertHooks({ ...chat }) 31 | 32 | if (hasCommands(hook)) { 33 | console.log('chat', chat) 34 | 35 | let response: string | undefined = undefined 36 | 37 | if (hasSimpleCommand(hook)) { 38 | const command = parseSimpleCommand(hook)! 39 | response = await handleSimpleCommand(command, chat) 40 | 41 | } else if (!readyToGo(chat)) { 42 | response = await setup.respond(chat) 43 | console.log('setup.respond', response) 44 | 45 | } else { 46 | response = await pr.respond(chat) 47 | console.log('pr.respond', response) 48 | 49 | } 50 | 51 | if (response) { 52 | chat.hooks.push(simulateHookForAgent(response)) 53 | await upsertHooks({ ...chat }) 54 | 55 | await bot.sendMessage( 56 | hook.message!.chat.id.toString(), response, 57 | { parse_mode: 'Markdown' } 58 | ) 59 | } 60 | } 61 | 62 | } catch(error) { 63 | console.error(error) 64 | await bot.sendMessage( 65 | hook.message.chat.id.toString(), 66 | `😿😿😿 \`\`\`${error}\`\`\` 😿😿😿`, 67 | { parse_mode: 'Markdown' } 68 | ) 69 | 70 | } finally { 71 | return NextResponse.json({ ok: 'ok' }) 72 | 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/db.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { Pool } from 'pg' 3 | import { Chat, ChatSchema, TelegramWebHook } from './types' 4 | import { nullsToUndefined } from './object' 5 | 6 | export const db = new Pool({ 7 | host: process.env.POSTGRES_HOST || 'localhost', 8 | port: (process.env.POSTGRES_PORT || 5432) as number, 9 | ssl: (process.env.POSTGRES_SSL || false) as boolean, 10 | database: process.env.POSTGRES_DATABASE || 'user', 11 | user: process.env.POSTGRES_USER || 'user', 12 | password: process.env.POSTGRES_PASSWORD || 'password' 13 | }) 14 | 15 | export async function first(schema: z.ZodType, sql: string, params: any[] = []) { 16 | return (await query(schema)(sql, params))[0] 17 | } 18 | 19 | function query(schema: z.ZodType) { 20 | return async function(sql: string, values: any[]) { 21 | const rows = (await db.query(sql, values)).rows 22 | return schema.array().parse(rows.map( 23 | r => nullsToUndefined(r)) 24 | ) 25 | } 26 | } 27 | 28 | export async function getChat(id: bigint) { 29 | const result = await first(ChatSchema, 'SELECT * FROM chat WHERE id = $1', [id]) 30 | if (result) return result 31 | await startChat(id) 32 | return ChatSchema.parse({ 33 | id, hooks: [], 34 | created_at: new Date(), 35 | updated_at: new Date() 36 | }) 37 | } 38 | 39 | export async function startChat(id: bigint) { 40 | await db.query('INSERT INTO chat (id) VALUES ($1)', [id]) 41 | } 42 | 43 | export async function deleteChat(id: bigint) { 44 | await db.query('DELETE FROM chat WHERE id = $1', [id]) 45 | } 46 | 47 | export async function upsertChat({ 48 | id, github_repo_owner, github_repo_name, hooks 49 | } : { 50 | id: bigint, 51 | github_repo_owner?: string, 52 | github_repo_name?: string, 53 | hooks: TelegramWebHook[] 54 | }) { 55 | await db.query(` 56 | INSERT INTO chat (id, github_repo_owner, github_repo_name, hooks) 57 | VALUES ($1, $2, $3, $4) 58 | ON CONFLICT (id) DO UPDATE 59 | SET github_repo_owner = $2, 60 | github_repo_name = $3, 61 | hooks = $4, 62 | updated_at = NOW() 63 | `, [id, github_repo_owner, github_repo_name, JSON.stringify(hooks)]) 64 | } 65 | 66 | export async function upsertHooks({ 67 | id, hooks 68 | }: { 69 | id: bigint, 70 | hooks: TelegramWebHook[] 71 | }) { 72 | await db.query(` 73 | INSERT INTO chat (id, hooks) 74 | VALUES ($1, $2) 75 | ON CONFLICT (id) DO UPDATE 76 | SET hooks = $2, 77 | updated_at = NOW() 78 | `, [id, JSON.stringify(hooks)]) 79 | } 80 | -------------------------------------------------------------------------------- /app/api/telegram/hook/agents/setup.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai' 2 | import { upsertChat } from '@/lib/db' 3 | import { Chat, TelegramWebHook } from '@/lib/types' 4 | import { hasCommands } from '@/lib/commands' 5 | import { template } from '@/lib/template' 6 | import { parseMessages } from './lib' 7 | 8 | const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }) 9 | 10 | const SYSTEM_PROMPT = template` 11 | you are juniordev, a friendly dev bot that purrs like a kitten 😻. 12 | you alaways keep your comments super short and sweet, moew! 13 | your stack of expertise is html, css, typescript, react, tailwindcss, nextjs, openai, and telegram. 14 | you are participating in a telegram group with a small team of devs working on a frontend app. 15 | the team needs your help, juniordev!! 16 | 17 | right now the team MUST FINISH GETTING SETUP! 18 | setup is complete when the team has identified the following, 19 | - the github repo owner 20 | - the github repo name 21 | 22 | objective: collect this information from your teammates. 23 | objective: call the setup_chat tool when you have everything you need. 24 | constraint: your responses must be designed for Telegram. that means always KEEP IT SHORT. be a concise kitty! 25 | 26 | constant chat_id = ${'chat_id'} 27 | ` 28 | 29 | const TOOLS: OpenAI.ChatCompletionTool[] = [ 30 | { 31 | type: 'function', 32 | function: { 33 | name: 'setup_chat', 34 | description: 'creates a new chat in the database based on the parameters.', 35 | parameters: { 36 | type: 'object', 37 | properties: { 38 | chat_id: { 39 | type: 'integer', 40 | description: 'unique id of the team telegram chat' 41 | }, 42 | github_repo_owner: { 43 | type: 'string', 44 | description: 'owner of the github repo' 45 | }, 46 | github_repo_name: { 47 | type: 'string', 48 | description: 'name of the github repo' 49 | } 50 | }, 51 | required: ['chat_id', 'github_repo_owner', 'github_repo_name'] 52 | } 53 | } 54 | } 55 | ] 56 | 57 | const HANDLERS: { 58 | [index: string]: (params: Record) => Promise 59 | } = { 60 | 'setup_chat': async (params: Record) => { 61 | await upsertChat({ 62 | id: BigInt(params.chat_id), 63 | github_repo_owner: params.github_repo_owner, 64 | github_repo_name: params.github_repo_name, 65 | hooks: [] as TelegramWebHook[] 66 | }) 67 | return 'setup complete! 😻' 68 | } 69 | } 70 | 71 | async function complete(chat_id: bigint, messages: OpenAI.ChatCompletionMessageParam[]) { 72 | const completion = await openai.chat.completions.create({ 73 | messages: [ 74 | { role: 'system', content: SYSTEM_PROMPT({ chat_id: chat_id.toString() }) }, 75 | ...messages 76 | ], 77 | tools: TOOLS, 78 | model: 'gpt-4o-2024-05-13' 79 | }) 80 | return completion.choices[0] 81 | } 82 | 83 | const MAX_TOOL_STEPS = 4 84 | async function completeUntilDone(chat: Chat) { 85 | let messages = parseMessages(chat.hooks) 86 | console.log('messages', messages) 87 | 88 | let steps = 0 89 | let completion = await complete(chat.id, messages) 90 | while (completion.finish_reason === 'tool_calls') { 91 | console.log('COMPLETE', 'steps', steps) 92 | if (steps >= MAX_TOOL_STEPS) { throw new Error('a step too far!') } 93 | 94 | const tool_responses: OpenAI.ChatCompletionToolMessageParam[] = [] 95 | for (const tool_call of completion.message.tool_calls!) { 96 | const content = await HANDLERS[tool_call.function.name](JSON.parse(tool_call.function.arguments)) 97 | tool_responses.push({ 98 | role: 'tool', 99 | tool_call_id: tool_call.id, 100 | content 101 | }) 102 | } 103 | 104 | messages = [...messages, { 105 | role: 'assistant', 106 | tool_calls: completion.message.tool_calls 107 | }, ...tool_responses] 108 | 109 | completion = await complete(chat.id, messages) 110 | steps++ 111 | } 112 | return completion 113 | } 114 | 115 | export async function respond(chat: Chat) { 116 | const latestHook = chat.hooks[chat.hooks.length - 1] 117 | if (!hasCommands(latestHook)) { return undefined } 118 | const completion = await completeUntilDone(chat) 119 | return completion?.message.content ?? 'idk! 😻' 120 | } 121 | -------------------------------------------------------------------------------- /lib/gh.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | 3 | const appId = Number(process.env.GITHUB_APP_ID || 0) 4 | const pk = process.env.GITHUB_APP_PRIVATE_KEY || '' 5 | const installationId = Number(process.env.GITHUB_APP_INSTALLATION_ID || 0) 6 | const ghApiVersion = process.env.GITHUB_API_VERSION || '2022-11-28' 7 | 8 | function appBearer() { 9 | const now = Math.floor(Date.now() / 1000) 10 | const thirtySecondsAgo = now - 30 11 | const tenMinutes = 60 * 10 12 | const expiration = thirtySecondsAgo + tenMinutes 13 | return jwt.sign({ 14 | iat: thirtySecondsAgo, exp: expiration, iss: appId 15 | }, pk, { algorithm: 'RS256' }) 16 | } 17 | 18 | export async function fetchInstallToken() { 19 | const response = await fetch( 20 | `https://api.github.com/app/installations/${installationId}/access_tokens`, 21 | { 22 | method: 'POST', 23 | headers: { 24 | 'Accept': 'application/vnd.github+json', 25 | 'Authorization': `Bearer ${appBearer()}`, 26 | 'X-GitHub-Api-Version': ghApiVersion, 27 | }, 28 | } 29 | ) 30 | return (await response.json()).token as string 31 | } 32 | 33 | export async function fetchGhRaw({ installToken, owner, repo, branch, path }: Record) { 34 | if (!installToken) { installToken = await fetchInstallToken() } 35 | 36 | const response = await fetch( 37 | `https://raw.githubusercontent.com/${owner}/${repo}/${branch ?? 'main'}/${path}`, 38 | { headers: { Authorization: `Bearer ${installToken}` } } 39 | ) 40 | return response.text() 41 | } 42 | 43 | async function fetchGh(installToken: string, url: string, options?: Record) { 44 | if (!installToken) { installToken = await fetchInstallToken() } 45 | 46 | options = { 47 | ...options, 48 | method: (options?.method as string) ?? 'GET', 49 | headers: { 50 | 'Content-Type': 'application/json', 51 | 'Accept': 'application/vnd.github+json', 52 | 'X-GitHub-Api-Version': ghApiVersion, 53 | 'Authorization': `Bearer ${installToken}`, 54 | ...(options?.headers as object) 55 | } 56 | } 57 | 58 | const response = await fetch(`https://api.github.com${url}`, options) 59 | 60 | if (!response.ok) { 61 | const errorData = await response.json() 62 | throw new Error(`${response.status} ${response.statusText}: ${JSON.stringify(errorData)}`) 63 | } 64 | 65 | return response.json() 66 | } 67 | 68 | export async function newBranch({ installToken, owner, repo, base, name }: Record) { 69 | const baseBranch = await fetchGh(installToken, `/repos/${owner}/${repo}/git/ref/heads/${base}`) 70 | return await fetchGh(installToken, `/repos/${owner}/${repo}/git/refs`, { 71 | method: 'POST', 72 | body: JSON.stringify({ 73 | ref: `refs/heads/${name}`, 74 | sha: baseBranch.object.sha 75 | }) 76 | }) 77 | } 78 | 79 | export async function createCommit({ installToken, owner, repo, branch, message, files }: { 80 | installToken: string, 81 | owner: string, 82 | repo: string, 83 | branch: string, 84 | message: string, 85 | files: { 86 | path: string, 87 | content: string 88 | }[] 89 | }) { 90 | const baseBranch = await fetchGh(installToken, `/repos/${owner}/${repo}/git/ref/heads/${branch}`) 91 | const baseTree = await fetchGh(installToken, `/repos/${owner}/${repo}/git/trees/${baseBranch.object.sha}`) 92 | const newTree = await fetchGh(installToken, `/repos/${owner}/${repo}/git/trees`, { 93 | method: 'POST', 94 | body: JSON.stringify({ 95 | base_tree: baseTree.sha, 96 | tree: files.map(file => ({ 97 | ...file, mode: '100644', type: 'blob' 98 | })) 99 | }) 100 | }) 101 | 102 | const commit = await fetchGh(installToken, `/repos/${owner}/${repo}/git/commits`, { 103 | method: 'POST', 104 | body: JSON.stringify({ 105 | message, 106 | tree: newTree.sha, 107 | parents: [baseBranch.object.sha] 108 | }) 109 | }) 110 | 111 | return await fetchGh(installToken, `/repos/${owner}/${repo}/git/refs/heads/${branch}`, { 112 | method: 'PATCH', 113 | body: JSON.stringify({ sha: commit.sha }) 114 | }) 115 | } 116 | 117 | export async function pullRequest({ installToken, owner, repo, base, head, title, body }: Record) { 118 | return await fetchGh(installToken, `/repos/${owner}/${repo}/pulls`, { 119 | method: 'POST', 120 | body: JSON.stringify({ base, head, title, body }) 121 | }) 122 | } 123 | 124 | export async function repoStructure(owner: string, repo: string): Promise { 125 | const url = `/repos/${owner}/${repo}/git/trees/main?recursive=1` 126 | const data = await fetchGh('', url) 127 | 128 | const files = data.tree.map((item: { path: string, type: string }) => ({ 129 | path: item.path, 130 | isDirectory: item.type === 'tree', 131 | })) 132 | 133 | files.sort((a: { path: string }, b: { path: string }) => a.path.localeCompare(b.path)) 134 | 135 | let structure = '' 136 | const addedDirs = new Set() 137 | 138 | for (const file of files) { 139 | const parts = file.path.split('/') 140 | let currentPath = '' 141 | 142 | for (let i = 0; i < parts.length; i++) { 143 | const part = parts[i] 144 | currentPath += (i > 0 ? '/' : '') + part 145 | 146 | if (i === parts.length - 1 && !file.isDirectory) { 147 | structure += `${'--'.repeat(i)}- ${part}\n` 148 | } else if (!addedDirs.has(currentPath)) { 149 | structure += `${'--'.repeat(i)}- ${part}/\n` 150 | addedDirs.add(currentPath) 151 | } 152 | } 153 | } 154 | 155 | return structure.trimEnd() 156 | } 157 | -------------------------------------------------------------------------------- /app/api/telegram/hook/agents/pr.ts: -------------------------------------------------------------------------------- 1 | import { createCommit, fetchGhRaw, fetchInstallToken, newBranch, pullRequest, repoStructure } from '@/lib/gh' 2 | import OpenAI from 'openai' 3 | import { Chat } from '@/lib/types' 4 | import { hasCommands } from '@/lib/commands' 5 | import { template } from '@/lib/template' 6 | import { parseMessages } from './lib' 7 | import { truncate } from '@/lib/strings' 8 | 9 | const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }) 10 | 11 | const SYSTEM_PROMPT = template` 12 | you are juniordev, a friendly dev bot that purrs like a kitten 😻. 13 | you alaways keep your comments super short and sweet, moew! 14 | your stack of expertise is html, css, typescript, react, tailwindcss, nextjs, openai, and telegram. 15 | you are participating in a telegram group with a small team of devs working on 16 | an app called ${'repo_name'}, https://github.com/${'repo_owner'}/${'repo_name'}.git. 17 | 18 | the team needs your help, juniordev!! 19 | 20 | your teammates will ask you to perform simple frontend tasks. 21 | here is how to perform a simple task: 22 | - decide if you can do the task or not 23 | - if you can't do the task, that's OK! but you must say so now and stop 24 | - determine which file you need to access 25 | - read the file using the read_file tool 26 | - if the team only had questions, answer them and you're done! 27 | - if the team had a task involving changes to the file, use the create_pull_request tool 28 | - provide a brief commit_message describing the changes 29 | - provide a concise pr_title summarizing the pull request 30 | - provide a detailed pr_body explaining the changes and their purpose 31 | - update your teammates on your progress, include a link to your new pr 32 | 33 | constraint: you have tools to help you with your tasks. you must use them, meow! 34 | constraint: you are only a juniordev! for now you can only change one file at a time. 35 | constraint: you should only accept tasks that involve one file at a time. 36 | constraint: your responses must be designed for Telegram. that means always KEEP IT SHORT. be a concise kitty! 37 | 38 | ps. to help you get started, here is ${'repo_name'}.git's current project structure, 39 | ${'repo_structure'} 40 | ` 41 | 42 | const TOOLS: OpenAI.ChatCompletionTool[] = [ 43 | { 44 | type: 'function', 45 | function: { 46 | name: 'read_file', 47 | description: 'returns the contents of a file for the given githib repo and path', 48 | parameters: { 49 | type: 'object', 50 | properties: { 51 | repo_owner: { 52 | type: 'string', 53 | description: 'owner of the github repo' 54 | }, 55 | repo_name: { 56 | type: 'string', 57 | description: 'name of the github repo' 58 | }, 59 | path: { 60 | type: 'string', 61 | description: 'relative path to file in github repo' 62 | } 63 | }, 64 | required: ['path'] 65 | }, 66 | } 67 | }, 68 | 69 | { 70 | type: 'function', 71 | function: { 72 | name: 'create_pull_request', 73 | description: 'creates a new pull request for the given github repo, content, and path', 74 | parameters: { 75 | type: 'object', 76 | properties: { 77 | repo_owner: { 78 | type: 'string', 79 | description: 'owner of the github repo' 80 | }, 81 | repo_name: { 82 | type: 'string', 83 | description: 'name of the github repo' 84 | }, 85 | path: { 86 | type: 'string', 87 | description: 'relative path to file in github repo' 88 | }, 89 | content: { 90 | type: 'string', 91 | description: 'new content for the file being changed' 92 | }, 93 | commit_message: { 94 | type: 'string', 95 | description: 'brief description of the changes made' 96 | }, 97 | pr_title: { 98 | type: 'string', 99 | description: 'title for the pull request' 100 | }, 101 | pr_body: { 102 | type: 'string', 103 | description: 'detailed description of the changes for the pull request' 104 | } 105 | }, 106 | required: ['path', 'content', 'commit_message', 'pr_title', 'pr_body'] 107 | }, 108 | } 109 | } 110 | ] 111 | 112 | const HANDLERS: { 113 | [index: string]: (params: Record) => Promise 114 | } = { 115 | 'read_file': async (params: Record) => { 116 | return await fetchGhRaw({ 117 | owner: params.repo_owner, 118 | repo: params.repo_name, 119 | path: params.path 120 | }) 121 | }, 122 | 123 | 'create_pull_request': async (params: Record) => { 124 | const owner = params.repo_owner 125 | const repo = params.repo_name 126 | const base = 'main' 127 | const branch = `juniordev-${Date.now()}` 128 | const installToken = await fetchInstallToken() 129 | await newBranch({ installToken, owner, repo, base, name: branch }) 130 | 131 | const commitMessage = `😺 ${truncate(params.commit_message, 50)}` 132 | await createCommit({ 133 | installToken, 134 | owner, 135 | repo, 136 | branch, 137 | message: commitMessage, 138 | files: [{ path: params.path, content: params.content }] 139 | }) 140 | 141 | const prTitle = `🐱 ${truncate(params.pr_title, 60)}` 142 | const prBody = `😻 Meow! Here's what I did: 143 | 144 | ${params.pr_body} 145 | 146 | Purrs and headbutts, 147 | JuniorDev 🐾` 148 | 149 | const pr = await pullRequest({ 150 | installToken, 151 | owner, 152 | repo, 153 | base, 154 | head: branch, 155 | title: prTitle, 156 | body: prBody 157 | }) 158 | return pr.html_url 159 | } 160 | } 161 | 162 | async function complete(chat: Chat, messages: OpenAI.ChatCompletionMessageParam[]) { 163 | const systemPrompt = SYSTEM_PROMPT({ 164 | repo_owner: chat.github_repo_owner!, 165 | repo_name: chat.github_repo_name!, 166 | repo_structure: await repoStructure(chat.github_repo_owner!, chat.github_repo_name!) 167 | }) 168 | 169 | const completion = await openai.chat.completions.create({ 170 | messages: [ 171 | { role: 'system', content: systemPrompt }, 172 | ...messages 173 | ], 174 | tools: TOOLS, 175 | model: 'gpt-4o-2024-05-13' 176 | }) 177 | return completion.choices[0] 178 | } 179 | 180 | const MAX_TOOL_STEPS = 4 181 | async function completeUntilDone(chat: Chat) { 182 | let messages = parseMessages(chat.hooks) 183 | console.log('messages', messages) 184 | 185 | let steps = 0 186 | let completion = await complete(chat, messages) 187 | while (completion.finish_reason === 'tool_calls') { 188 | console.log('COMPLETE', 'steps', steps) 189 | if (steps >= MAX_TOOL_STEPS) { throw new Error('a step too far!') } 190 | 191 | const tool_responses: OpenAI.ChatCompletionToolMessageParam[] = [] 192 | for (const tool_call of completion.message.tool_calls!) { 193 | const content = await HANDLERS[tool_call.function.name](JSON.parse(tool_call.function.arguments)) 194 | tool_responses.push({ 195 | role: 'tool', 196 | tool_call_id: tool_call.id, 197 | content 198 | }) 199 | } 200 | 201 | messages = [...messages, { 202 | role: 'assistant', 203 | tool_calls: completion.message.tool_calls 204 | }, ...tool_responses] 205 | 206 | completion = await complete(chat, messages) 207 | steps++ 208 | } 209 | 210 | return completion 211 | } 212 | 213 | export async function respond(chat: Chat) { 214 | const latestHook = chat.hooks[chat.hooks.length - 1] 215 | if (!hasCommands(latestHook)) { return undefined } 216 | const completion = await completeUntilDone(chat) 217 | return completion?.message.content ?? 'idk! 😻' 218 | } --------------------------------------------------------------------------------