Files
fluxer/fluxer_api/src/api/channel/controllers/MessageInteractionController.ts
T

431 lines
16 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {createChannelID, createMessageID, createUserID} from '@app/api/BrandedTypes';
import {isPersonalNotesChannel} from '@app/api/channel/services/message/MessageHelpers';
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
import {THREAD_FEATURE_CHANNEL_TYPES, viewerActive, viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate';
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import {readStateCapable} from '@app/api/read_state/ReadStateChannelMeta';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
import {UnclaimedAccountCannotAddReactionsError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotAddReactionsError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {
ChannelIdMessageIdEmojiParam,
ChannelIdMessageIdEmojiTargetIdParam,
ChannelIdMessageIdParam,
ChannelIdParam,
SessionIdQuerySchema,
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
ChannelPinsQuerySchema,
ReactionUsersQuerySchema,
} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
import {
ChannelPinsResponse,
ReactionUsersListResponse,
ReactionUsersPageResponse,
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
export function MessageInteractionController(app: HonoApp) {
app.get(
'/channels/:channel_id/messages/pins',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_PINS),
LoginRequired,
Validator('param', ChannelIdParam),
Validator('query', ChannelPinsQuerySchema),
OpenAPI({
operationId: 'list_pinned_messages',
summary: 'List pinned messages',
description:
'Retrieves a paginated list of messages pinned in a channel. User must have permission to view the channel. Supports pagination via limit and before parameters. Returns pinned messages with their pin timestamps.',
responseSchema: ChannelPinsResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const requestCache = ctx.get('requestCache');
const {limit, before} = ctx.req.valid('query');
return ctx.json(
await ctx.get('channelService').interactions.getChannelPins({
viewer: viewerFromCtx(ctx),
userId,
channelId,
requestCache,
limit,
beforeTimestamp: before,
}),
);
},
);
app.post(
'/channels/:channel_id/pins/ack',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_PINS),
LoginRequired,
Validator('param', ChannelIdParam),
OpenAPI({
operationId: 'acknowledge_pins',
summary: 'Acknowledge new pin notifications',
description:
'Marks all new pin notifications in a channel as acknowledged. Clears the notification badge for pinned messages. Returns 204 No Content on success.',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const channelService = ctx.get('channelService');
const channel = await channelService.channelData.operations.getChannelSystem(channelId);
if (channel && THREAD_FEATURE_CHANNEL_TYPES.has(channel.type)) {
const viewer = viewerFromCtx(ctx);
if (channel.guildId === null || !viewerActive(viewer, channel.guildId)) {
return ctx.body(null, 204);
}
if (channel.isThreadOnly()) {
throw new InvalidChannelTypeError();
}
await channelService.channelData.auth.getChannelAuthenticated({
userId,
channelId,
viewer,
skipNsfwValidation: true,
});
}
const timestamp = channel?.lastPinTimestamp;
if (timestamp != null) {
await ctx.get('readStateService').ackPins({
userId,
channelId,
timestamp,
capable: readStateCapable(ctx),
channel,
});
}
return ctx.body(null, 204);
},
);
app.put(
'/channels/:channel_id/pins/:message_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_PINS),
LoginRequired,
Validator('param', ChannelIdMessageIdParam),
OpenAPI({
operationId: 'pin_message',
summary: 'Pin a message',
description:
'Pins a message to the channel. Requires permission to manage pins (typically moderator or higher). Pinned messages are highlighted and searchable. Returns 204 No Content on success.',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const {channel_id, message_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('channelService').interactions.pinMessage({
viewer: viewerFromCtx(ctx),
userId,
channelId,
messageId,
requestCache,
auditLogReason,
});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/pins/:message_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_PINS),
LoginRequired,
Validator('param', ChannelIdMessageIdParam),
OpenAPI({
operationId: 'unpin_message',
summary: 'Unpin a message',
description:
'Unpins a message from the channel. Requires permission to manage pins. The message remains in the channel but is no longer highlighted. Returns 204 No Content on success.',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const {channel_id, message_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('channelService').interactions.unpinMessage({
viewer: viewerFromCtx(ctx),
userId,
channelId,
messageId,
requestCache,
auditLogReason,
});
return ctx.body(null, 204);
},
);
app.get(
'/channels/:channel_id/messages/:message_id/reactions/:emoji',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
LoginRequired,
Validator('param', ChannelIdMessageIdEmojiParam),
Validator('query', ReactionUsersQuerySchema),
OpenAPI({
operationId: 'list_reaction_users',
summary: 'List users who reacted with emoji',
description:
'Retrieves a paginated list of users who reacted to a message with a specific emoji. Supports pagination via limit and after parameters. Returns user objects for each reaction.',
responseSchema: ReactionUsersListResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const {channel_id, message_id, emoji} = ctx.req.valid('param');
const {limit, after} = ctx.req.valid('query');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const afterUserId = after ? createUserID(after) : undefined;
const result = await ctx.get('channelService').interactions.getUsersForReaction({
viewer: viewerFromCtx(ctx),
userId,
channelId,
messageId,
emoji,
limit,
after: afterUserId,
});
ctx.header('X-Has-More', result.has_more ? 'true' : 'false');
if (result.next_after !== null) {
ctx.header('X-Next-After', result.next_after);
}
return ctx.json(result.users, 200);
},
);
app.get(
'/channels/:channel_id/messages/:message_id/reactions/:emoji/users',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
LoginRequired,
Validator('param', ChannelIdMessageIdEmojiParam),
Validator('query', ReactionUsersQuerySchema),
OpenAPI({
operationId: 'list_reaction_users_v2',
summary: 'List users who reacted with emoji',
description:
'Retrieves a paginated list of users who reacted to a message with a specific emoji. The response body includes pagination metadata, including the next cursor, so clients do not need to infer it from returned users.',
responseSchema: ReactionUsersPageResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const {channel_id, message_id, emoji} = ctx.req.valid('param');
const {limit, after} = ctx.req.valid('query');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const afterUserId = after ? createUserID(after) : undefined;
const result = await ctx.get('channelService').interactions.getUsersForReaction({
viewer: viewerFromCtx(ctx),
userId,
channelId,
messageId,
emoji,
limit,
after: afterUserId,
});
return ctx.json(
{
items: result.users,
has_more: result.has_more,
next_after: result.next_after,
},
200,
);
},
);
app.put(
'/channels/:channel_id/messages/:message_id/reactions/:emoji/@me',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
LoginRequired,
Validator('param', ChannelIdMessageIdEmojiParam),
Validator('query', SessionIdQuerySchema),
OpenAPI({
operationId: 'add_reaction',
summary: 'Add reaction to message',
description:
'Adds an emoji reaction to a message. Each user can react once with each emoji. Cannot be used from unclaimed accounts outside personal notes. Returns 204 No Content on success.',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const {channel_id, message_id, emoji} = ctx.req.valid('param');
const user = ctx.get('user');
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const sessionId = ctx.req.valid('query').session_id;
const requestCache = ctx.get('requestCache');
if (user.isUnclaimedAccount() && !isPersonalNotesChannel({userId: user.id, channelId})) {
throw new UnclaimedAccountCannotAddReactionsError();
}
if (!user.isBot && user.id !== SYSTEM_USER_ID && !(user.flags & UserFlags.HAS_SESSION_STARTED)) {
throw InputValidationError.fromCode('emoji', ValidationErrorCodes.MUST_START_SESSION_BEFORE_SENDING);
}
await ctx.get('channelService').interactions.addReaction({
viewer: viewerFromCtx(ctx),
userId: user.id,
sessionId,
channelId,
messageId,
emoji,
requestCache,
});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/messages/:message_id/reactions/:emoji/@me',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
LoginRequired,
Validator('param', ChannelIdMessageIdEmojiParam),
Validator('query', SessionIdQuerySchema),
OpenAPI({
operationId: 'remove_own_reaction',
summary: 'Remove own reaction from message',
description:
"Removes your own emoji reaction from a message. Returns 204 No Content on success. Has no effect if you haven't reacted with that emoji.",
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const {channel_id, message_id, emoji} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const sessionId = ctx.req.valid('query').session_id;
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').interactions.removeOwnReaction({
viewer: viewerFromCtx(ctx),
userId,
sessionId,
channelId,
messageId,
emoji,
requestCache,
});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/messages/:message_id/reactions/:emoji/:target_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
LoginRequired,
Validator('param', ChannelIdMessageIdEmojiTargetIdParam),
Validator('query', SessionIdQuerySchema),
OpenAPI({
operationId: 'remove_reaction',
summary: 'Remove reaction from message',
description:
"Removes a specific user's emoji reaction from a message. Requires moderator or higher permissions to remove reactions from other users. Returns 204 No Content on success.",
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const {channel_id, message_id, emoji, target_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const targetId = createUserID(target_id);
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const sessionId = ctx.req.valid('query').session_id;
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').interactions.removeReaction({
viewer: viewerFromCtx(ctx),
userId,
sessionId,
channelId,
messageId,
emoji,
targetId,
requestCache,
});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/messages/:message_id/reactions/:emoji',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
LoginRequired,
Validator('param', ChannelIdMessageIdEmojiParam),
OpenAPI({
operationId: 'remove_all_reactions_for_emoji',
summary: 'Remove all reactions with emoji',
description:
"Removes all emoji reactions of a specific type from a message. All users' reactions with that emoji are deleted. Requires moderator or higher permissions. Returns 204 No Content on success.",
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const {channel_id, message_id, emoji} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
await ctx
.get('channelService')
.interactions.removeAllReactionsForEmoji({viewer: viewerFromCtx(ctx), userId, channelId, messageId, emoji});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/messages/:message_id/reactions',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
LoginRequired,
Validator('param', ChannelIdMessageIdParam),
OpenAPI({
operationId: 'remove_all_reactions',
summary: 'Remove all reactions from message',
description:
'Removes all emoji reactions from a message, regardless of emoji type or user. All reactions are permanently deleted. Requires moderator or higher permissions. Returns 204 No Content on success.',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const {channel_id, message_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
await ctx
.get('channelService')
.interactions.removeAllReactions({viewer: viewerFromCtx(ctx), userId, channelId, messageId});
return ctx.body(null, 204);
},
);
}