diff --git a/SHA256SUMS b/SHA256SUMS index 11ba42f..0fb91f2 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -20,10 +20,11 @@ ecd8294e24a1cd55cc1aa0b3c5d6cde68c956524cfb1d7707766b4f6287b5e3e activity/index 2c22d3a5e0aff57971932e24cbdf4dc93c12f589b060203cc20417fd97a75142 activity/legal/legal.css 6c8b74d4d57bff38a393ab038418c8f410627dc66721bc416905289110f151ac activity/legal/privacy.html 297c926eb462285e52e196af2d23f2ac70cb280324bfd30aeff74836e02f159c activity/legal/terms.html +83aaa4a638d927b803c08e84b30b1b09e4d152e325caf57583acb77d2e59ca35 activity/src/channel-selectors.ts ac5a46a57d2c4ba90ed861c840b7240c9367359bbb21c8167890a7a61211f783 activity/src/connection.ts -2e6808727e3230b32d41b1c37a3c50c1029d8a48edda6d43eddd20ad4cebeed1 activity/src/main.ts -4ebc81c7b8afd960ee6249ee60086606082e88240c1d7fbc05c30e125b75d307 activity/src/onboarding.ts -66988416b7ab216ad9d3947ed528bfe9f2e45f74943971d506a686ff0b044508 activity/style.css +c867e30a7ff9a3973c1da711e3f490d36f00486d83b90031cc8eeff9c7e52af3 activity/src/main.ts +70bb37e2c8ae4922d743719423c39724249d63cdb36535e724e37abaf2107339 activity/src/onboarding.ts +e27c8e4fcb06b80be53356b8c22494867e43dfe0816cd8ae008b2d01814a9b9a activity/style.css 3fb468ea85cd082f252d85b34bb3da8b62a10ae4aa984b48ef93f0ecacc7017f data/scams/README.md 2b1733d3ba70cd80dc5e91720f0deacf0fced2145e9af5d0e5f4029c50001694 data/scams/SHA256SUMS cbd96b7facc13c15deea51a8db245044bfba2b234d2573aa3cb638247ff0b82a data/scams/images/scam_03_cbd96b7f.jpg @@ -70,8 +71,8 @@ aec6cfde3d69458895924c9bf66e9a0f5831a8f4b18c31dbfef8a35899001531 data/scams/ima 38e139c05672e5fdf2db5eebfb1d1fc28ead5f014bc77ac65d005dda9f9a2429 db/mysql/004_packs.sql 10c485e37724a59c7fab34713b065a9dd271788b82a2a1eeb7242d001f8e6c79 docker-compose.yml c5fd8324e1e65527f7208a0fc0669ceab4cd4aab9d1dae517e46bbe754f3c101 docs/ACCEPTANCE.md -ce3922770e54865dab09b7fe5d3ac44018015595193e84f35c3018a14c1503cf docs/AGENT_ONBOARDING.md -e77b36946c2cd3e87400a8ef30821a9e620775034537e5a61b1d00fa408d0f78 docs/API.md +86a574d815a17151c47e25121bb3934d619ffcf7c256b3b72bb6c92aaf0fa57e docs/AGENT_ONBOARDING.md +e87992811040458e7261ef934b26961334aa1ca1061abe26ce05b9d4b6af161e docs/API.md 24861f458f5bba54f223f5903aefe9300793c154cd25958b7ac4f46072a8647a docs/CONTENT_ANALYSIS.md 9c81d1916b565043cc438787fc43b2e5719ff7aac78846f5d32ec7035fdb9d7d docs/COSTS.md 9ade67158bf9c08e1bde20d12401658a23c08df186871f869f32fa476dbd800f docs/DEPENDENCIES.md @@ -107,6 +108,9 @@ cbaaa4a0c8764151326e307e68769959b0b838e83870dca37bd221bd58a91593 docs/assets/pa 9f51edae070e5e17499d400697eedbef32b3c649a2ee16d33aaba077302daf3e evidence/browser-smoke.json 9f51edae070e5e17499d400697eedbef32b3c649a2ee16d33aaba077302daf3e evidence/browser-smoke.stdout.txt cd5753b7b637f976f1d2934f02a951331fba6f0cbae493c685a4c31c5e784005 evidence/build-attempt.txt +3ef9def3dc5e3dd0f903908ee587ee2ab2bb46ac44b6c7c8aa7a3d2969883cf5 evidence/channel-picker-browser.json +18095ab84e72c1593724e18e783ef64b1b021a3e245124230e30edd1492a9da7 evidence/channel-picker-dark-browser.json +d45811f4a4adc48ba88064266670ca0e0401b9e0ca3e831e101d7f217283d6dd evidence/channel-picker-validation.md 4404379e485bc76e1ac574effc6501012a4bb688830ed6bc881e302c84624957 evidence/claude-handoff.txt 588479fe70623fde7e67c3b5672e07cca655d8e9cd1b444f31d80db1144a4e07 evidence/claude-launch.json 8e6ac2c64a73048fbc317fd6e1d6d41f297404c4b7993bafb96b134ec3eaf0fe evidence/claude-live-status.json @@ -347,9 +351,10 @@ aca709e061483406ad4d148f406a228d7f0903d3d9a57b7c9a8e809539b791be package-lock.j 8fe23e9220e323d9645efa4ec5a9fdb0fa3af54aad8b12b236d14b336f66128a package.json 75a51a1edf0401ec598fc10d3de13092c89798b2fb98e9955bd82978e7591e99 planning/typesafe_theme/PROMPT.md 6072f6baa634eabd00a3c94d31fe4eb09b6536bbacf2c1b09a3defe917b4b249 scripts/benchmark_costs_test.py -46024b769c5f7d0438d9b94734abe5cb0892715182d7df2bdf7ed1f138ca1c4e scripts/browser-smoke.py +df6d3e89cc8d5fd122597c9d753e862e8625733a92d764c8035d8fbfcb6b3311 scripts/browser-smoke.py b04d6d317aa99ec83e08e5fe6bc799316b89e4990624c5c779af7eb53999792c scripts/build-starter-packs.mjs 14ff797db89891ca6e55eb27cbd39002ef6541f626fa649ef8bdcf134712094c scripts/bundle-sdk.mjs +e569cb02a830b91b559caa2eb5c12baf7f5090359fb7dd25a17f231dc7cec04d scripts/channel-picker-browser.mjs b959732b8d60eade13e147a7217fbdd043e6663ba91d8c3a8d578d2c99387d57 scripts/format-source.mjs 2b02528f27795107fe6b25d0d6c3c481ca85dfa8bed7680794aa285dae87acff scripts/generate-content-fixtures.mjs a86918d94a72b36c6b2fec7699945b9c82937ec1bcfcb92f8e00410af1b04a25 scripts/generate-fixtures.py @@ -360,7 +365,7 @@ c29b2921019f406d52ea97f99b24f5fb1b3d7d188bd5be54bf40a2b3ca60de58 scripts/kaggle d3454c1ef2f5bd1b871358485104dfb5b849c53f13d8b9b892cc0e2cdfbe2570 scripts/migration-receipt.mjs fbe41f990d155b158571ba0b8db5c5138f5cbbeb20ce6b84059aecb7d9f9b00a scripts/migration-receipt.test.mjs b2d7f56ef5420637227298102779b2b2e07d3c56b7dbbdc32ff1bb1c99107acb scripts/onboarding-browser-server.mjs -5699a82961b50dfbb0eabc02833b378a7447160c753af8e306aa749bca212d1d scripts/onboarding-browser.py +74cd5220191c15544a56687f63e8289fa2075f45b39eee02239a3f0a06d056c0 scripts/onboarding-browser.py 2c2432b67c1739d50bd339ba9d6504ace9a9f2bf20afa8deef67daef7110ed50 scripts/onboarding-live.mjs a9ba710e3953aebf3557647bda6447a807dca2e023f3dd588955ae6faf65edea scripts/pack-browser-workflow.js 071b1afae64f798864ce9cefcb95d4642a2b17e97d3f06efb1df235653a9c32a scripts/pack-browser.py @@ -368,7 +373,7 @@ a9ba710e3953aebf3557647bda6447a807dca2e023f3dd588955ae6faf65edea scripts/pack-b cd6086ef58cb792cbba53b22720fb262325cfc3f7ef1991ad3084a230416cba0 scripts/prepare-kaggle-benchmark.py a0ef941ecb2ffab8069cb7bbd5a65d986ac73a156150c88bfbeb6a93d48ad121 scripts/preview-cost-data.mjs 137a80462534c728edbcdb1a3a99d58b12085e73f168e6fc84b642ed80ba38dc scripts/preview-data.mjs -ad328f143a469015bef8257df90cfe9a43b2bb9d20ed4d2d86ecaeb94c89f6bd scripts/preview.mjs +a4c40fd2993d319c8244cdd1b3c6cf504924eea574da23abfa51cf6accbd760d scripts/preview.mjs 2c6a2e079315c17bd9440a6e8459f681e7e0f7096d47c847408dae6fb129d802 scripts/release-guard.mjs 75ed3eb1a7d030bdcda453db532d59509ba4ab4bf2506762a2f018f4b89d1717 scripts/release-guard.test.mjs c950b5c85188d4fc4a40be4e097e4e60dcd5bcd675059b02ce63108532f36704 scripts/release-source.mjs @@ -397,12 +402,12 @@ a44e797125685b375a99f8ea8311972906255b05cdc4df91ea9beb0c8860e185 scripts/verify b5e028d6a57cf4c6458f70c8411d2b95f561b48b212bf88cb8d04e3ea3efeeee server/content-evaluate-cli.ts c7678b37fef2837eece5de6da59b919a9d00bbec87c44c677930b5ad717ebd04 server/content.ts c3a5c6c6c01a10e2682f827d04f43fcb72f50fb8f3eb70690f28d78e130fd7b0 server/database.ts -bc116f5d23abe9b47bf28b328f360c5176b50eb4e8907783ba2f841f531db0cc server/discord.ts +a694df578b067610c3fecee5fa795079a171a0f6bf11cfc732449e521bc49352 server/discord.ts ba6fb59bda1bf219d1e09d787a52437bf4a0ae6bfabfbda2bcfb37bced886adc server/durable.ts 89e116491a077a347c18b0b0c6a2801f867f0005c224ab5ed6e2dd3d783c047a server/evaluate-cli.ts 6e00b4f2c76c2b68165105b9ca9a00dbc74183043d15ba4d5ffb82967b84130f server/gateway.ts f72b36e636d98f35f21d9e185aaf5ca7376ef20de2769e5c4a8a107022bcf832 server/historical.ts -af65a397602bd3b3907c59a542e44e8047010766016471696e926b839e29b48b server/http.ts +d1caddc1ebc77442d5be4445edad8bd3e182211852052c95be8781a8db8e424a server/http.ts 98a99e3407ace650bc8b1c9140d31548c378e0b2e1069e43dbed6325f5acf26b server/images.ts 6af6aa0e5917a1ec062d2076c6ae60a4cb94af546266e9fd4b746502a7a743a3 server/jev.ts b556bb54b61153d824e83a5a931c7031974736e065dc63633e259090c3e7eb0d server/leadership.ts @@ -410,7 +415,7 @@ b378f81be6a5ec3d533c525ed6e99669a848484639bcbff468912b8284d3c27c server/main.ts fafb6781d39a74e7c1cf3e1be1daf28ee71141cf93cdbd28d37a1327e2574b3c server/migrate.ts 37c04181dfa9f14b7dd6da07376b277831d6665c5e8500814d007255c7aa1968 server/mysql-pool.ts 3846c2120719c93d6c9d97c74f8760ee20540f95762c2e937add6df137fea6dd server/mysql.ts -cb4d6d5b69db9f50bec2740550b3834ff0008241d8e36086423995e3d7ba5929 server/onboarding.ts +90901119d5ca1f8de03d906ba4bafebbfc0eddb331be3b9b416e6efa9e91bd71 server/onboarding.ts 9ccf13b87d8bdbca1557edc7f8e3f7e6f104598a75c9c74f9801cb9224ac8dfd server/pack-starters.ts 65592be0bb260c0f821237187c78c52ed0c62f7d51e7b80094c0be38ffa1f542 server/packs.ts ee71b7b16b22b417ea8bf27c51aa7342e725070e4186f72fc80bf5d661121880 server/policy.ts @@ -426,6 +431,7 @@ b3a578846c1e5d503d63ac65fd098c78e42f984dda22bd086f3afdb18b875ca2 server/text-st 2a9e121432d479f8901b11594f0b48e62d9bd45c6fd3b58d797017d3388d8c0d server/usage-sql.ts 4a4258f350c742104aa4c33f9d889774e66466cc6b9d3ce017e3f7e1a9067d71 server/worker.ts dec2ef8a73677cdfccba0ba8bb59a8d578e921b314398cafda3c23765da5da59 shared/analytics.ts +5b642b8f6e2a23b300438cb1a6375cd8f67865cfb29ff059c99bc023bf9a3363 shared/channels.ts 73a43aba903b47d573c7ebd7ef966dcfe6d16da8f2420d3b894c72ba57b0a906 shared/content-metrics.ts 969336e4689745de39e5dd84dc16868bbd52181fe262ece8ee5aee215d767fe8 shared/content.ts cc655a15e777c5bb85cc16a93a2fc1cfb5008e6db654441ced48604bd3059df0 shared/domain.ts @@ -442,6 +448,7 @@ d1611476abf0bc8ab0e1d4ccfea0abaa232183aa144390d56a8cf00f7468d984 starters/custo 20868d9d3d3760a4f19cc5aa97ef6496dd3e076507d9eb1b333a8f5ea9691d1e starters/reviewed-scam-references.typecheck.json 28dda44df880208b1aaf2fe7454563d866fc83ac8096b0f5e88cd7c3ef2261c8 tests/activity-connection.test.ts 2257548bf1aa1de3113925dbdbd367db1211f5692ea6e0e69dae9830ef6ca611 tests/analytics.test.ts +3f636811cb93ccb0e24cd278e442b3145ed2fe3e258591151600ea05093ae8f5 tests/channels.test.ts 977ac57a09795d86d5d5211ca91fa42db9e4f2b63442b0daadfb16025d42ef1c tests/compose.env f9c22e2e2b1b12d1463e433966978b3cffb6f57e6903aae9d97d2d47a13eaed7 tests/compose.validation.yml 67cd5970cfecef6cd92fb9b0be95c00bb106bcac1cc4a0f8f368aa95905ef4a8 tests/content.test.ts diff --git a/activity/src/channel-selectors.ts b/activity/src/channel-selectors.ts new file mode 100644 index 0000000..7b8bdea --- /dev/null +++ b/activity/src/channel-selectors.ts @@ -0,0 +1,80 @@ +import { orderedChannelGroups, type ChannelMetadata } from '../../shared/channels.js'; + +export interface RoleMetadata { id: string; name: string; position?: number } + +function element(tag: K, text = '', className = '') { + const node = document.createElement(tag); + node.textContent = text; + node.className = className; + return node; +} + +function option(value: string, name: string) { + const node = element('option', name); + node.value = value; + return node; +} + +function channelOptions(channels: readonly ChannelMetadata[]) { + const select = element('select'); + for (const group of orderedChannelGroups(channels)) { + const section = element('optgroup'); + section.label = group.name; + for (const channel of group.channels) section.append(option(channel.id, channel.name || 'Unnamed channel')); + select.append(section); + } + return select; +} + +function multipleField(title: string, input: HTMLSelectElement, selected: readonly string[], onChange?: (values: string[]) => void) { + const node = element('label', '', 'field channel-picker'); + node.append(element('span', title, 'field-label'), input); + input.multiple = true; + input.size = 8; + for (const item of input.options) item.selected = selected.includes(item.value); + const values = () => [...input.selectedOptions].filter(item => !item.disabled).map(item => item.value); + input.addEventListener('change', () => onChange?.(values())); + return { node, input, values }; +} + +/** Category headings are optgroups; only text and announcement channels are selectable. */ +export function channelMultiSelect(title: string, channels: readonly ChannelMetadata[], selected: readonly string[] = [], onChange?: (values: string[]) => void) { + const input = channelOptions(channels); + const available = new Set([...input.options].map(item => item.value)); + const unavailable = selected.filter(id => !available.has(id)); + // A refresh must not silently erase a saved selection if Discord no longer returns it. + if (unavailable.length) { + const section = element('optgroup'); + section.label = 'Unavailable channels'; + for (const id of unavailable) section.append(option(id, `Unavailable channel (${id})`)); + input.append(section); + } + if (!input.options.length) { + const empty = option('', 'No text or announcement channels are available.'); + empty.disabled = true; + input.append(empty); + input.disabled = true; + } + return multipleField(title, input, selected, onChange); +} + +export function roleMultiSelect(title: string, roles: readonly RoleMetadata[], selected: readonly string[] = [], onChange?: (values: string[]) => void) { + const input = element('select'); + for (const role of [...roles].sort((a, b) => (b.position ?? 0) - (a.position ?? 0))) input.append(option(role.id, role.name)); + for (const id of selected.filter(id => !roles.some(role => role.id === id))) input.append(option(id, `Unavailable role (${id})`)); + if (!input.options.length) { + const empty = option('', 'No roles are available.'); + empty.disabled = true; + input.append(empty); + input.disabled = true; + } + return multipleField(title, input, selected, onChange); +} + +export function channelSelect(channels: readonly ChannelMetadata[], selected = '', emptyLabel = 'No log channel') { + const select = channelOptions(channels); + select.prepend(option('', emptyLabel)); + if (selected && ![...select.options].some(item => item.value === selected)) select.append(option(selected, `Unavailable channel (${selected})`)); + select.value = selected; + return select; +} diff --git a/activity/src/main.ts b/activity/src/main.ts index 6757580..eb0dd8e 100644 --- a/activity/src/main.ts +++ b/activity/src/main.ts @@ -1,5 +1,7 @@ import { canManageServer } from '../../shared/domain.js'; import { setupPanel } from './onboarding.js'; +import { channelMultiSelect, channelSelect, roleMultiSelect } from './channel-selectors.js'; +import type { ChannelMetadata } from '../../shared/channels.js'; import type { Pack, PackPreview, ImportOptions, PackKind } from '../../shared/packs.js'; async function editDefinition(d: any) { const name = input(d.name), content = structuredClone(d.content), body = h('div', { class: 'form-stack' }, field('Definition name', name)); @@ -155,12 +157,9 @@ interface Metadata { id: string; name: string; permissions: string; + position?: number; }[]; - channels: { - id: string; - name?: string; - type: number; - }[]; + channels: ChannelMetadata[]; } interface SettingsVersion { value: Settings; @@ -639,32 +638,20 @@ async function permissions(parent: HTMLElement) { throw new Error('This role has no grant'); await api(`grants/${role.value}`, 'DELETE', { revision: old.revision }); await render(); }, 'danger')))); parent.append(panel('Your effective permissions', `User ${authority!.userId} · ${admin() ? 'Server management access' : 'Delegated role access'}`, h('div', { class: 'cap-list' }, ...authority!.capabilities.map(c => badge(pretty(c)))))); } -function checklist(title: string, entries: { - id: string; - name: string; -}[], selected: string[]) { const boxes = new Map(); const node = h('div', { class: 'selector-list' }); for (const r of entries) { - const c = check(r.name, selected.includes(r.id)); - boxes.set(r.id, c.box); - node.append(c.node); -} return { node: field(title, node), values: () => [...boxes].filter(([, v]) => v.checked).map(([id]) => id) }; } async function settingsPage(parent: HTMLElement) { - settings = await api('settings'); + [settings, meta] = await Promise.all([api('settings'), api('meta')]); const s = structuredClone(settings.value); const textEnabled = check('Enable native message-text analysis', s.textModerationEnabled ?? false, 'Separate from image context. Only enabled, configured text rules run. Automatic actions additionally require current text calibration approval and global enforcement.'); parent.append(panel('Native text moderation', 'Disabled by default. Raw message text is refetched transiently and is not retained in jobs or decision evidence.', textEnabled.node)); const contentContext = check('Allow posting-message text as labeled context for content rules', s.contentMessageContext ?? false, 'Never includes history, private messages or unrelated attachments.'); parent.append(panel('Image content context', 'Opt-in applies only to rules configured to use the posting message.', contentContext.node)); - const channels = meta.channels.filter(c => [0, 5].includes(c.type)).map(c => ({ id: c.id, name: c.name ?? c.id })); - const monitored = checklist('Monitored channels', channels, s.channels), excluded = checklist('Excluded channels', channels, s.excludedChannels); + const monitored = channelMultiSelect('Monitored channels (multiple selection)', meta.channels, s.channels), excluded = channelMultiSelect('Excluded channels', meta.channels, s.excludedChannels); if (!admin()) { - parent.append(panel('Channel configuration', 'Monitoring uses exact channel IDs; no channel is monitored when none is selected.', h('div', { class: 'columns' }, monitored.node, excluded.node), button('Save channels', async () => { await api('channels', 'PUT', { channels: monitored.values(), excludedChannels: excluded.values(), revision: settings!.revision }); await render(); }))); + parent.append(panel('Channel configuration', 'Choose channels by server category. No channel is monitored when none is selected.', monitored.node, excluded.node, button('Save channels', async () => { await api('channels', 'PUT', { channels: monitored.values(), excludedChannels: excluded.values(), revision: settings!.revision }); await render(); }))); return; } const monitoring = check('Monitor selected channels', s.monitoring), enforcement = check('Enable automatic enforcement', s.enforcement), high = check('Allow kick and ban policies', s.highImpactEnabled), any = check('Allow explicit any-image rules', s.anyImageEnabled, 'These rules do not require a Jev match.'), semantic = check('Enable semantic tests and review-only rules', s.semanticEnabled), bots = check('Inspect other bots’ image attachments', s.processBots), webhooks = check('Inspect webhook messages', s.processWebhooks, 'Webhook messages can be deleted; no user-level punishment is applied to webhook identities.'), dm = check('Send optional direct-message notifications', s.dmNotify); - const exempt = checklist('Exempt Discord roles', meta.roles, s.exemptRoles), log = select([['', 'No log channel'], ...channels.map(c => [c.id, c.name] as [ - string, - string - ])], s.logChannel ?? ''), daily = input(String(s.dailyCallLimit), 'number'), retention = input(String(s.logRetentionDays), 'number'), model = input(s.model), uncertain = select([['review', 'Add uncertain matches to review'], ['ignore', 'Ignore uncertain matches']], s.uncertain); + const exempt = roleMultiSelect('Exempt Discord roles', meta.roles, s.exemptRoles), log = channelSelect(meta.channels, s.logChannel ?? ''), daily = input(String(s.dailyCallLimit), 'number'), retention = input(String(s.logRetentionDays), 'number'), model = input(s.model), uncertain = select([['review', 'Add uncertain matches to review'], ['ignore', 'Ignore uncertain matches']], s.uncertain); const thresholds = new Map(); const thresholdGrid = h('div', { class: 'threshold-grid' }); for (const action of ACTIONS) { @@ -675,7 +662,7 @@ async function settingsPage(parent: HTMLElement) { thresholds.set(action, el); thresholdGrid.append(field(action === 'review' ? 'Human-review threshold' : `${pretty(action)} action floor`, el)); } - parent.append(panel('Monitoring & enforcement', 'Default: no monitored channels, no automatic actions. Source images are transient and not retained.', h('div', { class: 'columns' }, h('div', { class: 'form-stack' }, monitoring.node, enforcement.node, high.node, any.node, semantic.node), h('div', { class: 'form-stack' }, bots.node, webhooks.node, dm.node, field('Uncertain result policy', uncertain))), h('div', { class: 'columns' }, monitored.node, excluded.node), exempt.node)); + parent.append(panel('Monitoring & enforcement', 'Default: no monitored channels, no automatic actions. Source images are transient and not retained.', h('div', { class: 'columns' }, h('div', { class: 'form-stack' }, monitoring.node, enforcement.node, high.node, any.node, semantic.node), h('div', { class: 'form-stack' }, bots.node, webhooks.node, dm.node, field('Uncertain result policy', uncertain))), monitored.node, excluded.node, exempt.node)); parent.append(panel('Matching, usage & retention', 'Action-specific floors combine with the rule threshold. Changing model or threshold settings makes prior approvals stale. Valid range: 0.5-1.0 inclusive; timeout <= kick <= ban. Standard 0.70 and Strict 0.90 are starting points, not accuracy claims.', h('div', { class: 'form-row' }, field('Pinned Jev model', model), field('Daily request limit (UTC)', daily), field('Log retention, days', retention)), field('Moderator log channel', log), thresholdGrid, h('p', {}, 'Jev automation: a comparison must reach the greater of its rule threshold and action floor. Equality qualifies; approvals, verification, permissions and all execution safeguards still apply.'), h('p', {}, 'Jev human review: when the uncertain result policy is review and no rule qualifies for its action, comparisons at or above the human-review threshold but below their effective action threshold enter Logs as open moderation events. Below the review threshold, no fallback review is created. Ignore disables this fallback; explicit review rules still apply.'), diff --git a/activity/src/onboarding.ts b/activity/src/onboarding.ts index ff78457..bee7000 100644 --- a/activity/src/onboarding.ts +++ b/activity/src/onboarding.ts @@ -1,5 +1,7 @@ import { canManageServer } from '../../shared/domain.js'; import { ONBOARDING_PRESETS, ONBOARDING_NOTICE, type SetupPlan } from '../../shared/onboarding.js'; +import type { ChannelMetadata } from '../../shared/channels.js'; +import { channelMultiSelect, channelSelect, roleMultiSelect, type RoleMetadata } from './channel-selectors.js'; type Api = (path: string, method?: string, body?: unknown) => Promise; export interface BrowserTool { name: string; description: string; inputSchema: unknown; execute(input: any): Promise; annotations: {readOnlyHint: boolean; untrustedContentHint: boolean} } @@ -45,6 +47,8 @@ function el(tag: string, text = '', cls = '') { const e=document.createElement(t // Inside Discord, openExternal opens the dashboard through the SDK; a plain link does nothing in the Activity iframe. export function setupPanel(api: Api, onPlan?: ()=>void, dashboardUrl = '/setup', openExternal?: (url: string)=>unknown) { let target: HTMLElement | undefined, plan: any, supported = false, active = true; + let metadata: {channels: ChannelMetadata[]; roles: RoleMetadata[]} | undefined, inventoryLoading = false, inventoryError = ''; + const draft = { presets: [] as string[], channels: [] as string[], excludedChannels: [] as string[], exemptRoles: [] as string[], logChannel: '', customizations: '{}' }; const controller = new AbortController(); const stop = () => { active=false; controller.abort(); clearTimeout(expiry); clearInterval(poll); supported=false; if(target)target.prepend(el('p','Session ended or authority changed. Reconnect to continue.','notice error')); }; const guardedApi: Api = async (...args) => { if(!active)throw new Error('session_expired'); try{const result=await api(...args);if(args[0]==='me'&&!canManageServer(result)){stop();throw new Error('server_management_required');}return result;}catch(e){ if(e && typeof e==='object' && 'status' in e && [401,403].includes(Number(e.status)))stop();throw e; } }; @@ -53,19 +57,46 @@ export function setupPanel(api: Api, onPlan?: ()=>void, dashboardUrl = '/setup', const poll=setInterval(()=>{void guardedApi('me').then(a=>{if(!canManageServer(a))stop();}).catch(()=>stop());},30000); const actions = (label:string, run:()=>Promise) => {const b=el('button',label,'button primary') as HTMLButtonElement;b.type='button';b.onclick=async()=>{b.disabled=true;try{await run();}catch(e){target?.append(el('p',e instanceof Error?e.message:'Request failed','notice error'));}finally{b.disabled=false;}};return b;}; const pretty=(v: unknown)=>JSON.stringify(v,null,2); + async function loadInventory() { + inventoryLoading = true; inventoryError = ''; draw(); + try { + // /meta returns the complete guild inventory, including category headings. + const result = await guardedApi('meta'); + if (active) metadata = result; + } catch (e) { + if (active) inventoryError = 'Could not load channels and roles. Refresh to try again.'; + } finally { + inventoryLoading = false; + if (active) draw(); + } + } function draw() { - if(!target)return;target.replaceChildren(); + if(!target || !active)return;target.replaceChildren(); target.append(el('p',supported?'Browser tools registered. Discovery by your agent still depends on browser support and iframe policy.':openExternal?'Set up TypeCheck with the planner below. To let an AI browser agent help instead, open the browser dashboard and sign in there; Discord does not let agents use tools inside Activities.':'Set up TypeCheck with the planner below. AI browser agent tools (WebMCP) are not available in this browser.','notice')); if(openExternal)target.append(actions('Open browser dashboard',async()=>{await openExternal(dashboardUrl);})); else{const external=el('a','Open browser dashboard','button quiet') as HTMLAnchorElement;external.href=dashboardUrl;external.target='_blank';external.rel='noopener noreferrer';target.append(external);} target.append(el('p',ONBOARDING_NOTICE,'muted')); const form=el('div','','panel form-stack'), chosen: HTMLInputElement[]=[]; - for(const preset of ONBOARDING_PRESETS){const label=el('label','','check'), box=document.createElement('input');box.type='checkbox';box.value=preset.id;chosen.push(box);const copy=el('span',preset.name);copy.append(el('small',preset.purpose+' Components: '+preset.components.join(', ')));label.append(box,copy);form.append(label);} + for(const preset of ONBOARDING_PRESETS){const label=el('label','','check'), box=document.createElement('input');box.type='checkbox';box.value=preset.id;box.checked=draft.presets.includes(preset.id);chosen.push(box);box.addEventListener('change',()=>{draft.presets=chosen.filter(b=>b.checked).map(b=>b.value);});const copy=el('span',preset.name);copy.append(el('small',preset.purpose+' Components: '+preset.components.join(', ')));label.append(box,copy);form.append(label);} const field=(label:string,value:string)=>{const node=el('label',label,'field'), input=document.createElement('textarea');input.value=value;node.append(input);form.append(node);return input;}; - const channels=field('Add monitored channel IDs (comma separated)',''),excluded=field('Add excluded channel IDs',''),exempt=field('Add exempt role IDs',''),log=field('Log channel ID (empty keeps current destination)',''); - const custom=field('Optional customization JSON keyed by text component ID (name, question, positive, negative, exceptions)','{}'); - const split=(v:string)=>v.split(',').map(s=>s.trim()).filter(Boolean); - form.append(actions('Preview configuration',async()=>show(await guardedApi('onboarding/plans','POST',{presets:chosen.filter(b=>b.checked).map(b=>b.value),channels:split(channels.value),excludedChannels:split(excluded.value),exemptRoles:split(exempt.value),logChannel:log.value.trim()||null,customizations:JSON.parse(custom.value)}))));target.append(form); + form.append(el('p','Choose one or more channels. Use Ctrl (Windows) or Command (Mac) to add individual selections, or Shift to select a range. Channels follow the server’s category order. These selections add to the current configuration.','muted')); + if(inventoryLoading)form.append(el('p','Loading channels and roles…','notice')); + if(inventoryError)form.append(el('p',inventoryError,'notice error')); + if(metadata) { + const channels=channelMultiSelect('Selected channels (multiple selection)',metadata.channels,draft.channels,values=>{draft.channels=values;}); + const excluded=channelMultiSelect('Excluded channels',metadata.channels,draft.excludedChannels,values=>{draft.excludedChannels=values;}); + const exempt=roleMultiSelect('Exempt roles',metadata.roles,draft.exemptRoles,values=>{draft.exemptRoles=values;}); + const logLabel=el('label','','field'), log=channelSelect(metadata.channels,draft.logChannel,'Keep current log destination'); + log.addEventListener('change',()=>{draft.logChannel=log.value;}); + logLabel.append(el('span','Log channel','field-label'),log); + form.append(channels.node,excluded.node,exempt.node,logLabel); + } + const refresh=actions('Refresh channels and roles',loadInventory);refresh.disabled=inventoryLoading;refresh.className='button quiet';form.append(refresh); + const custom=field('Optional customization JSON keyed by text component ID (name, question, positive, negative, exceptions)',draft.customizations); + custom.addEventListener('input',()=>{draft.customizations=custom.value;}); + const preview=actions('Preview configuration',async()=>show(await guardedApi('onboarding/plans','POST',{presets:draft.presets,channels:draft.channels,excludedChannels:draft.excludedChannels,exemptRoles:draft.exemptRoles,logChannel:draft.logChannel||null,customizations:JSON.parse(draft.customizations)}))); + preview.disabled=inventoryLoading || Boolean(inventoryError) || !metadata?.channels.some(c=>[0,5].includes(c.type)); + form.append(preview);target.append(form); if(plan){ target.append(el('h2','Proposed configuration'),el('p',`Plan ${plan.id} · ${plan.status} · expires ${new Date(plan.expiresAt).toLocaleString()}`)); const diff=el('div','','setup-diff');for(const [label,value]of [['Before',plan.before],['After',plan.after]] as const){const section=el('section','','panel');section.append(el('h3',label),el('pre',pretty(value)));diff.append(section);}target.append(diff); @@ -82,5 +113,6 @@ export function setupPanel(api: Api, onPlan?: ()=>void, dashboardUrl = '/setup', } } void registerTools((document as Document & {modelContext?:ModelContext}).modelContext, guardedApi,show,controller.signal).then(ok=>{supported=ok;draw();}).catch(()=>{controller.abort();supported=false;draw();}); + void loadInventory(); return {mount(parent:HTMLElement){target=parent;draw();},stop}; } diff --git a/activity/style.css b/activity/style.css index 838f77d..1bd4752 100644 --- a/activity/style.css +++ b/activity/style.css @@ -217,6 +217,10 @@ td>div>strong:first-child{display:inline-block;background:var(--pink);color:var( .field small{font-size:.72rem} .field input,.field select,.field textarea,select{width:100%;min-height:40px;background:var(--field);border:1px solid var(--line);color:var(--text);padding:8px 10px;font-size:.82rem} .field textarea{font-family:var(--font-display)} +.channel-picker select[multiple]{height:164px;min-height:164px;padding:2px 8px;overflow:auto} +.channel-picker option{padding:1px 4px} +.channel-picker optgroup{font-style:normal} +.channel-picker select:disabled{opacity:.65} input[type=file]{font-size:.74rem;padding:6px} input::file-selector-button{background:var(--inverse);border:1px solid var(--line);color:var(--inverse-text);padding:5px 10px;margin-right:10px;font:inherit;cursor:pointer} .form-row{display:flex;align-items:end;gap:15px;margin-bottom:12px} diff --git a/docs/AGENT_ONBOARDING.md b/docs/AGENT_ONBOARDING.md index 5b1eb73..c7ebbcb 100644 --- a/docs/AGENT_ONBOARDING.md +++ b/docs/AGENT_ONBOARDING.md @@ -41,11 +41,11 @@ Combinations union stable component IDs; repeated IDs install once. Per-componen ## Manual completion and validation -Without WebMCP, the same planner works with preview, review acknowledgement, acceptance and application buttons. Server managers can also use existing Settings, Image library and Rules & tests entirely manually. Channel/role IDs in the planner can be copied from Discord Developer Mode; agents can retrieve bounded destination metadata from inventory. Missing credentials stay in the user-facing Settings flow. +Without WebMCP, the same planner works with preview, review acknowledgement, acceptance and application buttons. Server managers can also use existing Settings, Image library and Rules & tests entirely manually. The planner lists channels by their Discord categories and server order in scrollable multiple-selection lists, including uncategorized channels. Choose monitored channels, exclusions and exempt roles by name; use Ctrl (Windows) or Command (Mac) for individual selections, or Shift for a range. The log destination is a single named channel selector with the same category grouping; leave **Keep current log destination** selected to preserve it. **Refresh channels and roles** reloads the complete server inventory without clearing the draft. Settings uses the same channel lists. Only supported text and announcement channels are selectable; category headings are not destinations. Agents can retrieve paginated destination metadata, including category IDs and positions, from inventory. Missing credentials stay in the user-facing Settings flow. Real standalone Discord OAuth, live administrator authority, native discovery/planning, explicit user acceptance, application of nine disabled review rules to isolated local SQL, receipt recovery, pause and logout cleanup also passed. The provider test was refused without a configured credential; no paid call ran. The Activity probe touched no application services and did not modify parent policy. See [sanitized live evidence](../evidence/issue44-live.json). -See [validation evidence](../evidence/issue44-validation.md) for exact commands and unresolved live gates. The screenshots below show the implemented UI with synthetic adapters, not mockup designs and not live integration evidence. +See [channel-selection validation](../evidence/channel-picker-validation.md) for the named selection controls and [validation evidence](../evidence/issue44-validation.md) for the original onboarding commands and unresolved live gates. The screenshots below show the implemented UI with synthetic adapters, not mockup designs and not live integration evidence. ![Offline synthetic starter picker with all four compositions and editable destinations.](../evidence/issue44-starters-desktop.png) ![Offline synthetic before/after configuration plan and disabled rule definitions.](../evidence/issue44-plan-desktop.png) diff --git a/docs/API.md b/docs/API.md index b67ca82..924ef35 100644 --- a/docs/API.md +++ b/docs/API.md @@ -155,7 +155,7 @@ All routes below are under `/api/g/:guild/onboarding/` and require a guild-bound | Method/suffix | Input/result | |---|---| | GET `context` | User/guild, safe credential status, advisory readiness/blockers/remaining actions | -| GET `inventory?offset=0` | Up to 50 channels/roles/rules/groups each, individual next offsets, settings/reference count, no paths | +| GET `inventory?offset=0` | Up to 50 channels/roles/rules/groups each, individual next offsets, settings/reference count, no paths; channels retain `parent_id` and `position` for server category ordering | | GET `starters` | Four compositions, canonical text definitions and requirements/cost notice | | POST `plans` | Strict `{presets,channels,excludedChannels,exemptRoles,logChannel,customizations}`; returns ten-minute exact before/after plan with additions/skips/digest | | POST `plan` | `{planId,digest}`; fresh plan or persisted applied receipt | diff --git a/evidence/channel-picker-browser.json b/evidence/channel-picker-browser.json new file mode 100644 index 0000000..ad34e63 --- /dev/null +++ b/evidence/channel-picker-browser.json @@ -0,0 +1,44 @@ +{ + "scope": "Offline compiled UI, real isolated HTTP and in-memory transactions. Synthetic Discord/provider and asynchronous WebMCP registry. No live Discord or provider acceptance claimed.", + "browser": "153.0.8010.0", + "results": [ + { + "viewport": "desktop", + "eligibleChannels": 63, + "verified": [ + "server category/channel ordering", + "uncategorized/orphaned channels", + "text and announcement types only", + "native multiple selection", + "escaped category/channel/role names", + "complete inventory beyond 50 channels", + "exact preview payload", + "draft retained across async tool registration, metadata refresh, HTTP failure, preview and navigation", + "saved Settings round trip", + "no horizontal viewport overflow" + ] + }, + { + "viewport": "mobile", + "eligibleChannels": 63, + "verified": [ + "server category/channel ordering", + "uncategorized/orphaned channels", + "text and announcement types only", + "native multiple selection", + "escaped category/channel/role names", + "complete inventory beyond 50 channels", + "exact preview payload", + "draft retained across async tool registration, metadata refresh, HTTP failure, preview and navigation", + "saved Settings round trip", + "no horizontal viewport overflow" + ] + }, + { + "inventory": "empty and HTTP error states; disabled preview after failure; complete retry recovery; selected draft survives failure" + } + ], + "pageErrors": [], + "externalRequests": [], + "passed": true +} diff --git a/evidence/channel-picker-dark-browser.json b/evidence/channel-picker-dark-browser.json new file mode 100644 index 0000000..669e22c --- /dev/null +++ b/evidence/channel-picker-dark-browser.json @@ -0,0 +1,37 @@ +{ + "scope": "Offline compiled UI, real isolated HTTP and in-memory transactions. Synthetic Discord/provider and asynchronous WebMCP registry. No live Discord or provider acceptance claimed.", + "browser": "153.0.8010.0", + "results": [ + { + "viewport": "desktop", + "width": 1440, + "height": 1000, + "colorScheme": "dark", + "eligibleChannels": 63, + "verified": [ + "native list rendering", + "category grouping and complete inventory", + "selected and excluded states", + "no horizontal viewport overflow" + ], + "screenshot": "channel-picker-dark-desktop.png" + }, + { + "viewport": "mobile", + "width": 390, + "height": 844, + "colorScheme": "dark", + "eligibleChannels": 63, + "verified": [ + "native list rendering", + "category grouping and complete inventory", + "selected and excluded states", + "no horizontal viewport overflow" + ], + "screenshot": "channel-picker-dark-mobile.png" + } + ], + "pageErrors": [], + "externalRequests": [], + "passed": true +} diff --git a/evidence/channel-picker-validation.md b/evidence/channel-picker-validation.md new file mode 100644 index 0000000..3c7dc88 --- /dev/null +++ b/evidence/channel-picker-validation.md @@ -0,0 +1,31 @@ +# Category-grouped channel selection validation + +The Agent setup planner and Settings use named, scrollable multiple-selection lists for monitored and excluded channels. Category headings and channel order come from Discord `parent_id` and `position`, with exact numeric ID ordering for position ties. The log destination is a single grouped channel selector; exempt roles use a named multiple-selection list. + +## Local checks + +- `npm run check`: passed. +- `npm run build`: passed. +- `npm test`: **388 tests passed**, with no failures or skipped tests. The HTTP tests used isolated loopback servers and synthetic adapters. +- Five new Node tests cover server ordering, large ID ties, missing parent/position metadata, Discord metadata preservation, and paginated onboarding inventory with category selection rejection. +- The compiled UI browser regression passed in Chromium **153.0.8010.0** at **1440 x 1000** and **390 x 844**. A focused dark-theme run also passed at both sizes; the native selected/excluded lists were visually compared with the requested control style. + +The browser run used the real HTTP handlers, OAuth state/session flow, planner, and Settings persistence against isolated in-memory data. Discord, the model provider, and delayed WebMCP registration were synthetic. It verified 63 eligible channels across multiple categories, including entries beyond the paginated agent inventory's first 50; exact submitted selection IDs; category/channel/role name escaping; preserved drafts after tool registration, refresh, failed requests, preview, and navigation; Settings save/reload; empty and failed inventory recovery; and no horizontal viewport overflow or page errors. No external Discord/provider requests ran. + +The full browser report is [channel-picker-browser.json](channel-picker-browser.json), and the focused dark-theme report is [channel-picker-dark-browser.json](channel-picker-dark-browser.json). Reproduce after building: + +```sh +node scripts/channel-picker-browser.mjs +node scripts/channel-picker-browser.mjs --render-dark +``` + +Playwright must be available to the validation environment; it is not a production dependency. `CHROMIUM_EXECUTABLE` optionally selects an installed Chromium binary, and `CHANNEL_PICKER_EVIDENCE_DIR` selects an output directory. This local browser harness is separate from GitHub CI, which runs the Node test suite and its existing database/container checks. + +## Review + +Independent source review covered grouping and selectable types, draft retention, safe rendering, authenticated metadata access, and release-scoped static module imports. The existing concatenated browser smoke script was updated to include the new selector dependencies, and the standalone onboarding smoke script now uses native listbox/combobox controls. + +These are implementation and isolated integration results. They do not certify a live Discord guild, native browser-agent discovery, or production deployment. + + +Generated browser captures are omitted from this portable source checkpoint. Run the documented browser harness to recreate them. The implementation, tests, and recorded results are preserved. diff --git a/scripts/browser-smoke.py b/scripts/browser-smoke.py index f5dd871..92b9e11 100644 --- a/scripts/browser-smoke.py +++ b/scripts/browser-smoke.py @@ -15,6 +15,8 @@ async def main(): connection=re.sub(r'^export ', '', (root/'dist/activity/src/connection.js').read_text(encoding='utf-8'), flags=re.M) text_module=re.sub(r'^import .*?;\n|^export ', '', (root/'dist/shared/text.js').read_text(encoding='utf-8'), flags=re.M) text_starters=re.sub(r'^import .*?;\n|^export ', '', (root/'dist/shared/text-starters.js').read_text(encoding='utf-8'), flags=re.M) + channels=re.sub(r'^import .*?;\n|^export ', '', (root/'dist/shared/channels.js').read_text(encoding='utf-8'), flags=re.M) + channel_selectors=re.sub(r'^import .*?;\n|^export ', '', (root/'dist/activity/src/channel-selectors.js').read_text(encoding='utf-8'), flags=re.M) frontend=(root/'dist/activity/src/main.js').read_text(encoding='utf-8') frontend=re.sub(r'^import .*?;\n','',frontend,flags=re.M) analytics=(root/'dist/shared/analytics.js').read_text(encoding='utf-8') @@ -53,7 +55,7 @@ async def main(): page=await browser.new_page(viewport={'width':w,'height':h},device_scale_factor=1) page.on('pageerror',lambda e:errors.append(str(e))) await page.set_content('
') - await page.add_script_tag(content=connection+'\n'+domain+'\n'+content+'\n'+text_module+'\n'+text_starters+'\n'+analytics+'\n'+mock+'\n'+frontend,type='module') + await page.add_script_tag(content=connection+'\n'+domain+'\n'+content+'\n'+text_module+'\n'+text_starters+'\n'+analytics+'\n'+channels+'\n'+channel_selectors+'\n'+mock+'\n'+frontend,type='module') await page.get_by_role('button',name='Open read-only preview').click() await page.get_by_role('heading',name='Recent moderation events').wait_for() await page.screenshot(path=str(root/'evidence'/f'{prefix}preview-{label}.png'),full_page=True) diff --git a/scripts/channel-picker-browser.mjs b/scripts/channel-picker-browser.mjs new file mode 100644 index 0000000..82d848e --- /dev/null +++ b/scripts/channel-picker-browser.mjs @@ -0,0 +1,370 @@ +// Compiled UI + real isolated HTTP/MemoryStore, with synthetic Discord/provider adapters. +// Run `npm run build`, then `node scripts/channel-picker-browser.mjs`. +// Add `--render-dark` for focused dark-theme list screenshots only. +// Playwright may be installed externally; no production browser dependency is required. +// Optional: CHROMIUM_EXECUTABLE, CHANNEL_PICKER_EVIDENCE_DIR, CODEX_PRIMARY_RUNTIME_NODE_MODULES. +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { createServer } from 'node:net'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { fixture, G, ROLE } from '../dist/tests/helpers.js'; +import { AppError } from '../dist/shared/domain.js'; +import { createHttp } from '../dist/server/http.js'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const require = createRequire(import.meta.url); +const paths = [root, process.env.CODEX_PRIMARY_RUNTIME_NODE_MODULES].filter(Boolean); +const { chromium } = require(require.resolve('playwright', { paths })); +const evidence = resolve(process.env.CHANNEL_PICKER_EVIDENCE_DIR ?? join(root, 'evidence')); +const darkOnly = process.argv.includes('--render-dark'); +const snowflake = n => String(100000000000000000n + BigInt(n)); +const ids = { + uncategorized: snowflake(21), orphan: snowflake(22), weird: snowflake(23), + announcement: snowflake(24), excluded: snowflake(25), voice: snowflake(26), + forum: snowflake(27), thread: snowflake(28), + alpha: snowflake(101), beta: snowflake(102), missing: snowflake(103), + weirdRole: snowflake(32), +}; +const names = { + alpha: 'Welcome <&> "lobby"', beta: 'Support & operations', + weird: ' & "quoted"', + weirdRole: 'Helpers & friends', +}; +const extraChannels = Array.from({ length: 58 }, (_, i) => ({ + id: snowflake(200 + i), name: `support-${String(i + 1).padStart(2, '0')}`, + type: 0, parent_id: ids.beta, position: 20 + i, guild_id: G, +})); +const farChannel = extraChannels.at(-1).id; +const inventory = { + name: 'OFFLINE SYNTHETIC SERVER — channel-selection regression', + roles: [ + { id: G, name: '@everyone', position: 0, permissions: '0' }, + { id: ROLE, name: 'Image moderator', position: 1, permissions: '0' }, + { id: ids.weirdRole, name: names.weirdRole, position: 2, permissions: '0' }, + ], + // Deliberately scramble transport order. Server positions, not alphabetic/input order, + // determine category and channel order. Include unsupported kinds and an orphan. + channels: [ + ...extraChannels.toReversed(), + { id: ids.beta, name: names.beta, type: 4, position: 8, guild_id: G }, + { id: ids.excluded, name: 'private-discussion', type: 0, parent_id: ids.beta, position: 1, guild_id: G }, + { id: ids.announcement, name: 'announcements', type: 5, parent_id: ids.alpha, position: 3, guild_id: G }, + { id: ids.voice, name: 'voice-only', type: 2, parent_id: ids.alpha, position: 0, guild_id: G }, + { id: ids.weird, name: names.weird, type: 0, parent_id: ids.alpha, position: 1, guild_id: G }, + { id: ids.alpha, name: names.alpha, type: 4, position: 2, guild_id: G }, + { id: ids.orphan, name: 'orphaned-text', type: 0, parent_id: ids.missing, position: 7, guild_id: G }, + { id: ids.uncategorized, name: 'general', type: 0, parent_id: null, position: 0, guild_id: G }, + { id: ids.forum, name: 'forum-only', type: 15, parent_id: ids.alpha, position: 4, guild_id: G }, + { id: ids.thread, name: 'thread-only', type: 11, parent_id: ids.uncategorized, position: 5, guild_id: G }, + ], +}; +const expectedOrder = [ids.uncategorized, ids.orphan, ids.weird, ids.announcement, ids.excluded, ...extraChannels.map(c => c.id)]; +const cleanup = []; +const f = await fixture({ after: fn => cleanup.push(fn) }); +let inventoryMode = 'full', metadataGate = null, failNextPlan = false; +f.discord.metadata = async () => { + if (metadataGate) await metadataGate; + if (inventoryMode === 'error') throw new AppError('synthetic_metadata_unavailable', 502); + return structuredClone(inventoryMode === 'empty' ? { ...inventory, channels: [], roles: [] } : inventory); +}; + +// Reserve an unused loopback port before constructing the immutable allowed-origin set. +const probe = createServer(); +probe.listen(0, '127.0.0.1'); +await once(probe, 'listening'); +const port = probe.address().port; +await new Promise(resolve => probe.close(resolve)); +const origin = `http://127.0.0.1:${port}`; +const config = { + port, production: false, publicOrigin: origin, clientId: G, + clientSecret: 'synthetic-only', botToken: 'synthetic-only', databaseUrl: 'unused', + masterKey: 'unused', objectDir: 'unused', model: 'jev-1.14.0', apiOnly: true, +}; +const server = createHttp(f.services, config); +const handler = server.listeners('request')[0]; +server.removeAllListeners('request'); +server.on('request', (request, response) => { + if (failNextPlan && request.method === 'POST' && request.url === `/api/g/${G}/onboarding/plans`) { + failNextPlan = false; + request.resume(); + response.writeHead(503, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'synthetic_plan_failure' })); + return; + } + handler(request, response); +}); +server.listen(port, '127.0.0.1'); +await once(server, 'listening'); + +const reports = [], errors = [], externalRequests = []; +let browser; +async function contextFor(viewport, delayedTools = false) { + const context = await browser.newContext({ viewport, deviceScaleFactor: 1, colorScheme: darkOnly ? 'dark' : 'light' }); + // Only the OAuth authorize redirect is simulated. State, cookies, code exchange, + // authentication and every application request use the real isolated HTTP service. + await context.route('**/*', async route => { + const url = new URL(route.request().url()); + if (url.origin === origin) return route.continue(); + if (url.origin === 'https://discord.com' && url.pathname === '/oauth2/authorize') { + return route.fulfill({ status: 302, headers: { + Location: `${origin}/api/auth/browser/callback?code=admin-code&state=${encodeURIComponent(url.searchParams.get('state'))}`, + } }); + } + externalRequests.push(url.origin + url.pathname); + return route.abort(); + }); + if (delayedTools) { + await context.addInitScript(() => { + const tools = new Map(); + let release; + const ready = new Promise(resolve => { release = resolve; }); + window.__channelPickerTools = tools; + window.__releaseChannelPickerTools = release; + Object.defineProperty(document, 'modelContext', { value: { + async registerTool(tool, { signal }) { + await ready; + tools.set(tool.name, tool); + signal.addEventListener('abort', () => tools.delete(tool.name), { once: true }); + }, + } }); + }); + } + const page = await context.newPage(); + page.setDefaultTimeout(15000); + page.on('pageerror', error => errors.push(String(error))); + return { context, page }; +} +async function login(page) { + await page.goto(origin + '/setup'); + await page.getByLabel('Discord server ID', { exact: true }).fill(G); + await page.getByRole('button', { name: 'Sign in with Discord', exact: true }).click(); + await page.getByRole('heading', { name: 'Agent setup', exact: true }).waitFor(); + await page.getByRole('button', { name: 'Refresh channels and roles', exact: true }).waitFor(); +} +const selected = control => control.evaluate(node => [...node.selectedOptions].map(option => option.value)); +const controls = page => ({ + channels: page.getByRole('listbox', { name: 'Selected channels (multiple selection)', exact: true }), + excluded: page.getByRole('listbox', { name: 'Excluded channels', exact: true }), + exempt: page.getByRole('listbox', { name: 'Exempt roles', exact: true }), + log: page.getByRole('combobox', { name: 'Log channel', exact: true }), + custom: page.getByLabel(/^Optional customization JSON/), +}); +async function assertDraft(page, draft) { + const c = controls(page); + assert.deepEqual(await selected(c.channels), draft.channels, 'selected channel draft must survive rendering'); + assert.deepEqual(await selected(c.excluded), draft.excludedChannels, 'excluded channel draft must survive rendering'); + assert.deepEqual(await selected(c.exempt), draft.exemptRoles, 'exempt role draft must survive rendering'); + assert.equal(await c.log.inputValue(), draft.logChannel); + assert.deepEqual(JSON.parse(await c.custom.inputValue()), draft.customizations); + assert.equal(await page.getByLabel('Scam and phishing review', { exact: false }).isChecked(), true); +} +async function assertNoOverflow(page, label) { + const sizes = await page.evaluate(() => ({ viewport: innerWidth, document: document.documentElement.scrollWidth })); + assert.ok(sizes.document <= sizes.viewport + 1, `${label}: document overflows ${JSON.stringify(sizes)}`); + for (const select of await page.locator('select').all()) { + const box = await select.boundingBox(); + assert.ok(!box || box.x >= -1 && box.x + box.width <= sizes.viewport + 1, `${label}: select extends outside viewport`); + } +} +async function assertInventory(page) { + const c = controls(page); + await page.waitForFunction(() => [...document.querySelectorAll('select[multiple]')].some(s => s.options.length > 50)); + for (const control of [c.channels, c.excluded]) { + assert.equal(await control.evaluate(node => node.multiple), true); + assert.equal(await control.getAttribute('size'), '8'); + assert.deepEqual(await control.locator('optgroup').evaluateAll(nodes => nodes.map(node => node.label)), ['Uncategorized', names.alpha, names.beta]); + assert.deepEqual(await control.locator('option').evaluateAll(nodes => nodes.map(node => node.value)), expectedOrder); + assert.equal(await control.locator(`option[value="${ids.weird}"]`).textContent(), names.weird); + assert.equal(await control.locator('img,script').count(), 0, 'Discord names must be rendered as text'); + } + assert.equal(await c.exempt.evaluate(node => node.multiple), true); + assert.equal(await c.exempt.locator(`option[value="${ids.weirdRole}"]`).textContent(), names.weirdRole); + assert.equal(await c.log.getAttribute('multiple'), null); + assert.equal(await c.log.locator('option').first().textContent(), 'Keep current log destination'); + assert.deepEqual(await c.log.locator('optgroup').evaluateAll(nodes => nodes.map(node => node.label)), ['Uncategorized', names.alpha, names.beta]); + assert.equal(await page.getByLabel(/channel IDs|role IDs|Log channel ID/i).count(), 0, 'manual ID controls must be removed'); + assert.equal(await page.locator('textarea').count(), 1, 'the only remaining textarea is optional customization JSON'); +} +async function preview(page, expected) { + const responsePromise = page.waitForResponse(response => response.url() === `${origin}/api/g/${G}/onboarding/plans` && response.request().method() === 'POST'); + await page.getByRole('button', { name: 'Preview configuration', exact: true }).click(); + const response = await responsePromise; + assert.equal(response.status(), 200, await response.text()); + assert.deepEqual(response.request().postDataJSON(), expected, 'named controls must submit exact selected IDs'); + await page.getByRole('heading', { name: 'Proposed configuration', exact: true }).waitFor(); + await assertDraft(page, expected); +} +async function seedSettings() { + const old = await f.store.getSettings(G); + await f.store.putSettings(G, { ...old.value, + channels: [ids.uncategorized], excludedChannels: [ids.excluded], exemptRoles: [ROLE], logChannel: ids.uncategorized, + }, old.revision); +} + +try { + await mkdir(evidence, { recursive: true }); + browser = await chromium.launch({ headless: true, + ...(process.env.CHROMIUM_EXECUTABLE ? { executablePath: process.env.CHROMIUM_EXECUTABLE } : {}), + args: ['--no-sandbox', '--disable-dev-shm-usage'], + }); + if (!darkOnly) { + for (const [label, width, height] of [['desktop', 1440, 1000], ['mobile', 390, 844]]) { + inventoryMode = 'full'; + await seedSettings(); + const { context, page } = await contextFor({ width, height }, true); + await login(page); + await assertInventory(page); + const draft = { + presets: ['scam_phishing'], channels: [ids.uncategorized, ids.weird, farChannel], + excludedChannels: [ids.excluded], exemptRoles: [ids.weirdRole], logChannel: ids.announcement, + customizations: { text_scams: { name: 'Synthetic browser regression scam review' } }, + }; + const c = controls(page); + await page.getByLabel('Scam and phishing review', { exact: false }).check(); + await c.channels.selectOption(draft.channels); + await c.excluded.selectOption(draft.excludedChannels); + await c.exempt.selectOption(draft.exemptRoles); + await c.log.selectOption(draft.logChannel); + await c.custom.fill(JSON.stringify(draft.customizations)); + await assertDraft(page, draft); + + // This registry is an explicit asynchronous contract double, not a claim of + // native browser-agent support. Its delayed completion previously reset drafts. + await page.evaluate(() => window.__releaseChannelPickerTools()); + await page.waitForFunction(() => window.__channelPickerTools?.size === 8); + await page.getByText('Browser tools registered.', { exact: false }).waitFor(); + await assertDraft(page, draft); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await assertDraft(page, draft); + + let releaseMetadata; + metadataGate = new Promise(resolve => { releaseMetadata = resolve; }); + await page.getByRole('button', { name: 'Refresh channels and roles', exact: true }).click(); + await page.waitForFunction(() => [...document.querySelectorAll('button')].some(b => b.textContent === 'Preview configuration' && b.disabled)); + metadataGate = null; + releaseMetadata(); + await page.waitForFunction(() => [...document.querySelectorAll('button')].some(b => b.textContent === 'Preview configuration' && !b.disabled)); + await assertDraft(page, draft); + + failNextPlan = true; + const failure = page.waitForResponse(response => response.url().endsWith('/onboarding/plans') && response.status() === 503); + await page.getByRole('button', { name: 'Preview configuration', exact: true }).click(); + await failure; + await page.locator('.notice.error').filter({ hasText: /synthetic.*plan.*failure/i }).waitFor(); + await assertDraft(page, draft); + await preview(page, draft); + await assertNoOverflow(page, `${label} Agent setup and preview`); + await page.screenshot({ path: join(evidence, `channel-picker-planner-${label}.png`), fullPage: true }); + + await page.getByRole('navigation').getByRole('button', { name: 'Settings', exact: true }).click(); + const monitored = page.getByRole('listbox', { name: 'Monitored channels (multiple selection)', exact: true }); + const excluded = page.getByRole('listbox', { name: 'Excluded channels', exact: true }); + const log = page.getByRole('combobox', { name: 'Moderator log channel', exact: true }); + const exempt = page.getByRole('listbox', { name: 'Exempt Discord roles', exact: true }); + await monitored.waitFor(); + assert.deepEqual(await selected(monitored), [ids.uncategorized], 'Settings loads saved channel IDs as selected names'); + assert.deepEqual(await selected(excluded), [ids.excluded]); + assert.equal(await log.inputValue(), ids.uncategorized); + assert.deepEqual(await selected(exempt), [ROLE]); + assert.deepEqual(await monitored.locator('optgroup').evaluateAll(nodes => nodes.map(node => node.label)), ['Uncategorized', names.alpha, names.beta]); + await monitored.selectOption([ids.weird, farChannel]); + await excluded.selectOption([ids.uncategorized]); + await log.selectOption(ids.announcement); + await exempt.selectOption([ids.weirdRole]); + const saved = page.waitForResponse(response => response.url().endsWith('/settings') && response.request().method() === 'PUT'); + await page.getByRole('button', { name: 'Save settings', exact: true }).click(); + const savedResponse = await saved; + assert.equal(savedResponse.status(), 200, await savedResponse.text()); + assert.deepEqual(savedResponse.request().postDataJSON().value.channels, [ids.weird, farChannel]); + assert.deepEqual(savedResponse.request().postDataJSON().value.excludedChannels, [ids.uncategorized]); + assert.deepEqual(savedResponse.request().postDataJSON().value.exemptRoles, [ids.weirdRole]); + await page.getByText('Settings saved. Review any stale rule approvals.', { exact: true }).waitFor(); + assert.deepEqual(await selected(monitored), [ids.weird, farChannel]); + assert.deepEqual(await selected(excluded), [ids.uncategorized]); + assert.equal(await log.inputValue(), ids.announcement); + assert.deepEqual(await selected(exempt), [ids.weirdRole]); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await monitored.waitFor(); + assert.deepEqual(await selected(monitored), [ids.weird, farChannel], 'saved selections survive a server reload'); + assert.deepEqual(await selected(excluded), [ids.uncategorized]); + await assertNoOverflow(page, `${label} Settings`); + await page.screenshot({ path: join(evidence, `channel-picker-settings-${label}.png`), fullPage: true }); + await page.getByRole('navigation').getByRole('button', { name: 'Agent setup', exact: true }).click(); + await assertDraft(page, draft); + reports.push({ viewport: label, eligibleChannels: expectedOrder.length, + verified: ['server category/channel ordering', 'uncategorized/orphaned channels', 'text and announcement types only', + 'native multiple selection', 'escaped category/channel/role names', 'complete inventory beyond 50 channels', + 'exact preview payload', 'draft retained across async tool registration, metadata refresh, HTTP failure, preview and navigation', + 'saved Settings round trip', 'no horizontal viewport overflow'], + }); + await context.close(); + } + + inventoryMode = 'empty'; + const { context, page } = await contextFor({ width: 390, height: 844 }); + await login(page); + await page.getByText('No text or announcement channels are available.', { exact: true }).first().waitFor(); + assert.equal(await controls(page).channels.locator('option:enabled').count(), 0); + assert.equal(await controls(page).excluded.locator('option:enabled').count(), 0); + assert.equal(await controls(page).channels.isDisabled(), true); + await assertNoOverflow(page, 'empty inventory'); + inventoryMode = 'error'; + await page.getByRole('button', { name: 'Refresh channels and roles', exact: true }).click(); + await page.locator('.notice.error').filter({ hasText: /channel/i }).waitFor(); + assert.equal(await page.getByRole('button', { name: 'Preview configuration', exact: true }).isDisabled(), true); + inventoryMode = 'full'; + await page.getByRole('button', { name: 'Refresh channels and roles', exact: true }).click(); + await assertInventory(page); + assert.equal(await page.getByRole('button', { name: 'Preview configuration', exact: true }).isEnabled(), true); + await controls(page).channels.selectOption([farChannel]); + inventoryMode = 'error'; + await page.getByRole('button', { name: 'Refresh channels and roles', exact: true }).click(); + await page.locator('.notice.error').filter({ hasText: /channel/i }).waitFor(); + inventoryMode = 'full'; + await page.getByRole('button', { name: 'Refresh channels and roles', exact: true }).click(); + await page.waitForFunction(() => [...document.querySelectorAll('button')].some(b => b.textContent === 'Preview configuration' && !b.disabled)); + assert.deepEqual(await selected(controls(page).channels), [farChannel], 'inventory retry must preserve draft IDs'); + reports.push({ inventory: 'empty and HTTP error states; disabled preview after failure; complete retry recovery; selected draft survives failure' }); + await context.close(); + } else { + for (const [label, width, height] of [['desktop', 1440, 1000], ['mobile', 390, 844]]) { + const { context, page } = await contextFor({ width, height }); + await login(page); + await assertInventory(page); + const c = controls(page); + await c.channels.selectOption([ids.uncategorized, ids.announcement]); + await c.excluded.selectOption([ids.excluded]); + await assertNoOverflow(page, `${label} dark-theme lists`); + assert.equal(await page.evaluate(() => matchMedia('(prefers-color-scheme: dark)').matches), true); + // Crop the browser capture to the two actual list controls, retaining their + // original page styles and native option rendering without image edits. + const clip = await c.channels.evaluate(node => { + const first = node.closest('label').getBoundingClientRect(); + const second = [...document.querySelectorAll('select[multiple]')][1].closest('label').getBoundingClientRect(); + return { x: first.x + scrollX, y: first.y + scrollY, width: Math.max(first.width, second.width), height: second.bottom - first.y }; + }); + await page.screenshot({ path: join(evidence, `channel-picker-dark-${label}.png`), fullPage: true, clip }); + reports.push({ viewport: label, width, height, colorScheme: 'dark', eligibleChannels: expectedOrder.length, + verified: ['native list rendering', 'category grouping and complete inventory', 'selected and excluded states', 'no horizontal viewport overflow'], + screenshot: `channel-picker-dark-${label}.png`, + }); + await context.close(); + } + } + assert.deepEqual(errors, [], 'browser page errors'); + assert.deepEqual(externalRequests, [], 'no live Discord/provider or other third-party requests'); + const report = { + scope: 'Offline compiled UI, real isolated HTTP and in-memory transactions. Synthetic Discord/provider and asynchronous WebMCP registry. No live Discord or provider acceptance claimed.', + browser: browser.version(), results: reports, pageErrors: errors, externalRequests, passed: true, + }; + await writeFile(join(evidence, darkOnly ? 'channel-picker-dark-browser.json' : 'channel-picker-browser.json'), JSON.stringify(report, null, 2) + '\n'); + console.log(JSON.stringify(report, null, 2)); +} finally { + metadataGate = null; + await browser?.close(); + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + for (const fn of cleanup) await fn(); +} diff --git a/scripts/onboarding-browser.py b/scripts/onboarding-browser.py index 36c99b1..86bc552 100644 --- a/scripts/onboarding-browser.py +++ b/scripts/onboarding-browser.py @@ -30,8 +30,8 @@ async def main(): await page.get_by_text('AI browser agent tools (WebMCP) are not available',exact=False).wait_for() await page.screenshot(path=str(root/'evidence'/f'issue44-fallback-{label}.png'),full_page=True) await page.get_by_label('Scam and phishing review',exact=False).check() - await page.get_by_label('Add monitored channel IDs',exact=False).fill('100000000000000021') - await page.get_by_label('Log channel ID',exact=False).fill('100000000000000021') + await page.get_by_role('listbox',name='Selected channels (multiple selection)',exact=True).select_option(['100000000000000021']) + await page.get_by_role('combobox',name='Log channel',exact=True).select_option('100000000000000021') await page.screenshot(path=str(root/'evidence'/f'issue44-starters-{label}.png'),full_page=True) await page.get_by_role('button',name='Preview configuration',exact=True).click() await page.get_by_role('heading',name='Proposed configuration',exact=True).wait_for() diff --git a/scripts/preview.mjs b/scripts/preview.mjs index 800fa3f..d791e0d 100644 --- a/scripts/preview.mjs +++ b/scripts/preview.mjs @@ -9,7 +9,7 @@ import { DEFAULT_SETTINGS, CAPABILITIES } from '../dist/shared/domain.js'; const root=fileURLToPath(new URL('..',import.meta.url)); import {guild,user,authority,metadata,settings,groups,images,rules,events,credential} from './preview-data.mjs'; const sdk=`export class DiscordSDK {guildId='${guild}';async ready(){};commands={authorize:async()=>({code:'offline-preview-code'}),authenticate:async()=>({user:{id:'${user}'}})};}`; -const files={'/':'activity/index.html','/index.html':'activity/index.html','/style.css':'activity/style.css','/terms':'activity/legal/terms.html','/privacy':'activity/legal/privacy.html','/legal.css':'activity/legal/legal.css','/activity/src/main.js':'dist/activity/src/main.js','/activity/src/connection.js':'dist/activity/src/connection.js','/shared/analytics.js':'dist/shared/analytics.js','/shared/content.js':'dist/shared/content.js','/shared/text.js':'dist/shared/text.js','/shared/text-starters.js':'dist/shared/text-starters.js','/shared/domain.js':'dist/shared/domain.js','/fonts/schibsted-grotesk-400.woff2':'activity/fonts/schibsted-grotesk-400.woff2','/fonts/schibsted-grotesk-500.woff2':'activity/fonts/schibsted-grotesk-500.woff2','/fonts/vt323-400.woff2':'activity/fonts/vt323-400.woff2','/fonts/jetbrains-mono-400.woff2':'activity/fonts/jetbrains-mono-400.woff2'}; +const files={'/':'activity/index.html','/index.html':'activity/index.html','/style.css':'activity/style.css','/terms':'activity/legal/terms.html','/privacy':'activity/legal/privacy.html','/legal.css':'activity/legal/legal.css','/activity/src/main.js':'dist/activity/src/main.js','/activity/src/connection.js':'dist/activity/src/connection.js','/activity/src/onboarding.js':'dist/activity/src/onboarding.js','/activity/src/channel-selectors.js':'dist/activity/src/channel-selectors.js','/shared/channels.js':'dist/shared/channels.js','/shared/onboarding.js':'dist/shared/onboarding.js','/shared/analytics.js':'dist/shared/analytics.js','/shared/content.js':'dist/shared/content.js','/shared/text.js':'dist/shared/text.js','/shared/text-starters.js':'dist/shared/text-starters.js','/shared/domain.js':'dist/shared/domain.js','/fonts/schibsted-grotesk-400.woff2':'activity/fonts/schibsted-grotesk-400.woff2','/fonts/schibsted-grotesk-500.woff2':'activity/fonts/schibsted-grotesk-500.woff2','/fonts/vt323-400.woff2':'activity/fonts/vt323-400.woff2','/fonts/jetbrains-mono-400.woff2':'activity/fonts/jetbrains-mono-400.woff2'}; const server=createServer(async(req,res)=>{ const path=new URL(req.url,'http://localhost').pathname; const send=(status,value,type='application/json')=>{res.writeHead(status,{'Content-Type':type,'Cache-Control':'no-store','X-Preview-Only':'true'});res.end(type.startsWith('application/json')?JSON.stringify(value):value);}; diff --git a/server/discord.ts b/server/discord.ts index 6646d89..b2b6f35 100644 --- a/server/discord.ts +++ b/server/discord.ts @@ -1,6 +1,7 @@ import { Throttle, Deferred, retryAt, scopeHash } from './throttle.js'; import { AppError, assert, snowflake, type Action, type ImageMessage, type MemberInfo, type Settings } from '../shared/domain.js'; import { readBounded } from './images.js'; +import type { ChannelMetadata } from '../shared/channels.js'; const API = 'https://discord.com/api/v10'; const P = { ADMIN: 8n, MANAGE_GUILD: 32n, VIEW: 1024n, SEND: 2048n, MANAGE_MESSAGES: 8192n, KICK: 2n, BAN: 4n, MODERATE: 1n << 40n }; export interface DiscordRole { @@ -23,12 +24,8 @@ export interface DiscordMember { roles: string[]; communication_disabled_until?: string | null; } -export interface DiscordChannel { - id: string; +export interface DiscordChannel extends ChannelMetadata { guild_id?: string; - name?: string; - type: number; - parent_id?: string | null; permission_overwrites?: { id: string; type: number; diff --git a/server/http.ts b/server/http.ts index e2a00ed..0e1c2cb 100644 --- a/server/http.ts +++ b/server/http.ts @@ -614,7 +614,7 @@ export function createHttp(services: Services, config: Config, root = process.cw const html = await readFile(resolve(root, legal[pathname]), 'utf8'); return { bytes: Buffer.from(html.replace('href="/legal.css"', `href="/r/${version}/legal.css"`)), type: 'text/html; charset=utf-8' }; } - const fixed: Record = { '/setup':'activity/index.html', '/style.css': 'activity/style.css', '/legal.css': 'activity/legal/legal.css', '/vendor/discord-sdk.js': 'dist/activity/vendor/discord-sdk.js', '/vendor/THIRD_PARTY_NOTICES.txt': 'dist/activity/vendor/THIRD_PARTY_NOTICES.txt', '/shared/onboarding.js':'dist/shared/onboarding.js','/shared/analytics.js':'dist/shared/analytics.js','/shared/content.js':'dist/shared/content.js','/shared/text.js':'dist/shared/text.js','/shared/text-starters.js':'dist/shared/text-starters.js','/shared/domain.js': 'dist/shared/domain.js' }; + const fixed: Record = { '/setup':'activity/index.html', '/style.css': 'activity/style.css', '/legal.css': 'activity/legal/legal.css', '/vendor/discord-sdk.js': 'dist/activity/vendor/discord-sdk.js', '/vendor/THIRD_PARTY_NOTICES.txt': 'dist/activity/vendor/THIRD_PARTY_NOTICES.txt', '/shared/channels.js':'dist/shared/channels.js','/shared/onboarding.js':'dist/shared/onboarding.js','/shared/analytics.js':'dist/shared/analytics.js','/shared/content.js':'dist/shared/content.js','/shared/text.js':'dist/shared/text.js','/shared/text-starters.js':'dist/shared/text-starters.js','/shared/domain.js': 'dist/shared/domain.js' }; const fonts: Record = { '/fonts/schibsted-grotesk-400.woff2': 'activity/fonts/schibsted-grotesk-400.woff2', '/fonts/schibsted-grotesk-500.woff2': 'activity/fonts/schibsted-grotesk-500.woff2', '/fonts/vt323-400.woff2': 'activity/fonts/vt323-400.woff2', '/fonts/jetbrains-mono-400.woff2': 'activity/fonts/jetbrains-mono-400.woff2' }; if (fonts[pathname]) { let bytes: Buffer; diff --git a/server/onboarding.ts b/server/onboarding.ts index 2a8f463..2d12e1c 100644 --- a/server/onboarding.ts +++ b/server/onboarding.ts @@ -55,7 +55,7 @@ export class Onboarding { assert(Number.isSafeInteger(offset) && offset >= 0 && offset <= 10000, 'invalid_offset'); const meta = await this.services.discord.metadata(guild), s = await snapshot(this.services.store, guild); const page = (items: T[]) => ({ items: items.slice(offset, offset + 50), nextOffset: items.length > offset + 50 ? offset + 50 : null, total: items.length }); - return { untrustedData: true, name: meta.name, channels: page(meta.channels.map(({id,name,type}) => ({id,name,type}))), roles: page(meta.roles.map(({id,name}) => ({id,name}))), settings: s.settings, rules: page(s.rules), groups: page(s.groups), referenceCount: s.images.length }; + return { untrustedData: true, name: meta.name, channels: page(meta.channels.map(({id,name,type,parent_id,position}) => ({id,name,type,parent_id,position}))), roles: page(meta.roles.map(({id,name}) => ({id,name}))), settings: s.settings, rules: page(s.rules), groups: page(s.groups), referenceCount: s.images.length }; } async plan(guild: string, actor: string, binding: string, raw: unknown) { await requireManager(this.services, guild, actor); diff --git a/shared/channels.ts b/shared/channels.ts new file mode 100644 index 0000000..39e20af --- /dev/null +++ b/shared/channels.ts @@ -0,0 +1,48 @@ +/** The Discord fields needed to reproduce the server's channel list. */ +export interface ChannelMetadata { + id: string; + name?: string; + type: number; + parent_id?: string | null; + position?: number; +} + +export interface ChannelGroup { + id: string | null; + name: string; + channels: ChannelMetadata[]; +} + +function byPosition(a: ChannelMetadata, b: ChannelMetadata): number { + const position = (channel: ChannelMetadata) => typeof channel.position === 'number' && Number.isFinite(channel.position) ? channel.position : Number.MAX_SAFE_INTEGER; + const difference = position(a) - position(b); + if (difference) return difference; + // Discord breaks position ties by snowflake ID, which exceeds Number's safe range. + // https://docs.discord.com/developers/resources/channel#channel-object + if (/^\d+$/.test(a.id) && /^\d+$/.test(b.id)) { + const first = BigInt(a.id), second = BigInt(b.id); + return first < second ? -1 : first > second ? 1 : 0; + } + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +/** Group supported text/announcement channels without making categories selectable. */ +export function orderedChannelGroups(channels: readonly ChannelMetadata[]): ChannelGroup[] { + const categories = channels.filter(channel => channel.type === 4).sort(byPosition); + const groups = new Map(categories.map(category => [category.id, { + id: category.id, + name: category.name || 'Unnamed category', + channels: [] as ChannelMetadata[] + }])); + const uncategorized: ChannelGroup = { id: null, name: 'Uncategorized', channels: [] }; + for (const channel of channels) { + if (channel.type !== 0 && channel.type !== 5) continue; + // An absent/deleted parent must not hide a selectable channel. + const group = channel.parent_id ? groups.get(channel.parent_id) : undefined; + (group ?? uncategorized).channels.push(channel); + } + return [uncategorized, ...groups.values()].filter(group => group.channels.length > 0).map(group => ({ + ...group, + channels: group.channels.sort(byPosition) + })); +} diff --git a/tests/channels.test.ts b/tests/channels.test.ts new file mode 100644 index 0000000..58c40f8 --- /dev/null +++ b/tests/channels.test.ts @@ -0,0 +1,99 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { orderedChannelGroups, type ChannelMetadata } from '../shared/channels.js'; +import { DiscordRest, type DiscordChannel } from '../server/discord.js'; +import { Onboarding } from '../server/onboarding.js'; +import { fixture, G, ADMIN, CHANNEL } from './helpers.js'; + +const categoryA = '100000000000000041', categoryB = '100000000000000042'; + +test('channel choices reproduce Discord category and channel positions instead of API or name order', () => { + const channels: ChannelMetadata[] = [ + { id: '100000000000000051', name: 'a-last', type: 0, parent_id: categoryA, position: 9 }, + { id: categoryA, name: 'Z • First category', type: 4, position: 0 }, + { id: '100000000000000052', name: 'second-category', type: 0, parent_id: categoryB, position: 0 }, + { id: '100000000000000053', name: 'announcements', type: 5, parent_id: categoryA, position: 1 }, + { id: categoryB, name: 'A • Second category', type: 4, position: 10 }, + { id: CHANNEL, name: 'general', type: 0, parent_id: null, position: 20 }, + { id: '100000000000000054', name: 'voice', type: 2, parent_id: categoryA, position: 0 }, + { id: '100000000000000055', name: 'forum', type: 15, parent_id: categoryB, position: 1 } + ]; + const original = structuredClone(channels); + const groups = orderedChannelGroups(channels); + assert.deepEqual(groups.map(({id,name,channels}) => ({id,name,channels:channels.map(channel => channel.name)})), [ + { id: null, name: 'Uncategorized', channels: ['general'] }, + { id: categoryA, name: 'Z • First category', channels: ['announcements', 'a-last'] }, + { id: categoryB, name: 'A • Second category', channels: ['second-category'] } + ]); + assert.deepEqual(channels, original); + assert.deepEqual(orderedChannelGroups([...channels].reverse()), groups); +}); + +test('position ties use exact numeric snowflakes for categories and children', () => { + const early = '999999999999999999', late = '1000000000000000000'; + const first = '1000000000000000051', second = '1000000000000000052'; + const groups = orderedChannelGroups([ + { id: late, name: 'later', type: 4, position: 0 }, + { id: second, type: 0, parent_id: early, position: 3 }, + { id: early, name: 'earlier', type: 4, position: 0 }, + { id: first, type: 5, parent_id: early, position: 3 }, + { id: CHANNEL, type: 0, parent_id: late, position: 0 } + ]); + assert.deepEqual(groups.map(group => group.id), [early, late]); + assert.deepEqual(groups[0]!.channels.map(channel => channel.id), [first, second]); +}); + +test('missing parents and optional metadata keep eligible channels visible and deterministic', () => { + const channels: ChannelMetadata[] = [ + { id: 'synthetic-z', name: 'without-position', type: 0, parent_id: 'missing' }, + { id: 'synthetic-a', type: 0 }, + { id: categoryA, type: 4 }, + { id: CHANNEL, type: 5, parent_id: categoryA }, + { id: categoryB, name: 'empty', type: 4, position: 0 }, + { id: 'synthetic-first', type: 0, position: 0 } + ]; + const groups = orderedChannelGroups(channels); + assert.deepEqual(groups.map(group => group.name), ['Uncategorized', 'Unnamed category']); + assert.deepEqual(groups[0]!.channels.map(channel => channel.id), ['synthetic-first', 'synthetic-a', 'synthetic-z']); + assert.deepEqual(orderedChannelGroups([...channels].reverse()), groups); + assert.deepEqual(orderedChannelGroups([{id:categoryA,type:4}, {id:CHANNEL,type:2}]), []); +}); + +test('Discord metadata retains category relationships and positions from the guild response', async () => { + const channels: DiscordChannel[] = [ + { id: CHANNEL, guild_id: G, name: 'messages', type: 0, parent_id: categoryA, position: 3 }, + { id: categoryA, guild_id: G, name: 'Community', type: 4, parent_id: null, position: 1 } + ]; + const paths: string[] = []; + const rest = new DiscordRest('synthetic-token', G, 'synthetic-secret', (async url => { + paths.push(String(url)); + return Response.json(String(url).endsWith('/channels') ? channels : { id: G, name: 'Test server', roles: [] }); + }) as typeof fetch); + const meta = await rest.metadata(G); + assert.deepEqual(meta.channels, channels); + assert.deepEqual(paths, [`https://discord.com/api/v10/guilds/${G}`, `https://discord.com/api/v10/guilds/${G}/channels`]); +}); + +test('onboarding inventory retains hierarchy across pages without leaking channel permission data', async t => { + const f = await fixture(t), onboarding = new Onboarding(f.services); + const channels: DiscordChannel[] = Array.from({length:51}, (_, index) => ({ + id: String(100000000000001000n + BigInt(index)), name: `channel-${index}`, type: index === 50 ? 5 : 0, + parent_id: index < 30 ? categoryA : categoryB, position: index, + permission_overwrites: [{ id: G, type: 0, allow: '1024', deny: '0' }] + })); + channels.push({id:categoryB,name:'Second category',type:4,position:1,parent_id:null}, {id:categoryA,name:'First category',type:4,position:0,parent_id:null}); + const meta = await f.services.discord.metadata(G); + f.services.discord.metadata = async () => ({...meta, channels}); + const first = await onboarding.inventory(G, ADMIN); + assert.equal(first.channels.nextOffset, 50); + const second = await onboarding.inventory(G, ADMIN, first.channels.nextOffset!); + assert.equal(second.channels.nextOffset, null); + assert.equal(second.channels.total, 53); + const items = [...first.channels.items, ...second.channels.items]; + assert.deepEqual(items[0], { id: channels[0]!.id, name: 'channel-0', type: 0, parent_id: categoryA, position: 0 }); + assert.ok(!JSON.stringify(items).includes('permission_overwrites')); + assert.deepEqual(orderedChannelGroups(items).map(group => [group.id, group.channels.length]), [[categoryA,30], [categoryB,21]]); + await assert.rejects(onboarding.plan(G, ADMIN, 'synthetic-session', { + presets:['scam_phishing'], channels:[categoryA], excludedChannels:[], exemptRoles:[], logChannel:null, customizations:{} + }), /channel_not_available/); +});