426 lines
12 KiB
TypeScript
426 lines
12 KiB
TypeScript
import {
|
|
DataSource,
|
|
EntityManager,
|
|
In
|
|
} from 'typeorm';
|
|
import {
|
|
ReactionEntity,
|
|
RoomChannelEntity,
|
|
RoomMemberEntity
|
|
} from '../entities';
|
|
import { ReactionPayload } from './types';
|
|
|
|
type ChannelType = 'text' | 'voice';
|
|
type RoomMemberRole = 'host' | 'admin' | 'moderator' | 'member';
|
|
|
|
export interface RoomChannelRecord {
|
|
id: string;
|
|
name: string;
|
|
type: ChannelType;
|
|
position: number;
|
|
}
|
|
|
|
export interface RoomMemberRecord {
|
|
id: string;
|
|
oderId?: string;
|
|
username: string;
|
|
displayName: string;
|
|
avatarUrl?: string;
|
|
role: RoomMemberRole;
|
|
joinedAt: number;
|
|
lastSeenAt: number;
|
|
}
|
|
|
|
interface RoomRelationRecord {
|
|
channels: RoomChannelRecord[];
|
|
members: RoomMemberRecord[];
|
|
}
|
|
|
|
function isFiniteNumber(value: unknown): value is number {
|
|
return typeof value === 'number' && Number.isFinite(value);
|
|
}
|
|
|
|
function normalizeChannelName(name: string): string {
|
|
return name.trim().replace(/\s+/g, ' ');
|
|
}
|
|
|
|
function channelNameKey(type: ChannelType, name: string): string {
|
|
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
|
}
|
|
|
|
function memberKey(member: { id?: string; oderId?: string }): string {
|
|
return member.oderId?.trim() || member.id?.trim() || '';
|
|
}
|
|
|
|
function fallbackDisplayName(member: Partial<RoomMemberRecord>): string {
|
|
return member.displayName || member.username || member.oderId || member.id || 'User';
|
|
}
|
|
|
|
function fallbackUsername(member: Partial<RoomMemberRecord>): string {
|
|
const base = fallbackDisplayName(member)
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\s+/g, '_');
|
|
|
|
return base || member.oderId || member.id || 'user';
|
|
}
|
|
|
|
function normalizeRoomMemberRole(value: unknown): RoomMemberRole {
|
|
return value === 'host' || value === 'admin' || value === 'moderator' || value === 'member'
|
|
? value
|
|
: 'member';
|
|
}
|
|
|
|
function mergeRoomMemberRole(
|
|
existingRole: RoomMemberRole,
|
|
incomingRole: RoomMemberRole,
|
|
preferIncoming: boolean
|
|
): RoomMemberRole {
|
|
if (existingRole === incomingRole) {
|
|
return existingRole;
|
|
}
|
|
|
|
if (incomingRole === 'member' && existingRole !== 'member') {
|
|
return existingRole;
|
|
}
|
|
|
|
if (existingRole === 'member' && incomingRole !== 'member') {
|
|
return incomingRole;
|
|
}
|
|
|
|
return preferIncoming ? incomingRole : existingRole;
|
|
}
|
|
|
|
function compareRoomMembers(firstMember: RoomMemberRecord, secondMember: RoomMemberRecord): number {
|
|
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' });
|
|
|
|
if (displayNameCompare !== 0) {
|
|
return displayNameCompare;
|
|
}
|
|
|
|
return memberKey(firstMember).localeCompare(memberKey(secondMember));
|
|
}
|
|
|
|
function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): RoomMemberRecord | null {
|
|
const normalizedId = typeof rawMember['id'] === 'string' ? rawMember['id'].trim() : '';
|
|
const normalizedOderId = typeof rawMember['oderId'] === 'string' ? rawMember['oderId'].trim() : '';
|
|
const normalizedKey = normalizedOderId || normalizedId;
|
|
|
|
if (!normalizedKey) {
|
|
return null;
|
|
}
|
|
|
|
const lastSeenAt = isFiniteNumber(rawMember['lastSeenAt'])
|
|
? rawMember['lastSeenAt']
|
|
: isFiniteNumber(rawMember['joinedAt'])
|
|
? rawMember['joinedAt']
|
|
: now;
|
|
const joinedAt = isFiniteNumber(rawMember['joinedAt']) ? rawMember['joinedAt'] : lastSeenAt;
|
|
const username = typeof rawMember['username'] === 'string' ? rawMember['username'].trim() : '';
|
|
const displayName = typeof rawMember['displayName'] === 'string' ? rawMember['displayName'].trim() : '';
|
|
const avatarUrl = typeof rawMember['avatarUrl'] === 'string' ? rawMember['avatarUrl'].trim() : '';
|
|
|
|
const member: RoomMemberRecord = {
|
|
id: normalizedId || normalizedKey,
|
|
oderId: normalizedOderId || undefined,
|
|
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
|
|
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
|
|
avatarUrl: avatarUrl || undefined,
|
|
role: normalizeRoomMemberRole(rawMember['role']),
|
|
joinedAt,
|
|
lastSeenAt
|
|
};
|
|
|
|
return member;
|
|
}
|
|
|
|
function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord {
|
|
if (!existingMember) {
|
|
return incomingMember;
|
|
}
|
|
|
|
const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt;
|
|
|
|
return {
|
|
id: existingMember.id || incomingMember.id,
|
|
oderId: incomingMember.oderId || existingMember.oderId,
|
|
username: preferIncoming
|
|
? (incomingMember.username || existingMember.username)
|
|
: (existingMember.username || incomingMember.username),
|
|
displayName: preferIncoming
|
|
? (incomingMember.displayName || existingMember.displayName)
|
|
: (existingMember.displayName || incomingMember.displayName),
|
|
avatarUrl: preferIncoming
|
|
? (incomingMember.avatarUrl || existingMember.avatarUrl)
|
|
: (existingMember.avatarUrl || incomingMember.avatarUrl),
|
|
role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming),
|
|
joinedAt: Math.min(existingMember.joinedAt, incomingMember.joinedAt),
|
|
lastSeenAt: Math.max(existingMember.lastSeenAt, incomingMember.lastSeenAt)
|
|
};
|
|
}
|
|
|
|
function normalizeReactionPayloads(rawReactions: unknown, messageId: string): ReactionPayload[] {
|
|
if (!Array.isArray(rawReactions)) {
|
|
return [];
|
|
}
|
|
|
|
const seen = new Set<string>();
|
|
const reactions: ReactionPayload[] = [];
|
|
|
|
for (const rawReaction of rawReactions) {
|
|
if (!rawReaction || typeof rawReaction !== 'object') {
|
|
continue;
|
|
}
|
|
|
|
const reaction = rawReaction as Record<string, unknown>;
|
|
const emoji = typeof reaction['emoji'] === 'string' ? reaction['emoji'] : '';
|
|
const userId = typeof reaction['userId'] === 'string' ? reaction['userId'] : '';
|
|
const dedupeKey = `${userId}:${emoji}`;
|
|
|
|
if (!emoji || seen.has(dedupeKey)) {
|
|
continue;
|
|
}
|
|
|
|
seen.add(dedupeKey);
|
|
reactions.push({
|
|
id: typeof reaction['id'] === 'string' && reaction['id'].trim() ? reaction['id'] : `${messageId}:${dedupeKey}`,
|
|
messageId,
|
|
oderId: typeof reaction['oderId'] === 'string' ? reaction['oderId'] : '',
|
|
userId,
|
|
emoji,
|
|
timestamp: isFiniteNumber(reaction['timestamp']) ? reaction['timestamp'] : 0
|
|
});
|
|
}
|
|
|
|
return reactions;
|
|
}
|
|
|
|
export function normalizeRoomChannels(rawChannels: unknown): RoomChannelRecord[] {
|
|
if (!Array.isArray(rawChannels)) {
|
|
return [];
|
|
}
|
|
|
|
const seenIds = new Set<string>();
|
|
const seenNames = new Set<string>();
|
|
const channels: RoomChannelRecord[] = [];
|
|
|
|
for (const [index, rawChannel] of rawChannels.entries()) {
|
|
if (!rawChannel || typeof rawChannel !== 'object') {
|
|
continue;
|
|
}
|
|
|
|
const channel = rawChannel as Record<string, unknown>;
|
|
const id = typeof channel['id'] === 'string' ? channel['id'].trim() : '';
|
|
const name = typeof channel['name'] === 'string' ? normalizeChannelName(channel['name']) : '';
|
|
const type = channel['type'] === 'text' || channel['type'] === 'voice' ? channel['type'] : null;
|
|
const position = isFiniteNumber(channel['position']) ? channel['position'] : index;
|
|
const nameKey = type ? channelNameKey(type, name) : '';
|
|
|
|
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
|
continue;
|
|
}
|
|
|
|
seenIds.add(id);
|
|
seenNames.add(nameKey);
|
|
channels.push({
|
|
id,
|
|
name,
|
|
type,
|
|
position
|
|
});
|
|
}
|
|
|
|
return channels;
|
|
}
|
|
|
|
export function normalizeRoomMembers(rawMembers: unknown, now: number = Date.now()): RoomMemberRecord[] {
|
|
if (!Array.isArray(rawMembers)) {
|
|
return [];
|
|
}
|
|
|
|
const membersByKey = new Map<string, RoomMemberRecord>();
|
|
|
|
for (const rawMember of rawMembers) {
|
|
if (!rawMember || typeof rawMember !== 'object') {
|
|
continue;
|
|
}
|
|
|
|
const member = normalizeRoomMember(rawMember as Record<string, unknown>, now);
|
|
|
|
if (!member) {
|
|
continue;
|
|
}
|
|
|
|
const key = memberKey(member);
|
|
|
|
membersByKey.set(key, mergeRoomMembers(membersByKey.get(key), member));
|
|
}
|
|
|
|
return Array.from(membersByKey.values()).sort(compareRoomMembers);
|
|
}
|
|
|
|
export async function replaceMessageReactions(
|
|
manager: EntityManager,
|
|
messageId: string,
|
|
rawReactions: unknown
|
|
): Promise<void> {
|
|
const repo = manager.getRepository(ReactionEntity);
|
|
|
|
await repo.delete({ messageId });
|
|
|
|
const reactions = normalizeReactionPayloads(rawReactions, messageId);
|
|
|
|
if (reactions.length === 0) {
|
|
return;
|
|
}
|
|
|
|
await repo.insert(
|
|
reactions.map((reaction) => ({
|
|
id: reaction.id,
|
|
messageId,
|
|
oderId: reaction.oderId || null,
|
|
userId: reaction.userId || null,
|
|
emoji: reaction.emoji,
|
|
timestamp: reaction.timestamp
|
|
}))
|
|
);
|
|
}
|
|
|
|
export async function loadMessageReactionsMap(
|
|
dataSource: DataSource,
|
|
messageIds: readonly string[]
|
|
): Promise<Map<string, ReactionPayload[]>> {
|
|
const groupedReactions = new Map<string, ReactionPayload[]>();
|
|
|
|
if (messageIds.length === 0) {
|
|
return groupedReactions;
|
|
}
|
|
|
|
const rows = await dataSource.getRepository(ReactionEntity).find({
|
|
where: { messageId: In([...messageIds]) }
|
|
});
|
|
|
|
for (const row of rows) {
|
|
const reactions = groupedReactions.get(row.messageId) ?? [];
|
|
|
|
reactions.push({
|
|
id: row.id,
|
|
messageId: row.messageId,
|
|
oderId: row.oderId ?? '',
|
|
userId: row.userId ?? '',
|
|
emoji: row.emoji,
|
|
timestamp: row.timestamp
|
|
});
|
|
groupedReactions.set(row.messageId, reactions);
|
|
}
|
|
|
|
for (const reactions of groupedReactions.values()) {
|
|
reactions.sort((first, second) => first.timestamp - second.timestamp);
|
|
}
|
|
|
|
return groupedReactions;
|
|
}
|
|
|
|
export async function replaceRoomRelations(
|
|
manager: EntityManager,
|
|
roomId: string,
|
|
options: { channels?: unknown; members?: unknown }
|
|
): Promise<void> {
|
|
if (options.channels !== undefined) {
|
|
const channelRepo = manager.getRepository(RoomChannelEntity);
|
|
const channels = normalizeRoomChannels(options.channels);
|
|
|
|
await channelRepo.delete({ roomId });
|
|
|
|
if (channels.length > 0) {
|
|
await channelRepo.insert(
|
|
channels.map((channel) => ({
|
|
roomId,
|
|
channelId: channel.id,
|
|
name: channel.name,
|
|
type: channel.type,
|
|
position: channel.position
|
|
}))
|
|
);
|
|
}
|
|
}
|
|
|
|
if (options.members !== undefined) {
|
|
const memberRepo = manager.getRepository(RoomMemberEntity);
|
|
const members = normalizeRoomMembers(options.members);
|
|
|
|
await memberRepo.delete({ roomId });
|
|
|
|
if (members.length > 0) {
|
|
await memberRepo.insert(
|
|
members.map((member) => ({
|
|
roomId,
|
|
memberKey: memberKey(member),
|
|
id: member.id,
|
|
oderId: member.oderId ?? null,
|
|
username: member.username,
|
|
displayName: member.displayName,
|
|
avatarUrl: member.avatarUrl ?? null,
|
|
role: member.role,
|
|
joinedAt: member.joinedAt,
|
|
lastSeenAt: member.lastSeenAt
|
|
}))
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function loadRoomRelationsMap(
|
|
dataSource: DataSource,
|
|
roomIds: readonly string[]
|
|
): Promise<Map<string, RoomRelationRecord>> {
|
|
const groupedRelations = new Map<string, RoomRelationRecord>();
|
|
|
|
if (roomIds.length === 0) {
|
|
return groupedRelations;
|
|
}
|
|
|
|
const [channelRows, memberRows] = await Promise.all([
|
|
dataSource.getRepository(RoomChannelEntity).find({
|
|
where: { roomId: In([...roomIds]) }
|
|
}),
|
|
dataSource.getRepository(RoomMemberEntity).find({
|
|
where: { roomId: In([...roomIds]) }
|
|
})
|
|
]);
|
|
|
|
for (const row of channelRows) {
|
|
const relation = groupedRelations.get(row.roomId) ?? { channels: [], members: [] };
|
|
|
|
relation.channels.push({
|
|
id: row.channelId,
|
|
name: row.name,
|
|
type: row.type,
|
|
position: row.position
|
|
});
|
|
groupedRelations.set(row.roomId, relation);
|
|
}
|
|
|
|
for (const row of memberRows) {
|
|
const relation = groupedRelations.get(row.roomId) ?? { channels: [], members: [] };
|
|
|
|
relation.members.push({
|
|
id: row.id,
|
|
oderId: row.oderId ?? undefined,
|
|
username: row.username,
|
|
displayName: row.displayName,
|
|
avatarUrl: row.avatarUrl ?? undefined,
|
|
role: row.role,
|
|
joinedAt: row.joinedAt,
|
|
lastSeenAt: row.lastSeenAt
|
|
});
|
|
groupedRelations.set(row.roomId, relation);
|
|
}
|
|
|
|
for (const relation of groupedRelations.values()) {
|
|
relation.channels.sort((first, second) => first.position - second.position || first.name.localeCompare(second.name));
|
|
relation.members.sort(compareRoomMembers);
|
|
}
|
|
|
|
return groupedRelations;
|
|
} |