mirror of
https://github.com/stoatchat/javascript-client-sdk.git
synced 2026-07-21 04:25:27 -04:00
760 lines
18 KiB
TypeScript
760 lines
18 KiB
TypeScript
import { batch } from "solid-js";
|
|
|
|
import type { ReactiveMap } from "@solid-primitives/map";
|
|
import type { ReactiveSet } from "@solid-primitives/set";
|
|
import type {
|
|
Server as APIServer,
|
|
AllMemberResponse,
|
|
BannedUser,
|
|
Category,
|
|
DataBanCreate,
|
|
DataCreateEmoji,
|
|
DataCreateServerChannel,
|
|
DataEditRole,
|
|
DataEditServer,
|
|
Override,
|
|
OverrideField,
|
|
Role,
|
|
} from "revolt-api";
|
|
import { decodeTime } from "ulid";
|
|
|
|
import type { ServerCollection } from "../collections/ServerCollection.js";
|
|
import { hydrate } from "../hydration/index.js";
|
|
import type { ServerFlags } from "../hydration/server.js";
|
|
import {
|
|
bitwiseAndEq,
|
|
calculatePermission,
|
|
} from "../permissions/calculator.js";
|
|
import { Permission } from "../permissions/definitions.js";
|
|
|
|
import type { Channel } from "./Channel.js";
|
|
import type { Emoji } from "./Emoji.js";
|
|
import type { File } from "./File.js";
|
|
import { ChannelInvite } from "./Invite.js";
|
|
import { ServerBan } from "./ServerBan.js";
|
|
import { ServerMember } from "./ServerMember.js";
|
|
import { User } from "./User.js";
|
|
|
|
/**
|
|
* Server Class
|
|
*/
|
|
export class Server {
|
|
readonly #collection: ServerCollection;
|
|
readonly id: string;
|
|
|
|
/**
|
|
* Construct Server
|
|
* @param collection Collection
|
|
* @param id Id
|
|
*/
|
|
constructor(collection: ServerCollection, id: string) {
|
|
this.#collection = collection;
|
|
this.id = id;
|
|
}
|
|
/**
|
|
* Convert to string
|
|
* @returns String
|
|
*/
|
|
toString(): string {
|
|
return `<%${this.id}>`;
|
|
}
|
|
|
|
/**
|
|
* Whether this object exists
|
|
*/
|
|
get $exists(): boolean {
|
|
return !!this.#collection.getUnderlyingObject(this.id).id;
|
|
}
|
|
|
|
/**
|
|
* Time when this server was created
|
|
*/
|
|
get createdAt(): Date {
|
|
return new Date(decodeTime(this.id));
|
|
}
|
|
|
|
/**
|
|
* Owner's user ID
|
|
*/
|
|
get ownerId(): string {
|
|
return this.#collection.getUnderlyingObject(this.id).ownerId;
|
|
}
|
|
|
|
/**
|
|
* Owner
|
|
*/
|
|
get owner(): User | undefined {
|
|
return this.#collection.client.users.get(
|
|
this.#collection.getUnderlyingObject(this.id).ownerId,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Name
|
|
*/
|
|
get name(): string {
|
|
return this.#collection.getUnderlyingObject(this.id).name;
|
|
}
|
|
|
|
/**
|
|
* Description
|
|
*/
|
|
get description(): string | undefined {
|
|
return this.#collection.getUnderlyingObject(this.id).description;
|
|
}
|
|
|
|
/**
|
|
* Icon
|
|
*/
|
|
get icon(): File | undefined {
|
|
return this.#collection.getUnderlyingObject(this.id).icon;
|
|
}
|
|
|
|
/**
|
|
* Banner
|
|
*/
|
|
get banner(): File | undefined {
|
|
return this.#collection.getUnderlyingObject(this.id).banner;
|
|
}
|
|
|
|
/**
|
|
* Channel IDs
|
|
*/
|
|
get channelIds(): ReactiveSet<string> {
|
|
return this.#collection.getUnderlyingObject(this.id).channelIds;
|
|
}
|
|
|
|
/**
|
|
* Channels
|
|
*/
|
|
get channels(): Channel[] {
|
|
return [
|
|
...this.#collection.getUnderlyingObject(this.id).channelIds.values(),
|
|
]
|
|
.map((id) => this.#collection.client.channels.get(id)!)
|
|
.filter((x) => x);
|
|
}
|
|
|
|
/**
|
|
* Categories
|
|
*/
|
|
get categories(): Category[] | undefined {
|
|
return this.#collection.getUnderlyingObject(this.id).categories;
|
|
}
|
|
|
|
/**
|
|
* System message channels
|
|
*/
|
|
get systemMessages(): APIServer["system_messages"] {
|
|
return this.#collection.getUnderlyingObject(this.id).systemMessages;
|
|
}
|
|
|
|
/**
|
|
* Roles
|
|
*/
|
|
get roles(): ReactiveMap<
|
|
string,
|
|
{
|
|
name: string;
|
|
permissions: OverrideField;
|
|
colour?: string | null;
|
|
hoist?: boolean;
|
|
rank?: number;
|
|
}
|
|
> {
|
|
return this.#collection.getUnderlyingObject(this.id).roles;
|
|
}
|
|
|
|
/**
|
|
* Default permissions
|
|
*/
|
|
get defaultPermissions(): number {
|
|
return this.#collection.getUnderlyingObject(this.id).defaultPermissions;
|
|
}
|
|
|
|
/**
|
|
* Server flags
|
|
*/
|
|
get flags(): ServerFlags {
|
|
return this.#collection.getUnderlyingObject(this.id).flags;
|
|
}
|
|
|
|
/**
|
|
* Whether analytics are enabled for this server
|
|
*/
|
|
get analytics(): boolean {
|
|
return this.#collection.getUnderlyingObject(this.id).analytics;
|
|
}
|
|
|
|
/**
|
|
* Whether this server is publicly discoverable
|
|
*/
|
|
get discoverable(): boolean {
|
|
return this.#collection.getUnderlyingObject(this.id).discoverable;
|
|
}
|
|
|
|
/**
|
|
* Whether this server is marked as mature
|
|
*/
|
|
get mature(): boolean {
|
|
return this.#collection.getUnderlyingObject(this.id).nsfw;
|
|
}
|
|
|
|
/**
|
|
* Get an array of ordered categories with their respective channels.
|
|
* Uncategorised channels are returned in `id="default"` category.
|
|
*/
|
|
get orderedChannels(): (Omit<Category, "channels"> & {
|
|
channels: Channel[];
|
|
})[] {
|
|
const uncategorised = new Set(this.channels.map((channel) => channel.id));
|
|
|
|
const elements = [];
|
|
let defaultCategory;
|
|
|
|
const categories = this.categories;
|
|
if (categories) {
|
|
for (const category of categories) {
|
|
const channels = [];
|
|
for (const key of category.channels) {
|
|
if (uncategorised.delete(key)) {
|
|
channels.push(this.#collection.client.channels.get(key)!);
|
|
}
|
|
}
|
|
|
|
const cat = {
|
|
...category,
|
|
channels,
|
|
};
|
|
|
|
if (cat.id === "default") {
|
|
if (channels.length === 0) continue;
|
|
|
|
defaultCategory = cat;
|
|
}
|
|
|
|
elements.push(cat);
|
|
}
|
|
}
|
|
|
|
if (uncategorised.size > 0) {
|
|
const channels = [...uncategorised].map(
|
|
(key) => this.#collection.client.channels.get(key)!,
|
|
);
|
|
|
|
if (defaultCategory) {
|
|
defaultCategory.channels = [...defaultCategory.channels, ...channels];
|
|
} else {
|
|
elements.unshift({
|
|
id: "default",
|
|
title: "Default",
|
|
channels,
|
|
});
|
|
}
|
|
}
|
|
|
|
return elements;
|
|
}
|
|
|
|
/**
|
|
* Default channel for this server
|
|
*/
|
|
get defaultChannel(): Channel | undefined {
|
|
return this.orderedChannels.find((cat) => cat.channels.length)?.channels[0];
|
|
}
|
|
|
|
/**
|
|
* Get an ordered array of roles with their IDs attached.
|
|
* The highest ranking roles will be first followed by lower
|
|
* ranking roles. This is dictated by the "rank" property
|
|
* which is smaller for higher priority roles.
|
|
*/
|
|
get orderedRoles(): {
|
|
name: string;
|
|
permissions: OverrideField;
|
|
colour?: string | null;
|
|
hoist?: boolean;
|
|
rank?: number;
|
|
id: string;
|
|
}[] {
|
|
const roles = this.roles;
|
|
return roles
|
|
? [...roles.entries()]
|
|
.map(([id, role]) => ({ id, ...role }))
|
|
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
|
|
: [];
|
|
}
|
|
|
|
/**
|
|
* Check whether the server is currently unread
|
|
* @returns Whether the server is unread
|
|
*/
|
|
get unread(): boolean {
|
|
return !!this.channels.find((channel) => channel.unread);
|
|
}
|
|
|
|
/**
|
|
* Find all message IDs of unread messages
|
|
* @returns Array of message IDs which are unread
|
|
*/
|
|
get mentions(): string[] {
|
|
const arr = this.channels.map((channel) =>
|
|
Array.from(channel.mentions?.values() ?? []),
|
|
);
|
|
|
|
return ([] as string[]).concat(...arr);
|
|
}
|
|
|
|
/**
|
|
* URL to the server's icon
|
|
*/
|
|
get iconURL(): string | undefined {
|
|
return this.icon?.createFileURL();
|
|
}
|
|
|
|
/**
|
|
* URL to the server's animated icon
|
|
*/
|
|
get animatedIconURL(): string | undefined {
|
|
return this.icon?.createFileURL(true);
|
|
}
|
|
|
|
/**
|
|
* URL to the server's banner
|
|
*/
|
|
get bannerURL(): string | undefined {
|
|
return this.banner?.createFileURL();
|
|
}
|
|
|
|
/**
|
|
* Own member object for this server
|
|
*/
|
|
get member(): ServerMember | undefined {
|
|
return this.#collection.client.serverMembers.getByKey({
|
|
server: this.id,
|
|
user: this.#collection.client.user!.id,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Permission the currently authenticated user has against this server
|
|
*/
|
|
get permission(): number {
|
|
return calculatePermission(this.#collection.client, this);
|
|
}
|
|
|
|
/**
|
|
* Check whether we have a given permission in a server
|
|
* @param permission Permission Names
|
|
* @returns Whether we have this permission
|
|
*/
|
|
havePermission(...permission: (keyof typeof Permission)[]): boolean {
|
|
return bitwiseAndEq(
|
|
this.permission,
|
|
...permission.map((x) => Permission[x]),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check whether we have at least one of the given permissions in a server
|
|
* @param permission Permission Names
|
|
* @returns Whether we have one of the permissions
|
|
*/
|
|
orPermission(...permission: (keyof typeof Permission)[]): boolean {
|
|
return (
|
|
permission.findIndex((x) =>
|
|
bitwiseAndEq(this.permission, Permission[x]),
|
|
) !== -1
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Helper function to retrieve cached server member by their ID in this server
|
|
* @param userId User's ID
|
|
* @returns Server Member (if cached)
|
|
*/
|
|
getMember(userId: string): ServerMember | undefined {
|
|
return this.#collection.client.serverMembers.getByKey({
|
|
server: this.id,
|
|
user: userId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a channel
|
|
* @param data Channel create route data
|
|
* @returns The newly-created channel
|
|
*/
|
|
async createChannel(data: DataCreateServerChannel): Promise<Channel> {
|
|
const channel = await this.#collection.client.api.post(
|
|
`/servers/${this.id as ""}/channels`,
|
|
data,
|
|
);
|
|
|
|
return this.#collection.client.channels.getOrCreate(channel._id, channel);
|
|
}
|
|
|
|
/**
|
|
* Edit a server
|
|
* @param data Changes
|
|
*/
|
|
async edit(data: DataEditServer): Promise<void> {
|
|
this.#collection.updateUnderlyingObject(
|
|
this.id,
|
|
hydrate(
|
|
"server",
|
|
await this.#collection.client.api.patch(
|
|
`/servers/${this.id as ""}`,
|
|
data,
|
|
),
|
|
this.#collection.client,
|
|
false,
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Delete the underlying server
|
|
* @param leaveEvent Whether we are leaving
|
|
*/
|
|
$delete(leaveEvent?: boolean): void {
|
|
batch(() => {
|
|
const server = this.#collection.client.servers.getUnderlyingObject(
|
|
this.id,
|
|
);
|
|
|
|
// Avoid race conditions
|
|
if (server.id) {
|
|
this.#collection.client.emit(
|
|
leaveEvent ? "serverLeave" : "serverDelete",
|
|
server,
|
|
);
|
|
|
|
for (const channel of this.channelIds) {
|
|
this.#collection.client.channels.delete(channel);
|
|
}
|
|
|
|
this.#collection.delete(this.id);
|
|
}
|
|
// TODO: delete members, emoji, etc
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete or leave a server
|
|
* @param leaveSilently Whether to not send a message on leave
|
|
*/
|
|
async delete(leaveSilently?: boolean): Promise<void> {
|
|
await this.#collection.client.api.delete(`/servers/${this.id as ""}`, {
|
|
leave_silently: leaveSilently,
|
|
});
|
|
|
|
this.$delete();
|
|
}
|
|
|
|
/**
|
|
* Mark a server as read
|
|
*/
|
|
async ack(): Promise<void> {
|
|
batch(() => {
|
|
for (const channel of this.channels) {
|
|
channel.ack(undefined, false, true);
|
|
}
|
|
});
|
|
|
|
await this.#collection.client.api.put(`/servers/${this.id}/ack`);
|
|
}
|
|
|
|
/**
|
|
* Ban user from this server
|
|
* @param user User
|
|
* @param options Ban options
|
|
*/
|
|
async banUser(
|
|
user: string | User | ServerMember,
|
|
options: DataBanCreate = {},
|
|
): Promise<ServerBan> {
|
|
const userId =
|
|
user instanceof User
|
|
? user.id
|
|
: user instanceof ServerMember
|
|
? user.id.user
|
|
: user;
|
|
|
|
const ban = await this.#collection.client.api.put(
|
|
`/servers/${this.id as ""}/bans/${userId as ""}`,
|
|
options,
|
|
);
|
|
|
|
return new ServerBan(this.#collection.client, ban);
|
|
}
|
|
|
|
/**
|
|
* Kick user from this server
|
|
* @param user User
|
|
*/
|
|
async kickUser(user: string | User | ServerMember): Promise<void> {
|
|
return await this.#collection.client.api.delete(
|
|
`/servers/${this.id as ""}/members/${
|
|
typeof user === "string"
|
|
? user
|
|
: user instanceof User
|
|
? user.id
|
|
: user.id.user
|
|
}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Pardon user's ban
|
|
* @param user User
|
|
*/
|
|
async unbanUser(user: string | User): Promise<void> {
|
|
const userId = user instanceof User ? user.id : user;
|
|
return await this.#collection.client.api.delete(
|
|
`/servers/${this.id as ""}/bans/${userId}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch a server's invites
|
|
* @returns An array of the server's invites
|
|
*/
|
|
async fetchInvites(): Promise<ChannelInvite[]> {
|
|
const invites = await this.#collection.client.api.get(
|
|
`/servers/${this.id as ""}/invites`,
|
|
);
|
|
|
|
return invites.map((invite) =>
|
|
ChannelInvite.from(this.#collection.client, invite),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch a server's bans
|
|
* @returns An array of the server's bans.
|
|
*/
|
|
async fetchBans(): Promise<ServerBan[]> {
|
|
const { users, bans } = await this.#collection.client.api.get(
|
|
`/servers/${this.id as ""}/bans`,
|
|
);
|
|
|
|
const userDict = users.reduce(
|
|
(d, c) => ({ ...d, [c._id]: c }),
|
|
{} as Record<string, BannedUser>,
|
|
);
|
|
|
|
return bans.map(
|
|
(ban) =>
|
|
new ServerBan(this.#collection.client, ban, userDict[ban._id.user]),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Set role permissions
|
|
* @param roleId Role Id, set to 'default' to affect all users
|
|
* @param permissions Permission value
|
|
*/
|
|
async setPermissions(
|
|
roleId = "default",
|
|
permissions: Override | number,
|
|
): Promise<APIServer> {
|
|
return await this.#collection.client.api.put(
|
|
`/servers/${this.id as ""}/permissions/${roleId as ""}`,
|
|
{ permissions: permissions as Override },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create role
|
|
* @param name Role name
|
|
*/
|
|
async createRole(name: string): Promise<{ id: string; role: Role }> {
|
|
return await this.#collection.client.api.post(
|
|
`/servers/${this.id as ""}/roles`,
|
|
{
|
|
name,
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Edit a role
|
|
* @param roleId Role ID
|
|
* @param data Role editing route data
|
|
*/
|
|
async editRole(roleId: string, data: DataEditRole): Promise<Role> {
|
|
return await this.#collection.client.api.patch(
|
|
`/servers/${this.id as ""}/roles/${roleId as ""}`,
|
|
data,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Delete role
|
|
* @param roleId Role ID
|
|
*/
|
|
async deleteRole(roleId: string): Promise<void> {
|
|
return await this.#collection.client.api.delete(
|
|
`/servers/${this.id as ""}/roles/${roleId as ""}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch a server member
|
|
* @param user User
|
|
* @returns Server member object
|
|
*/
|
|
async fetchMember(user: User | string): Promise<ServerMember> {
|
|
const userId = typeof user === "string" ? user : user.id;
|
|
const existing = this.#collection.client.serverMembers.getByKey({
|
|
server: this.id,
|
|
user: userId,
|
|
});
|
|
|
|
if (existing) return existing;
|
|
return this.#collection.client.serverMembers.fetch(this.id, userId);
|
|
}
|
|
|
|
#synced: undefined | "partial" | "full";
|
|
|
|
/**
|
|
* Optimised member fetch route
|
|
* @param excludeOffline
|
|
*/
|
|
async syncMembers(excludeOffline?: boolean): Promise<void> {
|
|
if (this.#synced && (this.#synced === "full" || excludeOffline)) return;
|
|
|
|
const data = await this.#collection.client.api.get(
|
|
`/servers/${this.id as ""}/members`,
|
|
{ exclude_offline: excludeOffline },
|
|
);
|
|
|
|
batch(() => {
|
|
if (excludeOffline) {
|
|
for (let i = 0; i < data.users.length; i++) {
|
|
const user = data.users[i];
|
|
if (user.online) {
|
|
this.#collection.client.users.getOrCreate(user._id, user);
|
|
this.#collection.client.serverMembers.getOrCreate(
|
|
data.members[i]._id,
|
|
data.members[i],
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
for (let i = 0; i < data.users.length; i++) {
|
|
this.#collection.client.users.getOrCreate(
|
|
data.users[i]._id,
|
|
data.users[i],
|
|
);
|
|
this.#collection.client.serverMembers.getOrCreate(
|
|
data.members[i]._id,
|
|
data.members[i],
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Reset member sync status
|
|
*/
|
|
resetSyncStatus(): void {
|
|
this.#synced = undefined;
|
|
}
|
|
|
|
/**
|
|
* Fetch a server's members
|
|
* @returns List of the server's members and their user objects
|
|
*/
|
|
async fetchMembers(): Promise<{ members: ServerMember[]; users: User[] }> {
|
|
const data = (await this.#collection.client.api.get(
|
|
// @ts-expect-error TODO weird typing issue
|
|
`/servers/${this.id as ""}/members`,
|
|
)) as AllMemberResponse;
|
|
|
|
return batch(() => ({
|
|
members: data.members.map((member) =>
|
|
this.#collection.client.serverMembers.getOrCreate(member._id, member),
|
|
),
|
|
users: data.users.map((user) =>
|
|
this.#collection.client.users.getOrCreate(user._id, user),
|
|
),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Query members from a server by name
|
|
* @param query Name
|
|
* @returns List of the server's members and their user objects
|
|
*/
|
|
async queryMembersExperimental(
|
|
query: string,
|
|
): Promise<{ members: ServerMember[]; users: User[] }> {
|
|
const data = (await this.#collection.client.api.get(
|
|
`/servers/${
|
|
this.id as ""
|
|
}/members_experimental_query?experimental_api=true&query=${encodeURIComponent(
|
|
query,
|
|
)}` as never,
|
|
)) as AllMemberResponse;
|
|
|
|
return batch(() => ({
|
|
members: data.members.map((member) =>
|
|
this.#collection.client.serverMembers.getOrCreate(member._id, member),
|
|
),
|
|
users: data.users.map((user) =>
|
|
this.#collection.client.users.getOrCreate(user._id, user),
|
|
),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Create an emoji on the server
|
|
* @param autumnId Autumn Id
|
|
* @param options Options
|
|
*/
|
|
async createEmoji(
|
|
autumnId: string,
|
|
options: Omit<DataCreateEmoji, "parent">,
|
|
): Promise<Emoji> {
|
|
const emoji = await this.#collection.client.api.put(
|
|
`/custom/emoji/${autumnId as ""}`,
|
|
{
|
|
parent: {
|
|
type: "Server",
|
|
id: this.id,
|
|
},
|
|
...options,
|
|
},
|
|
);
|
|
|
|
return this.#collection.client.emojis.getOrCreate(emoji._id, emoji, true);
|
|
}
|
|
|
|
/**
|
|
* Fetch a server's emoji
|
|
* @returns List of server emoji
|
|
*/
|
|
async fetchEmojis(): Promise<Emoji[]> {
|
|
const emojis = await this.#collection.client.api.get(
|
|
`/servers/${this.id as ""}/emojis`,
|
|
);
|
|
|
|
return batch(() =>
|
|
emojis.map((emoji) =>
|
|
this.#collection.client.emojis.getOrCreate(emoji._id, emoji),
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Delete emoji
|
|
* @param emojiId Emoji ID
|
|
*/
|
|
async deleteEmoji(emojiId: string): Promise<void> {
|
|
await this.#collection.client.api.delete(`/custom/emoji/${emojiId}`);
|
|
}
|
|
}
|