diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml
deleted file mode 100644
index c2a7ad0f..00000000
--- a/.idea/data_source_mapping.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
- <component name="DataSourcePerFileMappings">
- <file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/61e66448-285a-4205-a1d7-c2704d5afb2c/console.sql" value="61e66448-285a-4205-a1d7-c2704d5afb2c" />
- </component>
-</project>
\ No newline at end of file
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 11e173ef..f43b9986 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -43,46 +43,46 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
- <component name="PropertiesComponent">{
- "keyToString": {
- "NIXITCH_NIXPKGS_CONFIG": "/etc/nix/nixpkgs-config.nix",
- "NIXITCH_NIX_CONF_DIR": "",
- "NIXITCH_NIX_OTHER_STORES": "",
- "NIXITCH_NIX_PATH": "/home/Rory/.nix-defexpr/channels:nixpkgs=/nix/store/wb6agba4kfsxpbnb5hzlq58vkjzvbsk6-source",
- "NIXITCH_NIX_PROFILES": "/run/current-system/sw /nix/var/nix/profiles/default /etc/profiles/per-user/Rory /home/Rory/.local/state/nix/profile /nix/profile /home/Rory/.nix-profile",
- "NIXITCH_NIX_REMOTE": "",
- "NIXITCH_NIX_USER_PROFILE_DIR": "/nix/var/nix/profiles/per-user/Rory",
- "Node.js.Server.ts.executor": "Debug",
- "RunOnceActivity.ShowReadmeOnStart": "true",
- "RunOnceActivity.git.unshallow": "true",
- "javascript.nodejs.core.library.configured.version": "24.8.0",
- "javascript.nodejs.core.library.typings.version": "24.7.0",
- "last_opened_file_path": "/home/Rory/git/spacebar/server-master/src/util/migration/postgres",
- "node.js.detected.package.eslint": "true",
- "node.js.selected.package.eslint": "(autodetect)",
- "node.js.selected.package.tslint": "(autodetect)",
- "nodejs_interpreter_path": "node",
- "nodejs_package_manager_path": "npm",
- "npm.Start API.executor": "Run",
- "npm.Start CDN.executor": "Run",
- "npm.Start Gateway.executor": "Run",
- "npm.build.executor": "Run",
- "npm.start.executor": "Debug",
- "prettierjs.PrettierConfiguration.Package": "/home/Rory/git/spacebar/server-master/node_modules/prettier",
- "settings.editor.selected.configurable": "preferences.pluginManager",
- "ts.external.directory.path": "/home/Rory/git/spacebar/server-master/node_modules/typescript/lib"
+ <component name="PropertiesComponent"><![CDATA[{
+ "keyToString": {
+ "NIXITCH_NIXPKGS_CONFIG": "/etc/nix/nixpkgs-config.nix",
+ "NIXITCH_NIX_CONF_DIR": "",
+ "NIXITCH_NIX_OTHER_STORES": "",
+ "NIXITCH_NIX_PATH": "/home/Rory/.nix-defexpr/channels:nixpkgs=/nix/store/wb6agba4kfsxpbnb5hzlq58vkjzvbsk6-source",
+ "NIXITCH_NIX_PROFILES": "/run/current-system/sw /nix/var/nix/profiles/default /etc/profiles/per-user/Rory /home/Rory/.local/state/nix/profile /nix/profile /home/Rory/.nix-profile",
+ "NIXITCH_NIX_REMOTE": "",
+ "NIXITCH_NIX_USER_PROFILE_DIR": "/nix/var/nix/profiles/per-user/Rory",
+ "Node.js.Server.ts.executor": "Debug",
+ "RunOnceActivity.ShowReadmeOnStart": "true",
+ "RunOnceActivity.git.unshallow": "true",
+ "javascript.nodejs.core.library.configured.version": "24.8.0",
+ "javascript.nodejs.core.library.typings.version": "24.7.0",
+ "last_opened_file_path": "/home/Rory/git/spacebar/server-master/src/util/migration/postgres",
+ "node.js.detected.package.eslint": "true",
+ "node.js.selected.package.eslint": "(autodetect)",
+ "node.js.selected.package.tslint": "(autodetect)",
+ "nodejs_interpreter_path": "node",
+ "nodejs_package_manager_path": "npm",
+ "npm.Start API.executor": "Run",
+ "npm.Start CDN.executor": "Run",
+ "npm.Start Gateway.executor": "Run",
+ "npm.build.executor": "Run",
+ "npm.start.executor": "Debug",
+ "prettierjs.PrettierConfiguration.Package": "/home/Rory/git/spacebar/server-master/node_modules/prettier",
+ "settings.editor.selected.configurable": "settings.javascript.linters.tslint",
+ "ts.external.directory.path": "/home/Rory/git/spacebar/server-master/node_modules/typescript/lib"
},
- "keyToStringList": {
- "DatabaseDriversLRU": [
- "postgresql"
+ "keyToStringList": {
+ "DatabaseDriversLRU": [
+ "postgresql"
],
- "GitStage.ChangesTree.GroupingKeys": [
- "directory",
- "module",
- "repository"
+ "GitStage.ChangesTree.GroupingKeys": [
+ "directory",
+ "module",
+ "repository"
]
}
-}</component>
+}]]></component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/util/migration/postgres" />
@@ -111,14 +111,24 @@
<value>
<map>
<entry key="Start API" value="STOPPED" />
+ <entry key="Start CDN" value="STOPPED" />
<entry key="Start Gateway" value="STOPPED" />
- <entry key="build" value="STOPPED" />
+ <entry key="Start bundle" value="STOPPED" />
</map>
</value>
</entry>
</map>
</option>
</component>
+ <component name="RunManager" selected="Compound.Start separated">
+ <list>
+ <item itemvalue="Compound.Start separated" />
+ <item itemvalue="npm.Start API" />
+ <item itemvalue="npm.Start CDN" />
+ <item itemvalue="npm.Start Gateway" />
+ <item itemvalue="npm.Start bundle" />
+ </list>
+ </component>
<component name="SharedIndexes">
<attachedChunks>
<set>
@@ -155,6 +165,17 @@
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
+ <component name="Vcs.Log.Tabs.Properties">
+ <option name="TAB_STATES">
+ <map>
+ <entry key="MAIN">
+ <value>
+ <State />
+ </value>
+ </entry>
+ </map>
+ </option>
+ </component>
<component name="XDebuggerManager">
<breakpoint-manager>
<breakpoints>
diff --git a/assets/openapi.json b/assets/openapi.json
index b62e2b43..c5e92764 100644
--- a/assets/openapi.json
+++ b/assets/openapi.json
@@ -14244,6 +14244,40 @@
]
}
},
+ "/users/@me/mentions": {
+ "get": {
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MessageListResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/APIErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "users"
+ ]
+ }
+ },
"/users/@me/library/": {
"get": {
"security": [
diff --git a/scripts/openapi.js b/scripts/openapi.js
index 6d4d6366..589614cc 100644
--- a/scripts/openapi.js
+++ b/scripts/openapi.js
@@ -24,6 +24,7 @@ const {
NO_AUTHORIZATION_ROUTES,
} = require("../dist/api/middlewares/Authentication");
require("../dist/util/util/extensions");
+const { bgRedBright } = require("picocolors");
const openapiPath = path.join(__dirname, "..", "assets", "openapi.json");
const SchemaPath = path.join(__dirname, "..", "assets", "schemas.json");
@@ -84,7 +85,7 @@ function combineSchemas(schemas) {
for (const key in definitions) {
if (!schemaRegEx.test(key)) {
- console.error(`Invalid schema name: ${key}, context:`, definitions[key]);
+ console.error(`${bgRedBright("ERROR")} Invalid schema name: ${key}, context:`, definitions[key]);
continue;
}
specification.components = specification.components || {};
diff --git a/scripts/util/getRouteDescriptions.js b/scripts/util/getRouteDescriptions.js
index 54b5e1bc..54c8fe19 100644
--- a/scripts/util/getRouteDescriptions.js
+++ b/scripts/util/getRouteDescriptions.js
@@ -2,6 +2,7 @@ const express = require("express");
const path = require("path");
const { traverseDirectory } = require("lambert-server");
const RouteUtility = require("../../dist/api/util/handlers/route.js");
+const { bgRedBright } = require("picocolors");
const methods = ["get", "post", "put", "delete", "patch"];
const routes = new Map();
@@ -24,7 +25,7 @@ function proxy(file, method, prefix, path, ...args) {
const opts = args.find((x) => x?.prototype?.OPTS_MARKER == true);
if (!opts)
return console.error(
- `${file} has route without route() description middleware`,
+ `${bgRedBright("ERROR")} ${file} has route without route() description middleware`,
);
console.log(`${method.toUpperCase().padStart("OPTIONS".length)} ${prefix + path}`);
diff --git a/src/api/routes/users/@me/mentions.ts b/src/api/routes/users/@me/mentions.ts
new file mode 100644
index 00000000..c4491ad0
--- /dev/null
+++ b/src/api/routes/users/@me/mentions.ts
@@ -0,0 +1,161 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2025 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 { route } from "@spacebar/api";
+import { Snowflake, User, Message, Member, Channel, Permissions, timePromise } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+import { In } from "typeorm";
+
+const router: Router = Router({ mergeParams: true });
+
+router.get(
+ "",
+ route({
+ responses: {
+ 200: {
+ body: "MessageListResponse",
+ },
+ 404: {
+ body: "APIErrorResponse",
+ },
+ },
+ }),
+ // AFAICT this endpoint doesn't list DMs
+ async (req: Request, res: Response) => {
+ const limit = req.query.limit && !isNaN(Number(req.query.limit)) ? Number(req.query.limit) : 50;
+ const everyone = !!req.query.everyone;
+ const roles = !!req.query.roles;
+
+ const user = await User.findOneOrFail({
+ where: { id: req.user_id },
+ });
+
+ const memberships = await Member.find({
+ where: { id: req.user_id },
+ select: {
+ guild_id: true,
+ id: true,
+ communication_disabled_until: true,
+ roles: {
+ // We don't want to include all guild roles, as this could cause a lot more explosive behavior
+ id: true,
+ position: true,
+ permissions: true,
+ mentionable: true, // cause we can skip querying for unmentionable roles
+ },
+ guild: {
+ id: true,
+ owner_id: true,
+ },
+ },
+ relations: ["guild", "roles"],
+ });
+
+ const channels = await Channel.find({
+ where: {
+ guild_id: In(memberships.map((m) => m.guild_id)),
+ },
+ select: { id: true, permission_overwrites: true },
+ });
+
+ const visibleChannels = channels.filter((c) => {
+ const member = memberships.find((m) => m.guild_id === c.guild_id)!;
+ return Permissions.finalPermission({
+ user: { id: member.id, roles: member.roles.map((r) => r.id), communication_disabled_until: member.communication_disabled_until, flags: 0 },
+ guild: { id: member.guild.id, owner_id: member.guild.owner_id!, roles: member.guild.roles },
+ channel: c,
+ }).has("VIEW_CHANNEL");
+ });
+ const visibleChannelIds = visibleChannels.map((c) => c.id);
+ const ownedMentionableRoleIds = memberships.reduce((acc, m) => {
+ acc.push(...m.roles.filter((r) => r.mentionable).map((r) => r.id));
+ return acc;
+ }, [] as Snowflake[]);
+
+ const [
+ { result: userMentions, elapsed: userMentionQueryTime },
+ { result: roleMentions, elapsed: roleMentionQueryTime },
+ { result: everyoneMentions, elapsed: everyoneMentionQueryTime },
+ ] = await Promise.all([
+ await timePromise(() =>
+ Message.find({
+ where: {
+ channel_id: In(visibleChannelIds),
+ mentions: { id: user.id },
+ },
+ select: {
+ id: true,
+ timestamp: true,
+ },
+ order: {
+ timestamp: "DESC",
+ },
+ take: limit ? Number(limit) : 50,
+ }),
+ ),
+ await timePromise(() =>
+ !roles
+ ? Promise.resolve([])
+ : Message.find({
+ where: {
+ channel_id: In(visibleChannelIds),
+ mention_roles: { id: In(ownedMentionableRoleIds) },
+ },
+ select: {
+ id: true,
+ timestamp: true,
+ },
+ order: {
+ timestamp: "DESC",
+ },
+ take: limit ? Number(limit) : 50,
+ }),
+ ),
+ await timePromise(() =>
+ !everyone
+ ? Promise.resolve([])
+ : Message.find({
+ where: {
+ channel_id: In(visibleChannelIds),
+ mention_everyone: true,
+ },
+ select: {
+ id: true,
+ timestamp: true,
+ },
+ order: {
+ timestamp: "DESC",
+ },
+ take: limit ? Number(limit) : 50,
+ }),
+ ),
+ ]);
+
+ const allMentions = [...userMentions, ...roleMentions, ...everyoneMentions];
+ console.log(`[Inbox/mentions] User ${user.id} query results: totalRecs=${allMentions.length} | user=${userMentions.length} (took ${userMentionQueryTime}ms), role=${roleMentions.length} (took ${roleMentionQueryTime}ms), everyone=${everyoneMentions.length} (took ${everyoneMentionQueryTime}ms)`);
+
+ return res.json(
+ allMentions
+ .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
+ .distinctBy((m) => m.id)
+ .slice(0, limit),
+ );
+ },
+);
+
+export default router;
|