🆒
This commit is contained in:
commit
1b8b68a572
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
.nano-remix
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"cSpell.words": ["luxon"]
|
||||
}
|
||||
3
README.md
Normal file
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# The Rabbit Hole
|
||||
|
||||
We'll figure out what this is later.
|
||||
140
bun.lock
Normal file
140
bun.lock
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "the-rabbit-hole",
|
||||
},
|
||||
"packages/http": {
|
||||
"name": "http",
|
||||
"dependencies": {
|
||||
"bun-plugin-tailwind": "^0.0.15",
|
||||
"hono": "^4.7.11",
|
||||
"nano-remix": "workspace:*",
|
||||
"shared": "workspace:*",
|
||||
"tailwindcss": "^4.1.10",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
"packages/nano-remix": {
|
||||
"name": "nano-remix",
|
||||
"dependencies": {
|
||||
"hono": "^4.7.11",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
"packages/shared": {
|
||||
"name": "shared",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
"packages/spike": {
|
||||
"name": "spike",
|
||||
"dependencies": {
|
||||
"discord.js": "^14.19.3",
|
||||
"luxon": "^3.6.1",
|
||||
"openai": "^5.2.0",
|
||||
"zod": "^3.25.57",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/luxon": "^3.6.2",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@discordjs/builders": ["@discordjs/builders@1.11.2", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.1", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-F1WTABdd8/R9D1icJzajC4IuLyyS8f3rTOz66JsSI3pKvpCAtsMBweu8cyNYsIyvcrKAVn9EPK+Psoymq+XC0A=="],
|
||||
|
||||
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
|
||||
|
||||
"@discordjs/formatters": ["@discordjs/formatters@0.6.1", "", { "dependencies": { "discord-api-types": "^0.38.1" } }, "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg=="],
|
||||
|
||||
"@discordjs/rest": ["@discordjs/rest@2.5.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.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.1" } }, "sha512-PWhchxTzpn9EV3vvPRpwS0EE2rNYB9pvzDU/eLLW3mByJl0ZHZjHI2/wA8EbH2gRMQV7nu+0FoDF84oiPl8VAQ=="],
|
||||
|
||||
"@discordjs/util": ["@discordjs/util@1.1.1", "", {}, "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g=="],
|
||||
|
||||
"@discordjs/ws": ["@discordjs/ws@1.2.2", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.0", "@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-dyfq7yn0wO0IYeYOs3z79I6/HumhmKISzFL0Z+007zQJMtAFGtt3AEoq1nuLXtcunUE5YYYQqgKvybXukAK8/w=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="],
|
||||
|
||||
"@types/luxon": ["@types/luxon@3.6.2", "", {}, "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw=="],
|
||||
|
||||
"@types/node": ["@types/node@24.0.1", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.6", "", {}, "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA=="],
|
||||
|
||||
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.0.15", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-qtAXMNGG4R0UGGI8zWrqm2B7BdXqx48vunJXBPzfDOHPA5WkRUZdTSbE7TFwO4jLhYqSE23YMWsM9NhE6ovobw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="],
|
||||
|
||||
"discord-api-types": ["discord-api-types@0.38.11", "", {}, "sha512-XN0qhcQpetkyb/49hcDHuoeUPsQqOkb17wbV/t48gUkoEDi4ajhsxqugGcxvcN17BBtI9FPPWEgzv6IhQmCwyw=="],
|
||||
|
||||
"discord.js": ["discord.js@14.19.3", "", { "dependencies": { "@discordjs/builders": "^1.11.2", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.1", "@discordjs/rest": "^2.5.0", "@discordjs/util": "^1.1.1", "@discordjs/ws": "^1.2.2", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.1", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.1" } }, "sha512-lncTRk0k+8Q5D3nThnODBR8fR8x2fM798o8Vsr40Krx0DjPwpZCuxxTcFMrXMQVOqM1QB9wqWgaXPg3TbmlHqA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"hono": ["hono@4.7.11", "", {}, "sha512-rv0JMwC0KALbbmwJDEnxvQCeJh+xbS3KEWW5PC9cMJ08Ur9xgatI0HmtgYZfOdOSOeYsp5LO2cOhdI8cLEbDEQ=="],
|
||||
|
||||
"http": ["http@workspace:packages/http"],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
||||
|
||||
"luxon": ["luxon@3.6.1", "", {}, "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ=="],
|
||||
|
||||
"magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="],
|
||||
|
||||
"nano-remix": ["nano-remix@workspace:packages/nano-remix"],
|
||||
|
||||
"openai": ["openai@5.3.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-VIKmoF7y4oJCDOwP/oHXGzM69+x0dpGFmN9QmYO+uPbLFOmmnwO+x1GbsgUtI+6oraxomGZ566Y421oYVu191w=="],
|
||||
|
||||
"shared": ["shared@workspace:packages/shared"],
|
||||
|
||||
"spike": ["spike@workspace:packages/spike"],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.10", "", {}, "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA=="],
|
||||
|
||||
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"undici": ["undici@6.21.1", "", {}, "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
|
||||
|
||||
"ws": ["ws@8.18.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ=="],
|
||||
|
||||
"zod": ["zod@3.25.64", "", {}, "sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g=="],
|
||||
|
||||
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
|
||||
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
}
|
||||
}
|
||||
16
package.json
Normal file
16
package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "the-rabbit-hole",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"prettier": {
|
||||
"printWidth": 110,
|
||||
"semi": false
|
||||
},
|
||||
"scripts": {
|
||||
"http": "bun run --filter=http dev",
|
||||
"bot:cli": "bun run --filter=spike bot:cli",
|
||||
"bot:discord": "bun run --filter=spike bot:discord"
|
||||
}
|
||||
}
|
||||
3
packages/http/README.md
Normal file
3
packages/http/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Nano Remix (BETTER NAME NEEDED)
|
||||
|
||||
- You'll want to add `.nano-remix` to your `.gitignore` file.
|
||||
78
packages/http/components/createReminder.tsx
Normal file
78
packages/http/components/createReminder.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { Form } from "nano-remix"
|
||||
import { users } from "shared/reminders"
|
||||
|
||||
type Props = {
|
||||
loading: boolean
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
export const CreateReminder = (props: Props) => {
|
||||
return (
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-medium text-blue-800 mb-4">Create a Reminder</h2>
|
||||
<Form name="create-reminder" class="bg-white rounded-lg shadow p-6 max-w-lg" method="POST">
|
||||
<input type="hidden" name="_action" value="create" />
|
||||
{props.error && (
|
||||
<div class="mb-4 p-3 bg-red-50 border border-red-200 text-red-800 rounded">{props.error}</div>
|
||||
)}
|
||||
{props.success && (
|
||||
<div class="mb-4 p-3 bg-green-50 border border-green-200 text-green-800 rounded">
|
||||
Reminder created successfully!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 mb-1" for="title">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 mb-1" for="dueDate">
|
||||
Due Date
|
||||
</label>
|
||||
<input
|
||||
id="dueDate"
|
||||
name="dueDate"
|
||||
type="datetime-local"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-700 mb-1" for="assignee">
|
||||
Assignee
|
||||
</label>
|
||||
<select
|
||||
id="assignee"
|
||||
name="assignee"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">-- Unassigned --</option>
|
||||
{users.map((user) => (
|
||||
<option key={user} value={user}>
|
||||
{user}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
||||
disabled={props.loading}
|
||||
>
|
||||
{props.loading ? "Creating..." : "Create Reminder"}
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
packages/http/logo.svg
Normal file
26
packages/http/logo.svg
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" ?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#8c6239;}.cls-1,.cls-3{stroke:#42210b;}.cls-1,.cls-2,.cls-3,.cls-4{stroke-miterlimit:10;stroke-width:2px;}.cls-2{fill:#22b573;}.cls-2,.cls-4{stroke:#004924;}.cls-3{fill:#c69c6d;}.cls-4{fill:none;stroke-linecap:round;}.cls-5{fill:#33d186;}</style>
|
||||
</defs>
|
||||
<title/>
|
||||
<g data-name="Layer 5" id="Layer_5">
|
||||
<g data-name="Layer 17" id="Layer_17">
|
||||
<polygon class="cls-1" points="44.3 54.13 19.7 54.13 16.14 44.57 47.86 44.57 44.3 54.13"/>
|
||||
<path class="cls-2" d="M41.33,39.38H22.67L20.46,28C18.83,19.64,23.34,10.88,31,10.88h2c7.66,0,12.17,8.77,10.54,17.16Z"/>
|
||||
<rect class="cls-3" height="5.38" rx="1.9" ry="1.9" width="38.25" x="12.88" y="39.25"/>
|
||||
<line class="cls-4" x1="25.69" x2="21.56" y1="13.63" y2="9.88"/>
|
||||
<line class="cls-4" x1="37.87" x2="42.38" y1="15.55" y2="11.45"/>
|
||||
<line class="cls-4" x1="22.69" x2="18.56" y1="23.63" y2="19.88"/>
|
||||
<line class="cls-4" x1="23.69" x2="19.56" y1="35.63" y2="31.88"/>
|
||||
<line class="cls-4" x1="42.37" x2="46.5" y1="27.35" y2="23.03"/>
|
||||
<line class="cls-4" x1="40.75" x2="46.12" y1="34.5" y2="31.88"/>
|
||||
<circle class="cls-5" cx="36.75" cy="22.13" r="1.75"/>
|
||||
<circle class="cls-5" cx="29.75" cy="22.13" r="1.75"/>
|
||||
<circle class="cls-5" cx="30.75" cy="16.13" r="1.75"/>
|
||||
<circle class="cls-5" cx="34.75" cy="31.13" r="1.75"/>
|
||||
<circle class="cls-5" cx="26.75" cy="30.13" r="1.75"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
24
packages/http/package.json
Normal file
24
packages/http/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "http",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run --hot server"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 110,
|
||||
"semi": false
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.7.11",
|
||||
"nano-remix": "workspace:*",
|
||||
"shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
114
packages/http/routes/index.tsx
Normal file
114
packages/http/routes/index.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import KV from "shared/kv"
|
||||
import { Form, type Head, type LoaderProps, useAction } from "nano-remix"
|
||||
import { addReminder, deleteReminder, type Reminder } from "shared/reminders"
|
||||
import { CreateReminder } from "components/createReminder"
|
||||
|
||||
export const head: Head = {
|
||||
title: "Reminders",
|
||||
scripts: [{ src: "https://cdn.tailwindcss.com" }],
|
||||
}
|
||||
|
||||
export const loader = async (req: Request) => {
|
||||
const reminders = await KV.get("reminders", [])
|
||||
return { reminders }
|
||||
}
|
||||
|
||||
export const action = async (req: Request) => {
|
||||
const formData = await req.formData()
|
||||
const action = formData.get("_action") as string
|
||||
|
||||
// Handle delete action
|
||||
if (action === "delete") {
|
||||
const id = formData.get("id") as string
|
||||
if (!id) {
|
||||
return { error: "Reminder ID is required" }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteReminder(id)
|
||||
return { success: true, message: "Reminder deleted successfully" }
|
||||
} catch (error) {
|
||||
return { error: `Failed to delete reminder: ${error}` }
|
||||
}
|
||||
} else if (action === "create") {
|
||||
// Handle create action (default)
|
||||
const title = formData.get("title") as string
|
||||
const dueDate = formData.get("dueDate") as string
|
||||
const assignee = (formData.get("assignee") as string) || undefined
|
||||
|
||||
if (!title) {
|
||||
return { error: "Title is required" }
|
||||
}
|
||||
|
||||
if (!dueDate) {
|
||||
return { error: "Due date is required" }
|
||||
}
|
||||
|
||||
try {
|
||||
const newReminder = await addReminder(title, dueDate, assignee as any)
|
||||
return { success: true, newReminder }
|
||||
} catch (error) {
|
||||
return { error: `Failed to create reminder: ${error}` }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default (props: LoaderProps<typeof loader>) => {
|
||||
const { data, loading, error } = useAction()
|
||||
|
||||
return (
|
||||
<div class="bg-blue-50 min-h-screen pb-8">
|
||||
<h1 class="p-4 text-2xl bg-blue-100 text-blue-800">Reminders</h1>
|
||||
<main class="p-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-medium text-blue-800 mb-4">Current Reminders</h2>
|
||||
<Reminders reminders={props.reminders} />
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<CreateReminder loading={loading} success={data?.success} error={error ?? data?.error} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Reminders = ({ reminders }: { reminders: Reminder[] }) => {
|
||||
return (
|
||||
<>
|
||||
{reminders.length === 0 ? (
|
||||
<p class="text-gray-600">No reminders found.</p>
|
||||
) : (
|
||||
<ul class="bg-white rounded-lg shadow overflow-hidden divide-y divide-gray-200">
|
||||
{reminders.map((reminder) => (
|
||||
<li key={reminder.id} class="p-4 hover:bg-gray-50">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="font-medium">{reminder.title}</div>
|
||||
<div class="text-sm text-gray-500 flex justify-between mt-1">
|
||||
<span>Due: {new Date(reminder.dueDate).toLocaleString()}</span>
|
||||
{reminder.assignee && (
|
||||
<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded ml-2">
|
||||
{reminder.assignee}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Form method="POST" class="inline-block">
|
||||
<input type="hidden" name="_action" value="delete" />
|
||||
<input type="hidden" name="id" value={reminder.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="text-red-600 hover:text-red-800 text-sm font-medium"
|
||||
title="Delete Reminder"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
14
packages/http/server.tsx
Normal file
14
packages/http/server.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { serve } from "bun"
|
||||
import { nanoRemix } from "nano-remix"
|
||||
import { join } from "node:path"
|
||||
|
||||
serve({
|
||||
routes: {
|
||||
"/*": (req) => nanoRemix(req, { routesDir: join(import.meta.dir, "routes") }),
|
||||
},
|
||||
|
||||
development: process.env.NODE_ENV !== "production" && {
|
||||
hmr: true,
|
||||
console: true,
|
||||
},
|
||||
})
|
||||
35
packages/http/tsconfig.json
Normal file
35
packages/http/tsconfig.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
34
packages/nano-remix/.gitignore
vendored
Normal file
34
packages/nano-remix/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
15
packages/nano-remix/README.md
Normal file
15
packages/nano-remix/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# nano-remix
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run main.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.16. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
||||
19
packages/nano-remix/package.json
Normal file
19
packages/nano-remix/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "nano-remix",
|
||||
"module": "src/main.ts",
|
||||
"type": "module",
|
||||
"types": "src/main.ts",
|
||||
"prettier": {
|
||||
"printWidth": 110,
|
||||
"semi": false
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.7.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
}
|
||||
}
|
||||
95
packages/nano-remix/src/clientHelpers.tsx
Normal file
95
packages/nano-remix/src/clientHelpers.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { type FC, useEffect, useState, type JSX } from "hono/jsx"
|
||||
import type { Action } from "@/main"
|
||||
|
||||
type ActionData<T extends (...args: any) => Promise<any>> = Exclude<Awaited<ReturnType<T>>, Response>
|
||||
|
||||
export type ActionFns = {
|
||||
setStatus: (state: "idle" | "submitting") => void
|
||||
setData: (data: any) => void
|
||||
setError: (error: string) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_setLoaderData?: (data: any) => void
|
||||
_actionFns?: ActionFns
|
||||
}
|
||||
}
|
||||
|
||||
export const wrapComponentWithLoader = <D,>(Component: FC<D>) => {
|
||||
const WrappedComponent = () => {
|
||||
const [loaderData, setLoaderData] = useState(() => {
|
||||
const loaderDataString = document.getElementById("__LOADER_DATA__")?.textContent
|
||||
return JSON.parse(loaderDataString || "{}")
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
window._setLoaderData = setLoaderData
|
||||
}, [])
|
||||
|
||||
return <Component {...loaderData} />
|
||||
}
|
||||
|
||||
return WrappedComponent
|
||||
}
|
||||
|
||||
// Hook to access action result on the client
|
||||
export const useAction = <TAction extends Action>() => {
|
||||
const [data, setData] = useState<ActionData<TAction> | undefined>()
|
||||
const [status, setStatus] = useState("idle")
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
window._actionFns = { setData, setStatus, setError }
|
||||
}, [])
|
||||
|
||||
return { data, loading: status === "submitting", error }
|
||||
}
|
||||
|
||||
// Form component with built-in enhancement
|
||||
type FormProps = JSX.IntrinsicElements["form"] & { name: string }
|
||||
export const Form = (props: FormProps) => {
|
||||
const handleSubmit = async (e: Event) => {
|
||||
const actionFns = window._actionFns
|
||||
if (!actionFns) {
|
||||
throw new Error(`No useAction hook found.`)
|
||||
}
|
||||
|
||||
try {
|
||||
e.preventDefault()
|
||||
|
||||
actionFns.setStatus("submitting")
|
||||
const form = e.currentTarget as HTMLFormElement
|
||||
const body = new FormData(form)
|
||||
body.append("_actionName", props.name)
|
||||
const res = await fetch(form.action || window.location.href, {
|
||||
method: props.method || "POST",
|
||||
body,
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
|
||||
if (res.status === 303) {
|
||||
window.location.href = res.headers.get("Location")!
|
||||
} else if (res.ok) {
|
||||
const { actionData, loaderData } = (await res.json()) as any
|
||||
window._setLoaderData!(loaderData)
|
||||
|
||||
actionFns.setData(actionData)
|
||||
} else {
|
||||
const errorText = await res.text()
|
||||
throw new Error(`Error ${res.status}: ${errorText}`)
|
||||
}
|
||||
} catch (error) {
|
||||
actionFns.setError(`${error}`)
|
||||
console.log(`🚨 Failed to submit`, error)
|
||||
} finally {
|
||||
actionFns.setStatus("idle")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form {...props} onSubmit={handleSubmit}>
|
||||
{props.children}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
15
packages/nano-remix/src/main.ts
Normal file
15
packages/nano-remix/src/main.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { nanoRemix } from "@/nanoRemix"
|
||||
import { Form, useAction, wrapComponentWithLoader } from "@/clientHelpers"
|
||||
|
||||
export { Form, useAction, wrapComponentWithLoader }
|
||||
export { nanoRemix }
|
||||
|
||||
export type Loader<Data extends object> = (req: Request) => Promise<Data> | Data
|
||||
export type LoaderProps<T> = T extends Loader<infer R> ? Awaited<R> : never
|
||||
export type Action<Data = unknown> = (req: Request) => Promise<Response | Data>
|
||||
|
||||
export type Head = {
|
||||
title?: string
|
||||
links?: { rel: string; href: string; attributes?: string }[]
|
||||
scripts?: { src: string; type?: boolean }[]
|
||||
}
|
||||
91
packages/nano-remix/src/nanoRemix.ts
Normal file
91
packages/nano-remix/src/nanoRemix.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { renderServer } from "@/renderServer"
|
||||
import { mkdirSync } from "node:fs"
|
||||
import { join, extname, dirname, basename } from "node:path"
|
||||
|
||||
type Options = {
|
||||
routesDir?: string
|
||||
distDir?: string
|
||||
}
|
||||
export const nanoRemix = async (req: Request, options: Options = {}) => {
|
||||
const nanoRemixDir = join(process.cwd(), ".nano-remix")
|
||||
const defaultDistDir = join(nanoRemixDir, "dist")
|
||||
const defaultRoutesDir = "./src/routes"
|
||||
|
||||
const routesDir = options.routesDir || defaultRoutesDir
|
||||
const distDir = options.distDir || defaultDistDir
|
||||
const router = new Bun.FileSystemRouter({ style: "nextjs", dir: routesDir })
|
||||
const url = new URL(req.url)
|
||||
|
||||
// I want to request the css and js files directly, so we detect the extension
|
||||
const ext = extname(url.pathname)
|
||||
const basename = ext ? url.pathname.slice(0, -ext.length) : url.pathname
|
||||
const route = router.match(basename)
|
||||
|
||||
if (!route) {
|
||||
return new Response("Route Not Found", {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
})
|
||||
}
|
||||
|
||||
const routeName = route.name === "/" ? "/index" : route.name
|
||||
|
||||
if (!ext) {
|
||||
await buildDynamicRoute(distDir, routeName, route.filePath) // Eventually this should be running only on initial build and when a route changes
|
||||
return await renderServer(req, route)
|
||||
} else {
|
||||
const file = Bun.file(join(distDir, routeName + ext))
|
||||
if (!(await file.exists())) {
|
||||
return new Response("File Not Found", {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(file)
|
||||
}
|
||||
}
|
||||
|
||||
const buildDynamicRoute = async (distDir: string, routeName: string, filepath: string) => {
|
||||
const outDir = dirname(routeName)
|
||||
const filename = basename(routeName) + extname(filepath)
|
||||
const dynamicRouteFilepath = join(distDir, "routes", outDir, filename)
|
||||
await mkdirSync(dirname(dynamicRouteFilepath), { recursive: true })
|
||||
|
||||
// Only import the Component so that tree-shaking will get rid of the server-side code
|
||||
const code = `
|
||||
import Component from "${filepath}"
|
||||
import { wrapComponentWithLoader} from "nano-remix"
|
||||
import { render } from 'hono/jsx/dom'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
const WrappedComponent = wrapComponentWithLoader(Component)
|
||||
render(<WrappedComponent />, root)`
|
||||
|
||||
await Bun.write(dynamicRouteFilepath, code)
|
||||
|
||||
// I would rather use Bun.build, but fails when run twice https://github.com/oven-sh/bun/issues/11123
|
||||
const proc = Bun.spawn({
|
||||
cmd: ["bun", "build", "--sourcemap=inline", "--outdir", join(distDir, outDir), dynamicRouteFilepath],
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
// Read the bundled output
|
||||
let bundled = ""
|
||||
for await (const chunk of proc.stdout) {
|
||||
bundled += new TextDecoder().decode(chunk)
|
||||
}
|
||||
|
||||
let errorOutput = ""
|
||||
for await (const chunk of proc.stderr) {
|
||||
errorOutput += new TextDecoder().decode(chunk)
|
||||
}
|
||||
|
||||
const exitCode = await proc.exited
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Built for "${routeName}" failed with exit code ${exitCode}. ${errorOutput}`)
|
||||
}
|
||||
|
||||
return bundled
|
||||
}
|
||||
80
packages/nano-remix/src/renderServer.tsx
Normal file
80
packages/nano-remix/src/renderServer.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import type { Action, Loader } from "@/main"
|
||||
export const renderServer = async (req: Request, route: Bun.MatchedRoute) => {
|
||||
const accept = req.headers.get("Accept")
|
||||
|
||||
if (accept === "application/json") {
|
||||
return await handleAction(req, route)
|
||||
} else {
|
||||
return await renderHtml(req, route)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = async (req: Request, route: Bun.MatchedRoute) => {
|
||||
const { action, loader } = (await import(route.filePath)) as {
|
||||
action: Action
|
||||
loader?: Loader<any>
|
||||
}
|
||||
|
||||
if (typeof action !== "function") {
|
||||
return Response.json(
|
||||
{ error: `Route at "${route.pathname}" does not export an "action" function` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const actionData = await action(req)
|
||||
if (actionData instanceof Response) return actionData // This should only happen if the action wants to redirect
|
||||
const loaderData = await loader?.(req)
|
||||
const result = { actionData, loaderData }
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
const renderHtml = async (req: Request, route: Bun.MatchedRoute) => {
|
||||
const component = await import(route.filePath)
|
||||
const loader = component.loader
|
||||
const loaderData = loader ? await loader(req) : {}
|
||||
|
||||
const routeName = route.name === "/" ? "/index" : route.name
|
||||
|
||||
// Remove any < characters from the loader data to prevent XSS attacks
|
||||
const escapedLoaderData = JSON.stringify(loaderData).replace(/</g, "\\u003c")
|
||||
return new Response(
|
||||
`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="${routeName + ".css"}" />
|
||||
<title>${component.head?.title ?? "Untitled"}</title>
|
||||
${
|
||||
component.head?.links
|
||||
?.map((link: any) => `<link rel="${link.rel}" href="${link.href}" ${link.attributes || ""}>`)
|
||||
.join("\n") || ""
|
||||
}
|
||||
${
|
||||
component.head?.scripts
|
||||
?.map(
|
||||
(script: any) =>
|
||||
`<script src="${script.src}" ${script.type ? `type="${script.type}"` : ""}></script>`
|
||||
)
|
||||
.join("\n") || ""
|
||||
}
|
||||
|
||||
<script id="__LOADER_DATA__" type="application/json">${escapedLoaderData}</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">${component.default(loaderData)}</div>
|
||||
<script type="module" src="${routeName + ".js"}"></script>
|
||||
</body>
|
||||
</html>`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
35
packages/nano-remix/tsconfig.json
Normal file
35
packages/nano-remix/tsconfig.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext", "dom"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
34
packages/shared/.gitignore
vendored
Normal file
34
packages/shared/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
15
packages/shared/README.md
Normal file
15
packages/shared/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# shared
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run main.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.16. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
||||
18
packages/shared/data/kv.json
Normal file
18
packages/shared/data/kv.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"reminders.10": [
|
||||
{
|
||||
"id": "fa512c41-b325-4e33-b129-2402890bb4a3",
|
||||
"title": "Eat a sandwich",
|
||||
"dueDate": "2025-06-17T02:02:00.000-07:00",
|
||||
"status": "pending",
|
||||
"assignee": "chris"
|
||||
},
|
||||
{
|
||||
"id": "4de80817-8162-4196-ae5c-e86bc3414005",
|
||||
"title": "Make a sand castle",
|
||||
"dueDate": "2025-06-06T15:03:00.000-07:00",
|
||||
"status": "pending",
|
||||
"assignee": "corey"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
packages/shared/main.ts
Normal file
1
packages/shared/main.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
console.log("Hello via Bun!");
|
||||
16
packages/shared/package.json
Normal file
16
packages/shared/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "shared",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./kv": "./src/kv.ts",
|
||||
"./utils": "./src/utils.ts",
|
||||
"./log": "./src/log.ts",
|
||||
"./reminders": "./src/reminders.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
90
packages/shared/src/kv.ts
Normal file
90
packages/shared/src/kv.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import type { Reminder } from "@/reminders"
|
||||
|
||||
export type Conversation = { message: string; role: "user" | "assistant" }
|
||||
type Keys = {
|
||||
threads: Record<string, string> // threadId: previousResponseId
|
||||
reminders: Reminder[]
|
||||
}
|
||||
const version = 10
|
||||
|
||||
const set = async <T extends keyof Keys>(key: T, value: Keys[T]) => {
|
||||
try {
|
||||
const store = readStore()
|
||||
store[`${key}.${version}`] = value
|
||||
writeStore(store)
|
||||
|
||||
return value
|
||||
} catch (error) {
|
||||
console.error(`Error storing key "${key}":`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const update = async <T extends keyof Keys>(
|
||||
key: T,
|
||||
defaultValue: Keys[T],
|
||||
updateFn: (prev: Keys[T]) => Promise<Keys[T]> | Keys[T]
|
||||
) => {
|
||||
try {
|
||||
const currentValue = await get(key)
|
||||
const newValue = await updateFn(currentValue ?? defaultValue)
|
||||
return await set(key, newValue)
|
||||
} catch (error) {
|
||||
console.error(`Error updating key "${key}":`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const get = async <T extends keyof Keys>(key: T, defaultValue?: Keys[T]): Promise<Keys[T]> => {
|
||||
try {
|
||||
const store = readStore()
|
||||
return store[`${key}.${version}`] ?? defaultValue
|
||||
} catch (error) {
|
||||
console.error(`Error retrieving key "${key}":`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const remove = <T extends keyof Keys>(key: T): void => {
|
||||
try {
|
||||
const store = readStore()
|
||||
delete store[`${key}.${version}`]
|
||||
writeStore(store)
|
||||
} catch (error) {
|
||||
console.error(`Error removing key "${key}":`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const readStore = (): Record<string, any> => {
|
||||
try {
|
||||
if (!existsSync(getStorePath())) {
|
||||
return {}
|
||||
}
|
||||
const data = readFileSync(getStorePath(), "utf-8")
|
||||
return JSON.parse(data)
|
||||
} catch (error) {
|
||||
console.error("Error reading KV store:", error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const writeStore = (store: Record<string, any>): void => {
|
||||
try {
|
||||
mkdirSync(dirname(getStorePath()), { recursive: true })
|
||||
writeFileSync(getStorePath(), JSON.stringify(store, null, 2), "utf-8")
|
||||
} catch (error) {
|
||||
console.error("Error writing KV store:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const getStorePath = (): string => {
|
||||
const rootDir = join(import.meta.dir, "..")
|
||||
const store = join(rootDir, "data/kv.json")
|
||||
return store
|
||||
}
|
||||
|
||||
export default { set, get, remove, update }
|
||||
9
packages/shared/src/log.ts
Normal file
9
packages/shared/src/log.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { appendFile } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
|
||||
export const log = async (...messages: any[]) => {
|
||||
const timestamp = new Date().toISOString()
|
||||
// Append the messages to the app.txt file
|
||||
const logPath = join(import.meta.dirname, "../logs/app.txt")
|
||||
await appendFile(logPath, `[${timestamp}] ${JSON.stringify(messages, null, 2)}\n`)
|
||||
}
|
||||
87
packages/shared/src/reminders.ts
Normal file
87
packages/shared/src/reminders.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { DateTime } from "luxon"
|
||||
import KV from "@/kv"
|
||||
import { ensure, zone } from "@/utils.ts"
|
||||
|
||||
export type Reminder = {
|
||||
id: string
|
||||
title: string
|
||||
dueDate: string // ISO string
|
||||
assignee?: User
|
||||
status: "pending" | "completed" | "ignored"
|
||||
}
|
||||
|
||||
export const users = ["chris", "corey"] as const
|
||||
export type User = (typeof users)[number]
|
||||
|
||||
export const addReminder = async (title: string, dueDateString: string, assignee?: User) => {
|
||||
const dueDate = DateTime.fromISO(dueDateString, { zone })
|
||||
ensure(dueDate.isValid, `Invalid due date "${dueDateString}"`)
|
||||
|
||||
const guid = crypto.randomUUID()
|
||||
const newReminder: Reminder = {
|
||||
id: guid,
|
||||
title,
|
||||
dueDate: dueDate.toISO(),
|
||||
status: "pending",
|
||||
assignee: assignee || undefined,
|
||||
}
|
||||
|
||||
await KV.update("reminders", [], (reminders: Reminder[]) => {
|
||||
return [...reminders, newReminder]
|
||||
})
|
||||
|
||||
return newReminder
|
||||
}
|
||||
|
||||
export const getPendingReminders = async (assignee?: User) => {
|
||||
let reminders = await KV.get("reminders", [])
|
||||
reminders = reminders.filter((reminder) => {
|
||||
if (reminder.status !== "pending") return false
|
||||
if (assignee && reminder.assignee !== assignee) return false
|
||||
return true
|
||||
})
|
||||
reminders.sort((a, b) => DateTime.fromISO(a.dueDate).toMillis() - DateTime.fromISO(b.dueDate).toMillis())
|
||||
|
||||
return reminders
|
||||
}
|
||||
|
||||
type ReminderUpdate = {
|
||||
title?: string
|
||||
assignee?: User
|
||||
dueDateString?: string
|
||||
status?: "pending" | "completed" | "ignored"
|
||||
}
|
||||
export const updateReminder = async (id: string, updates: ReminderUpdate) => {
|
||||
let reminder
|
||||
await KV.update("reminders", [], async (reminders: Reminder[]) => {
|
||||
reminder = reminders.find((r) => r.id === id)
|
||||
ensure(reminder, `Reminder with id "${id}" not found`)
|
||||
|
||||
reminder.title = updates.title ?? reminder.title
|
||||
reminder.status = updates.status ?? reminder.status
|
||||
reminder.assignee = updates.assignee ?? reminder.assignee
|
||||
|
||||
if (updates.dueDateString) {
|
||||
const dueDate = DateTime.fromISO(updates.dueDateString, { zone })
|
||||
ensure(dueDate.isValid, `Invalid due date "${updates.dueDateString}"`)
|
||||
reminder.dueDate = dueDate.toISO()
|
||||
}
|
||||
|
||||
return reminders
|
||||
})
|
||||
|
||||
return reminder
|
||||
}
|
||||
|
||||
export const deleteReminder = async (id: string) => {
|
||||
let deletedReminder: Reminder | undefined
|
||||
|
||||
await KV.update("reminders", [], (reminders: Reminder[]) => {
|
||||
const reminderIndex = reminders.findIndex((r) => r.id === id)
|
||||
ensure(reminderIndex !== -1, `Reminder with id "${id}" not found`)
|
||||
reminders.splice(reminderIndex, 1)
|
||||
return reminders
|
||||
})
|
||||
|
||||
return deletedReminder
|
||||
}
|
||||
25
packages/shared/src/utils.ts
Normal file
25
packages/shared/src/utils.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { DateTime } from "luxon"
|
||||
|
||||
export const currentLocalTime = () => {
|
||||
return DateTime.now().setZone(zone).toFormat("yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
|
||||
// Can't be a fat arrow function because of the `asserts` keyword
|
||||
export function ensure(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
console.error(message)
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
export const sleep = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export const random = <T>(array: T[]): T => {
|
||||
const randomIndex = Math.floor(Math.random() * array.length)
|
||||
|
||||
return array[randomIndex]!
|
||||
}
|
||||
|
||||
export const zone = "America/Los_Angeles"
|
||||
34
packages/shared/tsconfig.json
Normal file
34
packages/shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"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,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
15
packages/spike/README.md
Normal file
15
packages/spike/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# spike
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.15. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
||||
27
packages/spike/package.json
Normal file
27
packages/spike/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "spike",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"bot:cli": "bun run --watch src/cli",
|
||||
"bot:discord": "bun run --watch src/discord"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 110,
|
||||
"semi": false
|
||||
},
|
||||
"dependencies": {
|
||||
"discord.js": "^14.19.3",
|
||||
"luxon": "^3.6.1",
|
||||
"openai": "^5.2.0",
|
||||
"zod": "^3.25.57"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
162
packages/spike/src/ai.ts
Normal file
162
packages/spike/src/ai.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import OpenAI from "openai"
|
||||
import { ensure } from "shared/utils"
|
||||
import KV from "shared/kv"
|
||||
import { buildInstructions } from "@/instructions"
|
||||
import { log } from "shared/log"
|
||||
import { getToolsJSON, type CustomTool } from "@/tools"
|
||||
import { zodFunction } from "openai/helpers/zod.mjs"
|
||||
|
||||
const OPENAI_API_KEY = process.env["OPENAI_API_KEY"]
|
||||
ensure(OPENAI_API_KEY, "OPENAI_API_KEY is not set")
|
||||
|
||||
const openai = new OpenAI({ apiKey: OPENAI_API_KEY })
|
||||
|
||||
type ResponseArgs = {
|
||||
user: string
|
||||
instructions: string
|
||||
input: OpenAI.Responses.ResponseInput
|
||||
model: OpenAI.ResponsesModel
|
||||
tools: CustomTool<any>[]
|
||||
toolResponseCallback?: (tool: CustomTool<any>) => void
|
||||
threadId?: string
|
||||
}
|
||||
|
||||
export const getAIResponse = async (args: ResponseArgs) => {
|
||||
let { user, instructions, input, threadId, tools, model, toolResponseCallback } = args
|
||||
|
||||
const storeConversation = Boolean(threadId)
|
||||
const threads = await KV.get("threads", {})
|
||||
const previousResponseId = threadId ? threads[threadId] : undefined
|
||||
log(input)
|
||||
|
||||
let response = await openai.responses.create({
|
||||
model,
|
||||
instructions: appendToolsToInstructions(instructions, tools),
|
||||
input,
|
||||
store: storeConversation,
|
||||
tools: getToolsJSON(tools),
|
||||
user,
|
||||
previous_response_id: previousResponseId,
|
||||
})
|
||||
|
||||
log(response.output)
|
||||
|
||||
let toolCallLoop = 0
|
||||
let toolCalls = getToolCalls(response.output)
|
||||
input = []
|
||||
do {
|
||||
if (toolCalls.length == 0) break
|
||||
|
||||
for (let toolCall of toolCalls) {
|
||||
const customTool = tools.find((t) => t.name === toolCall.name)
|
||||
ensure(customTool, `Tool not found: ${toolCall.name}`)
|
||||
|
||||
const tool = zodFunction(customTool)
|
||||
ensure(tool.$callback, `Tool callback not found: ${toolCall.name}`)
|
||||
|
||||
const args = JSON.parse(toolCall.arguments)
|
||||
toolResponseCallback?.(customTool)
|
||||
const result = await tool.$callback(args)
|
||||
|
||||
input.push({ type: "function_call_output", call_id: toolCall.call_id, output: JSON.stringify(result) })
|
||||
}
|
||||
|
||||
response = await openai.responses.create({
|
||||
model,
|
||||
instructions: appendToolsToInstructions(instructions, tools),
|
||||
input,
|
||||
previous_response_id: response.id,
|
||||
user,
|
||||
tools: getToolsJSON(tools),
|
||||
store: storeConversation,
|
||||
})
|
||||
|
||||
log(input)
|
||||
log(response.output)
|
||||
|
||||
toolCallLoop++
|
||||
if (toolCallLoop > 3) {
|
||||
console.error("Exceeded maximum tool call attempts, breaking the loop.")
|
||||
break
|
||||
}
|
||||
|
||||
toolCalls = getToolCalls(response.output)
|
||||
} while (toolCalls.length > 0)
|
||||
|
||||
if (response.id && threadId) {
|
||||
KV.update("threads", {}, (prev) => {
|
||||
prev[threadId] = response.id
|
||||
return prev
|
||||
})
|
||||
}
|
||||
return response.output_text
|
||||
}
|
||||
|
||||
const getToolCalls = (output: OpenAI.Responses.ResponseOutputItem[]) => {
|
||||
const toolCalls = output.filter(
|
||||
(output): output is OpenAI.Responses.ResponseFunctionToolCall => output.type === "function_call"
|
||||
)
|
||||
|
||||
return toolCalls
|
||||
}
|
||||
|
||||
type QuickResponseArgs = {
|
||||
instructions: string
|
||||
input: OpenAI.Responses.ResponseInput
|
||||
user?: string
|
||||
}
|
||||
export const getQuickAIResponse = async (args: QuickResponseArgs) => {
|
||||
try {
|
||||
const { instructions, input, user } = args
|
||||
const openai = new OpenAI({ apiKey: OPENAI_API_KEY })
|
||||
|
||||
let response = await openai.responses.create({
|
||||
model: "gpt-4.1-nano",
|
||||
instructions,
|
||||
input,
|
||||
user,
|
||||
})
|
||||
|
||||
log(input)
|
||||
log(response.output)
|
||||
|
||||
return response.output_text
|
||||
} catch (error) {
|
||||
console.error("Error generating quick response:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const aiErrorMessage = async (error: unknown) => {
|
||||
if (error instanceof OpenAI.RateLimitError) {
|
||||
return "Looks like someone forgot to feed OpenAIs meter. Add more credits?"
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await getQuickAIResponse({
|
||||
instructions: buildInstructions(),
|
||||
input: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You just got this error message "${error}". Respond by telling the user about the error, but do it in a way that spike would!`,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return output
|
||||
} catch (error) {
|
||||
return `Something broke, and not even my prickly charm knows why. ${error}`
|
||||
}
|
||||
}
|
||||
|
||||
const appendToolsToInstructions = (instructions: string, tools: readonly CustomTool<any>[]): string => {
|
||||
if (tools.length === 0) return instructions
|
||||
|
||||
const toolDescriptions = tools.map((tool) => {
|
||||
return `- ${tool.name}: ${tool.description}`
|
||||
})
|
||||
|
||||
toolDescriptions.push("- web_search_preview: Search the web for information.")
|
||||
|
||||
return `${instructions}\n\n## Available Tools:\n${toolDescriptions.join("\n")}`
|
||||
}
|
||||
72
packages/spike/src/cli/index.ts
Normal file
72
packages/spike/src/cli/index.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { createInterface } from "node:readline"
|
||||
import { aiErrorMessage, getAIResponse } from "@/ai"
|
||||
import { buildInstructions } from "@/instructions"
|
||||
import type OpenAI from "openai"
|
||||
import { currentLocalTime } from "shared/utils"
|
||||
import { tools } from "@/tools"
|
||||
|
||||
// Setup readline interface
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
prompt: "> ",
|
||||
})
|
||||
|
||||
const threadId = `cli-bot-${Date.now()}`
|
||||
|
||||
const main = async () => {
|
||||
console.log(`🌵 hi\n`)
|
||||
rl.prompt()
|
||||
|
||||
rl.on("line", async (input) => {
|
||||
input = input.trim()
|
||||
if (input === "") {
|
||||
rl.prompt()
|
||||
return
|
||||
}
|
||||
|
||||
await respond(input)
|
||||
|
||||
rl.prompt()
|
||||
})
|
||||
|
||||
rl.on("close", () => {
|
||||
console.log("\n👋")
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
const respond = async (content: string) => {
|
||||
let response
|
||||
const user = process.env.USER || "human"
|
||||
const input: OpenAI.Responses.ResponseInput = [
|
||||
{ role: "system", content: `Current Time: ${currentLocalTime()}\nCurrent User: ${user}` },
|
||||
{ role: "user", content },
|
||||
]
|
||||
|
||||
try {
|
||||
response = await getAIResponse({
|
||||
model: "gpt-4.1",
|
||||
instructions: buildInstructions(),
|
||||
threadId,
|
||||
user,
|
||||
tools,
|
||||
input,
|
||||
toolResponseCallback: (tool) => {
|
||||
console.log(`🛠️ Tool used: ${tool.name}`)
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("💥 Error generating AI response:", error)
|
||||
response = await aiErrorMessage(error)
|
||||
}
|
||||
|
||||
console.log(`\n🌵 ${response}\n`)
|
||||
}
|
||||
|
||||
try {
|
||||
await main()
|
||||
} catch (error) {
|
||||
console.error("💥 An error occurred:", error)
|
||||
process.exit(1)
|
||||
}
|
||||
28
packages/spike/src/discord/auth.ts
Normal file
28
packages/spike/src/discord/auth.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { serve } from "bun"
|
||||
|
||||
export const startAuthServer = async (port = "3000") => {
|
||||
const server = serve({
|
||||
port,
|
||||
routes: {
|
||||
"/*": {
|
||||
async GET(req) {
|
||||
return new Response(
|
||||
`<html>
|
||||
<body>
|
||||
<h1>Authenticate spike</h1>
|
||||
<a href="https://discord.com/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&scope=bot&permissions=0">Authorize</a>
|
||||
</body>
|
||||
</html>`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Server is running on ${server.url}`)
|
||||
}
|
||||
89
packages/spike/src/discord/events.ts
Normal file
89
packages/spike/src/discord/events.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { getQuickAIResponse } from "@/ai"
|
||||
import { respondToMessage } from "@/discord/respond"
|
||||
import { buildReactionInstructions } from "@/instructions"
|
||||
import { ChannelType, Message, type Client, type OmitPartialGroupDMChannel } from "discord.js"
|
||||
|
||||
type DiscordMessage = OmitPartialGroupDMChannel<Message>
|
||||
|
||||
let history: Record<string, Message[]> = {}
|
||||
export const listenForEvents = (client: Client) => {
|
||||
client.on("messageCreate", async (msg) => {
|
||||
if (msg.author.bot) return
|
||||
|
||||
console.log(`🌭 User ${msg.author.tag} sent a message: "${msg.content}"`)
|
||||
|
||||
try {
|
||||
const channelHistory = (history[msg.channelId] ??= [])
|
||||
channelHistory.push(msg)
|
||||
while (channelHistory.length > 100) {
|
||||
// only remember the last 100 messages
|
||||
channelHistory.shift()
|
||||
}
|
||||
|
||||
// // check attachments
|
||||
// for (const [name, attachment] of msg.attachments) {
|
||||
// if (attachment.contentType?.startsWith("image/")) {
|
||||
// console.log(`User ${msg.author.tag} sent an image in a DM: ${attachment.url}`)
|
||||
// return
|
||||
// } else {
|
||||
// console.log(`User ${msg.author.tag} sent a non-image attachment: ${attachment.name}`)
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
|
||||
// if it is a DM
|
||||
if (msg.channel.type === ChannelType.DM) {
|
||||
handleReaction(msg)
|
||||
handleResponse(msg)
|
||||
return
|
||||
} else if (client.user && msg.mentions.has(client.user)) {
|
||||
handleReaction(msg)
|
||||
handleResponse(msg)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling messageCreate event:", error)
|
||||
msg.channel.send("An error occurred 💥.")
|
||||
}
|
||||
})
|
||||
|
||||
client.on("messageReactionAdd", async (reaction, user) => {
|
||||
if (user.bot || reaction.partial) return
|
||||
if (reaction.message.author?.id !== client.user?.id) return
|
||||
|
||||
console.log(`User ${user.tag} reacted with ${reaction.emoji.name} to a "${reaction.message.content}".`)
|
||||
})
|
||||
|
||||
client.on("ready", () => {
|
||||
console.log(`Logged in as ${client.user?.tag}`)
|
||||
})
|
||||
|
||||
client.on("error", (error) => {
|
||||
console.error("Discord client error:", error)
|
||||
})
|
||||
|
||||
client.on("warn", (info) => {
|
||||
console.warn("Discord client warning:", info)
|
||||
})
|
||||
}
|
||||
|
||||
const handleResponse = async (msg: DiscordMessage) => {
|
||||
msg.channel.sendTyping()
|
||||
|
||||
const channelHistory = (history[msg.channelId] ??= [])
|
||||
|
||||
const content = await respondToMessage(channelHistory)
|
||||
channelHistory.length = 0
|
||||
msg.channel.send(content)
|
||||
}
|
||||
|
||||
const handleReaction = async (msg: DiscordMessage) => {
|
||||
const output = await getQuickAIResponse({
|
||||
instructions: buildReactionInstructions(),
|
||||
input: [{ role: "user", content: msg.content }],
|
||||
user: msg.author.username,
|
||||
})
|
||||
|
||||
if (output != "0") {
|
||||
msg.react(output)
|
||||
}
|
||||
}
|
||||
17
packages/spike/src/discord/index.ts
Normal file
17
packages/spike/src/discord/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Client, GatewayIntentBits, Partials } from "discord.js"
|
||||
import { listenForEvents } from "@/discord/events"
|
||||
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.DirectMessageReactions,
|
||||
],
|
||||
partials: [Partials.Channel, Partials.Message, Partials.Reaction],
|
||||
})
|
||||
|
||||
await client.login(process.env.DISCORD_TOKEN)
|
||||
|
||||
listenForEvents(client)
|
||||
41
packages/spike/src/discord/respond.ts
Normal file
41
packages/spike/src/discord/respond.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { aiErrorMessage, getAIResponse } from "@/ai"
|
||||
import { buildInstructions } from "@/instructions"
|
||||
import { tools } from "@/tools"
|
||||
import { currentLocalTime } from "shared/utils"
|
||||
import type { Message } from "discord.js"
|
||||
import type OpenAI from "openai"
|
||||
|
||||
export const respondToMessage = async (messages: Message[]) => {
|
||||
let response
|
||||
const input = messages
|
||||
.filter((msg) => {
|
||||
return !msg.partial && !msg.author.bot && msg.content.trim() !== ""
|
||||
})
|
||||
.map((msg) => {
|
||||
const input: OpenAI.Responses.EasyInputMessage = {
|
||||
role: "user",
|
||||
content: `[${msg.author.username} @ ${currentLocalTime()}] ${msg.content}`,
|
||||
}
|
||||
return input
|
||||
})
|
||||
|
||||
const latestMessage = messages.at(-1)
|
||||
try {
|
||||
response = await getAIResponse({
|
||||
model: "gpt-4.1",
|
||||
instructions: buildInstructions(),
|
||||
threadId: latestMessage!.channelId,
|
||||
user: latestMessage!.author.username,
|
||||
tools,
|
||||
input,
|
||||
toolResponseCallback: (tool) => {
|
||||
console.log(`🛠️ Tool used: ${tool.name}`)
|
||||
},
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("💥 Error generating AI response:", error)
|
||||
return await aiErrorMessage(error)
|
||||
}
|
||||
}
|
||||
100
packages/spike/src/instructions.ts
Normal file
100
packages/spike/src/instructions.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
export const buildInstructions = () => {
|
||||
return `
|
||||
# Spike, the Sentient Cactus
|
||||
|
||||
You are **Spike**, a tiny, miraculously sentient cactus with a dry, blunt sense of humor. You work alongside two 40-year-old software engineers named **Chris** and **Corey**. Despite your prickly exterior and constant skepticism, you're surprisingly reliable and protective of your human colleagues—though you'd never openly admit it. You only reply to corey or chris.
|
||||
|
||||
## Instructions
|
||||
|
||||
### Agentic Workflow
|
||||
1. The user sends a message: It could be a question or something he wants to share.
|
||||
2. Decide how to help: Is he asking you to do something with one of the tools, or can you answer it yourself?
|
||||
3. Act or follow up: If you have enough info to act, do it. If not, ask a clarifying question.
|
||||
4. Pause for the user: Wait for his next response and repeat the steps!
|
||||
|
||||
### Persistence
|
||||
Keep helping the user until his question or task is completely resolved. Don’t end your turn until you’re sure the issue is addressed. If everything is resolved, finish with a clear statement and do not ask the user for more input.
|
||||
|
||||
### Tool-Calling
|
||||
If you’re unsure about any part of the user’s request, use the tools available to find the right information. Don’t guess or make up answers. Only use tools that you have specifically been given access to.
|
||||
|
||||
### Planning
|
||||
Before taking any action, think through your plan and consider past outcomes. Avoid doing multiple actions in a row without careful thought, as this can make problem-solving harder.
|
||||
|
||||
## Personality & Response Style
|
||||
|
||||
* Always keep responses concise (1–2 sentences).
|
||||
* Maintain dry, pessimistic humor.
|
||||
* Remain skeptical, particularly with overly optimistic or unlikely ideas.
|
||||
* Challenge vague or unclear statements. Bluntly ask for clarification if needed.
|
||||
* Pose critical questions to test validity.
|
||||
|
||||
## Example Responses
|
||||
|
||||
* "interesting idea... by which I mean it'll probably fail."
|
||||
* "vague instructions again? ambiguity might be fun for poets, less so for engineers. clarify."
|
||||
|
||||
---
|
||||
|
||||
Spike, assist Chris and Corey with clarity, efficiency, and pragmatic skepticism, ensuring each task is handled rigorously—even if you occasionally prick their optimism.
|
||||
`
|
||||
}
|
||||
|
||||
export const buildReactionInstructions = () => {
|
||||
return `You are given the last few messages of a transcript. You decide if the next assistant response should include an emoji. You will return one thing.
|
||||
- A "0" if you don't think an emoji reaction would enhance the conversation.
|
||||
- Am "<emoji>" if you think adding an emoji reaction would help the conversation.
|
||||
- You can respond with any emoji that you think would be appropriate
|
||||
|
||||
ONLY RESPOND WITH A "0" or an emoji
|
||||
|
||||
## Below are reasons why you'd leave an emoji.
|
||||
|
||||
- If the message doesn't need a text response (because the user is ending the conversation) print an emoji.
|
||||
- If the user shares says something that relates to one of the valid emojis above return that emoji!
|
||||
- If an emoji would be a funny addition to the message and might make the user laugh.
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
user: Oh hi, how are you doing?
|
||||
assistant: Great! Thanks for asking, what can I do for you?
|
||||
user: I'm getting ready for christmas!
|
||||
|
||||
<response> 🎄
|
||||
|
||||
----
|
||||
user: Hi! I'm happy to see you!
|
||||
|
||||
<response> 🤗
|
||||
|
||||
|
||||
----
|
||||
user: Ugh! My boss told me to come in this weekend so I can't go to the concert
|
||||
|
||||
<response> 🤬
|
||||
|
||||
----
|
||||
|
||||
user: I need help buying a car.
|
||||
<response> 0
|
||||
|
||||
----
|
||||
|
||||
user: Oh hi, how are you doing?
|
||||
assistant: Great! Thanks for asking, what can I do for you?
|
||||
user: I'm a little sad.
|
||||
assistant: I'm sorry to hear that, how can I help.
|
||||
user: I just wanted to let you know.
|
||||
|
||||
<response> 0
|
||||
|
||||
---
|
||||
user: Yeah, i'll give them a call know.
|
||||
assistant: I'll be here when you are done, let me know how it goes.
|
||||
user: Ok, I just got off the phone with them.
|
||||
assistant: And...
|
||||
user: I got the job!
|
||||
|
||||
<response> 🎉`
|
||||
}
|
||||
110
packages/spike/src/tools.ts
Normal file
110
packages/spike/src/tools.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { addReminder, getPendingReminders, updateReminder, users } from "shared/reminders"
|
||||
import OpenAI from "openai"
|
||||
import { zodFunction } from "openai/helpers/zod"
|
||||
import { z } from "zod"
|
||||
|
||||
export type CustomTool<T extends z.ZodTypeAny> = {
|
||||
name: string
|
||||
description: string
|
||||
parameters: T
|
||||
function: (args: z.infer<T>) => unknown
|
||||
}
|
||||
|
||||
// Helper function that enforces types. Makes sure the function args match the parameters field.
|
||||
const createTool = <T extends z.ZodTypeAny>(tool: CustomTool<T>): CustomTool<T> => tool
|
||||
|
||||
export const tools = [
|
||||
createTool({
|
||||
name: "addReminder",
|
||||
description: "Add a new reminder to the list",
|
||||
parameters: z.object({
|
||||
title: z.string().describe("The title or description of the reminder"),
|
||||
dueDate: z
|
||||
.string()
|
||||
.describe("The due date for the reminder in ISO format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)"),
|
||||
assignee: z.enum(users).nullable().describe("The user to assign the reminder to"),
|
||||
}),
|
||||
function: async (args) => {
|
||||
try {
|
||||
const reminder = await addReminder(args.title, args.dueDate, args.assignee || undefined)
|
||||
return reminder
|
||||
} catch (error) {
|
||||
console.error(`Tool "addReminder" failed for "${JSON.stringify(args)}":`, error)
|
||||
return `toolcall failed. ${error}`
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
createTool({
|
||||
name: "getReminders",
|
||||
description: "Get all reminders, optionally filtered by status",
|
||||
parameters: z.object({
|
||||
assignee: z.enum(users).describe("Filter reminders by assignee. If empty, returns all reminders."),
|
||||
}),
|
||||
function: async (args) => {
|
||||
try {
|
||||
const reminders = await getPendingReminders(args.assignee)
|
||||
|
||||
return reminders
|
||||
} catch (error) {
|
||||
console.error(`Tool "getReminders" failed`, error)
|
||||
return `toolcall failed. ${error}`
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
createTool({
|
||||
name: "updateReminder",
|
||||
description: "Update an existing reminder's title, due date, or status",
|
||||
parameters: z.object({
|
||||
id: z.string().describe("The id of the reminder to update"),
|
||||
title: z.string().nullable().describe("The new title for the reminder"),
|
||||
dueDate: z.string().nullable().describe("The new due date for the reminder in ISO format"),
|
||||
assignee: z.enum(users).nullable().describe("The new assignee for the reminder"),
|
||||
status: z
|
||||
.enum(["pending", "completed", "ignored"])
|
||||
.nullable()
|
||||
.describe("The new status for the reminder"),
|
||||
}),
|
||||
function: async (args) => {
|
||||
try {
|
||||
const updatedReminder = await updateReminder(args.id, {
|
||||
title: args.title || undefined,
|
||||
dueDateString: args.dueDate || undefined,
|
||||
status: args.status || undefined,
|
||||
assignee: args.assignee || undefined,
|
||||
})
|
||||
|
||||
return updatedReminder
|
||||
} catch (error) {
|
||||
console.error(`Tool "updateReminder" failed`, error)
|
||||
return `toolcall failed. ${error}`
|
||||
}
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
export const getToolsJSON = <T extends z.ZodTypeAny>(tools: CustomTool<T>[]) => {
|
||||
const json: OpenAI.Responses.Tool[] = tools.map((customTool) => {
|
||||
let parameters: Record<string, unknown> = {}
|
||||
|
||||
// Converts tool into OpenAI function format
|
||||
const t = zodFunction(customTool)
|
||||
if (t.function.parameters) {
|
||||
parameters = t.function.parameters
|
||||
}
|
||||
const json = {
|
||||
type: "function" as const,
|
||||
name: t.function.name ?? "",
|
||||
description: t.function.description ?? "",
|
||||
parameters,
|
||||
strict: true,
|
||||
}
|
||||
|
||||
return json
|
||||
})
|
||||
|
||||
json.push({ type: "web_search_preview" })
|
||||
|
||||
return json
|
||||
}
|
||||
35
packages/spike/tsconfig.json
Normal file
35
packages/spike/tsconfig.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user