mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
docs(threads): publish threads, forums and media channels (#3342)
This commit is contained in:
1 parent
4ce4d4e09a
commit
7a5b9a0ded
56 files changed
+1328
-683
No files matched your search
@@ -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(/</gu, '<')
|
||||
.replace(/>/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();
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}],
|
||||
|
||||
Reference in new issue
Block a user