diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 2459869..d445fc5 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -649,16 +649,19 @@ class MessagesModule { int chatId, String messageId, { required String text, + List> elements = const [], + bool sendAttachments = false, }) async { final id = int.tryParse(messageId); if (id == null) return false; - final payload = { + final payload = { 'messageId': id, 'chatId': chatId, - 'elements': [], + 'elements': elements, 'text': text, }; + if (sendAttachments) payload['attachments'] = const []; final response = await _api.sendRequest(Opcode.msgEdit, payload); return response.isOk; diff --git a/lib/core/config/app_commands.dart b/lib/core/config/app_commands.dart new file mode 100644 index 0000000..cb66576 --- /dev/null +++ b/lib/core/config/app_commands.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppCommands { + static const prefKey = 'dev_commands'; + static const bool defaultValue = false; + + static final ValueNotifier current = ValueNotifier(defaultValue); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? defaultValue; + } + + static Future save(bool value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } +} diff --git a/lib/frontend/commands/command_registry.dart b/lib/frontend/commands/command_registry.dart new file mode 100644 index 0000000..2d16898 --- /dev/null +++ b/lib/frontend/commands/command_registry.dart @@ -0,0 +1,21 @@ +import 'crush_command.dart'; +import 'info_command.dart'; +import 'slash_command.dart'; + +const List kSlashCommands = [ + SlashCommand('/test', '12345 test отображение'), + SlashCommand('/info', 'сводка данных о человеке', run: runInfo), + SlashCommand( + '/crush', + 'Тест устойчивости веб клиента макса', + run: runCrush, + hidden: true, + ), +]; + +SlashCommand? findSlashCommand(String text) { + for (final c in kSlashCommands) { + if (c.name == text) return c; + } + return null; +} diff --git a/lib/frontend/commands/crush_command.dart b/lib/frontend/commands/crush_command.dart new file mode 100644 index 0000000..d118cde --- /dev/null +++ b/lib/frontend/commands/crush_command.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'slash_command.dart'; + +const int _cycles = 40; +const Duration _delay = Duration(milliseconds: 400); + +const String _text = + 'burmaldaburmaldaburmaldaburmaldaburmaldaburmaldaburmaldaburmalda'; + +const List> _elements = [ + {'type': 'STRONG', 'from': 0, 'length': 1}, + {'type': 'EMPHASIZED', 'from': 1, 'length': 1}, + {'type': 'UNDERLINE', 'from': 2, 'length': 1}, + {'type': 'STRIKETHROUGH', 'from': 3, 'length': 1}, + {'type': 'STRONG', 'from': 4, 'length': 2}, + {'type': 'STRONG', 'from': 6, 'length': 1}, + {'type': 'EMPHASIZED', 'from': 6, 'length': 1}, + {'type': 'STRONG', 'from': 7, 'length': 1}, + {'type': 'EMPHASIZED', 'from': 7, 'length': 1}, + {'type': 'STRIKETHROUGH', 'from': 7, 'length': 1}, + {'type': 'STRONG', 'from': 8, 'length': 1}, + {'type': 'EMPHASIZED', 'from': 8, 'length': 1}, + {'type': 'UNDERLINE', 'from': 8, 'length': 1}, + {'type': 'STRIKETHROUGH', 'from': 8, 'length': 1}, + {'type': 'STRONG', 'from': 9, 'length': 1}, + {'type': 'EMPHASIZED', 'from': 9, 'length': 1}, + {'type': 'STRIKETHROUGH', 'from': 9, 'length': 1}, + {'type': 'STRIKETHROUGH', 'from': 13, 'length': 3}, + {'type': 'STRONG', 'from': 16, 'length': 2}, + {'type': 'STRIKETHROUGH', 'from': 16, 'length': 2}, + {'type': 'STRONG', 'from': 18, 'length': 5}, + {'type': 'EMPHASIZED', 'from': 18, 'length': 5}, + {'type': 'STRIKETHROUGH', 'from': 18, 'length': 5}, + {'type': 'STRONG', 'from': 23, 'length': 2}, + {'type': 'EMPHASIZED', 'from': 23, 'length': 2}, + {'type': 'UNDERLINE', 'from': 23, 'length': 2}, + {'type': 'STRIKETHROUGH', 'from': 23, 'length': 2}, + {'type': 'STRONG', 'from': 25, 'length': 1}, + {'type': 'EMPHASIZED', 'from': 25, 'length': 1}, + {'type': 'UNDERLINE', 'from': 25, 'length': 1}, + {'type': 'STRONG', 'from': 26, 'length': 2}, + {'type': 'UNDERLINE', 'from': 26, 'length': 2}, + {'type': 'STRONG', 'from': 28, 'length': 3}, + {'type': 'EMPHASIZED', 'from': 28, 'length': 3}, + {'type': 'UNDERLINE', 'from': 28, 'length': 3}, + {'type': 'EMPHASIZED', 'from': 31, 'length': 2}, + {'type': 'EMPHASIZED', 'from': 33, 'length': 2}, + {'type': 'UNDERLINE', 'from': 33, 'length': 2}, + {'type': 'STRONG', 'from': 35, 'length': 6}, + {'type': 'EMPHASIZED', 'from': 35, 'length': 6}, + {'type': 'UNDERLINE', 'from': 35, 'length': 6}, + {'type': 'STRONG', 'from': 41, 'length': 2}, + {'type': 'UNDERLINE', 'from': 41, 'length': 2}, + {'type': 'STRONG', 'from': 43, 'length': 8}, + {'type': 'UNDERLINE', 'from': 43, 'length': 8}, + {'type': 'STRIKETHROUGH', 'from': 43, 'length': 8}, + {'type': 'UNDERLINE', 'from': 51, 'length': 7}, + {'type': 'STRIKETHROUGH', 'from': 51, 'length': 7}, + {'type': 'STRIKETHROUGH', 'from': 58, 'length': 5}, + {'type': 'MONOSPACED', 'from': 0, 'length': 4}, + {'type': 'MONOSPACED', 'from': 9, 'length': 4}, + {'type': 'MONOSPACED', 'from': 16, 'length': 7}, + {'type': 'MONOSPACED', 'from': 25, 'length': 6}, + {'type': 'MONOSPACED', 'from': 33, 'length': 8}, + {'type': 'MONOSPACED', 'from': 43, 'length': 8}, + {'type': 'MONOSPACED', 'from': 51, 'length': 7}, + {'type': 'MONOSPACED', 'from': 58, 'length': 5}, + { + 'type': 'LINK', + 'from': 0, + 'length': 8, + 'attributes': {'url': 'https://vk.com'}, + }, + { + 'type': 'LINK', + 'from': 24, + 'length': 16, + 'attributes': {'url': 'https://max.ru'}, + }, + { + 'type': 'LINK', + 'from': 48, + 'length': 16, + 'attributes': {'url': 'https://web.max.ru'}, + }, +]; + +Future runCrush(CommandContext ctx) async { + if (!ctx.isOnline()) { + ctx.notify('Нет соединения'); + return; + } + ctx.notify('Crush: запуск ($_cycles циклов)'); + for (var i = 0; i < _cycles; i++) { + if (!ctx.isActive()) return; + unawaited(_cycle(ctx)); + await Future.delayed(_delay); + } + if (ctx.isActive()) ctx.notify('Crush: завершено'); +} + +Future _cycle(CommandContext ctx) async { + try { + final id = await ctx.messages.sendMessage(ctx.accountId, ctx.chatId, _text); + if (id.isEmpty) return; + await Future.wait([ + ctx.messages.editMessage( + ctx.chatId, + id, + text: _text, + elements: _elements, + sendAttachments: true, + ), + ctx.messages.deleteMessages(ctx.chatId, [id], forEveryone: true), + ]); + } catch (_) {} +} diff --git a/lib/frontend/commands/info_command.dart b/lib/frontend/commands/info_command.dart new file mode 100644 index 0000000..97d4dc2 --- /dev/null +++ b/lib/frontend/commands/info_command.dart @@ -0,0 +1,56 @@ +import '../../core/cache/info_cache.dart'; +import '../../core/utils/format.dart'; +import 'slash_command.dart'; + +Future runInfo(CommandContext ctx) async { + final targetId = ctx.otherUserId; + if (targetId == null) { + ctx.notify('Команда доступна только в диалоге'); + return; + } + + if (!ctx.isOnline()) { + ctx.notify('Нет соединения'); + return; + } + + final messageId = await ctx.postMessage('сбор данных...'); + if (messageId.isEmpty) return; + + final contact = await ContactInfoFetch.get(targetId, forceRefresh: true); + if (!ctx.isActive()) return; + + await ctx.updateMessage( + messageId, + contact == null ? 'Данные не получены' : _summary(contact, targetId), + ); +} + +String _summary(Map c, int targetId) { + final flags = (c['options'] as List?)?.whereType().toList() ?? const []; + final region = (c['country'] as String?)?.trim(); + + return 'Никнейм: ${_nick(c)}\n' + 'Дата регистрации: ${_date(c['registrationTime'])}\n' + 'Дата последнего изменения профиля: ${_date(c['updateTime'])}\n' + 'id: ${c['id'] ?? targetId}\n' + 'Регион: ${region == null || region.isEmpty ? '—' : region}\n' + 'Флаги: ${flags.isEmpty ? '—' : flags.join(', ')}\n' + 'ip: not fetched'; +} + +String _nick(Map c) { + final names = c['names']; + if (names is List && names.isNotEmpty && names.first is Map) { + final n = names.first as Map; + final name = (n['name'] as String?) ?? + '${n['firstName'] ?? ''} ${n['lastName'] ?? ''}'.trim(); + if (name.isNotEmpty) return name; + } + return '—'; +} + +String _date(dynamic ms) { + if (ms is! int || ms <= 0) return '—'; + return formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(ms)); +} diff --git a/lib/frontend/commands/slash_command.dart b/lib/frontend/commands/slash_command.dart new file mode 100644 index 0000000..5c86436 --- /dev/null +++ b/lib/frontend/commands/slash_command.dart @@ -0,0 +1,41 @@ +import '../../backend/modules/messages.dart'; + +class CommandContext { + final int accountId; + final int chatId; + final int? otherUserId; + final MessagesModule messages; + final bool Function() isOnline; + final bool Function() isActive; + final void Function(String message) notify; + final Future Function(String text) postMessage; + final Future Function(String id, String text) updateMessage; + + const CommandContext({ + required this.accountId, + required this.chatId, + required this.otherUserId, + required this.messages, + required this.isOnline, + required this.isActive, + required this.notify, + required this.postMessage, + required this.updateMessage, + }); +} + +typedef CommandRunner = Future Function(CommandContext ctx); + +class SlashCommand { + final String name; + final String description; + final CommandRunner? run; + final bool hidden; + + const SlashCommand( + this.name, + this.description, { + this.run, + this.hidden = false, + }); +} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 3364e7d..a463aa0 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -34,10 +34,14 @@ import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_message_actions_style.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_pranks.dart'; +import '../../../core/config/app_commands.dart'; import '../../../core/config/app_visual_style.dart'; import '../../../core/config/komet_settings.dart'; import '../../../models/attachment.dart'; +import '../../commands/command_registry.dart'; +import '../../commands/slash_command.dart'; import '../../widgets/glossy_pill.dart'; +import '../../widgets/command_suggestions_panel.dart'; import '../../widgets/online_dot.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/message_bubble.dart'; @@ -158,6 +162,8 @@ class _ChatScreenState extends State final ValueNotifier _otherReadTime = ValueNotifier(0); int _tempIdCounter = 0; late final AnimationController _attachAnim; + late final AnimationController _commandAnim; + bool _commandPanelVisible = false; String _nextTempId() => 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; @@ -201,6 +207,11 @@ class _ChatScreenState extends State reverseDuration: const Duration(milliseconds: 240), ); _showAttachmentPanel.addListener(_onAttachPanelToggle); + _commandAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + AppCommands.current.addListener(_updateCommandPanel); _pushSub = api.pushStream .where( (p) => @@ -581,6 +592,8 @@ class _ChatScreenState extends State _finishPrankReveal(); _uploadStatus.dispose(); _attachAnim.dispose(); + AppCommands.current.removeListener(_updateCommandPanel); + _commandAnim.dispose(); _messageController.dispose(); _messageFocusNode.dispose(); _scrollController.dispose(); @@ -594,6 +607,28 @@ class _ChatScreenState extends State if (newHasText != _hasText.value) { _hasText.value = newHasText; } + _updateCommandPanel(); + } + + void _updateCommandPanel() { + final show = + AppCommands.current.value && _messageController.text.startsWith('/'); + if (show == _commandPanelVisible) return; + _commandPanelVisible = show; + if (show) { + _commandAnim.forward(); + } else { + _commandAnim.reverse(); + } + } + + void _onCommandSelected(SlashCommand c) { + final text = '${c.name} '; + _messageController.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + _messageFocusNode.requestFocus(); } void _restoreDraft() { @@ -620,6 +655,26 @@ class _ChatScreenState extends State } } + Widget _buildCommandPanel() { + return AnimatedBuilder( + animation: _commandAnim, + builder: (context, _) { + final t = _commandAnim.value; + if (t == 0) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: IgnorePointer( + ignoring: t < 1, + child: Opacity( + opacity: t, + child: CommandSuggestionsPanel(onSelected: _onCommandSelected), + ), + ), + ); + }, + ); + } + int _computeOtherReadTime() { final c = chat; if (c == null) return 0; @@ -1442,6 +1497,16 @@ class _ChatScreenState extends State final text = _messageController.text.trim(); if (text.isEmpty || _myId == 0) return; + if (AppCommands.current.value) { + final command = findSlashCommand(text); + if (command?.run != null) { + _messageController.clear(); + _hasText.value = false; + unawaited(command!.run!(_commandContext())); + return; + } + } + final tempId = _nextTempId(); final now = DateTime.now().millisecondsSinceEpoch; final online = api.state == SessionState.online; @@ -1550,6 +1615,111 @@ class _ChatScreenState extends State } } + int? _resolveOtherId() { + if (widget.chatType != 'DIALOG' || _myId == 0) return null; + final id = widget.chatId ^ _myId; + return id > 0 ? id : null; + } + + CachedMessage _replaceMessage( + int index, { + String? id, + String? text, + String? status, + }) { + final old = _messages[index]; + final updated = CachedMessage( + id: id ?? old.id, + accountId: old.accountId, + chatId: old.chatId, + senderId: old.senderId, + text: text ?? old.text, + time: old.time, + status: status ?? old.status, + payload: old.payload, + attachments: old.attachments, + isControl: old.isControl, + ); + _messages[index] = updated; + _bumpMessages(); + return updated; + } + + Future _postCommandMessage(String text) async { + if (!mounted || _myId == 0) return ''; + final tempId = _nextTempId(); + final now = DateTime.now().millisecondsSinceEpoch; + final online = api.state == SessionState.online; + final composed = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: text, + time: now, + status: online ? 'sending' : 'pending', + ); + _messages.add(composed); + _bumpMessages(); + _scrollToBottom(); + unawaited(_persistOutgoing(composed)); + unawaited(ChatsModule.applyOutgoing( + _myId, + widget.chatId, + messageId: tempId, + time: now, + text: text, + status: composed.status ?? 'sending', + )); + if (!online) return tempId; + try { + final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text); + final realId = actualId.isNotEmpty ? actualId : tempId; + final i = _messages.indexWhere((m) => m.id == tempId); + if (i != -1) { + final sent = _replaceMessage(i, id: realId, status: 'sent'); + unawaited(_persistOutgoing(sent, removeId: tempId)); + unawaited(ChatsModule.applyOutgoing( + _myId, + widget.chatId, + messageId: realId, + time: now, + text: text, + status: 'sent', + )); + } + return realId; + } catch (_) { + return tempId; + } + } + + Future _updateCommandMessage(String id, String text) async { + if (id.isEmpty) return; + final i = _messages.indexWhere((m) => m.id == id); + if (i != -1) { + final edited = _replaceMessage(i, text: text, status: 'EDITED'); + unawaited(_persistOutgoing(edited)); + } + if (!id.startsWith('temp_')) { + await messagesModule.editMessage(widget.chatId, id, text: text); + } + } + + CommandContext _commandContext() => CommandContext( + accountId: _myId, + chatId: widget.chatId, + otherUserId: _resolveOtherId(), + messages: messagesModule, + isOnline: () => api.state == SessionState.online, + isActive: () => mounted, + notify: (message) { + if (mounted) showCustomNotification(context, message); + }, + postMessage: _postCommandMessage, + updateMessage: _updateCommandMessage, + ); + Future _scheduleMessage() async { final text = _messageController.text.trim(); if (text.isEmpty || _myId == 0) return; @@ -2066,9 +2236,21 @@ class _ChatScreenState extends State body: Column( children: [ Expanded( - child: _isLoading && _messages.isEmpty - ? _buildShimmerLoading() - : _buildMessagesList(), + child: Stack( + children: [ + Positioned.fill( + child: _isLoading && _messages.isEmpty + ? _buildShimmerLoading() + : _buildMessagesList(), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildCommandPanel(), + ), + ], + ), ), AnimatedBuilder( animation: _attachAnim, diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index b20c310..ff88fe3 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -6,6 +6,7 @@ import '../../../backend/modules/chats.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_pranks.dart'; import '../../../core/config/app_stories.dart'; +import '../../../core/config/app_commands.dart'; import '../../../core/config/app_link_preview.dart'; import '../../../core/config/app_digital_id_mode.dart'; import '../../../core/config/app_media_cache.dart'; @@ -786,6 +787,65 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: ValueListenableBuilder( + valueListenable: AppCommands.current, + builder: (context, commandsOn, _) { + return GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + depth: 6, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.terminal, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Команды', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Панель команд по вводу «/» в строке сообщения', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: commandsOn, + onChanged: (v) { + AppCommands.save(v); + }, + ), + ], + ), + ); + }, + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/frontend/widgets/command_suggestions_panel.dart b/lib/frontend/widgets/command_suggestions_panel.dart new file mode 100644 index 0000000..520a597 --- /dev/null +++ b/lib/frontend/widgets/command_suggestions_panel.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import '../commands/command_registry.dart'; +import '../commands/slash_command.dart'; + +class CommandSuggestionsPanel extends StatelessWidget { + final List commands; + final double maxHeight; + final ValueChanged? onSelected; + + const CommandSuggestionsPanel({ + super.key, + this.commands = kSlashCommands, + this.maxHeight = 220, + this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final visible = commands.where((c) => !c.hidden).toList(growable: false); + return Material( + type: MaterialType.transparency, + child: Container( + constraints: BoxConstraints(maxHeight: maxHeight), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)), + ), + clipBehavior: Clip.antiAlias, + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 6), + itemCount: visible.length, + separatorBuilder: (_, _) => Divider( + height: 1, + thickness: 1, + indent: 14, + endIndent: 14, + color: cs.outlineVariant.withValues(alpha: 0.18), + ), + itemBuilder: (context, i) { + final c = visible[i]; + return InkWell( + onTap: onSelected == null ? null : () => onSelected!(c), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 84, + child: Text( + c.name, + style: TextStyle( + color: cs.primary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + c.description, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index a0f20df..c1705cc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,6 +25,7 @@ import 'core/config/app_message_actions_style.dart'; import 'core/config/app_swipe_back_desktop.dart'; import 'core/config/app_pranks.dart'; import 'core/config/app_stories.dart'; +import 'core/config/app_commands.dart'; import 'core/config/app_link_preview.dart'; import 'core/config/app_media_cache.dart'; import 'core/config/app_pill_gradient.dart'; @@ -112,6 +113,7 @@ void main() async { final swipeBackFuture = AppSwipeBackDesktop.load(); final pranksFuture = AppPranks.load(); final storiesFuture = AppStories.load(); + final commandsFuture = AppCommands.load(); final linkPreviewFuture = AppLinkPreview.load(); final cacheLimitFuture = AppMediaCacheLimit.load(); final digitalIdNativeFuture = AppDigitalIdNative.load(); @@ -150,6 +152,7 @@ void main() async { AppSwipeBackDesktop.current.value = await swipeBackFuture; AppPranks.current.value = await pranksFuture; AppStories.current.value = await storiesFuture; + AppCommands.current.value = await commandsFuture; AppLinkPreview.current.value = await linkPreviewFuture; AppMediaCacheLimit.current.value = await cacheLimitFuture; AppDigitalIdNative.current.value = await digitalIdNativeFuture; diff --git a/ws2_dump.py b/ws2_dump.py deleted file mode 100644 index 2695067..0000000 --- a/ws2_dump.py +++ /dev/null @@ -1,72 +0,0 @@ -"""mitmproxy addon: dump okcdn call signaling (ws2 WebSocket + HTTP) to a log.""" -import json -import time - -from mitmproxy import http, ctx - -LOG = r"C:\Users\klockky\Komet\docs\ws2_capture.log" -HOSTS = ("okcdn.ru", "videowebrtc") - - -def _interesting(host: str) -> bool: - return any(h in host for h in HOSTS) - - -def _w(line: str) -> None: - with open(LOG, "a", encoding="utf-8") as f: - f.write(line + "\n") - - -def _fmt(content: bytes) -> str: - try: - text = content.decode("utf-8") - try: - return json.dumps(json.loads(text), ensure_ascii=False, indent=2) - except Exception: - return text - except Exception: - return "HEX " + content.hex() - - -def websocket_start(flow: http.HTTPFlow) -> None: - if not _interesting(flow.request.pretty_host): - return - _w("=" * 70) - _w(f"# WS OPEN {flow.request.pretty_host} {flow.request.path}") - _w(f" headers: {dict(flow.request.headers)}") - _w("=" * 70) - - -def websocket_message(flow: http.HTTPFlow) -> None: - if not _interesting(flow.request.pretty_host): - return - msg = flow.websocket.messages[-1] - arrow = "TX (client->server)" if msg.from_client else "RX (server->client)" - ts = time.strftime("%H:%M:%S") - _w(f"\n--- {arrow} {ts} {len(msg.content)} B host={flow.request.pretty_host} ---") - _w(_fmt(msg.content)) - - -def websocket_end(flow: http.HTTPFlow) -> None: - if not _interesting(flow.request.pretty_host): - return - _w(f"\n# WS CLOSE {flow.request.pretty_host}\n") - - -def response(flow: http.HTTPFlow) -> None: - host = flow.request.pretty_host - if not _interesting(host): - return - if flow.websocket is not None: - return - _w("\n" + "#" * 70) - _w(f"# HTTP {flow.request.method} {host}{flow.request.path} -> {flow.response.status_code}") - if flow.request.content: - _w(" REQ: " + _fmt(flow.request.content)[:2000]) - if flow.response.content: - _w(" RES: " + _fmt(flow.response.content)[:2000]) - - -def load(loader) -> None: - _w(f"\n\n########## capture session start {time.strftime('%Y-%m-%d %H:%M:%S')} ##########") - ctx.log.info("ws2_dump addon loaded")