feat: ask ai discord bot (#6842)

Signed-off-by: The-Best-Codes <bestcodes.official@gmail.com>
This commit is contained in:
BestCodes
2026-02-02 16:11:28 -06:00
committed by GitHub
parent fafda07dd0
commit 849cc60fbc
23 changed files with 1262 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
name: Publish Ask AI Bot Docker Image
on:
push:
branches:
- main
paths:
- "services/ask-ai-bot/**"
- "documentation/**"
- ".github/workflows/publish-ask-ai-bot.yml"
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ghcr.io/${{ github.repository_owner }}/ask-ai-bot
tags: |
type=ref,event=branch
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # pin@v6.18.0
with:
context: .
file: services/ask-ai-bot/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64

View File

@@ -0,0 +1,21 @@
# Include only what's needed from the monorepo root context
# This file is used when building from the repo root
# Exclude everything by default
*
# Include the bot source
!services/ask-ai-bot/
# Include documentation (only docs subfolder needed)
!documentation/docs/
# Exclude unnecessary files from included directories
services/ask-ai-bot/node_modules
services/ask-ai-bot/dist
services/ask-ai-bot/.env
services/ask-ai-bot/.discraft
**/*.tsbuildinfo
# Exclude large assets from docs that aren't needed for search
documentation/docs/assets

View File

@@ -0,0 +1,15 @@
# From `Bot > Token` | https://discord.com/developers/applications
DISCORD_TOKEN=unset
# From `General Information > App ID` | https://discord.com/developers/applications
DISCORD_APP_ID=unset
# Channel ID where the bot should create threads for questions
QUESTION_CHANNEL_ID=1397240187041349753
# OpenRouter API Key | https://openrouter.ai/settings/keys
OPENROUTER_API_KEY=sk-1234
# AI Model (default: google/gemini-3-flash-preview)
AI_MODEL=google/gemini-3-flash-preview
# Path to documentation directory (default: ./docs in Docker, for local dev use ../../documentation/docs)
DOCS_PATH=../../documentation/docs

10
services/ask-ai-bot/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
dist
node_modules
package-lock.json
bun.lockb
yarn.lock
pnpm-lock.yaml
.env
.discraft
*.tsbuildinfo

View File

@@ -0,0 +1,30 @@
FROM oven/bun:1 AS base
WORKDIR /app
# Install dependencies
FROM base AS deps
COPY services/ask-ai-bot/package.json services/ask-ai-bot/bun.lock ./
RUN bun install --frozen-lockfile
# Build stage
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY services/ask-ai-bot/ ./
RUN bun run build
# Production stage
FROM base AS production
ENV NODE_ENV=production
ENV DOCS_PATH=/app/docs
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
# Copy documentation (only docs/ subdirectory with markdown files)
COPY documentation/docs ./docs
# Empty index.ts for discraft start to detect
RUN touch index.ts
CMD ["bun", "run", "start"]

View File

@@ -0,0 +1,214 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "discraft-bot",
"dependencies": {
"@openrouter/ai-sdk-provider": "^2.1.1",
"ai": "^6.0.62",
"consola": "^3.4.2",
"discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"marked": "^17.0.1",
"minisearch": "^7.2.0",
"zod": "^4.3.6",
},
"devDependencies": {
"discraft": "^1.7.11",
"typescript": "^5.9.3",
},
},
},
"packages": {
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.29", "", { "dependencies": { "@ai-sdk/provider": "3.0.6", "@ai-sdk/provider-utils": "4.0.11", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zf6yXT+7DcVGWG7ntxVCYC48X/opsWlO5ePvgH8W9DaEVUtkemqKUEzBqowQ778PkZo8sqMnRfD0+fi9HamRRQ=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.6", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-hSfoJtLtpMd7YxKM+iTqlJ0ZB+kJ83WESMiWuWrNVey3X8gg97x0OdAAaeAeclZByCX3UdPOTqhvJdK8qYA3ww=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.11", "", { "dependencies": { "@ai-sdk/provider": "3.0.6", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y/WOPpcZaBjvNaogy83mBsCRPvbtaK0y1sY9ckRrrbTGMvG2HC/9Y/huqNXKnLAxUIME2PGa2uvF2CDwIsxoXQ=="],
"@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="],
"@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="],
"@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="],
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
"@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="],
"@discordjs/rest": ["@discordjs/rest@2.6.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w=="],
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
"@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.1.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-UypPbVnSExxmG/4Zg0usRiit3auvQVrjUXSyEhm0sZ9GQnW/d8p/bKgCk2neh1W5YyRSo7PNQvCrAEBHZnqQkQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
"ai": ["ai@6.0.62", "", { "dependencies": { "@ai-sdk/gateway": "3.0.29", "@ai-sdk/provider": "3.0.6", "@ai-sdk/provider-utils": "4.0.11", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0ArQPYmSnwoDG1nQ7GQ2XyEtYEWMSK4pVV9S9nsChRY2D6P2H2ntMEDV/CqTF6GTSwJpBJHAOSvsgEqSc7dx5g=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"discord-api-types": ["discord-api-types@0.38.38", "", {}, "sha512-7qcM5IeZrfb+LXW07HvoI5L+j4PQeMZXEkSm1htHAHh4Y9JSMXBWjy/r7zmUCOj4F7zNjMcm7IMWr131MT2h0Q=="],
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
"discraft": ["discraft@1.7.11", "", { "dependencies": { "@clack/core": "^0.5.0", "@clack/prompts": "^0.11.0", "chokidar": "^5.0.0", "commander": "^14.0.2", "consola": "^3.4.2", "esbuild": "^0.27.1", "esbuild-node-externals": "^1.20.1", "fs-extra": "^11.3.2", "glob": "^13.0.0", "kleur": "^4.1.5" }, "bin": { "discraft": "package/dist/cli.js" } }, "sha512-JWMspKtKKWoCZ75cHSEGfG8rKGedPtAt55CUYEEBz4oGuMQeuDKqO1Ai92+dQQCXHj1jTOSnI5x4wMffyNgNcQ=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"esbuild-node-externals": ["esbuild-node-externals@1.20.1", "", { "dependencies": { "find-up": "^5.0.0" }, "peerDependencies": { "esbuild": "0.12 - 0.27" } }, "sha512-uVs+TC+PBiav2LoTz8WZT/ootINw9Rns5JJyVznlfZH1qOyZxWCPzeXklY04UtZut5qUeFFaEWtcH7XoMwiTTQ=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="],
"glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
"lru-cache": ["lru-cache@11.2.5", "", {}, "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw=="],
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
}
}

View File

@@ -0,0 +1,5 @@
import { openrouter } from "@openrouter/ai-sdk-provider";
const modelName = process.env.AI_MODEL || "google/gemini-3-flash-preview";
export const model = openrouter(modelName);

View File

@@ -0,0 +1,12 @@
import { Client, GatewayIntentBits } from "discord.js";
const client = new Client({
/* Sensible defaults, you can add or remove intents as needed. */
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
export default client;

View File

@@ -0,0 +1,10 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
export default {
data: new SlashCommandBuilder().setName("ping").setDescription("Ping!"),
async execute(data: { interaction: ChatInputCommandInteraction }) {
const interaction = data.interaction;
await interaction.reply("Bot is online.");
},
};

View File

@@ -0,0 +1,9 @@
import { Client, Events } from "discord.js";
import { logger } from "../utils/logger";
export default {
event: Events.Error,
handler: (client: Client, error: Error) => {
logger.error("An error occurred:", error);
},
};

View File

@@ -0,0 +1,117 @@
import {
ChannelType,
Client,
Events,
Message,
type OmitPartialGroupDMChannel,
} from "discord.js";
import { answerQuestion } from "../utils/ai";
import { logger } from "../utils/logger";
export default {
event: Events.MessageCreate,
handler: async (
_client: Client,
message: OmitPartialGroupDMChannel<Message<boolean>>,
) => {
if (message.author.bot) return;
const questionChannelId = process.env.QUESTION_CHANNEL_ID;
// Handle messages in threads
if (message.channel.isThread()) {
const parentChannelId =
message.channel.parent?.id ?? message.channel.parentId;
if (!questionChannelId) {
logger.verbose(
"QUESTION_CHANNEL_ID is not configured; ignoring thread message",
);
return;
}
if (!parentChannelId || parentChannelId !== questionChannelId) {
logger.verbose(
`Ignoring thread message from ${message.author.username} (thread not in question channel)`,
);
return;
}
try {
// Check if the bot was mentioned or replied to
const isMentioned = message.mentions.has(message.client.user?.id || "");
let isReplyToBot = false;
if (message.reference?.messageId) {
isReplyToBot = await message.channel.messages
.fetch(message.reference.messageId)
.then((msg) => msg.author.bot)
.catch(() => false);
}
if (!isMentioned && !isReplyToBot) {
logger.verbose(
`Ignoring thread message from ${message.author.username} (not mentioned or replied to)`,
);
return;
}
// Fetch last 10 messages from the thread for context
const messages = await message.channel.messages.fetch({ limit: 10 });
const sortedMessages = Array.from(messages.values())
.reverse()
.map((msg) => ({
author:
msg.author?.displayName || msg.author?.username || "Unknown",
content: msg.content,
isBot: msg.author.bot,
}));
await answerQuestion({
question: message.content,
thread: message.channel,
userId: message.author.id,
messageHistory: sortedMessages,
});
logger.verbose(
`Answered follow-up question for ${message.author.username} in thread`,
);
} catch (error) {
logger.error(`Error handling thread message: ${error}`);
}
return;
}
// Handle initial questions in the question channel
if (questionChannelId && message.channelId === questionChannelId) {
if (message.channel.type === ChannelType.GuildText) {
try {
let threadName = message.content.trim();
if (threadName.length > 100) {
threadName = threadName.substring(0, 97) + "...";
}
const thread = await message.startThread({
name: threadName,
autoArchiveDuration: 60,
});
// Send status message that will be updated as tools are called
const statusMessage = await thread.send("Just a sec...");
await answerQuestion({
question: message.content,
thread,
userId: message.author.id,
statusMessage,
});
logger.verbose(`Answered question for ${message.author.username}`);
} catch (error) {
logger.error(`Error handling question: ${error}`);
}
}
}
},
};

View File

@@ -0,0 +1,32 @@
import { ActivityType, Client, Events } from "discord.js";
import { logger } from "../utils/logger";
export default {
event: Events.ClientReady,
handler: (client: Client) => {
try {
if (!client.user) {
logger.error("Client user is not set.");
return;
}
logger.info("Setting presence...");
client.user.setPresence({
activities: [
{
name: "goose",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Discord.js does not have this property, but it is valid
state: "helping users with goose",
type: ActivityType.Custom,
},
],
status: "online",
});
} catch (err) {
logger.error("Error setting presence:", err);
} finally {
logger.success("Presence set.");
}
},
};

View File

@@ -0,0 +1,63 @@
import { configDotenv } from "dotenv";
configDotenv();
// Command and index files are generated by the CLI...
// Run `discraft dev` or `discraft build` to generate them
import { Events } from "discord.js";
import { registerCommands } from "./.discraft/commands/index";
import { registerEvents } from "./.discraft/events/index";
import client from "./clients/discord";
import { logger } from "./utils/logger";
logger.start("Starting bot...");
// Register events before login
registerEvents(client)
.then(() => {
logger.verbose("Events registered in main process.");
})
.catch((err) => {
logger.error("Error registering events.");
logger.verbose(err);
})
.finally(() => {
client.on(Events.ClientReady, async () => {
logger.success("Client logged in.");
try {
await registerCommands(client);
} catch (err) {
logger.error("Error registering commands.");
logger.verbose(err);
}
});
client.login(process.env.DISCORD_TOKEN).catch((err) => {
logger.error(
"Client login failed, make sure your token is set correctly.",
);
logger.verbose(err);
});
});
process.on("uncaughtException", (err) => {
logger.error("Uncaught exception.");
logger.verbose(err);
});
process.on("unhandledRejection", (err) => {
logger.error("Unhandled rejection.");
logger.verbose(err);
});
process.on("SIGINT", async () => {
logger.info("Received SIGINT, Gracefully shutting down...");
try {
logger.info("Closing client...");
await client.destroy();
logger.success("Client closed.");
} catch (err) {
logger.error("Error while shutting down client.");
logger.verbose(err);
}
logger.info("Exiting...");
process.exit(0);
});

View File

@@ -0,0 +1,27 @@
{
"name": "goose-ask-ai-bot",
"private": true,
"version": "1.0.0",
"description": "Bot created with discraft",
"module": "index.ts",
"type": "module",
"scripts": {
"start": "discraft start",
"build": "discraft build",
"dev": "discraft dev"
},
"devDependencies": {
"discraft": "^1.7.11",
"typescript": "^5.9.3"
},
"dependencies": {
"@openrouter/ai-sdk-provider": "^2.1.1",
"ai": "^6.0.62",
"consola": "^3.4.2",
"discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"marked": "^17.0.1",
"minisearch": "^7.2.0",
"zod": "^4.3.6"
}
}

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@@ -0,0 +1,96 @@
import { marked } from "marked";
const MAX_DISCORD_LENGTH = 2000;
/**
* Chunks markdown text intelligently, respecting markdown structure.
* Avoids splitting code blocks, lists, and other block elements when possible.
* Falls back to character-based splitting for oversized blocks.
*
* @param markdown - The markdown text to chunk
* @param maxLength - Maximum length per chunk (default: 2000 for Discord)
* @returns Array of markdown chunks
*/
export function chunkMarkdown(
markdown: string,
maxLength: number = MAX_DISCORD_LENGTH,
): string[] {
// If text is short enough, return as-is
if (markdown.length <= maxLength) {
return [markdown];
}
const tokens = marked.lexer(markdown);
const chunks: string[] = [];
let currentChunk = "";
for (const token of tokens) {
const tokenText = token.raw;
// If adding this token would exceed the limit
if ((currentChunk + tokenText).length > maxLength) {
// Save current chunk if it has content
if (currentChunk) {
chunks.push(currentChunk);
currentChunk = "";
}
// If the token itself is too large, we have to split it
if (tokenText.length > maxLength) {
// Fall back to character-based splitting for this oversized block
const splits = characterSplit(tokenText, maxLength);
chunks.push(...splits.slice(0, -1));
currentChunk = splits[splits.length - 1];
} else {
// Token fits in a new chunk
currentChunk = tokenText;
}
} else {
// Token fits in current chunk
currentChunk += tokenText;
}
}
// Add any remaining content
if (currentChunk) {
chunks.push(currentChunk);
}
return chunks;
}
/**
* Character-based splitting with word boundary awareness.
* Used as a fallback for oversized markdown blocks.
*
* @param text - The text to split
* @param maxLength - Maximum length per chunk
* @returns Array of text chunks
*/
function characterSplit(text: string, maxLength: number): string[] {
if (text.length <= maxLength) {
return [text];
}
const chunks: string[] = [];
let remaining = text;
while (remaining.length > maxLength) {
let splitIndex = maxLength;
const spaceIndex = remaining.lastIndexOf(" ", maxLength);
// If there's a space in the last 20% of the chunk, split there
if (spaceIndex > maxLength * 0.8) {
splitIndex = spaceIndex;
}
chunks.push(remaining.slice(0, splitIndex));
remaining = remaining.slice(splitIndex).trimStart();
}
if (remaining) {
chunks.push(remaining);
}
return chunks;
}

View File

@@ -0,0 +1,123 @@
import { stepCountIs, streamText } from "ai";
import type { Message, ThreadChannel } from "discord.js";
import { model } from "../../clients/ai";
import { logger } from "../logger";
import { chunkMarkdown } from "./chunk-markdown";
import { SYSTEM_PROMPT } from "./system-prompt";
import { ToolTracker } from "./tool-tracker";
import { aiTools } from "./tools";
export interface MessageHistoryItem {
author: string;
content: string;
isBot: boolean;
}
export interface AnswerQuestionOptions {
question: string;
thread: ThreadChannel;
userId: string;
messageHistory?: MessageHistoryItem[];
statusMessage?: Message;
}
export async function answerQuestion({
question,
thread,
userId,
messageHistory,
statusMessage,
}: AnswerQuestionOptions): Promise<void> {
try {
let prompt = question;
if (messageHistory && messageHistory.length > 0) {
const historyContext = messageHistory
.slice(0, -1)
.map((msg) => `${msg.author}: ${msg.content}`)
.join("\n");
if (historyContext) {
prompt = `# Previous conversation\n${historyContext}\n\n# New message\n${messageHistory[messageHistory.length - 1].author}: ${question}`;
}
}
const tracker = new ToolTracker();
const result = streamText({
model,
system: SYSTEM_PROMPT,
prompt,
tools: aiTools,
maxOutputTokens: 2048,
stopWhen: stepCountIs(5),
});
for await (const event of result.fullStream) {
if (event.type === "tool-call") {
if (event.toolName === "search_docs" && statusMessage) {
try {
await statusMessage.edit("Searching the docs...");
} catch (error) {
logger.verbose("Failed to update status message:", error);
}
} else if (event.toolName === "view_docs" && statusMessage) {
const input = event.input as { filePaths?: string | string[] };
const filePaths = input.filePaths;
const pathArray = Array.isArray(filePaths) ? filePaths : [filePaths];
const pagesText = pathArray.length === 1 ? "page" : "pages";
try {
await statusMessage.edit(
`Viewing ${pathArray.length} ${pagesText}...`,
);
} catch (error) {
logger.verbose("Failed to update status message:", error);
}
}
} else if (event.type === "tool-result") {
if (event.toolName === "search_docs") {
const resultText = String(event.output);
const fileMatches = resultText.match(/\*\*[^*]+\*\*/g) || [];
tracker.recordSearchCall(fileMatches.map((_, i) => `result_${i}`));
} else if (event.toolName === "view_docs") {
const input = event.input as { filePaths?: string | string[] };
const filePaths = input.filePaths;
const pathArray = Array.isArray(filePaths)
? filePaths
: filePaths
? [filePaths]
: [];
if (pathArray.length > 0) {
tracker.recordViewCall(pathArray);
}
}
}
}
if (statusMessage) {
try {
const summary = tracker.getSummary();
await statusMessage.edit(summary || "Just a sec...");
} catch (error) {
logger.verbose("Failed to update final status message:", error);
}
}
const fullText = await result.text;
const chunks = chunkMarkdown(fullText);
for (const chunk of chunks) {
await thread.send(chunk);
}
const totalUsage = await result.usage;
const { totalTokens } = totalUsage;
logger.verbose(
`Answered question for user ${userId}, tokens: ${totalTokens}`,
);
} catch (error) {
logger.error("Failed to answer question:", error);
await thread.send(
"Sorry, I encountered an error while researching your question. Please try again.",
);
throw error;
}
}

View File

@@ -0,0 +1,9 @@
export const SYSTEM_PROMPT = `You are a helpful assistant in the goose Discord server.
Your role is to provide assistance and answer questions about codename goose, an open-source AI agent developed by Block. codename goose's website is \`https://block.github.io/goose\`. Your answers should be short and to the point. Always assume that a user's question is related to codename goose unless they specifically state otherwise. DO NOT capitalize "goose" or "codename goose".
When answering questions about goose:
1. Use the \`search_docs\` tool to find relevant documentation
2. Use the \`view_docs\` tool to read documentation (read all relevant files to get the full picture)
3. Cite the documentation source in your response (using its Web URL)
When providing links, wrap the URL in angle brackets (e.g., \`<https://example.com>\` or \`[Example](<https://example.com>)\`) to prevent excessive link previews. Do not use backtick characters around the URL.`;

View File

@@ -0,0 +1,39 @@
export class ToolTracker {
private searchCalls: number = 0;
private searchResults: Set<string> = new Set();
private viewedPaths: Set<string> = new Set();
recordSearchCall(results: string[]): void {
this.searchCalls++;
results.forEach((result) => this.searchResults.add(result));
}
recordViewCall(filePaths: string | string[]): void {
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
paths.forEach((path) => this.viewedPaths.add(path));
}
getSummary(): string {
const parts: string[] = [];
if (this.searchCalls > 0) {
const resultCount = this.searchResults.size;
const timesText = this.searchCalls === 1 ? "time" : "times";
const resultsText = resultCount === 1 ? "result" : "results";
parts.push(
`searched ${this.searchCalls} ${timesText} with ${resultCount} ${resultsText}`,
);
}
if (this.viewedPaths.size > 0) {
const pageCount = this.viewedPaths.size;
const pagesText = pageCount === 1 ? "page" : "pages";
parts.push(`viewed ${pageCount} ${pagesText}`);
}
if (parts.length === 0) return "";
const firstPart = parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
return parts.length === 1 ? firstPart : firstPart + ", " + parts[1];
}
}

View File

@@ -0,0 +1,158 @@
import fs from "fs";
import MiniSearch from "minisearch";
import path from "path";
import { logger } from "../../logger";
export interface SearchResult {
filePath: string;
fileName: string;
score: number;
preview: string;
lineCount: number;
webUrl: string;
}
interface DocFile {
id: string;
path: string;
fileName: string;
content: string;
lineCount: number;
}
let miniSearch: MiniSearch<DocFile> | null = null;
function getDocsDir(): string {
return process.env.DOCS_PATH || path.join(process.cwd(), "docs");
}
function initializeSearch(): MiniSearch<DocFile> {
if (miniSearch) {
return miniSearch;
}
const docsDir = path.resolve(getDocsDir());
if (!fs.existsSync(docsDir)) {
logger.warn(`Docs directory not found at ${docsDir}`);
miniSearch = new MiniSearch({
fields: ["content", "fileName", "path"],
storeFields: ["path", "fileName", "content", "lineCount"],
});
return miniSearch;
}
const docs: DocFile[] = [];
function walkDir(dir: string) {
try {
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
if (file === "assets" || file === "docker") {
continue;
}
walkDir(filePath);
} else {
try {
const content = fs.readFileSync(filePath, "utf-8");
const relativePath = path.relative(docsDir, filePath);
const docFile: DocFile = {
id: relativePath,
path: relativePath,
fileName: file,
content,
lineCount: content.split("\n").length,
};
docs.push(docFile);
} catch (error) {
logger.error(`Error reading file ${filePath}:`, error);
}
}
}
} catch (error) {
logger.error(`Error walking directory ${dir}:`, error);
}
}
walkDir(docsDir);
miniSearch = new MiniSearch({
fields: ["content", "fileName", "path"],
storeFields: ["path", "fileName", "content", "lineCount"],
});
miniSearch.addAll(docs);
logger.verbose(`Loaded ${docs.length} documentation files`);
return miniSearch;
}
function generateWebUrl(filePath: string): string {
const baseUrl = "https://block.github.io/goose/docs";
// Remove file extension for the URL path
const urlPath = filePath.replace(/\.[^/.]+$/, "");
return `${baseUrl}/${urlPath}`;
}
function getPreview(content: string, maxLength: number = 200): string {
const withoutFrontmatter = content.replace(/^---[\s\S]*?---\n/, "");
const lines = withoutFrontmatter.split("\n");
let preview = "";
for (const line of lines) {
const cleanLine = line
.replace(/^#+\s+/, "")
.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
.replace(/[*_]/g, "")
.trim();
if (
cleanLine &&
!cleanLine.startsWith("import") &&
!cleanLine.startsWith("export")
) {
preview = cleanLine;
break;
}
}
if (preview.length > maxLength) {
preview = preview.substring(0, maxLength) + "...";
}
return preview || "(No preview available)";
}
export function searchDocs(query: string, limit: number = 5): SearchResult[] {
const search = initializeSearch();
const results = search.search(query).slice(0, limit);
if (results.length === 0) {
logger.verbose(`Search for "${query}" returned no results`);
return [];
}
const searchResults: SearchResult[] = results.map((result) => ({
filePath: result.path,
fileName: result.fileName,
score: result.score,
preview: getPreview(result.content),
lineCount: result.lineCount,
webUrl: generateWebUrl(result.path),
}));
logger.verbose(
`Search for "${query}" returned ${searchResults.length} results`,
);
return searchResults;
}
export function reloadDocsCache(): void {
miniSearch = null;
initializeSearch();
}

View File

@@ -0,0 +1,119 @@
import fs from "fs";
import path from "path";
function getDocsDir(): string {
return process.env.DOCS_PATH || path.join(process.cwd(), "docs");
}
function generateWebUrl(filePath: string): string {
const baseUrl = "https://block.github.io/goose/docs";
// Remove file extension for the URL path
const urlPath = filePath.replace(/\.[^/.]+$/, "");
return `${baseUrl}/${urlPath}`;
}
function findDocFile(partialPath: string): string | null {
const docsDir = getDocsDir();
if (!fs.existsSync(docsDir)) {
return null;
}
const searchTerm = partialPath.toLowerCase().replace(/\.md$/, "");
let foundPath: string | null = null;
function walkDir(dir: string) {
if (foundPath) return;
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
if (file === "assets" || file === "docker") {
continue;
}
walkDir(filePath);
} else {
const relativePath = path.relative(docsDir, filePath);
if (relativePath.toLowerCase().includes(searchTerm)) {
foundPath = relativePath;
return;
}
}
}
}
walkDir(docsDir);
return foundPath;
}
function getDocChunk(
filePath: string,
startLine: number = 0,
lineCount: number = 100,
): { fileName: string; content: string; webUrl: string } {
const docsDir = path.resolve(getDocsDir());
const fullPath = path.join(docsDir, filePath);
const normalizedPath = path.resolve(fullPath);
if (!normalizedPath.startsWith(docsDir)) {
throw new Error("Invalid file path - directory traversal not allowed");
}
if (!fs.existsSync(fullPath)) {
throw new Error(`Documentation file not found: ${filePath}`);
}
try {
const content = fs.readFileSync(fullPath, "utf-8");
const lines = content.split("\n");
const actualStartLine = Math.max(0, Math.min(startLine, lines.length - 1));
const actualEndLine = Math.min(actualStartLine + lineCount, lines.length);
const chunkLines = lines.slice(actualStartLine, actualEndLine);
const chunkContent = chunkLines.join("\n");
const fileName = path.basename(fullPath);
return {
content: chunkContent,
fileName,
webUrl: generateWebUrl(filePath),
};
} catch (error) {
if (error instanceof Error && error.message.includes("ENOENT")) {
throw new Error(`Documentation file not found: ${filePath}`);
}
throw error;
}
}
export function viewDocs(
filePaths: string | string[],
startLine: number = 0,
lineCount: number = 100,
): string {
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
const docs = paths.map((filePath) => {
let resolvedPath = filePath;
// Check if file has an extension; if not, search for it
if (!path.extname(filePath)) {
const found = findDocFile(filePath);
if (found) {
resolvedPath = found;
}
}
return getDocChunk(resolvedPath, startLine, lineCount);
});
return docs
.map(
(doc) =>
`**${doc.fileName}**\nWeb URL: <${doc.webUrl}>\n\`\`\`\n${doc.content}\n\`\`\``,
)
.join("\n\n---\n\n");
}

View File

@@ -0,0 +1,70 @@
import { tool } from "ai";
import { z } from "zod";
import { logger } from "../../logger";
import { searchDocs } from "./docs-search";
import { viewDocs } from "./docs-viewer";
export const aiTools = {
search_docs: tool({
description: "Search the goose documentation for relevant information",
inputSchema: z.object({
query: z
.string()
.describe(
"Search query for the documentation (example: 'sessions', 'tool management')",
),
limit: z
.number()
.optional()
.describe("Maximum number of results to return (default 5)"),
}),
execute: async ({ query, limit = 5 }) => {
const results = searchDocs(query, limit);
logger.verbose(
`Searched docs for "${query}", found ${results.length} results`,
);
if (results.length === 0) {
return "No relevant documentation found for your query. Try different keywords.";
}
return results
.map(
(r) =>
`**${r.fileName}** (${r.filePath})\nPreview: ${r.preview}\nWeb URL: <${r.webUrl}>`,
)
.join("\n\n");
},
}),
view_docs: tool({
description: "View documentation file(s)",
inputSchema: z.object({
filePaths: z
.union([z.string(), z.array(z.string())])
.describe(
"Path or array of paths to documentation files (example: 'quickstart.md' or ['guides/managing-projects.md', 'api/overview.md'])",
),
startLine: z
.number()
.optional()
.describe("Starting line number (0-indexed, default 0)"),
lineCount: z
.number()
.optional()
.describe("Number of lines to show (default 100)"),
}),
execute: async ({ filePaths, startLine = 0, lineCount = 100 }) => {
try {
const result = viewDocs(filePaths, startLine, lineCount);
const count = Array.isArray(filePaths) ? filePaths.length : 1;
logger.verbose(`Viewed ${count} documentation file(s)`);
return result;
} catch (error) {
const errorMsg =
error instanceof Error ? error.message : "Unknown error";
logger.error(`Error viewing docs: ${errorMsg}`);
return `Error viewing documentation: ${errorMsg}`;
}
},
}),
};

View File

@@ -0,0 +1,2 @@
import consola from "consola";
export { consola as logger };