Files
chat-server/lib/src/handlers/message_handlers.dart
T

488 lines
12 KiB
Dart

import 'dart:convert';
import 'package:chat_server/models.dart';
import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart';
import '../auth/auth_middleware.dart';
import '../gateway/gateway_handlers.dart';
import '../state/app_state.dart';
import '../utils/extensions.dart';
const _defaultMessageLimit = 50;
const _maxMessageLimit = 100;
/// Get messages for a channel
/// Query params: channelId (required), limit, before, after
Future<Response> getMessagesHandler(Request req) async {
final channelIdStr = req.url.queryParameters['channelId'];
final limitStr = req.url.queryParameters['limit'];
final beforeStr = req.url.queryParameters['before'];
final afterStr = req.url.queryParameters['after'];
if (channelIdStr == null) {
return Response.badRequest(
body: Body.fromString('channelId required'),
);
}
if (beforeStr != null && afterStr != null) {
return Response.badRequest(
body: Body.fromString('Use either `before` or `after`, not both'),
);
}
final channelId = int.tryParse(channelIdStr);
final parsedLimit =
limitStr == null ? _defaultMessageLimit : int.tryParse(limitStr);
int? before;
int? after;
if (channelId == null) {
return Response.badRequest(
body: Body.fromString('Invalid channelId'),
);
}
if (parsedLimit == null || parsedLimit <= 0) {
return Response.badRequest(
body: Body.fromString('Invalid limit'),
);
}
final limit = parsedLimit > _maxMessageLimit ? _maxMessageLimit : parsedLimit;
if (beforeStr != null) {
before = int.tryParse(beforeStr);
if (before == null || before <= 0) {
return Response.badRequest(
body: Body.fromString('Invalid `before` cursor'),
);
}
}
if (afterStr != null) {
after = int.tryParse(afterStr);
if (after == null || after <= 0) {
return Response.badRequest(
body: Body.fromString('Invalid `after` cursor'),
);
}
}
// Check channel exists
final channel = channelBox.get(channelId);
if (channel == null) {
return Response.notFound(
body: Body.fromString('Channel not found'),
);
}
var condition = ChatMessage_.channelId.equals(channelId);
if (before != null) {
condition = condition & ChatMessage_.id.lessThan(before);
} else if (after != null) {
condition = condition & ChatMessage_.id.greaterThan(after);
}
final queryBuilder = messageBox.query(condition);
if (after != null) {
queryBuilder.order(ChatMessage_.id);
} else {
queryBuilder.order(ChatMessage_.id, flags: Order.descending);
}
final query = queryBuilder.build();
query.limit = limit + 1;
final fetchedMessages = query.find();
query.close();
final hasMore = fetchedMessages.length > limit;
final messages =
hasMore ? fetchedMessages.sublist(0, limit) : fetchedMessages;
int? nextBefore;
int? nextAfter;
if (messages.isNotEmpty) {
if (after != null) {
nextBefore = messages.first.id;
nextAfter = messages.last.id;
} else {
nextBefore = messages.last.id;
nextAfter = messages.first.id;
}
}
return Response.ok(
body: {
'messages': messages.map(_messageToMap).toList(),
'channelId': channelId,
'limit': limit,
'hasMore': hasMore,
'nextBefore': nextBefore,
'nextAfter': nextAfter,
}.toBody,
);
}
/// Send a message
Future<Response> sendMessageHandler(Request req) async {
final userId = userIdProperty[req];
if (userId == null) {
return Response.unauthorized(
body: Body.fromString('Not authenticated'),
);
}
final body = await req.asJson();
final channelId = body['channelId'] as int?;
final components = _parseComponents(body);
final contentOverride = (body['content'] as String?)?.trim();
if (components == null) {
return Response.badRequest(
body: Body.fromString('content or components/componentsJson required'),
);
}
if (channelId == null) {
return Response.badRequest(
body: Body.fromString('channelId required'),
);
}
// Check channel exists
final channel = channelBox.get(channelId);
if (channel == null) {
return Response.notFound(
body: Body.fromString('Channel not found'),
);
}
// Get user info for caching
final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
final user = userQuery.findFirst();
userQuery.close();
if (user == null) {
return Response.unauthorized(
body: Body.fromString('User not found'),
);
}
// Create message with cached author info
final message = ChatMessage(
channelId: channelId,
authorLocalId: user.id,
authorUserId: user.userId,
authorUsername: user.username,
authorAvatarUrl: user.avatarUrl,
authorHomeServer: user.homeServer,
content: _buildSearchContent(components, contentOverride),
componentsJson: jsonEncode(components),
);
final id = messageBox.put(message);
message.id = id;
_attachReferencedFiles(components);
broadcastMessageCreated(
channelId: channelId,
message: _messageToMap(message),
);
return Response.ok(
body: {
'id': id,
'content': message.content,
'componentsJson': message.componentsJson,
'authorId': user.userId,
'authorUsername': user.username,
'authorAvatarUrl': user.avatarUrl,
'channelId': channelId,
'createdAt': message.createdAt.toIso8601String(),
}.toBody,
);
}
/// Edit a message
Future<Response> editMessageHandler(Request req) async {
final userId = userIdProperty[req];
if (userId == null) {
return Response.unauthorized(
body: Body.fromString('Not authenticated'),
);
}
final messageIdStr = req.rawPathParameters[#messageId];
if (messageIdStr == null) {
return Response.badRequest(
body: Body.fromString('Invalid message ID'),
);
}
final messageId = int.tryParse(messageIdStr);
if (messageId == null) {
return Response.badRequest(
body: Body.fromString('Invalid message ID'),
);
}
final message = messageBox.get(messageId);
if (message == null) {
return Response.notFound(
body: Body.fromString('Message not found'),
);
}
// Get user to verify ownership
final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
final user = userQuery.findFirst();
userQuery.close();
if (user == null || message.authorLocalId != user.id) {
return Response.forbidden(
body: Body.fromString('You can only edit your own messages'),
);
}
final body = await req.asJson();
final components = _parseComponents(body);
final contentOverride = (body['content'] as String?)?.trim();
if (components == null) {
return Response.badRequest(
body: Body.fromString('content or components/componentsJson required'),
);
}
message.content = _buildSearchContent(components, contentOverride);
message.componentsJson = jsonEncode(components);
message.editedAt = DateTime.now();
messageBox.put(message);
_attachReferencedFiles(components);
broadcastMessageUpdated(
channelId: message.channelId,
message: _messageToMap(message),
);
return Response.ok(
body: {
'id': message.id,
'content': message.content,
'componentsJson': message.componentsJson,
'editedAt': message.editedAt?.toIso8601String(),
}.toBody,
);
}
/// Delete a message
Future<Response> deleteMessageHandler(Request req) async {
final userId = userIdProperty[req];
if (userId == null) {
return Response.unauthorized(
body: Body.fromString('Not authenticated'),
);
}
final messageIdStr = req.rawPathParameters[#messageId];
if (messageIdStr == null) {
return Response.badRequest(
body: Body.fromString('Invalid message ID'),
);
}
final messageId = int.tryParse(messageIdStr);
if (messageId == null) {
return Response.badRequest(
body: Body.fromString('Invalid message ID'),
);
}
final message = messageBox.get(messageId);
if (message == null) {
return Response.notFound(
body: Body.fromString('Message not found'),
);
}
// Get user to verify ownership
final userQuery = userBox.query(ChatUser_.userId.equals(userId)).build();
final user = userQuery.findFirst();
userQuery.close();
if (user == null || message.authorLocalId != user.id) {
return Response.forbidden(
body: Body.fromString('You can only delete your own messages'),
);
}
final channelId = message.channelId;
messageBox.remove(messageId);
broadcastMessageDeleted(
channelId: channelId,
messageId: messageId,
);
return Response.ok(
body: {
'deleted': true,
'id': messageId,
}.toBody,
);
}
Map<String, dynamic> _messageToMap(ChatMessage message) {
return {
'id': message.id,
'content': message.content,
'componentsJson': message.componentsJson,
'authorId': message.authorUserId,
'authorUsername': message.authorUsername,
'authorAvatarUrl': message.authorAvatarUrl,
'authorHomeServer': message.authorHomeServer,
'channelId': message.channelId,
'createdAt': message.createdAt.toIso8601String(),
'editedAt': message.editedAt?.toIso8601String(),
};
}
List<Map<String, dynamic>>? _parseComponents(Map<String, dynamic> body) {
final rawComponents = body['components'];
if (rawComponents is List) {
final components = <Map<String, dynamic>>[];
for (final component in rawComponents) {
if (component is Map<String, dynamic>) {
components.add(component);
} else if (component is Map) {
components.add(component.map((k, v) => MapEntry(k.toString(), v)));
} else {
return null;
}
}
return components;
}
final rawComponentsJson = body['componentsJson'];
if (rawComponentsJson is String && rawComponentsJson.trim().isNotEmpty) {
dynamic decoded;
try {
decoded = jsonDecode(rawComponentsJson);
} catch (_) {
return null;
}
if (decoded is! List) {
return null;
}
final components = <Map<String, dynamic>>[];
for (final component in decoded) {
if (component is Map<String, dynamic>) {
components.add(component);
} else if (component is Map) {
components.add(component.map((k, v) => MapEntry(k.toString(), v)));
} else {
return null;
}
}
return components;
}
final content = (body['content'] as String?)?.trim();
if (content == null || content.isEmpty) {
return null;
}
return <Map<String, dynamic>>[
{
'type': 'text',
'modifiers': <String>[],
'fallback': content,
'data': {
'text': content,
},
}
];
}
String _buildSearchContent(
List<Map<String, dynamic>> components,
String? contentOverride,
) {
if (contentOverride != null && contentOverride.isNotEmpty) {
return contentOverride;
}
final parts = <String>[];
for (final component in components) {
final fallback = component['fallback'];
if (fallback is String && fallback.trim().isNotEmpty) {
parts.add(fallback.trim());
}
final data = component['data'];
if (data is! Map) {
continue;
}
void addDataString(String key) {
final value = data[key];
if (value is String && value.trim().isNotEmpty) {
parts.add(value.trim());
}
}
addDataString('text');
addDataString('title');
addDataString('description');
addDataString('caption');
}
final content = parts.join(' ').trim();
return content.isEmpty ? '[component]' : content;
}
void _attachReferencedFiles(List<Map<String, dynamic>> components) {
final referencedFileIds = <int>{};
for (final component in components) {
final data = component['data'];
if (data is! Map) {
continue;
}
final items = data['items'];
if (items is! List) {
continue;
}
for (final item in items) {
if (item is! Map) {
continue;
}
final fileId = item['fileId'];
final parsedId = switch (fileId) {
int id when id > 0 => id,
String value => int.tryParse(value),
_ => null,
};
if (parsedId != null && parsedId > 0) {
referencedFileIds.add(parsedId);
}
}
}
for (final fileId in referencedFileIds) {
final file = fileBox.get(fileId);
if (file == null || file.isAttached) {
continue;
}
file.isAttached = true;
fileBox.put(file);
}
}