494 lines
13 KiB
TypeScript
494 lines
13 KiB
TypeScript
/*
|
|
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
|
|
Copyright (C) 2023 Spacebar and Spacebar Contributors
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published
|
|
by the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import {
|
|
CLOSECODES,
|
|
Capabilities,
|
|
OPCODES,
|
|
Payload,
|
|
Send,
|
|
WebSocket,
|
|
setupListener,
|
|
} from "@spacebar/gateway";
|
|
import {
|
|
Application,
|
|
Config,
|
|
DMChannel,
|
|
DefaultUserGuildSettings,
|
|
EVENTEnum,
|
|
Guild,
|
|
GuildOrUnavailable,
|
|
IdentifySchema,
|
|
Intents,
|
|
Member,
|
|
MemberPrivateProjection,
|
|
OPCodes,
|
|
Permissions,
|
|
PresenceUpdateEvent,
|
|
PrivateSessionProjection,
|
|
PrivateUserProjection,
|
|
PublicUser,
|
|
PublicUserProjection,
|
|
ReadState,
|
|
ReadyEventData,
|
|
ReadyGuildDTO,
|
|
ReadyUserGuildSettingsEntries,
|
|
Recipient,
|
|
Session,
|
|
SessionsReplace,
|
|
UserSettings,
|
|
checkToken,
|
|
emitEvent,
|
|
getDatabase,
|
|
} from "@spacebar/util";
|
|
import { check } from "./instanceOf";
|
|
|
|
// TODO: user sharding
|
|
// TODO: check privileged intents, if defined in the config
|
|
|
|
const tryGetUserFromToken = async (...args: Parameters<typeof checkToken>) => {
|
|
try {
|
|
return (await checkToken(...args)).user;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export async function onIdentify(this: WebSocket, data: Payload) {
|
|
if (this.user_id) {
|
|
// we've already identified
|
|
return this.close(CLOSECODES.Already_authenticated);
|
|
}
|
|
|
|
clearTimeout(this.readyTimeout);
|
|
|
|
// Check payload matches schema
|
|
check.call(this, IdentifySchema, data.d);
|
|
const identify: IdentifySchema = data.d;
|
|
|
|
this.capabilities = new Capabilities(identify.capabilities || 0);
|
|
|
|
const user = await tryGetUserFromToken(identify.token, {
|
|
relations: ["relationships", "relationships.to", "settings"],
|
|
select: [...PrivateUserProjection, "relationships"],
|
|
});
|
|
if (!user) return this.close(CLOSECODES.Authentication_failed);
|
|
this.user_id = user.id;
|
|
|
|
// Check intents
|
|
if (!identify.intents) identify.intents = 30064771071n; // TODO: what is this number?
|
|
this.intents = new Intents(identify.intents);
|
|
|
|
// TODO: actually do intent things.
|
|
|
|
// Validate sharding
|
|
if (identify.shard) {
|
|
this.shard_id = identify.shard[0];
|
|
this.shard_count = identify.shard[1];
|
|
|
|
if (
|
|
this.shard_count == null ||
|
|
this.shard_id == null ||
|
|
this.shard_id > this.shard_count ||
|
|
this.shard_id < 0 ||
|
|
this.shard_count <= 0
|
|
) {
|
|
// TODO: why do we even care about this right now?
|
|
console.log(
|
|
`[Gateway] Invalid sharding from ${user.id}: ${identify.shard}`,
|
|
);
|
|
return this.close(CLOSECODES.Invalid_shard);
|
|
}
|
|
}
|
|
|
|
// Generate a new gateway session ( id is already made, just save it in db )
|
|
const session = Session.create({
|
|
user_id: this.user_id,
|
|
session_id: this.session_id,
|
|
status: identify.presence?.status || "online",
|
|
client_info: {
|
|
client: identify.properties?.$device,
|
|
os: identify.properties?.os,
|
|
version: 0,
|
|
},
|
|
activities: identify.presence?.activities, // TODO: validation
|
|
});
|
|
|
|
// Get from database:
|
|
// * the users read states
|
|
// * guild members for this user
|
|
// * recipients ( dm channels )
|
|
// * the bot application, if it exists
|
|
const [, application, read_states, members, recipients] = await Promise.all(
|
|
[
|
|
session.save(),
|
|
|
|
Application.findOne({
|
|
where: { id: this.user_id },
|
|
select: ["id", "flags"],
|
|
}),
|
|
|
|
ReadState.find({
|
|
where: { user_id: this.user_id },
|
|
select: [
|
|
"id",
|
|
"channel_id",
|
|
"last_message_id",
|
|
"last_pin_timestamp",
|
|
"mention_count",
|
|
],
|
|
}),
|
|
|
|
Member.find({
|
|
where: { id: this.user_id },
|
|
select: {
|
|
// We only want some member props
|
|
...Object.fromEntries(
|
|
MemberPrivateProjection.map((x) => [x, true]),
|
|
),
|
|
settings: true, // guild settings
|
|
roles: { id: true }, // the full role is fetched from the `guild` relation
|
|
|
|
// TODO: we don't really need every property of
|
|
// guild channels, emoji, roles, stickers
|
|
// but we do want almost everything from guild.
|
|
// How do you do that without just enumerating the guild props?
|
|
guild: Object.fromEntries(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
getDatabase()!
|
|
.getMetadata(Guild)
|
|
.columns.map((x) => [x.propertyName, true]),
|
|
),
|
|
},
|
|
relations: [
|
|
"guild",
|
|
"guild.channels",
|
|
"guild.emojis",
|
|
"guild.roles",
|
|
"guild.stickers",
|
|
"roles",
|
|
|
|
// For these entities, `user` is always just the logged in user we fetched above
|
|
// "user",
|
|
],
|
|
}),
|
|
|
|
Recipient.find({
|
|
where: { user_id: this.user_id, closed: false },
|
|
relations: [
|
|
"channel",
|
|
"channel.recipients",
|
|
"channel.recipients.user",
|
|
],
|
|
select: {
|
|
channel: {
|
|
id: true,
|
|
flags: true,
|
|
// is_spam: true, // TODO
|
|
last_message_id: true,
|
|
last_pin_timestamp: true,
|
|
type: true,
|
|
icon: true,
|
|
name: true,
|
|
owner_id: true,
|
|
recipients: {
|
|
// we don't actually need this ID or any other information about the recipient info,
|
|
// but typeorm does not select anything from the users relation of recipients unless we select
|
|
// at least one column.
|
|
id: true,
|
|
// We only want public user data for each dm channel
|
|
user: Object.fromEntries(
|
|
PublicUserProjection.map((x) => [x, true]),
|
|
),
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
);
|
|
|
|
// We forgot to migrate user settings from the JSON column of `users`
|
|
// to the `user_settings` table theyre in now,
|
|
// so for instances that migrated, users may not have a `user_settings` row.
|
|
if (!user.settings) {
|
|
user.settings = new UserSettings();
|
|
await user.settings.save();
|
|
}
|
|
|
|
// Generate merged_members
|
|
const merged_members = members.map((x) => {
|
|
return [
|
|
{
|
|
...x,
|
|
roles: x.roles.map((x) => x.id),
|
|
|
|
// add back user, which we don't fetch from db
|
|
// TODO: For guild profiles, this may need to be changed.
|
|
// TODO: The only field required in the user prop is `id`,
|
|
// but our types are annoying so I didn't bother.
|
|
user: user.toPublicUser(),
|
|
|
|
guild: {
|
|
id: x.guild.id,
|
|
},
|
|
settings: undefined,
|
|
},
|
|
];
|
|
});
|
|
|
|
// Populated with guilds 'unavailable' currently
|
|
// Just for bots
|
|
const pending_guilds: Guild[] = [];
|
|
|
|
// Generate guilds list ( make them unavailable if user is bot )
|
|
const guilds: GuildOrUnavailable[] = members.map((member) => {
|
|
// filter guild channels we don't have permission to view
|
|
// TODO: check if this causes issues when the user is granted other roles?
|
|
member.guild.channels = member.guild.channels
|
|
.filter((channel) => {
|
|
const perms = Permissions.finalPermission({
|
|
user: {
|
|
id: member.id,
|
|
roles: member.roles.map((x) => x.id),
|
|
},
|
|
guild: member.guild,
|
|
channel,
|
|
});
|
|
|
|
return perms.has("VIEW_CHANNEL");
|
|
})
|
|
.map((channel) => {
|
|
channel.position = member.guild.channel_ordering.indexOf(
|
|
channel.id,
|
|
);
|
|
return channel;
|
|
})
|
|
.sort((a, b) => a.position - b.position);
|
|
|
|
if (user.bot) {
|
|
pending_guilds.push(member.guild);
|
|
return { id: member.guild.id, unavailable: true };
|
|
}
|
|
|
|
return {
|
|
...member.guild.toJSON(),
|
|
joined_at: member.joined_at,
|
|
|
|
threads: [],
|
|
};
|
|
});
|
|
|
|
// Generate user_guild_settings
|
|
const user_guild_settings_entries: ReadyUserGuildSettingsEntries[] =
|
|
members.map((x) => ({
|
|
...DefaultUserGuildSettings,
|
|
...x.settings,
|
|
guild_id: x.guild_id,
|
|
channel_overrides: Object.entries(
|
|
x.settings.channel_overrides ?? {},
|
|
).map((y) => ({
|
|
...y[1],
|
|
channel_id: y[0],
|
|
})),
|
|
}));
|
|
|
|
// Popultaed with users from private channels, relationships.
|
|
// Uses a set to dedupe for us.
|
|
const users: Set<PublicUser> = new Set();
|
|
|
|
// Generate dm channels from recipients list. Append recipients to `users` list
|
|
const channels = recipients
|
|
.filter(({ channel }) => channel.isDm())
|
|
.map((r) => {
|
|
// TODO: fix the types of Recipient
|
|
// Their channels are only ever private (I think) and thus are always DM channels
|
|
const channel = r.channel as DMChannel;
|
|
|
|
// Remove ourself from the list of other users in dm channel
|
|
channel.recipients = channel.recipients.filter(
|
|
(recipient) => recipient.user.id !== this.user_id,
|
|
);
|
|
|
|
const channelUsers = channel.recipients?.map((recipient) =>
|
|
recipient.user.toPublicUser(),
|
|
);
|
|
|
|
if (channelUsers && channelUsers.length > 0)
|
|
channelUsers.forEach((user) => users.add(user));
|
|
|
|
return {
|
|
id: channel.id,
|
|
flags: channel.flags,
|
|
last_message_id: channel.last_message_id,
|
|
type: channel.type,
|
|
recipients: channelUsers || [],
|
|
is_spam: false, // TODO
|
|
};
|
|
});
|
|
|
|
// From user relationships ( friends ), also append to `users` list
|
|
user.relationships.forEach((x) => users.add(x.to.toPublicUser()));
|
|
|
|
// Send SESSIONS_REPLACE and PRESENCE_UPDATE
|
|
const allSessions = (
|
|
await Session.find({
|
|
where: { user_id: this.user_id },
|
|
select: PrivateSessionProjection,
|
|
})
|
|
).map((x) => ({
|
|
// TODO how is active determined?
|
|
// in our lazy request impl, we just pick the 'most relevant' session
|
|
active: x.session_id == session.session_id,
|
|
activities: x.activities ?? [],
|
|
client_info: x.client_info,
|
|
session_id: x.session_id, // TODO: discord.com sends 'all', what is that???
|
|
status: x.status,
|
|
}));
|
|
|
|
Promise.all([
|
|
emitEvent({
|
|
event: "SESSIONS_REPLACE",
|
|
user_id: this.user_id,
|
|
data: allSessions,
|
|
} as SessionsReplace),
|
|
emitEvent({
|
|
event: "PRESENCE_UPDATE",
|
|
user_id: this.user_id,
|
|
data: {
|
|
user: user.toPublicUser(),
|
|
activities: session.activities,
|
|
client_status: session.client_info,
|
|
status: session.status,
|
|
},
|
|
} as PresenceUpdateEvent),
|
|
]);
|
|
|
|
// Build READY
|
|
|
|
read_states.forEach((x) => {
|
|
x.id = x.channel_id;
|
|
});
|
|
|
|
const d: ReadyEventData = {
|
|
v: 9,
|
|
application: application
|
|
? { id: application.id, flags: application.flags }
|
|
: undefined,
|
|
user: user.toPrivateUser(),
|
|
user_settings: user.settings,
|
|
guilds: this.capabilities.has(Capabilities.FLAGS.CLIENT_STATE_V2)
|
|
? guilds.map((x) => new ReadyGuildDTO(x).toJSON())
|
|
: guilds,
|
|
relationships: user.relationships.map((x) => x.toPublicRelationship()),
|
|
read_state: {
|
|
entries: read_states,
|
|
partial: false,
|
|
version: 0, // TODO
|
|
},
|
|
user_guild_settings: {
|
|
entries: user_guild_settings_entries,
|
|
partial: false,
|
|
version: 0, // TODO
|
|
},
|
|
private_channels: channels,
|
|
session_id: this.session_id,
|
|
country_code: user.settings.locale, // TODO: do ip analysis instead
|
|
users: Array.from(users),
|
|
merged_members: merged_members,
|
|
sessions: allSessions,
|
|
|
|
resume_gateway_url:
|
|
Config.get().gateway.endpointClient ||
|
|
Config.get().gateway.endpointPublic ||
|
|
"ws://127.0.0.1:3001",
|
|
|
|
// lol hack whatever
|
|
required_action:
|
|
Config.get().login.requireVerification && !user.verified
|
|
? "REQUIRE_VERIFIED_EMAIL"
|
|
: undefined,
|
|
|
|
consents: {
|
|
personalization: {
|
|
consented: false, // TODO
|
|
},
|
|
},
|
|
experiments: [],
|
|
guild_join_requests: [],
|
|
connected_accounts: [],
|
|
guild_experiments: [],
|
|
geo_ordered_rtc_regions: [],
|
|
api_code_version: 1,
|
|
friend_suggestion_count: 0,
|
|
analytics_token: "",
|
|
tutorial: null,
|
|
session_type: "normal", // TODO
|
|
auth_session_id_hash: "", // TODO
|
|
notification_settings: {
|
|
// ????
|
|
flags: 0,
|
|
},
|
|
};
|
|
|
|
// Send READY
|
|
await Send(this, {
|
|
op: OPCODES.Dispatch,
|
|
t: EVENTEnum.Ready,
|
|
s: this.sequence++,
|
|
d,
|
|
});
|
|
|
|
// If we're a bot user, send GUILD_CREATE for each unavailable guild
|
|
await Promise.all(
|
|
pending_guilds.map((x) =>
|
|
Send(this, {
|
|
op: OPCODES.Dispatch,
|
|
t: EVENTEnum.GuildCreate,
|
|
s: this.sequence++,
|
|
d: x,
|
|
})?.catch((e) =>
|
|
console.error(`[Gateway] error when sending bot guilds`, e),
|
|
),
|
|
),
|
|
);
|
|
|
|
// TODO: ready supplemental
|
|
await Send(this, {
|
|
op: OPCodes.DISPATCH,
|
|
t: EVENTEnum.ReadySupplemental,
|
|
s: this.sequence++,
|
|
d: {
|
|
merged_presences: {
|
|
guilds: [],
|
|
friends: [],
|
|
},
|
|
// these merged members seem to be all users currently in vc in your guilds
|
|
merged_members: [],
|
|
lazy_private_channels: [],
|
|
guilds: [], // { voice_states: [], id: string, embedded_activities: [] }
|
|
// embedded_activities are users currently in an activity?
|
|
disclose: [], // Config.get().general.uniqueUsernames ? ["pomelo"] : []
|
|
},
|
|
});
|
|
|
|
//TODO send GUILD_MEMBER_LIST_UPDATE
|
|
//TODO send VOICE_STATE_UPDATE to let the client know if another device is already connected to a voice channel
|
|
|
|
await setupListener.call(this);
|
|
}
|