mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-06-14 16:49:10 +00:00
fix(interactions): add async responses
This commit is contained in:
parent
3601d435b2
commit
26bc74bcbc
11 changed files with 220 additions and 149 deletions
|
@ -66,5 +66,5 @@ router.addFallback('root', () => {
|
|||
});
|
||||
|
||||
addEventListener('fetch', (event: FetchEvent) => {
|
||||
event.respondWith(router.handle(event.request));
|
||||
event.respondWith(router.handle(event));
|
||||
});
|
||||
|
|
|
@ -4,7 +4,7 @@ import {
|
|||
permissions as Permissions,
|
||||
} from '@roleypoly/misc-utils/hasPermission';
|
||||
import { SessionData, UserGuildPermissions } from '@roleypoly/types';
|
||||
import { Handler, WrappedKVNamespace } from '@roleypoly/worker-utils';
|
||||
import { Handler, HandlerTools, WrappedKVNamespace } from '@roleypoly/worker-utils';
|
||||
import KSUID from 'ksuid';
|
||||
import {
|
||||
allowedCallbackHosts,
|
||||
|
@ -107,7 +107,7 @@ const NotAuthenticated = (extra?: string) =>
|
|||
|
||||
export const withSession =
|
||||
(wrappedHandler: (session: SessionData) => Handler): Handler =>
|
||||
async (request: Request): Promise<Response> => {
|
||||
async (request: Request, tools: HandlerTools): Promise<Response> => {
|
||||
const sessionID = getSessionID(request);
|
||||
if (!sessionID) {
|
||||
return NotAuthenticated('missing authentication');
|
||||
|
@ -118,7 +118,7 @@ export const withSession =
|
|||
return NotAuthenticated('authentication expired or not found');
|
||||
}
|
||||
|
||||
return await wrappedHandler(session)(request);
|
||||
return await wrappedHandler(session)(request, tools);
|
||||
};
|
||||
|
||||
export const setupStateSession = async <T>(data: T): Promise<string> => {
|
||||
|
@ -138,9 +138,9 @@ export const getStateSession = async <T>(stateID: string): Promise<T | undefined
|
|||
export const isRoot = (userID: string): boolean => rootUsers.includes(userID);
|
||||
|
||||
export const onlyRootUsers = (handler: Handler): Handler =>
|
||||
withSession((session) => (request: Request) => {
|
||||
withSession((session) => (request: Request, tools: HandlerTools) => {
|
||||
if (isRoot(session.user.id)) {
|
||||
return handler(request);
|
||||
return handler(request, tools);
|
||||
}
|
||||
|
||||
return respond(
|
||||
|
@ -166,11 +166,11 @@ export const isAllowedCallbackHost = (host: string): boolean => {
|
|||
|
||||
export const interactionsEndpoint =
|
||||
(handler: Handler): Handler =>
|
||||
async (request: Request): Promise<Response> => {
|
||||
async (request: Request, tools: HandlerTools): Promise<Response> => {
|
||||
const authHeader = request.headers.get('authorization') || '';
|
||||
if (authHeader !== `Shared ${interactionsSharedKey}`) {
|
||||
return notAuthenticated();
|
||||
}
|
||||
|
||||
return handler(request);
|
||||
return handler(request, tools);
|
||||
};
|
||||
|
|
|
@ -101,6 +101,9 @@ const server = http.createServer((req, res) => {
|
|||
);
|
||||
isResponseConstructorAllowed = false;
|
||||
},
|
||||
waitUntil: async (promise) => {
|
||||
await promise;
|
||||
},
|
||||
request: new fetch.Request(
|
||||
new URL(`http://${req.headers.host || 'localhost'}${req.url}`),
|
||||
{
|
||||
|
|
|
@ -2,21 +2,17 @@ import {
|
|||
InteractionData,
|
||||
InteractionRequest,
|
||||
InteractionRequestCommand,
|
||||
InteractionResponse,
|
||||
InteractionType,
|
||||
} from '@roleypoly/types';
|
||||
import { respond } from '@roleypoly/worker-utils';
|
||||
import { verifyRequest } from '../utils/interactions';
|
||||
import { HandlerTools, respond } from '@roleypoly/worker-utils';
|
||||
import { CommandHandler, verifyRequest } from '../utils/interactions';
|
||||
import { somethingWentWrong } from '../utils/responses';
|
||||
import { helloWorld } from './interactions/hello-world';
|
||||
import { pickRole } from './interactions/pick-role';
|
||||
import { pickableRoles } from './interactions/pickable-roles';
|
||||
import { roleypoly } from './interactions/roleypoly';
|
||||
|
||||
const commands: Record<
|
||||
InteractionData['name'],
|
||||
(request: InteractionRequestCommand) => Promise<InteractionResponse>
|
||||
> = {
|
||||
const commands: Record<InteractionData['name'], CommandHandler> = {
|
||||
'hello-world': helloWorld,
|
||||
roleypoly: roleypoly,
|
||||
'pickable-roles': pickableRoles,
|
||||
|
@ -24,7 +20,10 @@ const commands: Record<
|
|||
'remove-role': pickRole('remove'),
|
||||
};
|
||||
|
||||
export const interactionHandler = async (request: Request): Promise<Response> => {
|
||||
export const interactionHandler = async (
|
||||
request: Request,
|
||||
{ waitUntil }: HandlerTools
|
||||
): Promise<Response> => {
|
||||
const interaction = (await request.json()) as InteractionRequest;
|
||||
|
||||
if (!verifyRequest(request, interaction)) {
|
||||
|
@ -49,7 +48,10 @@ export const interactionHandler = async (request: Request): Promise<Response> =>
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await handler(interaction as InteractionRequestCommand);
|
||||
const response = await handler(interaction as InteractionRequestCommand, {
|
||||
request,
|
||||
waitUntil,
|
||||
});
|
||||
return respond(response);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { selectRole } from '@roleypoly/interactions/utils/api';
|
||||
import { asyncResponse } from '@roleypoly/interactions/utils/interactions';
|
||||
import { invalid, mustBeInGuild } from '@roleypoly/interactions/utils/responses';
|
||||
import {
|
||||
InteractionCallbackType,
|
||||
|
@ -7,74 +8,75 @@ import {
|
|||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const pickRole =
|
||||
(mode: 'add' | 'remove') =>
|
||||
async (interaction: InteractionRequestCommand): Promise<InteractionResponse> => {
|
||||
if (!interaction.guild_id) {
|
||||
return mustBeInGuild();
|
||||
}
|
||||
export const pickRole = (mode: 'add' | 'remove') =>
|
||||
asyncResponse(
|
||||
async (interaction: InteractionRequestCommand): Promise<InteractionResponse> => {
|
||||
if (!interaction.guild_id) {
|
||||
return mustBeInGuild();
|
||||
}
|
||||
|
||||
const userID = interaction.member?.user?.id;
|
||||
if (!userID) {
|
||||
return mustBeInGuild();
|
||||
}
|
||||
const userID = interaction.member?.user?.id;
|
||||
if (!userID) {
|
||||
return mustBeInGuild();
|
||||
}
|
||||
|
||||
const roleID = interaction.data.options?.find(
|
||||
(option) => option.name === 'role'
|
||||
)?.value;
|
||||
if (!roleID) {
|
||||
return invalid();
|
||||
}
|
||||
const roleID = interaction.data.options?.find(
|
||||
(option) => option.name === 'role'
|
||||
)?.value;
|
||||
if (!roleID) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const code = await selectRole(mode, interaction.guild_id, userID, roleID);
|
||||
const code = await selectRole(mode, interaction.guild_id, userID, roleID);
|
||||
|
||||
if (code === 409) {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:x: You ${mode === 'add' ? 'already' : "don't"} have that role.`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (code === 404) {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:x: <@&${roleID}> isn't pickable.`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (code === 403) {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:x: <@&${roleID}> has unsafe permissions.`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (code !== 200) {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:x: Something went wrong, please try again later.`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (code === 409) {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:x: You ${mode === 'add' ? 'already' : "don't"} have that role.`,
|
||||
content: `:white_check_mark: You ${
|
||||
mode === 'add' ? 'got' : 'removed'
|
||||
} the role: <@&${roleID}>`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (code === 404) {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:x: <@&${roleID}> isn't pickable.`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (code === 403) {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:x: <@&${roleID}> has unsafe permissions.`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (code !== 200) {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:x: Something went wrong, please try again later.`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:white_check_mark: You ${
|
||||
mode === 'add' ? 'got' : 'removed'
|
||||
} the role: <@&${roleID}>`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
};
|
||||
);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { getPickableRoles } from '@roleypoly/interactions/utils/api';
|
||||
import { uiPublicURI } from '@roleypoly/interactions/utils/config';
|
||||
import { asyncResponse } from '@roleypoly/interactions/utils/interactions';
|
||||
import { mustBeInGuild } from '@roleypoly/interactions/utils/responses';
|
||||
import {
|
||||
CategoryType,
|
||||
|
@ -10,49 +11,49 @@ import {
|
|||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const pickableRoles = async (
|
||||
interaction: InteractionRequestCommand
|
||||
): Promise<InteractionResponse> => {
|
||||
if (!interaction.guild_id) {
|
||||
return mustBeInGuild();
|
||||
export const pickableRoles = asyncResponse(
|
||||
async (interaction: InteractionRequestCommand): Promise<InteractionResponse> => {
|
||||
if (!interaction.guild_id) {
|
||||
return mustBeInGuild();
|
||||
}
|
||||
|
||||
const pickableRoles = await getPickableRoles(interaction.guild_id);
|
||||
const embed: Embed = {
|
||||
color: 0xab9b9a,
|
||||
fields: [],
|
||||
title: 'You can pick any of these roles with /pick-role',
|
||||
};
|
||||
|
||||
for (let categoryName in pickableRoles) {
|
||||
const { roles, type } = pickableRoles[categoryName];
|
||||
|
||||
embed.fields.push({
|
||||
name: `${categoryName}${type === CategoryType.Single ? ' *(pick one)*' : ''}`,
|
||||
value: roles.map((role) => `<@&${role}>`).join('\n'),
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
embeds: [embed],
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
components: [
|
||||
{
|
||||
type: 1,
|
||||
components: [
|
||||
// Link to Roleypoly
|
||||
{
|
||||
type: 2,
|
||||
label: 'Pick roles on your browser',
|
||||
url: `${uiPublicURI}/s/${interaction.guild_id}`,
|
||||
style: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const pickableRoles = await getPickableRoles(interaction.guild_id);
|
||||
const embed: Embed = {
|
||||
color: 0xab9b9a,
|
||||
fields: [],
|
||||
title: 'You can pick any of these roles with /pick-role',
|
||||
};
|
||||
|
||||
for (let categoryName in pickableRoles) {
|
||||
const { roles, type } = pickableRoles[categoryName];
|
||||
|
||||
embed.fields.push({
|
||||
name: `${categoryName}${type === CategoryType.Single ? ' *(pick one)*' : ''}`,
|
||||
value: roles.map((role) => `<@&${role}>`).join('\n'),
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
embeds: [embed],
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
components: [
|
||||
{
|
||||
type: 1,
|
||||
components: [
|
||||
// Link to Roleypoly
|
||||
{
|
||||
type: 2,
|
||||
label: 'Pick roles on your browser',
|
||||
url: `${uiPublicURI}/s/${interaction.guild_id}`,
|
||||
style: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
);
|
||||
|
|
|
@ -25,5 +25,5 @@ router.addFallback('root', () => {
|
|||
});
|
||||
|
||||
addEventListener('fetch', (event: FetchEvent) => {
|
||||
event.respondWith(router.handle(event.request));
|
||||
event.respondWith(router.handle(event));
|
||||
});
|
||||
|
|
|
@ -1,5 +1,12 @@
|
|||
import { publicKey } from '@roleypoly/interactions/utils/config';
|
||||
import { InteractionRequest } from '@roleypoly/types';
|
||||
import {
|
||||
InteractionCallbackType,
|
||||
InteractionFlags,
|
||||
InteractionRequest,
|
||||
InteractionRequestCommand,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
import { AuthType, discordFetch, HandlerTools } from '@roleypoly/worker-utils';
|
||||
import nacl from 'tweetnacl';
|
||||
|
||||
export const verifyRequest = (
|
||||
|
@ -25,3 +32,47 @@ export const verifyRequest = (
|
|||
|
||||
return true;
|
||||
};
|
||||
|
||||
export type RequestInfo = HandlerTools & { request: Request };
|
||||
|
||||
export type CommandHandler = (
|
||||
request: InteractionRequestCommand,
|
||||
requestInfo: RequestInfo
|
||||
) => Promise<InteractionResponse>;
|
||||
|
||||
export const asyncResponse =
|
||||
(handler: CommandHandler): CommandHandler =>
|
||||
async (
|
||||
command: InteractionRequestCommand,
|
||||
requestInfo: RequestInfo
|
||||
): Promise<InteractionResponse> => {
|
||||
requestInfo.waitUntil(
|
||||
(async () => {
|
||||
const response = await handler(command, requestInfo);
|
||||
await updateOriginalMessage(command.application_id, command.token, response);
|
||||
})()
|
||||
);
|
||||
|
||||
return {
|
||||
type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const updateOriginalMessage = async (
|
||||
appID: string,
|
||||
token: string,
|
||||
response: InteractionResponse
|
||||
) => {
|
||||
const url = `/webhooks/${appID}/${token}/messages/@original`;
|
||||
|
||||
return await discordFetch(url, '', AuthType.None, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(response.data),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -5,6 +5,7 @@ export const discordAPIBase = 'https://discordapp.com/api/v9';
|
|||
export enum AuthType {
|
||||
Bearer = 'Bearer',
|
||||
Bot = 'Bot',
|
||||
None = 'None',
|
||||
}
|
||||
|
||||
export const discordFetch = async <T>(
|
||||
|
@ -17,7 +18,11 @@ export const discordFetch = async <T>(
|
|||
...(init || {}),
|
||||
headers: {
|
||||
...(init?.headers || {}),
|
||||
authorization: `${AuthType[authType]} ${auth}`,
|
||||
...(authType !== AuthType.None
|
||||
? {
|
||||
authorization: `${AuthType[authType]} ${auth}`,
|
||||
}
|
||||
: {}),
|
||||
'user-agent': userAgent,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,4 +1,11 @@
|
|||
export type Handler = (request: Request) => Promise<Response> | Response;
|
||||
export type Handler = (
|
||||
request: Request,
|
||||
tools: HandlerTools
|
||||
) => Promise<Response> | Response;
|
||||
|
||||
export type HandlerTools = {
|
||||
waitUntil: FetchEvent['waitUntil'];
|
||||
};
|
||||
|
||||
type RoutingTree = {
|
||||
[method: string]: {
|
||||
|
@ -40,17 +47,17 @@ export class Router {
|
|||
this.routingTree[lowerMethod][rootPath] = handler;
|
||||
}
|
||||
|
||||
async handle(request: Request): Promise<Response> {
|
||||
const response = await this.processRequest(request);
|
||||
this.injectCORSHeaders(request, response.headers);
|
||||
async handle(event: FetchEvent): Promise<Response> {
|
||||
const response = await this.processRequest(event);
|
||||
this.injectCORSHeaders(event.request, response.headers);
|
||||
return response;
|
||||
}
|
||||
|
||||
private async processRequest(request: Request): Promise<Response> {
|
||||
private async processRequest({ request, waitUntil }: FetchEvent): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === '/' || url.pathname === '') {
|
||||
return this.fallbacks.root(request);
|
||||
return this.fallbacks.root(request, { waitUntil });
|
||||
}
|
||||
const lowerMethod = request.method.toLowerCase();
|
||||
const rootPath = url.pathname.split('/')[1];
|
||||
|
@ -58,11 +65,11 @@ export class Router {
|
|||
|
||||
if (handler) {
|
||||
try {
|
||||
const response = await handler(request);
|
||||
const response = await handler(request, { waitUntil });
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return this.fallbacks[500](request);
|
||||
return this.fallbacks[500](request, { waitUntil });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -70,7 +77,7 @@ export class Router {
|
|||
return new Response(null, {});
|
||||
}
|
||||
|
||||
return this.fallbacks[404](request);
|
||||
return this.fallbacks[404](request, { waitUntil });
|
||||
}
|
||||
|
||||
private respondToRoot(): Response {
|
||||
|
|
30
terraform/.terraform.lock.hcl
generated
30
terraform/.terraform.lock.hcl
generated
|
@ -2,23 +2,23 @@
|
|||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/cloudflare/cloudflare" {
|
||||
version = "2.24.0"
|
||||
version = "2.25.0"
|
||||
constraints = ">= 2.23.0"
|
||||
hashes = [
|
||||
"h1:+fGNZaqk0IPH3M5yOsu978u5t9Q5YP1PrGXSggJUlFQ=",
|
||||
"zh:10bb13bff60c8c9e234b64ea3d8c37be512459f40fdd97aafc5d60631377b46e",
|
||||
"zh:1ba01e5636fe79c205908e55a966cb6249f66a657aca62ea040b5b41717a1763",
|
||||
"zh:1f5870e2602ebaeca40f048c1466e976ac0db66e41297b327ac816001c4090d5",
|
||||
"zh:203f03b9aa58e9a7516f09f13cff08c00e8e534921ac597cf05634e793f6c9fe",
|
||||
"zh:2cae731aeee1c511ba26aa64ecb4537931f5ab467e4bc8e07bbbdf82fe11e6c0",
|
||||
"zh:89f0eb8df82407fb48add3fe4dd38817e4625f5986a69259535bdf5b6ac6d281",
|
||||
"zh:952c5a213acdace04f86ca4a79a99f476b7da6f69edd0e616e47fb75aa3b77f9",
|
||||
"zh:958d08bc7a3ca6275106db0d4251a19fa8a5ad0302652439b3c2cc57a80fed74",
|
||||
"zh:b7797b2fa0377a5c2610d42bf9a1306c1dec4895d5d52e8f7e50340d072d3065",
|
||||
"zh:c95a1680531f3c1640d7869d69a4abbc184f36a060462920acc756b6ac6c91d9",
|
||||
"zh:ca7e8438967d31afb8a73473fff237dcddcefc5e5ca3a3159ab941df1e683de1",
|
||||
"zh:ddfae3c9305aa7299744992e70b61e4919cbe44a4ca561161f77e011a77a0233",
|
||||
"zh:e85b3814322e1f0a73718fec46bbf3a3d0bda3ea86cc6a875b4b584517558051",
|
||||
"h1:raEhY+rMHKBNMwXBWYZp4DUua3JNDq5frLu6VOl71Zg=",
|
||||
"zh:0c2e4665615fa732cd7a025599afa788cf980d1e3e89b20127f8dd5198a3e124",
|
||||
"zh:1d09a74f6ee533ae9db4064a9a6ec466ca7e7f53ed2033f95ce66ff066becc9e",
|
||||
"zh:21f9c1b5d30dc691909b4d1f4635171dc53009109cd7b15ac3ee9c23d84dcc10",
|
||||
"zh:332cbf7da45231fda97d4ca9d840d70558055b45fb63247f57717572c09edde4",
|
||||
"zh:4056c8fd7b6ca9f2b96a583ee11e3ee5e11ec0706a4f5fa1816f2bacda359a31",
|
||||
"zh:5a6e134331acc5c9833993f98cbe48e05c995b8d09ac5d4a11fe4c1488fa34ed",
|
||||
"zh:71c30ce22b906f9130b7d1afe6ca5be281f7dda1b46c6565b9acd22ca3e30fcb",
|
||||
"zh:83e53ed8489a8c65d8e39e43ba5c8298b19eba0dfa30c0d2f99d69e81e6516af",
|
||||
"zh:89900baa4735eb9c678815689979765653ff639a528ac0bbe3fceee50028bea8",
|
||||
"zh:8ba0ea263f0e04589ec95de2381913e6a3b71d7b67c2e2ddbdd78a023ce5949f",
|
||||
"zh:a98c952cda50a7286e8286697b195d1dc8c016090023b4b8cd6f772734b7fd71",
|
||||
"zh:c670798e60fd4807524bf7d407ad284c5674b32f42e56005f63cd356233a2310",
|
||||
"zh:dadd6787f390379f7f231e1176d24ff7841ac9c6b1e742c6bf506309492f89e1",
|
||||
]
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue