my-first-valREADME.md1 match
9Feel free to edit these examples or add new files to your project.1011For inspiration, check out our [Docs](https://docs.val.town/), [Templates](https://www.val.town/explore/use-cases), [Showcase](https://www.val.town/explore/community-showcase) and [Discord](https://discord.val.town/)!1213Stay as long as you'd like in this project, or head to your main [dashboard](/dashboard) to keep exploring the rest of Val Town.
12This "migration" runs once on every app startup because it's imported in `index.ts`. You can comment this line out for a slight (30ms) performance improvement on cold starts. It's left in so that users who fork this project will have the migration run correctly.1314SQLite has much more limited support for altering existing tables as compared to other databases. Often it's easier to create new tables with the schema you want, and then copy the data over. Happily LLMs are quite good at those sort of database operations, but please reach out in the [Val Town Discord](https://discord.com/invite/dHv45uN5RY) if you need help.1516## Queries
reactHonoExampleREADME.md1 match
24want, and then copy the data over. Happily LLMs are quite good at those sort of25database operations, but please reach out in the26[Val Town Discord](https://discord.com/invite/dHv45uN5RY) if you need help.2728## Queries
portfolio-trackermain.ts3 matches
13}1415const discordUrl = Deno.env.get("DISCORD_URL")!;1617let body: any = {};4243if (isLeaving) {44await fetch(discordUrl, {45method: "POST",46headers: { "Content-Type": "application/json" },66});67} else {68await fetch(discordUrl, {69method: "POST",70headers: { "Content-Type": "application/json" },
catnipdiscord-api_test.ts25 matches
1import "../test/_mocks/env.ts";2import { assertEquals, assert } from "../test/assert.ts";3import { commandsPath, discordBotFetch, remainingMs, _internals } from "./discord-api.ts";4import { mockFetch, getCalls, restoreFetch, setNextThrow } from "../test/_mocks/fetch.ts";520});2122Deno.test("discordBotFetch: retries on 429 and succeeds", async () => {23mockFetch({24responses: [28});29try {30const result = await discordBotFetch("GET", "test");31assertEquals(result.ok, true);32assertEquals(result.data?.id, "msg1");37});3839Deno.test("discordBotFetch: retries on 5xx and succeeds", async () => {40mockFetch({41responses: [45});46try {47const result = await discordBotFetch("POST", "channels/1/messages", { content: "hi" });48assertEquals(result.ok, true);49assertEquals(getCalls().length, 2);53});5455Deno.test("discordBotFetch: does not retry on 4xx", async () => {56mockFetch({57responses: [60});61try {62const result = await discordBotFetch("GET", "test");63assertEquals(result.ok, false);64assertEquals(result.status, 403);69});7071Deno.test("discordBotFetch: retries on network error and succeeds", async () => {72mockFetch({73responses: [77setNextThrow(new Error("network down"));78try {79const result = await discordBotFetch("GET", "test");80assertEquals(result.ok, true);81assertEquals(result.data?.recovered, true);88// --- 204 No Content ---8990Deno.test("discordBotFetch: 204 No Content returns ok with no data", async () => {91mockFetch({92responses: [95});96try {97const result = await discordBotFetch("DELETE", "channels/1/messages/2");98assertEquals(result.ok, true);99assertEquals(result.status, 204);106// --- undefined body omits Content-Type ---107108Deno.test("discordBotFetch: undefined body omits Content-Type header", async () => {109mockFetch({ default: { status: 200, body: { ok: true } } });110try {111await discordBotFetch("GET", "test");112const call = getCalls()[0];113const headers = call.init?.headers as Record<string, string> | undefined;120});121122Deno.test("discordBotFetch: body provided includes Content-Type header", async () => {123mockFetch({ default: { status: 200, body: { ok: true } } });124try {125await discordBotFetch("POST", "test", { content: "hi" });126const call = getCalls()[0];127const headers = call.init?.headers as Record<string, string> | undefined;150// --- network error exhausts retries ---151152Deno.test("discordBotFetch: persistent network error returns error after retries", async () => {153mockFetch({ default: { status: 200, body: {} } });154setNextThrow(new Error("first fail"));155try {156// First call throws (network error), retry succeeds157const result = await discordBotFetch("GET", "test");158assertEquals(result.ok, true);159} finally {175});176177Deno.test("discordBotFetch: 429 rejected when time budget insufficient", async () => {178// Set isolate start far in the past so remainingMs is very small179_internals.setIsolateStart(Date.now() - 9.5 * 60 * 1000 + 5000); // ~5s left184});185try {186const result = await discordBotFetch("GET", "test");187assertEquals(result.ok, false);188assertEquals(result.status, 429);195});196197Deno.test("discordBotFetch: 5xx not retried when time budget insufficient", async () => {198_internals.setIsolateStart(Date.now() - 9.5 * 60 * 1000 + 10000); // ~10s left, < 32s threshold199mockFetch({203});204try {205const result = await discordBotFetch("GET", "test");206assertEquals(result.ok, false);207assertEquals(result.status, 500);213});214215Deno.test("discordBotFetch: network error not retried when time budget insufficient", async () => {216_internals.setIsolateStart(Date.now() - 9.5 * 60 * 1000 + 10000); // ~10s left217mockFetch({ default: { status: 200, body: {} } });218setNextThrow(new Error("network down"));219try {220const result = await discordBotFetch("GET", "test");221assertEquals(result.ok, false);222assertEquals(result.status, 0);229});230231Deno.test("discordBotFetch: max retries returns error after 2 attempts", async () => {232_internals.resetIsolateStart(); // plenty of time233mockFetch({238});239try {240const result = await discordBotFetch("GET", "test");241assertEquals(result.ok, false);242assertEquals(result.status, 500);
catnipdiscord-api.ts8 matches
1/**2* discord/discord-api.ts3*4* Shared helper for Discord Bot API calls.5* Centralizes URL construction, Bot auth headers, and error handling.6*/19}2021export interface DiscordApiResult {22ok: boolean;23status: number;2728/**29* Make an authenticated request to the Discord Bot API.30* Builds the full URL, attaches Bot token, and handles response parsing.31*/32/**33* Build the Discord API path for application commands.34*/35export function commandsPath(appId: string, guildId?: string, commandId?: string): string {44}4546export async function discordBotFetch(47method: string,48path: string,49body?: unknown,50): Promise<DiscordApiResult> {51const headers: Record<string, string> = {52Authorization: `Bot ${CONFIG.botToken}`,60for (let attempt = 0; attempt < 2; attempt++) {61try {62const response = await fetch(`https://discord.com/api/v10/${path}`, {63method,64headers,
catnipsend_test.ts28 matches
3import { _internals, send } from "./send.ts";45const { truncateText, splitMessage, calculateEmbedSize, sanitizeEmbed, chunkEmbeds, DISCORD_LIMITS } = _internals;67// --- truncateText ---36assert(chunks.length > 1);37for (const chunk of chunks) {38assert(chunk.length <= DISCORD_LIMITS.contentLength);39}40assertEquals(chunks.join(""), msg);55assert(chunks.length >= 2);56for (const chunk of chunks) {57assert(chunk.length <= DISCORD_LIMITS.contentLength);58}59assertEquals(chunks.join(""), msg);86Deno.test("sanitizeEmbed: truncates long title", () => {87const result = sanitizeEmbed({ title: "a".repeat(300) });88assert(result.title!.length <= DISCORD_LIMITS.title);89});9091Deno.test("sanitizeEmbed: truncates long description", () => {92const result = sanitizeEmbed({ description: "a".repeat(5000) });93assert(result.description!.length <= DISCORD_LIMITS.description);94});95111footer: { text: "f".repeat(3000) },112});113assert(result.author!.name.length <= DISCORD_LIMITS.authorName);114assert(result.footer!.text.length <= DISCORD_LIMITS.footerText);115});116151for (const chunk of result) {152const totalSize = chunk.reduce((sum, e) => sum + calculateEmbedSize(e), 0);153assert(totalSize <= DISCORD_LIMITS.totalCharacters);154}155});161globalThis.fetch = () => Promise.resolve(new Response(JSON.stringify({ id: "1" }), { status: 200 }));162try {163const result = await send("short message", "https://discord.com/api/webhooks/test/token");164assertEquals(result.success, true);165assertEquals(result.partialFailure, false);176// Message long enough to split into multiple chunks177const longMsg = "x".repeat(4500);178const result = await send(longMsg, "https://discord.com/api/webhooks/test/token");179assertEquals(result.success, true);180assertEquals(result.partialFailure, false);192callCount++;193// First two calls succeed (initial send + rate limit retry = 2 per chunk, but we have 1 success)194// Actually sendWithFallback calls sendToDiscordApi which has its own retry loop195// First chunk succeeds, second chunk fails196if (callCount <= 1) {201try {202const longMsg = "x".repeat(4500); // splits into 3 chunks203const result = await send(longMsg, "https://discord.com/api/webhooks/test/token");204assertEquals(result.success, true);205assertEquals(result.partialFailure, true);216globalThis.fetch = () => Promise.resolve(new Response("Bad Request", { status: 400 }));217try {218const result = await send("short message", "https://discord.com/api/webhooks/test/token");219assertEquals(result.success, false);220// partialFailure should be false (or undefined) when nothing succeeded234for (const chunk of result) {235const totalSize = chunk.reduce((sum, e) => sum + calculateEmbedSize(e), 0);236assert(totalSize <= DISCORD_LIMITS.totalCharacters, `Chunk exceeds ${DISCORD_LIMITS.totalCharacters} chars`);237}238});244fields: [{ name: "x".repeat(300), value: "v".repeat(1500) }],245});246assert(result.fields![0].name.length <= DISCORD_LIMITS.fieldName);247assert(result.fields![0].value.length <= DISCORD_LIMITS.fieldValue);248assert(result.fields![0].name.endsWith("..."));249assert(result.fields![0].value.endsWith("..."));256globalThis.fetch = () => Promise.resolve(new Response(JSON.stringify({ id: "1" }), { status: 200 }));257try {258const result = await send({ title: "Test", description: "Hello" }, "https://discord.com/api/webhooks/test/token");259assertEquals(result.success, true);260assertEquals(result.sentDirectly, 1);268Deno.test("send: no webhook URL returns error", async () => {269// Save and clear the config webhook270const origEnv = Deno.env.get("DISCORD_CONSOLE_WEBHOOK");271Deno.env.delete("DISCORD_CONSOLE_WEBHOOK");272try {273const result = await send("test", undefined);275assert(result.error?.includes("No webhook URL"));276} finally {277if (origEnv) Deno.env.set("DISCORD_CONSOLE_WEBHOOK", origEnv);278}279});282283Deno.test("send: fallback webhook used on 4xx from primary", async () => {284const origEnv = Deno.env.get("DISCORD_CONSOLE");285Deno.env.set("DISCORD_CONSOLE", "https://discord.com/api/webhooks/fallback/token");286const originalFetch = globalThis.fetch;287let callCount = 0;295};296try {297const result = await send("test message", "https://discord.com/api/webhooks/primary/token");298assertEquals(result.success, true);299assertEquals(result.usedFallback, true);300} finally {301globalThis.fetch = originalFetch;302if (origEnv) Deno.env.set("DISCORD_CONSOLE", origEnv);303else Deno.env.delete("DISCORD_CONSOLE");304}305});321};322try {323const result = await send("short msg", "https://discord.com/api/webhooks/test/token");324assertEquals(result.success, true);325assert(callCount >= 2, "Should have retried after 429");335globalThis.fetch = () => Promise.reject(new Error("network down"));336try {337const result = await send("msg", "https://discord.com/api/webhooks/test/token");338assertEquals(result.success, false);339} finally {352};353try {354const result = await send("hello world", "https://discord.com/api/webhooks/test/token");355assertEquals(result.success, true);356assert(bodies.length > 0);
catniplogger_test.ts27 matches
1import "../../test/_mocks/env.ts";2import { assertEquals, assert } from "../../test/assert.ts";3import { DiscordLogger, finalizeAllLoggers, createLogger } from "./logger.ts";45// Helper: create a logger with no webhook (buffer-only, no network)6function createBufferLogger(opts?: { minLevel?: "debug" | "info" | "warn" | "error"; maxBatchSize?: number }) {7return new DiscordLogger({8context: "Test",9webhookUrl: null,58console.log = (msg: string) => messages.push(msg);59try {60const logger = new DiscordLogger({61context: "ErrTest",62webhookUrl: null,78console.log = (msg: string) => messages.push(msg);79try {80const logger = new DiscordLogger({81context: "ErrTest",82webhookUrl: null,95console.log = (msg: string) => messages.push(msg);96try {97const logger = new DiscordLogger({98context: "ErrTest",99webhookUrl: null,118};119try {120const logger = new DiscordLogger({121context: "SendTest",122webhookUrl: "https://discord.com/api/webhooks/test/token",123fallbackToConsole: false,124batchIntervalMs: 100_000, // prevent auto-flush140};141try {142const logger = new DiscordLogger({143context: "FailTest",144webhookUrl: "https://discord.com/api/webhooks/test/token",145fallbackToConsole: false,146});160globalThis.fetch = () => Promise.reject(new Error("always fails"));161try {162const logger = new DiscordLogger({163context: "CapTest",164webhookUrl: "https://discord.com/api/webhooks/test/token",165fallbackToConsole: false,166maxBatchSize: 200,198199Deno.test("sanitize: redacts webhook URLs", () => {200const input = "Sending to https://discord.com/api/webhooks/123456789/ABCdef-token_123";201const result = sanitize(input);202assert(result.includes("[WEBHOOK_URL_REDACTED]"));219};220try {221const logger = new DiscordLogger({222context: "ErrorFlush",223webhookUrl: "https://discord.com/api/webhooks/test/token",224fallbackToConsole: false,225batchIntervalMs: 100_000, // very long to prevent timed flush245};246try {247const logger = new DiscordLogger({248context: "BatchFlush",249webhookUrl: "https://discord.com/api/webhooks/test/token",250fallbackToConsole: false,251maxBatchSize: 3,274};275try {276const logger = new DiscordLogger({277context: "DupFlush",278webhookUrl: "https://discord.com/api/webhooks/test/token",279fallbackToConsole: false,280batchIntervalMs: 100_000,303try {304const logger = createLogger("GlobalTest", {305webhookUrl: "https://discord.com/api/webhooks/test/token",306fallbackToConsole: false,307batchIntervalMs: 100_000,332};333try {334const logger = new DiscordLogger({335context: "TimerTest",336webhookUrl: "https://discord.com/api/webhooks/test/token",337fallbackToConsole: false,338batchIntervalMs: 50, // very short interval358};359try {360const logger = new DiscordLogger({361context: "FinalizeTest",362webhookUrl: "https://discord.com/api/webhooks/test/token",363fallbackToConsole: false,364batchIntervalMs: 999_999, // very long — should never fire naturally382globalThis.fetch = () => Promise.reject(new Error("send failed"));383try {384const logger = new DiscordLogger({385context: "NoDump",386webhookUrl: "https://discord.com/api/webhooks/test/token",387fallbackToConsole: false,388});407};408try {409const logger = new DiscordLogger({410context: "EmojiTest",411webhookUrl: "https://discord.com/api/webhooks/test/token",412fallbackToConsole: false,413batchIntervalMs: 999_999,
catniproutes_test.ts3 matches
51});5253Deno.test("handleLinkedRolesRedirect Location header points to discord.com/oauth2/authorize", async () => {54setVerifier(testVerifier);55const req = new Request("https://example.com/linked-roles");56const res = await handleLinkedRolesRedirect(req);57const location = res.headers.get("Location")!;58assert(location.startsWith("https://discord.com/oauth2/authorize?"));59});6083const location = res.headers.get("Location")!;84const params = new URLSearchParams(location.split("?")[1]);85// Test env sets DISCORD_APP_ID to "11111111111111111"86assertEquals(params.get("client_id"), "11111111111111111");87});
catniphandler_test.ts1 match
215import "../../test/_mocks/sqlite.ts";216import { mockFetch, restoreFetch, getCalls } from "../../test/_mocks/fetch.ts";217import { kv } from "../../discord/persistence/kv.ts";218import { sqlite } from "https://esm.town/v/std/sqlite/main.ts";219