From 420bf461ef6337192e248747a9b0c4b2f0571e32 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Mon, 19 Oct 2026 16:03:17 -0400 Subject: [PATCH] Tell members to add the bot when an Activity is launched in a server without it Rogue's Community Server could launch the TypeCheck Activity, but the bot is not a member there. Sign-in then asks Discord for the guild, Discord answers 404 Unknown Guild (code 10004), and the generic mapping turned it into HTTP 502 discord_http_404, which the hosting proxy replaced with an unreadable error page. Map that 404 to bot_not_in_server (409) and an unknown member to not_a_guild_member (403), and show how to add the bot. Also prefix operational log lines with TYPECHECK_EVENT_V1: the GoDaddy log processor kept only the event and path keys of bare JSON lines, which hid the failing code and stage timings. Co-Authored-By: Claude Opus 5.5 --- SHA256SUMS | 10 +++++----- activity/src/connection.ts | 2 ++ server/discord.ts | 6 ++++-- server/http.ts | 3 ++- tests/activity-connection.test.ts | 6 ++++++ tests/core.test.ts | 8 ++++++++ 6 files changed, 27 insertions(+), 8 deletions(-) diff --git a/SHA256SUMS b/SHA256SUMS index 8704fa5..c13253c 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -20,7 +20,7 @@ ecd8294e24a1cd55cc1aa0b3c5d6cde68c956524cfb1d7707766b4f6287b5e3e activity/index 2c22d3a5e0aff57971932e24cbdf4dc93c12f589b060203cc20417fd97a75142 activity/legal/legal.css 6c8b74d4d57bff38a393ab038418c8f410627dc66721bc416905289110f151ac activity/legal/privacy.html 297c926eb462285e52e196af2d23f2ac70cb280324bfd30aeff74836e02f159c activity/legal/terms.html -92481a623badff677f19c6eb71a34038931ce059a1b5f6c4e652cb745e590b35 activity/src/connection.ts +d2c03ccd185041e743b25a9c000bececdd2fc6464ed66b78a4d9e390070dbf3d activity/src/connection.ts 8e1c4a409e52f6677d2fb51c3afabae5de8de05be606b83a44271c6d31f35992 activity/src/main.ts 8010da4164c86394863cb645eaf4567f066822114c40edf1d2762c1a5da3806a activity/src/onboarding.ts 66988416b7ab216ad9d3947ed528bfe9f2e45f74943971d506a686ff0b044508 activity/style.css @@ -397,12 +397,12 @@ a44e797125685b375a99f8ea8311972906255b05cdc4df91ea9beb0c8860e185 scripts/verify b5e028d6a57cf4c6458f70c8411d2b95f561b48b212bf88cb8d04e3ea3efeeee server/content-evaluate-cli.ts c7678b37fef2837eece5de6da59b919a9d00bbec87c44c677930b5ad717ebd04 server/content.ts c3a5c6c6c01a10e2682f827d04f43fcb72f50fb8f3eb70690f28d78e130fd7b0 server/database.ts -e08839ca4a25c1087e39f2d98c5bc97ca9c2f4366cf27f5404088c5e00471d6c server/discord.ts +bc116f5d23abe9b47bf28b328f360c5176b50eb4e8907783ba2f841f531db0cc server/discord.ts ba6fb59bda1bf219d1e09d787a52437bf4a0ae6bfabfbda2bcfb37bced886adc server/durable.ts 89e116491a077a347c18b0b0c6a2801f867f0005c224ab5ed6e2dd3d783c047a server/evaluate-cli.ts 6e00b4f2c76c2b68165105b9ca9a00dbc74183043d15ba4d5ffb82967b84130f server/gateway.ts f72b36e636d98f35f21d9e185aaf5ca7376ef20de2769e5c4a8a107022bcf832 server/historical.ts -78955eb8836d39e72cc5be4d95938bd4805ef6913f8c697cf0feb359f0f09b5a server/http.ts +af65a397602bd3b3907c59a542e44e8047010766016471696e926b839e29b48b server/http.ts 98a99e3407ace650bc8b1c9140d31548c378e0b2e1069e43dbed6325f5acf26b server/images.ts 6af6aa0e5917a1ec062d2076c6ae60a4cb94af546266e9fd4b746502a7a743a3 server/jev.ts b556bb54b61153d824e83a5a931c7031974736e065dc63633e259090c3e7eb0d server/leadership.ts @@ -440,12 +440,12 @@ f379be142cd234fb82c6b55ebb66e5ea94c184cb972cda576606d8dd872187ff starters/combi d1611476abf0bc8ab0e1d4ccfea0abaa232183aa144390d56a8cf00f7468d984 starters/custom-template.typecheck.json 79ba12d8b758c4bcabc7c79b357cebc584442d3f2a368c2142576ef6a95975dd starters/image-scam-analysis.typecheck.json 20868d9d3d3760a4f19cc5aa97ef6496dd3e076507d9eb1b333a8f5ea9691d1e starters/reviewed-scam-references.typecheck.json -e6ffc74de5d3e48bed4ab821bedee76b7d8e2a4df887df00a7a5d52b28c08354 tests/activity-connection.test.ts +a791c7dc4bbecce78b91dc309287b2533156f11be43742fcbb89cf8ec285d542 tests/activity-connection.test.ts 2257548bf1aa1de3113925dbdbd367db1211f5692ea6e0e69dae9830ef6ca611 tests/analytics.test.ts 977ac57a09795d86d5d5211ca91fa42db9e4f2b63442b0daadfb16025d42ef1c tests/compose.env f9c22e2e2b1b12d1463e433966978b3cffb6f57e6903aae9d97d2d47a13eaed7 tests/compose.validation.yml 67cd5970cfecef6cd92fb9b0be95c00bb106bcac1cc4a0f8f368aa95905ef4a8 tests/content.test.ts -8868687c82a15c5fe4ef3d1dc13bfa280b201769a9f21655d7c64c1c429423de tests/core.test.ts +f42285940327aa27a801178df3b9cdc4da8b1eb3cbe00ff72f0a0f3f7091c42e tests/core.test.ts 672cef009e0e6eacfc213d6ebadee6b8e5a5687ff0323ea7492d6ca12bae12c7 tests/evaluate.test.ts 3093de7974045706da57d933b802999a8642677b2d52e6048a4d4c8f276470e3 tests/exact-hash.test.ts 4b9b9b42437ba56db989269d4f46b5d25a9a54d89b70ed3cb15bcc66a0281366 tests/gateway.test.ts diff --git a/activity/src/connection.ts b/activity/src/connection.ts index b5fb3c9..7223039 100644 --- a/activity/src/connection.ts +++ b/activity/src/connection.ts @@ -16,6 +16,8 @@ export async function apiResponse(response: Response): Promise { return data as T; } export function connectionError(stage: string, error: unknown): Error { + if (error instanceof HttpError && error.status === 409 && error.message === 'bot_not_in_server') + return new Error('The TypeCheck bot is not in this server yet. A member with Manage Server needs to add it: open TypeCheck in a browser, click Add to your server, choose this server and authorize. Then click Connect in Discord.'); if (error instanceof HttpError && error.status === 403 && error.message === 'no_delegated_access') return new Error('You need permission to use TypeCheck in this server. Ask a server owner or member with Manage Server to open TypeCheck → Permissions and grant access to your Discord role, then click Connect in Discord. Server owners and members with Administrator or Manage Server have access automatically.'); const detail = error instanceof HttpError ? ` (HTTP ${error.status}: ${error.message}${error.reference ? `; reference ${error.reference}` : ''})` : diff --git a/server/discord.ts b/server/discord.ts index a4c2c1c..6646d89 100644 --- a/server/discord.ts +++ b/server/discord.ts @@ -184,8 +184,10 @@ export class DiscordRest implements DiscordAdapter { return auth.user.id; } async memberInfo(guild: string, user: string): Promise { - const g: DiscordGuild = await this.request(`/guilds/${guild}`); - const m: DiscordMember = await this.request(`/guilds/${guild}/members/${user}`); + // Discord answers 404 Unknown Guild when the bot is not installed; an Activity can still be launched there. + const notFound = (code: string, status: number) => (e: unknown) => { throw e instanceof AppError && e.code === 'discord_http_404' ? new AppError(code, status) : e; }; + const g: DiscordGuild = await this.request(`/guilds/${guild}`).catch(notFound('bot_not_in_server', 409)); + const m: DiscordMember = await this.request(`/guilds/${guild}/members/${user}`).catch(notFound('not_a_guild_member', 403)); assert(g.id === guild && m.user?.id === user, 'discord_membership_mismatch', 403); const permissions = guildPermissions(g, m); const administrator = (permissions & P.ADMIN) !== 0n; diff --git a/server/http.ts b/server/http.ts index a3a465b..e2a00ed 100644 --- a/server/http.ts +++ b/server/http.ts @@ -84,7 +84,8 @@ export function createHttp(services: Services, config: Config, root = process.cw if (v.at < now - 60000) limits.delete(k); } // Operational log lines carry codes, timings and request IDs only: never tokens, bodies or member data. - const log = (event: Record) => console.log(JSON.stringify(event)); + // The plain-text prefix keeps every field: the hosting log processor collapses bare JSON to a few keys. + const log = (event: Record) => console.log('TYPECHECK_EVENT_V1 ' + JSON.stringify(event)); // Starter references are copied after the sign-in reply. The copy normalizes 33 images in one transaction, // which can outlast the hosting/Discord proxy timeout and turn a completed sign-in into a 502. const seeding = new Map>(); diff --git a/tests/activity-connection.test.ts b/tests/activity-connection.test.ts index 382c04d..18eec1f 100644 --- a/tests/activity-connection.test.ts +++ b/tests/activity-connection.test.ts @@ -93,3 +93,9 @@ test('renewal permission or network failures do not start another OAuth flow', a await assert.rejects(f.connect(), /Session renewal failed.*server could not be reached/); assert.equal(f.calls.filter(c => c.startsWith('authorize:')).length, 1); }); +test('a server without the bot explains how to add it instead of reporting a server error', () => { + const error = connectionError('Authorization code exchange', new HttpError('bot_not_in_server', 409)); + assert.match(error.message, /bot is not in this server yet/); + assert.match(error.message, /Add to your server/); + assert.doesNotMatch(error.message, /HTTP 409/); +}); diff --git a/tests/core.test.ts b/tests/core.test.ts index f9147bc..7cb9436 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -98,3 +98,11 @@ test('message reader permission checks apply live channel overwrites and private deny = '0'; privateThread = true; await rest.assertCanReadMessage(G, MOD, CHANNEL); joined = false; await assert.rejects(rest.assertCanReadMessage(G, MOD, CHANNEL), /message_read_permission_required/); }); +test('Discord Unknown Guild means the bot is not installed, not an upstream failure', async () => { + const respond = (guild: number, member: number) => (async (url: string | URL) => String(url).includes('/members/') + ? Response.json(member === 404 ? { message: 'Unknown Member', code: 10007 } : { user: { id: MOD }, roles: [] }, { status: member }) + : Response.json(guild === 404 ? { message: 'Unknown Guild', code: 10004 } : { id: G, owner_id: '1', roles: [] }, { status: guild })) as typeof fetch; + await assert.rejects(new DiscordRest('bot', 'id', 'secret', respond(404, 200)).memberInfo(G, MOD), (e: unknown) => e instanceof AppError && e.code === 'bot_not_in_server' && e.status === 409); + await assert.rejects(new DiscordRest('bot', 'id', 'secret', respond(200, 404)).memberInfo(G, MOD), (e: unknown) => e instanceof AppError && e.code === 'not_a_guild_member' && e.status === 403); + await assert.rejects(new DiscordRest('bot', 'id', 'secret', respond(500, 200)).memberInfo(G, MOD), (e: unknown) => e instanceof AppError && e.code === 'discord_http_500' && e.status === 502); +});