docs(threads): publish threads, forums and media channels (#3342)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 02:44:06 +02:00
1 parent 4ce4d4e09a
commit 7a5b9a0ded
56 files changed
+1328 -683

No files matched your search

+29 -115
View File
@@ -165,63 +165,6 @@ const OUT_OF_BAND_CREDENTIAL = new Map<string, OutOfBandRoute>([
const HEALTH_AND_METRICS_PATHS = new Set(['/_health', '/_health/ready', '/_health/drain', '/_healthz', '/_metrics']);
const CHANNEL_THREADS_UNTIL_GA = {
routes: new Set<string>([
'POST /channels/{}/messages/{}/threads',
'POST /channels/{}/threads',
'GET /guilds/{}/threads/active',
'GET /channels/{}/threads/archived/public',
'GET /channels/{}/threads/archived/private',
'GET /channels/{}/users/@me/threads/archived/private',
'GET /channels/{}/thread-members',
'GET /channels/{}/thread-members/{}',
'PUT /channels/{}/thread-members/@me',
'PUT /channels/{}/thread-members/{}',
'DELETE /channels/{}/thread-members/@me',
'DELETE /channels/{}/thread-members/{}',
'PATCH /channels/{}/thread-members/@me/settings',
'GET /channels/{}/threads/search',
'POST /channels/{}/post-data',
'POST /channels/{}/tags',
'PUT /channels/{}/tags/{}',
'DELETE /channels/{}/tags/{}',
'GET /admin/guilds/{}/threads',
'DELETE /admin/channels/{}',
]),
events: new Set<string>([
'THREAD_CREATE',
'THREAD_UPDATE',
'THREAD_DELETE',
'THREAD_LIST_SYNC',
'THREAD_MEMBER_UPDATE',
'THREAD_MEMBERS_UPDATE',
]),
opcodes: new Set<number>([28]),
auditActions: new Set<string>(['delete_thread', 'list_guild_threads']),
errorCodes: new Set<string>([
'CHANNEL_HAS_THREADS',
'FORUM_TAG_NAMES_MUST_BE_UNIQUE',
'FORUM_TAG_REQUIRED',
'HIDE_MEDIA_DOWNLOAD_OPTION_MEDIA_ONLY',
'INVALID_THREAD_NOTIFICATION_SETTINGS',
'MAX_ACTIVE_THREADS',
'MAX_FORUM_TAGS',
'MAX_PINNED_THREADS_IN_FORUM',
'MAX_THREAD_MEMBERS',
'NO_TAGS_AVAILABLE_TO_NON_MODERATORS',
'SEARCH_INDEX_NOT_READY',
'THREAD_ALREADY_CREATED_FOR_MESSAGE',
'THREAD_ARCHIVED',
'THREAD_LOCKED',
'UNKNOWN_FORUM_TAG',
'UNKNOWN_THREAD_MEMBER',
'WEBHOOK_FORUM_TARGET_CONFLICT',
'WEBHOOK_FORUM_TARGET_REQUIRED',
'WEBHOOK_SERVICE_FORUM_UNSUPPORTED',
'WEBHOOK_THREAD_NAME_REQUIRES_FORUM',
]),
};
const LEGACY_REPORT_ROUTES = new Set(['POST /reports/message', 'POST /reports/user', 'POST /reports/guild']);
interface ExemptionRule {
@@ -286,19 +229,6 @@ const EXEMPTION_RULES: ReadonlyArray<ExemptionRule> = [
anchors: [{file: 'packages/openapi/src/generator/OpenAPIGeneratorCatalog.ts', anchor: "'/reports/message'"}],
covers: (shape) => LEGACY_REPORT_ROUTES.has(shape),
},
{
name: 'channel_threads',
justification:
'threads, forum and media channels are behind the channel_threads experiment and ChannelThreadsRouteGuard answers 404 outside it. Their pages are withheld until general availability, which removes this rule',
mayCoverNothing: true,
anchors: [
{
file: 'fluxer_api/src/api/channel/threads/ChannelThreadsRouteGuard.ts',
anchor: 'export function ChannelThreadsRouteGuard',
},
],
covers: (shape) => CHANNEL_THREADS_UNTIL_GA.routes.has(shape),
},
];
const MEDIA_PROXY_ROUTES = new Map([
@@ -672,7 +602,7 @@ failures += section(
if (aliasShapes.has(shape)) {
return false;
}
return !DELIBERATELY_UNDOCUMENTED.has(shape) && !CHANNEL_THREADS_UNTIL_GA.routes.has(shape);
return !DELIBERATELY_UNDOCUMENTED.has(shape);
})
.map((operation) => `${operation.method} ${operation.path} [${operation.tags.join(', ')}]`)
.sort(),
@@ -696,12 +626,6 @@ const wronglyDocumented = [...DELIBERATELY_UNDOCUMENTED.entries()]
.filter(([shape]) => documentedMain.has(shape))
.map(([shape, reason]) => `${shape} must not be documented: ${reason}`);
failures += section('documented despite belonging to an unshipped system', wronglyDocumented);
failures += section(
'published despite being withheld until channel_threads general availability',
[...CHANNEL_THREADS_UNTIL_GA.routes]
.filter((shape) => documentedMain.has(shape) || documentedAdmin.has(shape))
.sort(),
);
console.log(
` deliberately undocumented and correctly absent: ${(DELIBERATELY_UNDOCUMENTED.size - wronglyDocumented.length).toString()}/${DELIBERATELY_UNDOCUMENTED.size.toString()}`,
);
@@ -757,7 +681,10 @@ console.log('gateway dispatch events');
await collect(gatewayDirectory);
const erlang = erlangSources.join('\n');
const eventsPage = await readFile(path.join(DOCS_ROOT, 'gateway/events.md'), 'utf8');
const eventsPage = [
await readFile(path.join(DOCS_ROOT, 'gateway/events.md'), 'utf8'),
await readFile(path.join(DOCS_ROOT, 'gateway/threads.md'), 'utf8'),
].join('\n');
const documentedEvents = new Set<string>();
for (const entry of eventsPage.matchAll(/^###\s+(?:<span[^>]*><\/span>)?([A-Z][A-Z0-9_]{3,})\s*$/gmu)) {
documentedEvents.add(entry[1]);
@@ -774,19 +701,13 @@ console.log('gateway dispatch events');
};
const fabricated = [...documentedEvents].filter((event) => !isReal(event)).sort();
const undocumented = [...apiEvents]
.filter((event) => !documentedEvents.has(event) && !CHANNEL_THREADS_UNTIL_GA.events.has(event))
.sort();
const undocumented = [...apiEvents].filter((event) => !documentedEvents.has(event)).sort();
console.log(` events in the GatewayDispatchEvent union: ${apiEvents.size.toString()}`);
console.log(` events documented in gateway/events.md: ${documentedEvents.size.toString()}`);
console.log(` events documented in gateway/events.md and gateway/threads.md: ${documentedEvents.size.toString()}`);
console.log(' a documented event counts as real if it is in the union, or appears in fluxer_gateway');
console.log(' as an uppercase binary or a lowercase atom');
failures += section('documented but not emitted by any service', fabricated);
failures += section('in the dispatch union but undocumented', undocumented);
failures += section(
'published despite being withheld until channel_threads general availability',
[...CHANNEL_THREADS_UNTIL_GA.events].filter((event) => documentedEvents.has(event)).sort(),
);
}
console.log('gateway opcodes and close codes');
@@ -826,14 +747,10 @@ console.log('gateway opcodes and close codes');
failures += section(
'opcodes in constants.erl but undocumented',
[...realOpcodes]
.filter((code) => !docOpcodes.has(code) && !CHANNEL_THREADS_UNTIL_GA.opcodes.has(code))
.filter((code) => !docOpcodes.has(code))
.sort((a, b) => a - b)
.map(String),
);
failures += section(
'opcodes published despite being withheld until channel_threads general availability',
[...CHANNEL_THREADS_UNTIL_GA.opcodes].filter((code) => docOpcodes.has(code)).map(String),
);
failures += section(
'close codes documented but absent from constants.erl',
[...docCloseCodes]
@@ -1060,12 +977,7 @@ console.log('admin audit actions');
failures += section(
'recorded but undocumented',
[...recorded]
.filter(
([action]) =>
!documentedRead.has(action) &&
!documentedWrite.has(action) &&
!CHANNEL_THREADS_UNTIL_GA.auditActions.has(action),
)
.filter(([action]) => !documentedRead.has(action) && !documentedWrite.has(action))
.map(([action, file]) => `${action} (${file})`)
.sort(),
);
@@ -1087,11 +999,22 @@ console.log('permission bits');
const constants = await readFile(path.join(REPO_ROOT, 'packages/constants/src/ChannelConstants.ts'), 'utf8');
const block = constants.match(/export const Permissions = \{([\s\S]*?)\} as const;/u);
const livePermissions = new Map<string, number>();
if (block != null) {
for (const entry of block[1].matchAll(/([A-Z][A-Z0-9_]*):\s*1n\s*<<\s*(\d+)n/gu)) {
const threadConstants = await readFile(
path.join(REPO_ROOT, 'packages/constants/src/ThreadPermissionUtils.ts'),
'utf8',
);
const threadBlock = threadConstants.match(/export const ThreadPermissionFlags = \{([\s\S]*?)\} as const;/u);
for (const source of [block, threadBlock]) {
if (source == null) {
continue;
}
for (const entry of source[1].matchAll(/([A-Z][A-Z0-9_]*):\s*1n\s*<<\s*(\d+)n/gu)) {
livePermissions.set(entry[1], Number.parseInt(entry[2], 10));
}
}
if (threadBlock == null) {
failures += section('ThreadPermissionFlags block not found in ThreadPermissionUtils.ts', ['parser drift']);
}
const page = (await readFile(path.join(DOCS_ROOT, 'http-api/permissions.mdx'), 'utf8'))
.replace(/&lt;/gu, '<')
.replace(/&gt;/gu, '>');
@@ -1926,19 +1849,17 @@ console.log('error registry');
}
const problems: Array<string> = [];
for (const code of registryCodes) {
if (!documentedCodes.has(code) && !CHANNEL_THREADS_UNTIL_GA.errorCodes.has(code)) {
if (!documentedCodes.has(code)) {
problems.push(`APIErrorCodes.${code} is not listed in the errors page registry`);
}
}
const documentedRegistryCodes = [...registryCodes].filter((c) => documentedCodes.has(c)).length;
const withheldRegistryCodes = [...registryCodes].filter((c) => CHANNEL_THREADS_UNTIL_GA.errorCodes.has(c)).length;
const UNDOCUMENTED_VALIDATION_CODES = new Set(['EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL']);
const expectedEntries =
registryCodes.size + validationCodes.size - UNDOCUMENTED_VALIDATION_CODES.size - withheldRegistryCodes;
const expectedEntries = registryCodes.size + validationCodes.size - UNDOCUMENTED_VALIDATION_CODES.size;
if (documentedEntries < expectedEntries) {
problems.push(
`errors.md code entries parsed fell to ${documentedEntries.toString()}, expected ${expectedEntries.toString()}, the ${registryCodes.size.toString()} API codes plus the ${validationCodes.size.toString()} validation codes less the ${UNDOCUMENTED_VALIDATION_CODES.size.toString()} named as undocumented and the ${withheldRegistryCodes.toString()} withheld until channel_threads general availability. Either a code lost its entry, or the registry parser stopped matching one of the \`| CODE |\` table row and \`### \`CODE\`\` or \`#### \`CODE\`\` heading shapes it reads`,
`errors.md code entries parsed fell to ${documentedEntries.toString()}, expected ${expectedEntries.toString()}, the ${registryCodes.size.toString()} API codes plus the ${validationCodes.size.toString()} validation codes less the ${UNDOCUMENTED_VALIDATION_CODES.size.toString()} named as undocumented. Either a code lost its entry, or the registry parser stopped matching one of the \`| CODE |\` table row and \`### \`CODE\`\` or \`#### \`CODE\`\` heading shapes it reads`,
);
}
for (const code of documentedCodes) {
@@ -1946,19 +1867,12 @@ console.log('error registry');
problems.push(`errors.md documents ${code}, which is in neither code registry`);
}
}
if (documentedRegistryCodes < registryCodes.size - withheldRegistryCodes) {
if (documentedRegistryCodes < registryCodes.size) {
problems.push(
`errors.md documents ${documentedRegistryCodes.toString()} of the ${registryCodes.size.toString()} registry codes, floor is every one of them less the ${withheldRegistryCodes.toString()} withheld until channel_threads general availability`,
`errors.md documents ${documentedRegistryCodes.toString()} of the ${registryCodes.size.toString()} registry codes, floor is every one of them`,
);
}
for (const code of CHANNEL_THREADS_UNTIL_GA.errorCodes) {
if (!registryCodes.has(code)) {
problems.push(`CHANNEL_THREADS_UNTIL_GA withholds ${code}, which is not in the API code registry`);
}
if (documentedCodes.has(code)) {
problems.push(`errors.md publishes ${code}, which is withheld until channel_threads general availability`);
}
}
console.log(` registry codes: ${registryCodes.size.toString()}, documented: ${documentedRegistryCodes.toString()}`);
failures += section('error registry disagreements', problems);
}
@@ -2336,7 +2250,7 @@ const adminTargetOnly = [...documentedAdmin.entries()]
const adminLiveOnly = admin
.filter((operation) => {
const shape = routeShape(operation.method, operation.path);
return !documentedAdmin.has(shape) && !CHANNEL_THREADS_UNTIL_GA.routes.has(shape);
return !documentedAdmin.has(shape);
})
.map((operation) => `${operation.method} ${operation.path}`)
.sort();
+1 -51
View File
@@ -22,37 +22,6 @@ import {
const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url));
const MAIN_SPEC = path.join(REPO_ROOT, 'fluxer_api/src/api/openapi/openapi.json');
const ADMIN_SPEC = path.join(REPO_ROOT, 'fluxer_admin/openapi-admin.json');
const CHANNEL_THREADS_FIELDS_UNTIL_GA = new Set<string>([
'applied_tags',
'archived',
'auto_archive_duration',
'available_tags',
'channel_threads',
'default_auto_archive_duration',
'default_forum_layout',
'default_reaction_emoji',
'default_sort_order',
'default_tag_setting',
'default_thread_rate_limit_per_user',
'flags',
'invitable',
'locked',
'member',
'member_count',
'member_ids_preview',
'members',
'message_count',
'message_send_cooldown_ms',
'thread',
'thread_create_cooldown_ms',
'thread_id',
'thread_metadata',
'thread_name',
'threads',
'threads_active',
'total_message_sent',
]);
export interface Mismatch {
readonly page: string;
readonly operation: string;
@@ -72,7 +41,6 @@ export interface SchemaCounters {
unlinkedObjects: number;
typesCompared: number;
optionalityCompared: number;
withheldFields: number;
}
export interface SchemaVerification {
@@ -630,7 +598,6 @@ class Verifier {
unlinkedObjects: 0,
typesCompared: 0,
optionalityCompared: 0,
withheldFields: 0,
};
private readonly objects = new Map<string, ObjectBlock>();
private readonly compared = new Set<string>();
@@ -823,17 +790,6 @@ class Verifier {
}
private push(page: string, operation: string, kind: string, detail: string): void {
if (kind.endsWith('-missing')) {
const field =
detail
.replace(/ \(variant \d+\)$/u, '')
.split('.')
.pop() ?? detail;
if (CHANNEL_THREADS_FIELDS_UNTIL_GA.has(field)) {
this.counters.withheldFields += 1;
return;
}
}
this.mismatches.push({page, operation, kind, detail});
}
@@ -1012,12 +968,7 @@ class Verifier {
): void {
const resolved = resolveRef(spec, schema);
const allBranches = isUnion(resolved) ? [...(resolved?.oneOf ?? []), ...(resolved?.anyOf ?? [])] : [schema];
const branches = allBranches.filter(
(branch) =>
allBranches.length === 1 ||
![...collectPropertySchemas(spec, branch).keys()].some((field) => CHANNEL_THREADS_FIELDS_UNTIL_GA.has(field)),
);
this.counters.withheldFields += allBranches.length - branches.length;
const branches = allBranches;
if (branches.length > 1) this.counters.unionBodies += 1;
const branchProperties = branches.map((branch) => new Set(collectPropertySchemas(spec, branch).keys()));
const branchRequired = branches.map((branch) => collectRequired(spec, branch));
@@ -1339,7 +1290,6 @@ async function main(): Promise<void> {
);
console.log(`request and response field types compared: ${counters.typesCompared.toString()}`);
console.log(`request body optionality compared: ${counters.optionalityCompared.toString()}`);
console.log(`channel_threads fields withheld until GA: ${counters.withheldFields.toString()}`);
console.log(`optionality advisories: ${optionalityAdvisories.length.toString()}`);
for (const entry of optionalityAdvisories) {
console.log(` ${entry}`);
+4 -4
View File
@@ -283,7 +283,7 @@ const ACCEPTED_TABLE_FINDINGS = new Map<string, Readonly<Partial<Record<TableRul
['http-api/authentication.mdx', {'table-identifier': 1}],
['http-api/billing.mdx', {'table-identifier': 4}],
['http-api/calls.mdx', {'table-fit': 1, 'table-cell': 3}],
['http-api/channels.mdx', {'table-cell': 6}],
['http-api/channels.mdx', {'table-cell': 6, 'table-identifier': 2}],
['http-api/connections.mdx', {'table-cell': 2}],
['http-api/discovery.mdx', {'table-cell': 2}],
['http-api/donations.mdx', {'table-cell': 1}],
@@ -292,7 +292,7 @@ const ACCEPTED_TABLE_FINDINGS = new Map<string, Readonly<Partial<Record<TableRul
['http-api/gifs.mdx', {'table-cell': 5}],
['http-api/gifts.mdx', {'table-cell': 1}],
['http-api/guild-audit-logs.mdx', {'table-identifier': 3}],
['http-api/guild-channels.mdx', {'table-cell': 2}],
['http-api/guild-channels.mdx', {'table-cell': 2, 'table-identifier': 1}],
['http-api/guild-emojis.mdx', {'table-cell': 4}],
['http-api/guild-members.mdx', {'table-identifier': 2}],
['http-api/guild-moderation.mdx', {'table-cell': 1}],
@@ -300,7 +300,7 @@ const ACCEPTED_TABLE_FINDINGS = new Map<string, Readonly<Partial<Record<TableRul
['http-api/guilds.mdx', {'table-fit': 1, 'table-identifier': 4}],
['http-api/instance.mdx', {'table-identifier': 8}],
['http-api/invites.mdx', {'table-cell': 6}],
['http-api/messages.mdx', {'table-fit': 1, 'table-cell': 20, 'table-identifier': 1}],
['http-api/messages.mdx', {'table-fit': 2, 'table-cell': 20, 'table-identifier': 2}],
['http-api/permissions.mdx', {'table-cell': 8}],
['http-api/premium.mdx', {'table-identifier': 5}],
['http-api/reports.mdx', {'table-identifier': 1}],
@@ -308,7 +308,7 @@ const ACCEPTED_TABLE_FINDINGS = new Map<string, Readonly<Partial<Record<TableRul
['http-api/streams.mdx', {'table-cell': 5}],
['http-api/unfurl.mdx', {'table-cell': 2, 'table-parallel': 1}],
['http-api/users.mdx', {'table-fit': 1, 'table-identifier': 3}],
['http-api/users/content.mdx', {'table-cell': 4}],
['http-api/users/content.mdx', {'table-cell': 2}],
['http-api/users/current-user.mdx', {'table-identifier': 1}],
['http-api/users/data-harvest.mdx', {'table-cell': 2}],
['http-api/users/email-and-password.mdx', {'table-identifier': 1}],