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 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 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 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 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 _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>? _parseComponents(Map body) { final rawComponents = body['components']; if (rawComponents is List) { final components = >[]; for (final component in rawComponents) { if (component is Map) { 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 = >[]; for (final component in decoded) { if (component is Map) { 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 >[ { 'type': 'text', 'modifiers': [], 'fallback': content, 'data': { 'text': content, }, } ]; } String _buildSearchContent( List> components, String? contentOverride, ) { if (contentOverride != null && contentOverride.isNotEmpty) { return contentOverride; } final parts = []; 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> components) { final referencedFileIds = {}; 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); } }