diff --git a/SHA256SUMS b/SHA256SUMS index c13253c..7706121 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -20,8 +20,8 @@ ecd8294e24a1cd55cc1aa0b3c5d6cde68c956524cfb1d7707766b4f6287b5e3e activity/index 2c22d3a5e0aff57971932e24cbdf4dc93c12f589b060203cc20417fd97a75142 activity/legal/legal.css 6c8b74d4d57bff38a393ab038418c8f410627dc66721bc416905289110f151ac activity/legal/privacy.html 297c926eb462285e52e196af2d23f2ac70cb280324bfd30aeff74836e02f159c activity/legal/terms.html -d2c03ccd185041e743b25a9c000bececdd2fc6464ed66b78a4d9e390070dbf3d activity/src/connection.ts -8e1c4a409e52f6677d2fb51c3afabae5de8de05be606b83a44271c6d31f35992 activity/src/main.ts +ac5a46a57d2c4ba90ed861c840b7240c9367359bbb21c8167890a7a61211f783 activity/src/connection.ts +df6b8511d942c38a498fc3a74b404d92556736be37e519021bb613871c1c2984 activity/src/main.ts 8010da4164c86394863cb645eaf4567f066822114c40edf1d2762c1a5da3806a activity/src/onboarding.ts 66988416b7ab216ad9d3947ed528bfe9f2e45f74943971d506a686ff0b044508 activity/style.css 3fb468ea85cd082f252d85b34bb3da8b62a10ae4aa984b48ef93f0ecacc7017f data/scams/README.md @@ -440,7 +440,7 @@ f379be142cd234fb82c6b55ebb66e5ea94c184cb972cda576606d8dd872187ff starters/combi d1611476abf0bc8ab0e1d4ccfea0abaa232183aa144390d56a8cf00f7468d984 starters/custom-template.typecheck.json 79ba12d8b758c4bcabc7c79b357cebc584442d3f2a368c2142576ef6a95975dd starters/image-scam-analysis.typecheck.json 20868d9d3d3760a4f19cc5aa97ef6496dd3e076507d9eb1b333a8f5ea9691d1e starters/reviewed-scam-references.typecheck.json -a791c7dc4bbecce78b91dc309287b2533156f11be43742fcbb89cf8ec285d542 tests/activity-connection.test.ts +28dda44df880208b1aaf2fe7454563d866fc83ac8096b0f5e88cd7c3ef2261c8 tests/activity-connection.test.ts 2257548bf1aa1de3113925dbdbd367db1211f5692ea6e0e69dae9830ef6ca611 tests/analytics.test.ts 977ac57a09795d86d5d5211ca91fa42db9e4f2b63442b0daadfb16025d42ef1c tests/compose.env f9c22e2e2b1b12d1463e433966978b3cffb6f57e6903aae9d97d2d47a13eaed7 tests/compose.validation.yml diff --git a/activity/src/connection.ts b/activity/src/connection.ts index 7223039..2d991e7 100644 --- a/activity/src/connection.ts +++ b/activity/src/connection.ts @@ -15,9 +15,17 @@ export async function apiResponse(response: Response): Promise { } return data as T; } +// Bot permissions requested at install: monitoring, warnings and the delete/timeout/kick/ban actions the bot checks for. +export const INSTALL_PERMISSIONS = '1374389611526'; +export function installUrl(clientId: string, guildId?: string) { + const url = new URL('https://discord.com/oauth2/authorize'); + url.search = new URLSearchParams({ client_id: clientId, scope: 'bot applications.commands', permissions: INSTALL_PERMISSIONS, integration_type: '0', ...(guildId ? { guild_id: guildId, disable_guild_select: 'true' } : {}) }).toString(); + return url.href; +} +export class BotNotInServerError extends Error {} 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.'); + return new BotNotInServerError('The TypeCheck bot is not in this server yet. Connect in Discord only signs you in; it cannot add the bot. A member with Manage Server needs to click Add TypeCheck to this server, keep this server selected 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/activity/src/main.ts b/activity/src/main.ts index 97950f4..6240c22 100644 --- a/activity/src/main.ts +++ b/activity/src/main.ts @@ -123,7 +123,7 @@ async function packsPage(parent: HTMLElement) { import { TEXT_STARTERS } from '../../shared/text-starters.js'; import { TEXT_LIMITS, parseText } from '../../shared/text.js'; import type { HistoricalScan } from '../../shared/historical.js'; -import { activityAuth, apiResponse, HttpError, serializeConnection } from './connection.js'; +import { activityAuth, apiResponse, BotNotInServerError, HttpError, installUrl, serializeConnection } from './connection.js'; import { timeoutPreset } from '../../shared/domain.js'; import { CONTENT_PRESETS, CONTENT_VERSION, parseContent } from '../../shared/content.js'; import { money, type AnalyticsReport, type Bucket, type Attempt, type Rate } from '../../shared/analytics.js'; @@ -144,6 +144,9 @@ interface SDK { authenticate(v: { access_token: string; }): Promise; + openExternalLink(v: { + url: string; + }): Promise; }; } interface Metadata { @@ -216,6 +219,10 @@ function message(value: string, error = false) { if (status) { function fail(e: unknown) { const text = e instanceof Error ? pretty(e.message) : 'Request failed'; message(text, true); + // An Activity can run in a server without the bot; offer Discord's install screen with this server preselected. + if (e instanceof BotNotInServerError && sdk && guild && status) + status.append(h('div', { class: 'actions' }, button('Add TypeCheck to this server', () => sdk!.commands.openExternalLink({ url: installUrl(connectionClientId, guild) }))), + h('small', {}, 'Requires the Manage Server permission in this server. Server owners and members with Administrator have it; otherwise ask one of them to add TypeCheck.')); const open = Array.from(document.querySelectorAll('dialog[open]')).at(-1); if (open) { open.querySelector('.modal-error')?.remove(); @@ -718,11 +725,9 @@ async function start(autoConnect = true) { status.hidden = true; const inDiscord = new URLSearchParams(location.search).has('frame_id'); const showInstall = !preview && !inDiscord; - const installUrl = new URL('https://discord.com/oauth2/authorize'); - installUrl.search = new URLSearchParams({ client_id: config.clientId, scope: 'bot applications.commands', permissions: '1374389611526', integration_type: '0' }).toString(); app.append(h('main', { class: 'login' }, h('div', { class: 'login-brand' }, h('span', { class: 'brand-mark' }, 'J'), h('p', { class: 'eyebrow' }, 'JEV / TYPECHECK')), badge(preview ? 'OFFLINE PREVIEW' : 'EXPERIMENTAL MODERATION', 'amber'), h('h1', {}, 'Text + image moderation.\nUnder your control.'), h('p', { class: 'login-copy' }, 'Manage text policies, reference images, permissions, and credentials inside Discord. The backend bot handles continuous monitoring.'), h('div', { class: 'actions login-actions' }, - showInstall ? h('a', { class: 'button primary', href: installUrl.href, target: '_blank', rel: 'noopener noreferrer', 'aria-describedby': 'install-help' }, 'Add to your server') : null, + showInstall ? h('a', { class: 'button primary', href: installUrl(config.clientId), target: '_blank', rel: 'noopener noreferrer', 'aria-describedby': 'install-help' }, 'Add to your server') : null, button(preview ? 'Open read-only preview' : 'Connect in Discord', () => connect(config.clientId), showInstall ? 'quiet' : 'primary')), showInstall ? h('p', { class: 'install-help', id: 'install-help' }, 'Opens Discord in a new tab. You need Manage Server permission to install. Then open the TypeCheck Activity in your server; you can connect immediately using the same Manage Server permission to choose channels, add a Jev API key, and configure rules. No separate TypeCheck role is needed. Automatic moderation stays off until approved and enabled.') : null, status, h('p', { class: 'muted' }, 'Launch from a server Activity. Server owners and members with Administrator or Manage Server have access automatically. Other members need a TypeCheck role grant.'), diff --git a/tests/activity-connection.test.ts b/tests/activity-connection.test.ts index 18eec1f..ae39548 100644 --- a/tests/activity-connection.test.ts +++ b/tests/activity-connection.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { activityAuth, apiResponse, connectionError, HttpError } from '../activity/src/connection.js'; +import { activityAuth, apiResponse, BotNotInServerError, connectionError, HttpError, installUrl } from '../activity/src/connection.js'; test('missing TypeCheck role grant explains access requirements instead of retrying Discord authorization', () => { const error = connectionError('Authorization code exchange', new HttpError('no_delegated_access', 403)); @@ -96,6 +96,17 @@ test('renewal permission or network failures do not start another OAuth flow', a 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.ok(error instanceof BotNotInServerError); + assert.match(error.message, /only signs you in; it cannot add the bot/); + assert.match(error.message, /Add TypeCheck to this server/); assert.doesNotMatch(error.message, /HTTP 409/); }); +test('the install link requests moderation permissions and can preselect the launching server', () => { + const general = new URL(installUrl('111')); + assert.equal(general.searchParams.get('permissions'), '1374389611526'); + assert.equal(general.searchParams.get('scope'), 'bot applications.commands'); + assert.equal(general.searchParams.get('guild_id'), null); + const server = new URL(installUrl('111', '222')); + assert.equal(server.searchParams.get('guild_id'), '222'); + assert.equal(server.searchParams.get('disable_guild_select'), 'true'); +});