├── 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 | 
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 | }
--------------------------------------------------------------------------------