mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
|
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
|
import {NatsConnectionManager} from '@pkgs/nats/src/NatsConnectionManager';
|
|
import {StringCodec} from 'nats';
|
|
import type {ChannelID, GuildID, MessageID, UserID} from '../../../BrandedTypes';
|
|
import {createUserID} from '../../../BrandedTypes';
|
|
import {Config} from '../../../Config';
|
|
import {throwForSvcErrorReply} from '../../../infrastructure/SvcErrorReply';
|
|
import {Logger} from '../../../Logger';
|
|
import type {Channel} from '../../../models/Channel';
|
|
import type {Message} from '../../../models/Message';
|
|
import {isJsonRecord, parseJsonRecord, parseJsonWithGuard} from '../../../utils/JsonBoundaryUtils';
|
|
|
|
const MESSAGE_RESPONSE_SERVICE_SUBJECT = 'svc.messages';
|
|
const MESSAGE_RESPONSE_SERVICE_TIMEOUT_MS = 6000;
|
|
let messageResponseDataService: MessageResponseDataService | undefined;
|
|
let injectedMessageResponseDataService: MessageResponseDataService | undefined;
|
|
|
|
export interface MessageResponseAccessContext {
|
|
sourceGuildId: GuildID | null;
|
|
messageHistoryCutoff: string | null;
|
|
canReadMessageHistory: boolean;
|
|
}
|
|
|
|
interface ExtractedMentions {
|
|
users: Array<string>;
|
|
roles: Array<string>;
|
|
channels: Array<string>;
|
|
everyone: boolean;
|
|
here: boolean;
|
|
}
|
|
|
|
type MessageServiceResponse =
|
|
| {
|
|
FoundApi: MessageResponse;
|
|
}
|
|
| {
|
|
FoundApiMany: Array<MessageResponse>;
|
|
}
|
|
| {
|
|
FoundMentions: Array<ExtractedMentions>;
|
|
}
|
|
| 'NotFound';
|
|
|
|
function isMessageResponse(value: unknown): value is MessageResponse {
|
|
return isJsonRecord(value) && typeof value.id === 'string';
|
|
}
|
|
|
|
function isExtractedMentions(value: unknown): value is ExtractedMentions {
|
|
if (!isJsonRecord(value)) return false;
|
|
return (
|
|
Array.isArray(value.users) &&
|
|
value.users.every((entry) => typeof entry === 'string') &&
|
|
Array.isArray(value.roles) &&
|
|
value.roles.every((entry) => typeof entry === 'string') &&
|
|
Array.isArray(value.channels) &&
|
|
value.channels.every((entry) => typeof entry === 'string') &&
|
|
typeof value.everyone === 'boolean' &&
|
|
typeof value.here === 'boolean'
|
|
);
|
|
}
|
|
|
|
function isMessageServiceResponse(value: unknown): value is MessageServiceResponse {
|
|
if (value === 'NotFound') return true;
|
|
if (!isJsonRecord(value)) return false;
|
|
if ('FoundApi' in value) {
|
|
return isMessageResponse(value.FoundApi);
|
|
}
|
|
if ('FoundApiMany' in value) {
|
|
return Array.isArray(value.FoundApiMany) && value.FoundApiMany.every(isMessageResponse);
|
|
}
|
|
if ('FoundMentions' in value) {
|
|
return Array.isArray(value.FoundMentions) && value.FoundMentions.every(isExtractedMentions);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export class MessageResponseDataService {
|
|
private readonly codec = StringCodec();
|
|
|
|
constructor(private readonly connectionManager: INatsConnectionManager) {}
|
|
|
|
async listMessages(params: {
|
|
userId: UserID;
|
|
channelId: ChannelID;
|
|
limit: number;
|
|
before?: MessageID;
|
|
after?: MessageID;
|
|
around?: MessageID;
|
|
access: MessageResponseAccessContext;
|
|
}): Promise<Array<MessageResponse>> {
|
|
const response = await this.request({
|
|
op: 'ListResponses',
|
|
channel_id: params.channelId.toString(),
|
|
viewer_user_id: params.userId.toString(),
|
|
limit: params.limit,
|
|
before_id: params.before?.toString(),
|
|
after_id: params.after?.toString(),
|
|
around_id: params.around?.toString(),
|
|
source_guild_id: params.access.sourceGuildId?.toString(),
|
|
message_history_cutoff_ms: params.access.messageHistoryCutoff
|
|
? new Date(params.access.messageHistoryCutoff).getTime()
|
|
: null,
|
|
can_read_message_history: params.access.canReadMessageHistory,
|
|
media_endpoint: Config.endpoints.media,
|
|
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
|
include_reactions: true,
|
|
});
|
|
if (typeof response === 'object' && 'FoundApiMany' in response) {
|
|
return response.FoundApiMany;
|
|
}
|
|
throw new Error(`[message-response-service] unexpected ListResponses response: ${JSON.stringify(response)}`);
|
|
}
|
|
|
|
async extractMentions(contents: Array<string>): Promise<Array<ExtractedMentions>> {
|
|
if (contents.length === 0) return [];
|
|
const response = await this.request({
|
|
op: 'ExtractMentions',
|
|
contents,
|
|
});
|
|
if (typeof response === 'object' && 'FoundMentions' in response) {
|
|
return response.FoundMentions;
|
|
}
|
|
throw new Error(`[message-response-service] unexpected ExtractMentions response: ${JSON.stringify(response)}`);
|
|
}
|
|
|
|
async getMessage(params: {
|
|
userId: UserID;
|
|
channelId: ChannelID;
|
|
messageId: MessageID;
|
|
access: MessageResponseAccessContext;
|
|
nonce?: string;
|
|
tts?: boolean;
|
|
}): Promise<MessageResponse | null> {
|
|
const response = await this.request({
|
|
op: 'GetResponseById',
|
|
channel_id: params.channelId.toString(),
|
|
message_id: params.messageId.toString(),
|
|
viewer_user_id: params.userId.toString(),
|
|
source_guild_id: params.access.sourceGuildId?.toString(),
|
|
message_history_cutoff_ms: params.access.messageHistoryCutoff
|
|
? new Date(params.access.messageHistoryCutoff).getTime()
|
|
: null,
|
|
can_read_message_history: params.access.canReadMessageHistory,
|
|
media_endpoint: Config.endpoints.media,
|
|
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
|
include_reactions: true,
|
|
nonce: params.nonce,
|
|
tts: params.tts,
|
|
});
|
|
if (response === 'NotFound') return null;
|
|
if (typeof response === 'object' && 'FoundApi' in response) {
|
|
return response.FoundApi;
|
|
}
|
|
throw new Error(`[message-response-service] unexpected GetResponseById response: ${JSON.stringify(response)}`);
|
|
}
|
|
|
|
async buildMessage(params: {
|
|
userId: UserID;
|
|
message: Message;
|
|
access: MessageResponseAccessContext;
|
|
nonce?: string;
|
|
tts?: boolean;
|
|
includeReactions?: boolean;
|
|
}): Promise<MessageResponse> {
|
|
const response = await this.request({
|
|
op: 'BuildResponse',
|
|
message: serializeMessageForService(params.message),
|
|
viewer_user_id: params.userId.toString(),
|
|
source_guild_id: params.access.sourceGuildId?.toString(),
|
|
message_history_cutoff_ms: params.access.messageHistoryCutoff
|
|
? new Date(params.access.messageHistoryCutoff).getTime()
|
|
: null,
|
|
can_read_message_history: params.access.canReadMessageHistory,
|
|
media_endpoint: Config.endpoints.media,
|
|
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
|
include_reactions: params.includeReactions ?? true,
|
|
nonce: params.nonce,
|
|
tts: params.tts,
|
|
});
|
|
if (typeof response === 'object' && 'FoundApi' in response) {
|
|
return response.FoundApi;
|
|
}
|
|
throw new Error(`[message-response-service] unexpected BuildResponse response: ${JSON.stringify(response)}`);
|
|
}
|
|
|
|
async buildMessageForChannel(params: {
|
|
channel: Pick<Channel, 'guildId'>;
|
|
message: Message;
|
|
userId?: UserID;
|
|
nonce?: string;
|
|
tts?: boolean;
|
|
}): Promise<MessageResponse> {
|
|
return this.buildMessage({
|
|
userId: params.userId ?? messageResponseViewerId(params.message),
|
|
message: params.message,
|
|
access: messageResponseAccessForChannel(params.channel),
|
|
nonce: params.nonce,
|
|
tts: params.tts,
|
|
});
|
|
}
|
|
|
|
async buildBroadcastMessage(params: {
|
|
channel: Pick<Channel, 'guildId'>;
|
|
message: Message;
|
|
userId?: UserID;
|
|
nonce?: string;
|
|
tts?: boolean;
|
|
sourceGuildId?: GuildID | null;
|
|
}): Promise<MessageResponse> {
|
|
return this.buildMessage({
|
|
userId: messageResponseViewerId(params.message, params.userId),
|
|
message: params.message,
|
|
access:
|
|
params.sourceGuildId !== undefined
|
|
? messageResponseAccessForGuild(params.sourceGuildId)
|
|
: messageResponseAccessForChannel(params.channel),
|
|
nonce: params.nonce,
|
|
tts: params.tts,
|
|
includeReactions: false,
|
|
});
|
|
}
|
|
|
|
async buildMessages(params: {
|
|
userId: UserID;
|
|
messages: Array<Message>;
|
|
access: MessageResponseAccessContext;
|
|
includeReactions?: boolean;
|
|
}): Promise<Array<MessageResponse>> {
|
|
if (params.messages.length === 0) return [];
|
|
const response = await this.request({
|
|
op: 'BuildResponses',
|
|
messages: params.messages.map(serializeMessageForService),
|
|
viewer_user_id: params.userId.toString(),
|
|
source_guild_id: params.access.sourceGuildId?.toString(),
|
|
message_history_cutoff_ms: params.access.messageHistoryCutoff
|
|
? new Date(params.access.messageHistoryCutoff).getTime()
|
|
: null,
|
|
can_read_message_history: params.access.canReadMessageHistory,
|
|
media_endpoint: Config.endpoints.media,
|
|
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
|
include_reactions: params.includeReactions ?? true,
|
|
});
|
|
if (typeof response === 'object' && 'FoundApiMany' in response) {
|
|
return response.FoundApiMany;
|
|
}
|
|
throw new Error(`[message-response-service] unexpected BuildResponses response: ${JSON.stringify(response)}`);
|
|
}
|
|
|
|
async buildMessagesForChannels(params: {
|
|
userId: UserID;
|
|
messages: Array<Message>;
|
|
channelById: ReadonlyMap<string, Pick<Channel, 'guildId'>>;
|
|
includeReactions?: boolean;
|
|
}): Promise<Array<MessageResponse>> {
|
|
const responses = new Array<MessageResponse>(params.messages.length);
|
|
const groups = new Map<
|
|
string,
|
|
{
|
|
access: MessageResponseAccessContext;
|
|
entries: Array<{index: number; message: Message}>;
|
|
}
|
|
>();
|
|
for (const [index, message] of params.messages.entries()) {
|
|
const channel = params.channelById.get(message.channelId.toString());
|
|
const sourceGuildId = channel?.guildId ?? null;
|
|
const key = sourceGuildId?.toString() ?? 'dm';
|
|
let group = groups.get(key);
|
|
if (!group) {
|
|
group = {
|
|
access: messageResponseAccessForGuild(sourceGuildId),
|
|
entries: [],
|
|
};
|
|
groups.set(key, group);
|
|
}
|
|
group.entries.push({index, message});
|
|
}
|
|
await Promise.all(
|
|
Array.from(groups.values()).map(async (group) => {
|
|
const mapped = await this.buildMessages({
|
|
userId: params.userId,
|
|
messages: group.entries.map((entry) => entry.message),
|
|
access: group.access,
|
|
includeReactions: params.includeReactions,
|
|
});
|
|
for (const [mappedIndex, entry] of group.entries.entries()) {
|
|
responses[entry.index] = mapped[mappedIndex];
|
|
}
|
|
}),
|
|
);
|
|
return responses;
|
|
}
|
|
|
|
private async request(payload: Record<string, unknown>): Promise<MessageServiceResponse> {
|
|
try {
|
|
if (this.connectionManager.isClosed()) {
|
|
await this.connectionManager.connect();
|
|
}
|
|
const connection = this.connectionManager.getConnection();
|
|
const response = await connection.request(
|
|
MESSAGE_RESPONSE_SERVICE_SUBJECT,
|
|
this.codec.encode(JSON.stringify(payload)),
|
|
{timeout: MESSAGE_RESPONSE_SERVICE_TIMEOUT_MS},
|
|
);
|
|
const decoded = this.codec.decode(response.data);
|
|
const parsed = parseJsonWithGuard(decoded, isMessageServiceResponse);
|
|
if (!parsed) {
|
|
throwForSvcErrorReply('message-response-service', parseJsonRecord(decoded));
|
|
throw new Error('[message-response-service] invalid response payload');
|
|
}
|
|
return parsed;
|
|
} catch (error) {
|
|
Logger.error({error, op: payload.op}, '[message-response-service] request failed');
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
function messageResponseViewerId(message: Message, currentUserId?: UserID): UserID {
|
|
return currentUserId ?? message.authorId ?? createUserID(0n);
|
|
}
|
|
|
|
export function messageResponseAccessForChannel(channel: Pick<Channel, 'guildId'>): MessageResponseAccessContext {
|
|
return messageResponseAccessForGuild(channel.guildId);
|
|
}
|
|
|
|
export function messageResponseAccessForGuild(sourceGuildId: GuildID | null): MessageResponseAccessContext {
|
|
return {
|
|
sourceGuildId,
|
|
messageHistoryCutoff: null,
|
|
canReadMessageHistory: true,
|
|
};
|
|
}
|
|
|
|
function serializeMessageForService(message: Message): Record<string, unknown> {
|
|
const row = serializeValue(message.toRow()) as Record<string, unknown>;
|
|
row.pinned = message.pinnedTimestamp != null;
|
|
return row;
|
|
}
|
|
|
|
function serializeValue(value: unknown): unknown {
|
|
if (value == null) return value;
|
|
if (typeof value === 'bigint') return value.toString();
|
|
if (value instanceof Date) return value.getTime();
|
|
if (value instanceof Set) return Array.from(value, serializeValue);
|
|
if (Array.isArray(value)) return value.map(serializeValue);
|
|
if (typeof value === 'object') {
|
|
return Object.fromEntries(Object.entries(value).map(([key, nestedValue]) => [key, serializeValue(nestedValue)]));
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function createMessageResponseDataService(): MessageResponseDataService {
|
|
if (injectedMessageResponseDataService !== undefined) {
|
|
return injectedMessageResponseDataService;
|
|
}
|
|
if (messageResponseDataService != null) return messageResponseDataService;
|
|
const manager = new NatsConnectionManager({
|
|
url: Config.nats.coreUrl,
|
|
token: Config.nats.authToken || undefined,
|
|
name: 'fluxer-api-message-responses',
|
|
});
|
|
void manager.connect().catch((error) => {
|
|
Logger.error({error}, '[message-response-service] Failed to establish NATS connection');
|
|
});
|
|
messageResponseDataService = new MessageResponseDataService(manager);
|
|
return messageResponseDataService;
|
|
}
|
|
|
|
export function setInjectedMessageResponseDataService(service: MessageResponseDataService | undefined): void {
|
|
injectedMessageResponseDataService = service;
|
|
messageResponseDataService = undefined;
|
|
}
|