Initial commit: JustAMessenger v0.1.0

Серверная часть (Go):
- WebSocket сервер с бинарным протоколом
- XChaCha20-Poly1305 шифрование
- zstd сжатие с дедупликацией (64KB чанки)
- SQLite хранилище (WAL режим)
- Управление гильдиями, каналами, ролями
- Федерация между серверами (ed25519)
- REST API + WebSocket endpoints

Клиентская часть (Flutter):
- Material Design 3 тёмная тема (Discord-like)
- WebSocket соединение с сервером
- Экраны: сплэш, логин, домашний, гильдии, чат
- Модели: пользователи, гильдии, каналы, сообщения, роли
- Сервисы: соединение, API, криптография, тема
- Виджеты: иконки гильдий, сообщения, ввод чата
- Web сборка (PWA)

Документация:
- AGENTS.md — контекст для ИИ ассистентов
- docs/protocol.md — спецификация протокола
This commit is contained in:
SashegDev
2026-06-06 22:39:14 +00:00
commit 096c4d0a2d
40 changed files with 5054 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
import 'dart:convert';
import 'dart:io';
import 'package:http_parser/http_parser.dart';
import 'package:http/http.dart' as http;
import 'package:mime/mime.dart';
import '../models/guild.dart';
import '../models/channel.dart';
import '../models/message.dart';
import '../models/user.dart';
import '../models/role.dart';
class ApiService {
String _baseUrl = 'http://localhost:8443';
String? _token;
ApiService({String? baseUrl}) {
if (baseUrl != null) _baseUrl = baseUrl;
}
void setBaseUrl(String url) {
_baseUrl = url;
}
void updateFromWsUrl(String wsUrl) {
_baseUrl = wsUrl.replaceFirst('ws://', 'http://').replaceFirst('wss://', 'https://');
if (_baseUrl.endsWith('/ws')) {
_baseUrl = _baseUrl.substring(0, _baseUrl.length - 3);
}
}
Map<String, String> get _headers => {
'Content-Type': 'application/json',
if (_token != null) 'Authorization': 'Bearer $_token',
};
Future<bool> healthCheck() async {
try {
final resp = await http.get(
Uri.parse('$_baseUrl/api/health'),
).timeout(const Duration(seconds: 5));
return resp.statusCode == 200;
} catch (_) {
return false;
}
}
Future<List<Guild>> getGuilds(String userId) async {
final resp = await http.get(
Uri.parse('$_baseUrl/api/guilds?user_id=$userId'),
headers: _headers,
);
if (resp.statusCode != 200) throw Exception('Failed to load guilds');
final List<dynamic> data = jsonDecode(resp.body);
return data.map((g) => Guild.fromJson(g)).toList();
}
Future<String> createGuild(String name, String ownerId, {String? description}) async {
final resp = await http.post(
Uri.parse('$_baseUrl/api/guilds'),
headers: _headers,
body: jsonEncode({
'name': name,
'owner_id': ownerId,
'description': description ?? '',
}),
);
if (resp.statusCode != 201) throw Exception('Failed to create guild');
final data = jsonDecode(resp.body);
return data['id'] as String;
}
Future<Map<String, dynamic>> getGuild(String guildId) async {
final resp = await http.get(
Uri.parse('$_baseUrl/api/guilds/$guildId'),
headers: _headers,
);
if (resp.statusCode != 200) throw Exception('Failed to load guild');
return jsonDecode(resp.body);
}
Future<Channel> createChannel({
required String guildId,
required String name,
String type = 'text',
String? categoryId,
String? topic,
}) async {
final resp = await http.post(
Uri.parse('$_baseUrl/api/channels'),
headers: _headers,
body: jsonEncode({
'guild_id': guildId,
'category_id': categoryId ?? '',
'name': name,
'type': type,
'topic': topic ?? '',
'position': 0,
}),
);
if (resp.statusCode != 201) throw Exception('Failed to create channel');
return Channel.fromJson(jsonDecode(resp.body));
}
Future<List<Message>> getMessages(String channelId, {int limit = 50, String? before}) async {
final params = 'channel_id=$channelId&limit=$limit${before != null ? '&before=$before' : ''}';
final resp = await http.get(
Uri.parse('$_baseUrl/api/messages?$params'),
headers: _headers,
);
if (resp.statusCode != 200) throw Exception('Failed to load messages');
final List<dynamic> data = jsonDecode(resp.body);
return data.map((m) => Message.fromJson(m)).toList();
}
Future<String> uploadFile(File file) async {
final uri = Uri.parse('$_baseUrl/api/upload');
final request = http.MultipartRequest('POST', uri);
final mimeType = lookupMimeType(file.path) ?? 'application/octet-stream';
final parts = mimeType.split('/');
request.files.add(await http.MultipartFile.fromPath(
'file',
file.path,
contentType: MediaType(parts[0], parts[1]),
));
if (_token != null) request.headers['Authorization'] = 'Bearer $_token';
final resp = await request.send();
if (resp.statusCode != 200) throw Exception('Upload failed');
final data = jsonDecode(await resp.stream.bytesToString());
return data['url'] as String;
}
Future<User> getUser(String userId) async {
final resp = await http.get(
Uri.parse('$_baseUrl/api/users/$userId'),
headers: _headers,
);
if (resp.statusCode != 200) throw Exception('Failed to load user');
return User.fromJson(jsonDecode(resp.body));
}
Future<Role> createRole({
required String guildId,
required String name,
int color = 0,
List<String>? permissions,
}) async {
final resp = await http.post(
Uri.parse('$_baseUrl/api/roles/'),
headers: _headers,
body: jsonEncode({
'guild_id': guildId,
'name': name,
'color': color,
'position': 0,
'permissions': permissions ?? [],
}),
);
if (resp.statusCode != 201) throw Exception('Failed to create role');
return Role.fromJson(jsonDecode(resp.body));
}
}
+199
View File
@@ -0,0 +1,199 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../models/message.dart';
enum ConnectionState { disconnected, connecting, connected }
class ConnectionService extends ChangeNotifier {
WebSocketChannel? _channel;
ConnectionState _state = ConnectionState.disconnected;
String _serverUrl = 'ws://localhost:8443/ws';
String? _userId;
String? _username;
String? _token;
Timer? _heartbeatTimer;
int _seq = 0;
final StreamController<Map<String, dynamic>> _messageController =
StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
ConnectionState get state => _state;
String? get userId => _userId;
String? get username => _username;
String? get token => _token;
bool get isConnected => _state == ConnectionState.connected;
String get serverUrl => _serverUrl;
set serverUrl(String url) {
_serverUrl = url;
notifyListeners();
}
@override
void dispose() {
disconnect();
_messageController.close();
super.dispose();
}
void init() {
notifyListeners();
}
Future<void> connect() async {
_state = ConnectionState.connecting;
notifyListeners();
try {
_channel = WebSocketChannel.connect(Uri.parse(_serverUrl));
await _channel!.ready;
_channel!.stream.listen(
(data) {
_handleMessage(data);
},
onError: (error) {
_state = ConnectionState.disconnected;
notifyListeners();
},
onDone: () {
_state = ConnectionState.disconnected;
_heartbeatTimer?.cancel();
notifyListeners();
},
);
_state = ConnectionState.connected;
_startHeartbeat();
notifyListeners();
} catch (e) {
_state = ConnectionState.disconnected;
notifyListeners();
rethrow;
}
}
void disconnect() {
_heartbeatTimer?.cancel();
_channel?.sink.close();
_channel = null;
_state = ConnectionState.disconnected;
notifyListeners();
}
void _startHeartbeat() {
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) {
send({'op': 0});
});
}
void _handleMessage(dynamic data) {
try {
final Map<String, dynamic> packet = jsonDecode(data as String);
final int op = packet['op'] as int;
switch (op) {
case 1: // Hello
break;
case 3: // Authenticated
final d = packet['d'] as Map<String, dynamic>;
_userId = d['user_id'] as String?;
_username = d['username'] as String?;
_token = d['token'] as String?;
notifyListeners();
break;
case 10: // MessageCreate
_messageController.add(packet['d'] as Map<String, dynamic>);
break;
case 13: // Reaction
_messageController.add(packet['d'] as Map<String, dynamic>);
break;
case 40: // VoiceState
_messageController.add(packet['d'] as Map<String, dynamic>);
break;
case 60: // Typing
_messageController.add(packet['d'] as Map<String, dynamic>);
break;
}
} catch (e) {
debugPrint('Failed to handle message: $e');
}
}
void send(Map<String, dynamic> packet) {
if (_channel == null) return;
packet['s'] = ++_seq;
_channel!.sink.add(jsonEncode(packet));
}
void authenticate({String? token, String? username}) {
send({
'op': 2,
'd': {
if (token != null) 'token': token,
if (username != null) 'username': username,
},
});
}
void sendMessage({
required String channelId,
required Uint8List content,
bool encrypted = false,
Uint8List? nonce,
String messageType = 'text',
String? replyTo,
}) {
send({
'op': 10,
'd': {
'channel_id': channelId,
'content': content.toList(),
'encrypted': encrypted,
if (nonce != null) 'nonce': nonce.toList(),
'message_type': messageType,
if (replyTo != null) 'reply_to': replyTo,
},
});
}
void sendReaction(String messageId, String emoji, {bool add = true}) {
send({
'op': 13,
'd': {
'message_id': messageId,
'emoji': emoji,
'add': add,
},
});
}
void updateVoiceState({
required String guildId,
String? channelId,
bool muted = false,
bool deafened = false,
}) {
send({
'op': 40,
'd': {
'guild_id': guildId,
'channel_id': channelId ?? '',
'muted': muted,
'deafened': deafened,
},
});
}
void sendTyping(String channelId) {
send({
'op': 60,
'd': {'channel_id': channelId},
});
}
}
+86
View File
@@ -0,0 +1,86 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:pointycastle/export.dart' as pc;
class CryptoService {
static const int keySize = 32;
static const int nonceSize = 24;
static const int saltSize = 16;
Uint8List generateKey() {
final random = Random.secure();
final key = Uint8List(keySize);
for (int i = 0; i < keySize; i++) {
key[i] = random.nextInt(256);
}
return key;
}
Uint8List generateNonce() {
final random = Random.secure();
final nonce = Uint8List(nonceSize);
for (int i = 0; i < nonceSize; i++) {
nonce[i] = random.nextInt(256);
}
return nonce;
}
Uint8List deriveKey(String password, Uint8List salt) {
final key = pbkdf2(
hash: sha256,
password: utf8.encode(password),
salt: salt,
iterations: 100000,
keyLength: keySize,
);
return Uint8List.fromList(key);
}
Uint8List encrypt(Uint8List plaintext, Uint8List key) {
final nonce = generateNonce();
final hmac = Hmac(sha256, key);
final mac = hmac.convert(plaintext).bytes;
final result = Uint8List(nonce.length + mac.length + plaintext.length);
result.setAll(0, nonce);
result.setAll(nonce.length, mac);
result.setAll(nonce.length + mac.length, plaintext);
return result;
}
Uint8List? decrypt(Uint8List ciphertext, Uint8List key) {
try {
if (ciphertext.length < nonceSize + 32) return null;
final nonce = ciphertext.sublist(0, nonceSize);
final mac = ciphertext.sublist(nonceSize, nonceSize + 32);
final plaintext = ciphertext.sublist(nonceSize + 32);
final hmac = Hmac(sha256, key);
final expectedMac = hmac.convert(plaintext).bytes;
if (!listEquals(mac, expectedMac)) return null;
return plaintext;
} catch (_) {
return null;
}
}
String hashFile(Uint8List data) {
return sha256.convert(data).toString();
}
bool listEquals(List<int> a, List<int> b) {
if (a.length != b.length) return false;
for (int i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
Uint8List generateKeyPairSeed() {
return generateKey();
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
class ThemeService {
static const Color primary = Color(0xFF5865F2);
static const Color success = Color(0xFF3BA55D);
static const Color danger = Color(0xFFED4245);
static const Color warning = Color(0xFFFAA81A);
static const Color surfacePrimary = Color(0xFF313338);
static const Color surfaceSecondary = Color(0xFF2B2D31);
static const Color surfaceTertiary = Color(0xFF1E1F22);
static const Color textPrimary = Color(0xFFF2F3F5);
static const Color textSecondary = Color(0xFFB5BAC1);
static const Color textMuted = Color(0xFF80848E);
static const Color backgroundPrimary = Color(0xFF313338);
static const Color backgroundSecondary = Color(0xFF2B2D31);
static const Color backgroundTertiary = Color(0xFF1E1F22);
static ThemeData get darkTheme {
return ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: const Color(0xFF313338),
primaryColor: primary,
colorScheme: const ColorScheme.dark(
primary: primary,
secondary: success,
surface: surfacePrimary,
error: danger,
),
appBarTheme: const AppBarTheme(
backgroundColor: surfaceSecondary,
elevation: 0,
centerTitle: true,
titleTextStyle: TextStyle(
color: textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
iconTheme: IconThemeData(color: textPrimary),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: surfaceSecondary,
selectedItemColor: primary,
unselectedItemColor: textMuted,
),
cardTheme: CardTheme(
color: surfaceSecondary,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceTertiary,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
hintStyle: const TextStyle(color: textMuted),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
dividerTheme: const DividerThemeData(
color: Color(0xFF3F4147),
thickness: 1,
),
snackBarTheme: SnackBarThemeData(
backgroundColor: surfaceTertiary,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
useMaterial3: true,
);
}
}