mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-06-15 17:19:10 +00:00
big overhaul (#474)
* miniflare init * feat(api): add tests * chore: more tests, almost 100% * add sessions/state spec * add majority of routes and datapaths, start on interactions * nevermind, no interactions * nevermind x2, tweetnacl is bad but SubtleCrypto has what we need apparently * simplify interactions verify * add brute force interactions tests * every primary path API route is refactored! * automatically import from legacy, or die trying. * check that we only fetch legacy once, ever * remove old-src, same some historic pieces * remove interactions & worker-utils package, update misc/types * update some packages we don't need specific pinning for anymore * update web references to API routes since they all changed * fix all linting issues, upgrade most packages * fix tests, divorce enzyme where-ever possible * update web, fix integration issues * pre-build api * fix tests * move api pretest to api package.json instead of CI * remove interactions from terraform, fix deploy side configs * update to tf 1.1.4 * prevent double writes to worker in GCS, port to newer GCP auth workflow * fix api.tf var refs, upgrade node action * change to curl-based script upload for worker script due to terraform provider limitations * oh no, cloudflare freaked out :(
This commit is contained in:
parent
b644a38aa7
commit
3291f9aacc
183 changed files with 9853 additions and 9924 deletions
252
packages/api/historic/audit-log.ts~
Normal file
252
packages/api/historic/audit-log.ts~
Normal file
|
@ -0,0 +1,252 @@
|
|||
import { uiPublicURI } from '@roleypoly/api/utils/config';
|
||||
import {
|
||||
Category,
|
||||
DiscordUser,
|
||||
Embed,
|
||||
GuildData,
|
||||
GuildDataUpdate,
|
||||
GuildSlug,
|
||||
WebhookValidationStatus,
|
||||
} from '@roleypoly/types';
|
||||
import { userAgent } from '@roleypoly/worker-utils';
|
||||
import deepEqual from 'deep-equal';
|
||||
import { sortBy, uniq } from 'lodash';
|
||||
|
||||
type WebhookPayload = {
|
||||
username: string;
|
||||
avatar_url: string;
|
||||
embeds: Embed[];
|
||||
provider: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ChangeHandler = (
|
||||
oldValue: GuildDataUpdate[keyof GuildDataUpdate],
|
||||
newValue: GuildData[keyof GuildDataUpdate]
|
||||
) => Embed[];
|
||||
|
||||
const changeHandlers: Record<keyof GuildDataUpdate, ChangeHandler> = {
|
||||
message: (oldValue, newValue) => [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
color: 0x453e3d,
|
||||
fields: [
|
||||
{
|
||||
name: 'Old Message',
|
||||
value: oldValue as string,
|
||||
inline: false,
|
||||
},
|
||||
{
|
||||
name: 'New Message',
|
||||
value: newValue as string,
|
||||
inline: false,
|
||||
},
|
||||
],
|
||||
title: `Server message was updated...`,
|
||||
},
|
||||
],
|
||||
auditLogWebhook: (oldValue, newValue) => [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
color: 0x5d5352,
|
||||
fields: [
|
||||
{
|
||||
name: 'Old Webhook ID',
|
||||
value: !oldValue ? '*unset*' : (oldValue as string).split('/')[5],
|
||||
inline: false,
|
||||
},
|
||||
{
|
||||
name: 'New Webhook ID',
|
||||
value: !newValue ? '*unset*' : (newValue as string).split('/')[5],
|
||||
inline: false,
|
||||
},
|
||||
],
|
||||
title: `Audit Log webhook URL was changed...`,
|
||||
},
|
||||
],
|
||||
categories: (oldValue, newValue) => [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
color: 0xab9b9a,
|
||||
fields: [
|
||||
{
|
||||
name: 'Changed Categories',
|
||||
value: getChangedCategories(
|
||||
oldValue as Category[],
|
||||
newValue as Category[]
|
||||
).join('\n'),
|
||||
inline: false,
|
||||
},
|
||||
],
|
||||
title: `Categories were changed...`,
|
||||
},
|
||||
],
|
||||
accessControl: (oldValue, newValue) => [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
color: 0xab9b9a,
|
||||
fields: [
|
||||
{
|
||||
name: 'Changed Access Control',
|
||||
value: getChangedAccessControl(
|
||||
oldValue as GuildDataUpdate['accessControl'],
|
||||
newValue as GuildDataUpdate['accessControl']
|
||||
).join('\n'),
|
||||
inline: false,
|
||||
},
|
||||
],
|
||||
title: `Access Control was changed...`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sendAuditLog = async (
|
||||
guild: GuildData,
|
||||
guildUpdate: GuildDataUpdate,
|
||||
user: DiscordUser
|
||||
) => {
|
||||
const auditLogWebhooks = uniq([
|
||||
guild.auditLogWebhook || '',
|
||||
guildUpdate.auditLogWebhook || '',
|
||||
]).filter((webhook) => webhook !== '');
|
||||
|
||||
if (auditLogWebhooks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = Object.keys(guildUpdate) as (keyof GuildDataUpdate)[];
|
||||
const webhookPayload: WebhookPayload = {
|
||||
username: 'Roleypoly (Audit Log)',
|
||||
avatar_url: `https://next.roleypoly.com/logo192.png`, //TODO: change to roleypoly.com when swapped.
|
||||
embeds: [
|
||||
{
|
||||
fields: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
title: `${user.username}#${user.discriminator} has edited Roleypoly settings`,
|
||||
color: 0x332d2d,
|
||||
author: {
|
||||
name: user.username,
|
||||
icon_url: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`,
|
||||
},
|
||||
},
|
||||
],
|
||||
provider: {
|
||||
name: 'Roleypoly',
|
||||
url: uiPublicURI,
|
||||
},
|
||||
};
|
||||
|
||||
for (let key of keys) {
|
||||
if (deepEqual(guildUpdate[key], guild[key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const handler = changeHandlers[key];
|
||||
if (!handler) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const changeFields = handler(guild[key], guildUpdate[key]);
|
||||
webhookPayload.embeds.push(...changeFields);
|
||||
}
|
||||
|
||||
if (webhookPayload.embeds.length === 1) {
|
||||
// No changes, don't bother sending
|
||||
return;
|
||||
}
|
||||
|
||||
// Colors are in order already, so use them to order the embeds.
|
||||
webhookPayload.embeds = sortBy(webhookPayload.embeds, 'color');
|
||||
|
||||
const doWebhook = (webhook: string) =>
|
||||
fetch(webhook, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookPayload),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'user-agent': userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(auditLogWebhooks.map((webhook) => doWebhook(webhook)));
|
||||
};
|
||||
|
||||
export const validateAuditLogWebhook = async (
|
||||
guild: GuildSlug,
|
||||
webhook: string | null
|
||||
): Promise<WebhookValidationStatus> => {
|
||||
if (!webhook) {
|
||||
return WebhookValidationStatus.NoneSet;
|
||||
}
|
||||
|
||||
const url = new URL(webhook);
|
||||
|
||||
if (
|
||||
url.hostname !== 'discord.com' ||
|
||||
url.protocol !== 'https:' ||
|
||||
url.pathname.startsWith('api/webhooks/')
|
||||
) {
|
||||
return WebhookValidationStatus.NotDiscordURL;
|
||||
}
|
||||
|
||||
const response = await fetch(webhook, { method: 'GET' });
|
||||
if (response.status !== 200) {
|
||||
return WebhookValidationStatus.DoesNotExist;
|
||||
}
|
||||
|
||||
const webhookData = await response.json();
|
||||
|
||||
if (webhookData.guild_id !== guild.id) {
|
||||
return WebhookValidationStatus.NotSameGuild;
|
||||
}
|
||||
|
||||
return WebhookValidationStatus.Ok;
|
||||
};
|
||||
|
||||
const getChangedCategories = (oldCategories: Category[], newCategories: Category[]) => {
|
||||
const addedCategories = newCategories.filter(
|
||||
(c) => !oldCategories.find((o) => o.id === c.id)
|
||||
);
|
||||
const removedCategories = oldCategories.filter(
|
||||
(c) => !newCategories.find((o) => o.id === c.id)
|
||||
);
|
||||
const changedCategories = newCategories.filter(
|
||||
(c) =>
|
||||
oldCategories.find((o) => o.id === c.id) &&
|
||||
!deepEqual(
|
||||
oldCategories.find((o) => o.id === c.id),
|
||||
newCategories.find((o) => o.id === c.id)
|
||||
)
|
||||
);
|
||||
|
||||
return [
|
||||
...addedCategories.map((c) => `➕ **Added** ${c.name}`),
|
||||
...removedCategories.map((c) => `➖ **Removed** ${c.name}`),
|
||||
...changedCategories.map((c) => `🔧 **Changed** ${c.name}`),
|
||||
];
|
||||
};
|
||||
|
||||
const getChangedAccessControl = (
|
||||
oldAccessControl: GuildDataUpdate['accessControl'],
|
||||
newAccessControl: GuildDataUpdate['accessControl']
|
||||
) => {
|
||||
const pendingChanged = newAccessControl.blockPending !== oldAccessControl.blockPending;
|
||||
|
||||
return [
|
||||
`✅ Allowed roles: ${
|
||||
newAccessControl.allowList.map((role) => `<@&${role}>`).join(', ') || `*all roles*`
|
||||
}`,
|
||||
`❌ Blocked roles: ${
|
||||
newAccessControl.blockList.map((role) => `<@&${role}>`).join(', ') || `*no roles*`
|
||||
}`,
|
||||
...(pendingChanged
|
||||
? [
|
||||
`🔧 Pending/Welcome Screening users are ${
|
||||
newAccessControl.blockPending ? 'blocked ❌' : 'allowed ✔'
|
||||
}`,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
};
|
104
packages/api/historic/interactions-pick-role.ts~
Normal file
104
packages/api/historic/interactions-pick-role.ts~
Normal file
|
@ -0,0 +1,104 @@
|
|||
import { CategoryType, Member, RoleSafety } from '@roleypoly/types';
|
||||
import { AuthType, discordFetch, respond } from '@roleypoly/worker-utils';
|
||||
import { difference, keyBy } from 'lodash';
|
||||
import { interactionsEndpoint } from '../utils/api-tools';
|
||||
import { botToken } from '../utils/config';
|
||||
import {
|
||||
getGuild,
|
||||
getGuildData,
|
||||
getGuildMember,
|
||||
updateGuildMember,
|
||||
} from '../utils/guild';
|
||||
import { conflict, invalid, notAuthenticated, notFound, ok } from '../utils/responses';
|
||||
|
||||
export const InteractionsPickRole = interactionsEndpoint(
|
||||
async (request: Request): Promise<Response> => {
|
||||
const mode = request.method === 'PUT' ? 'add' : 'remove';
|
||||
const reqURL = new URL(request.url);
|
||||
const [, , guildID, userID, roleID] = reqURL.pathname.split('/');
|
||||
if (!guildID || !userID || !roleID) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const guildP = getGuild(guildID);
|
||||
const guildDataP = getGuildData(guildID);
|
||||
const guildMemberP = getGuildMember(
|
||||
{ serverID: guildID, userID },
|
||||
{ skipCachePull: true }
|
||||
);
|
||||
|
||||
const [guild, guildData, guildMember] = await Promise.all([
|
||||
guildP,
|
||||
guildDataP,
|
||||
guildMemberP,
|
||||
]);
|
||||
|
||||
if (!guild || !guildData || !guildMember) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
let memberRoles = guildMember.roles;
|
||||
|
||||
if (
|
||||
(mode === 'add' && memberRoles.includes(roleID)) ||
|
||||
(mode !== 'add' && !memberRoles.includes(roleID))
|
||||
) {
|
||||
return conflict();
|
||||
}
|
||||
|
||||
const roleMap = keyBy(guild.roles, 'id');
|
||||
|
||||
const category = guildData.categories.find((category) =>
|
||||
category.roles.includes(roleID)
|
||||
);
|
||||
// No category? illegal.
|
||||
if (!category) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// Category is hidden, this is illegal
|
||||
if (category.hidden) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// Role is unsafe, super illegal.
|
||||
if (roleMap[roleID].safety !== RoleSafety.Safe) {
|
||||
return notAuthenticated();
|
||||
}
|
||||
|
||||
// In add mode, if the category is a single-mode, remove the other roles in the category.
|
||||
if (mode === 'add' && category.type === CategoryType.Single) {
|
||||
memberRoles = difference(memberRoles, category.roles);
|
||||
}
|
||||
|
||||
if (mode === 'add') {
|
||||
memberRoles = [...memberRoles, roleID];
|
||||
} else {
|
||||
memberRoles = memberRoles.filter((id) => id !== roleID);
|
||||
}
|
||||
|
||||
const patchMemberRoles = await discordFetch<Member>(
|
||||
`/guilds/${guildID}/members/${userID}`,
|
||||
botToken,
|
||||
AuthType.Bot,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-audit-log-reason': `Picked their roles via slash command`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
roles: memberRoles,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!patchMemberRoles) {
|
||||
return respond({ error: 'discord rejected the request' }, { status: 500 });
|
||||
}
|
||||
|
||||
await updateGuildMember({ serverID: guildID, userID });
|
||||
|
||||
return ok();
|
||||
}
|
||||
);
|
33
packages/api/historic/interactions-pickable-roles.ts~
Normal file
33
packages/api/historic/interactions-pickable-roles.ts~
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { Category, CategorySlug } from '@roleypoly/types';
|
||||
import { respond } from '@roleypoly/worker-utils';
|
||||
import { interactionsEndpoint } from '../utils/api-tools';
|
||||
import { getGuildData } from '../utils/guild';
|
||||
import { notFound } from '../utils/responses';
|
||||
|
||||
export const InteractionsPickableRoles = interactionsEndpoint(
|
||||
async (request: Request): Promise<Response> => {
|
||||
const reqURL = new URL(request.url);
|
||||
const [, , serverID] = reqURL.pathname.split('/');
|
||||
|
||||
const guildData = await getGuildData(serverID);
|
||||
if (!guildData) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const roleMap: Record<Category['name'], CategorySlug> = {};
|
||||
|
||||
for (let category of guildData.categories) {
|
||||
if (category.hidden) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: role safety?
|
||||
roleMap[category.name] = {
|
||||
roles: category.roles,
|
||||
type: category.type,
|
||||
};
|
||||
}
|
||||
|
||||
return respond(roleMap);
|
||||
}
|
||||
);
|
16
packages/api/historic/interactions/hello-world.ts~
Normal file
16
packages/api/historic/interactions/hello-world.ts~
Normal file
|
@ -0,0 +1,16 @@
|
|||
import {
|
||||
InteractionCallbackType,
|
||||
InteractionRequestCommand,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const helloWorld = async (
|
||||
interaction: InteractionRequestCommand
|
||||
): Promise<InteractionResponse> => {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `Hey there, ${interaction.member?.nick || interaction.user?.username}`,
|
||||
},
|
||||
};
|
||||
};
|
86
packages/api/historic/interactions/pick-role.ts~
Normal file
86
packages/api/historic/interactions/pick-role.ts~
Normal file
|
@ -0,0 +1,86 @@
|
|||
import { selectRole } from '@roleypoly/interactions/utils/api';
|
||||
import {
|
||||
asyncPreflightEphemeral,
|
||||
asyncResponse,
|
||||
} from '@roleypoly/interactions/utils/interactions';
|
||||
import { invalid, mustBeInGuild } from '@roleypoly/interactions/utils/responses';
|
||||
import {
|
||||
InteractionCallbackType,
|
||||
InteractionFlags,
|
||||
InteractionRequestCommand,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
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 roleID = interaction.data.options?.find(
|
||||
(option) => option.name === 'role'
|
||||
)?.value;
|
||||
if (!roleID) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:white_check_mark: You ${
|
||||
mode === 'add' ? 'got' : 'removed'
|
||||
} the role: <@&${roleID}>`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
},
|
||||
asyncPreflightEphemeral
|
||||
);
|
63
packages/api/historic/interactions/pickable-roles.ts~
Normal file
63
packages/api/historic/interactions/pickable-roles.ts~
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { getPickableRoles } from '@roleypoly/interactions/utils/api';
|
||||
import { uiPublicURI } from '@roleypoly/interactions/utils/config';
|
||||
import {
|
||||
asyncPreflightEphemeral,
|
||||
asyncResponse,
|
||||
} from '@roleypoly/interactions/utils/interactions';
|
||||
import { mustBeInGuild } from '@roleypoly/interactions/utils/responses';
|
||||
import {
|
||||
CategoryType,
|
||||
Embed,
|
||||
InteractionCallbackType,
|
||||
InteractionFlags,
|
||||
InteractionRequestCommand,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
asyncPreflightEphemeral
|
||||
);
|
56
packages/api/historic/interactions/roleypoly.ts~
Normal file
56
packages/api/historic/interactions/roleypoly.ts~
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { uiPublicURI } from '@roleypoly/interactions/utils/config';
|
||||
import {
|
||||
Embed,
|
||||
InteractionCallbackType,
|
||||
InteractionFlags,
|
||||
InteractionRequestCommand,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const roleypoly = async (
|
||||
interaction: InteractionRequestCommand
|
||||
): Promise<InteractionResponse> => {
|
||||
if (interaction.guild_id) {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
embeds: [
|
||||
{
|
||||
color: 0x453e3d,
|
||||
title: `:beginner: Hey there, ${
|
||||
interaction.member?.nick || interaction.member?.user?.username || 'friend'
|
||||
}!`,
|
||||
description: `Try these slash commands, or pick roles from your browser!`,
|
||||
fields: [
|
||||
{ name: 'See all the roles', value: '/pickable-roles' },
|
||||
{ name: 'Pick a role', value: '/pick-role' },
|
||||
{ name: 'Remove a role', value: '/remove-role' },
|
||||
],
|
||||
} as Embed,
|
||||
],
|
||||
components: [
|
||||
{
|
||||
type: 1,
|
||||
components: [
|
||||
// Link to Roleypoly
|
||||
{
|
||||
type: 2,
|
||||
label: `Pick roles on ${new URL(uiPublicURI).hostname}`,
|
||||
url: `${uiPublicURI}/s/${interaction.guild_id}`,
|
||||
style: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:beginner: Hey! I don't know what server you're in, so check out ${uiPublicURI}`,
|
||||
},
|
||||
};
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue