feat: ask ai discord bot (#6842)
Signed-off-by: The-Best-Codes <bestcodes.official@gmail.com>
This commit is contained in:
54
.github/workflows/publish-ask-ai-bot.yml
vendored
Normal file
54
.github/workflows/publish-ask-ai-bot.yml
vendored
Normal 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
|
||||
21
services/ask-ai-bot/.dockerignore
Normal file
21
services/ask-ai-bot/.dockerignore
Normal 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
|
||||
15
services/ask-ai-bot/.env.example
Normal file
15
services/ask-ai-bot/.env.example
Normal 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
10
services/ask-ai-bot/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
bun.lockb
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
.env
|
||||
.discraft
|
||||
|
||||
*.tsbuildinfo
|
||||
30
services/ask-ai-bot/Dockerfile
Normal file
30
services/ask-ai-bot/Dockerfile
Normal 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"]
|
||||
214
services/ask-ai-bot/bun.lock
Normal file
214
services/ask-ai-bot/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
5
services/ask-ai-bot/clients/ai.ts
Normal file
5
services/ask-ai-bot/clients/ai.ts
Normal 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);
|
||||
12
services/ask-ai-bot/clients/discord.ts
Normal file
12
services/ask-ai-bot/clients/discord.ts
Normal 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;
|
||||
10
services/ask-ai-bot/commands/ping.ts
Normal file
10
services/ask-ai-bot/commands/ping.ts
Normal 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.");
|
||||
},
|
||||
};
|
||||
9
services/ask-ai-bot/events/error.ts
Normal file
9
services/ask-ai-bot/events/error.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
117
services/ask-ai-bot/events/messageCreate.ts
Normal file
117
services/ask-ai-bot/events/messageCreate.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
32
services/ask-ai-bot/events/ready.ts
Normal file
32
services/ask-ai-bot/events/ready.ts
Normal 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.");
|
||||
}
|
||||
},
|
||||
};
|
||||
63
services/ask-ai-bot/index.ts
Normal file
63
services/ask-ai-bot/index.ts
Normal 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);
|
||||
});
|
||||
27
services/ask-ai-bot/package.json
Normal file
27
services/ask-ai-bot/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
27
services/ask-ai-bot/tsconfig.json
Normal file
27
services/ask-ai-bot/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
96
services/ask-ai-bot/utils/ai/chunk-markdown.ts
Normal file
96
services/ask-ai-bot/utils/ai/chunk-markdown.ts
Normal 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;
|
||||
}
|
||||
123
services/ask-ai-bot/utils/ai/index.ts
Normal file
123
services/ask-ai-bot/utils/ai/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
services/ask-ai-bot/utils/ai/system-prompt.ts
Normal file
9
services/ask-ai-bot/utils/ai/system-prompt.ts
Normal 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.`;
|
||||
39
services/ask-ai-bot/utils/ai/tool-tracker.ts
Normal file
39
services/ask-ai-bot/utils/ai/tool-tracker.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
158
services/ask-ai-bot/utils/ai/tools/docs-search.ts
Normal file
158
services/ask-ai-bot/utils/ai/tools/docs-search.ts
Normal 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();
|
||||
}
|
||||
119
services/ask-ai-bot/utils/ai/tools/docs-viewer.ts
Normal file
119
services/ask-ai-bot/utils/ai/tools/docs-viewer.ts
Normal 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");
|
||||
}
|
||||
70
services/ask-ai-bot/utils/ai/tools/index.ts
Normal file
70
services/ask-ai-bot/utils/ai/tools/index.ts
Normal 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}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
2
services/ask-ai-bot/utils/logger.ts
Normal file
2
services/ask-ai-bot/utils/logger.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import consola from "consola";
|
||||
export { consola as logger };
|
||||
Reference in New Issue
Block a user