import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:uuid/uuid.dart'; import '../http_client.dart'; import '../http_util.dart'; import '../models.dart'; import 'base_provider.dart'; import 'webview_base_provider.dart'; /// Microsoft Copilot: Cronet session + WebSocket, then origin WebView (nodriver analog). class CopilotApp extends BaseProvider { @override String get name => 'CopilotApp'; @override String get url => 'https://copilot.microsoft.com'; @override bool get working => !kIsWeb; @override Duration get requestTimeout => const Duration(seconds: 90); @override List get models => ['smart', 'chat', 'reasoning']; @override String get defaultModel => 'smart'; static const _ua = 'CopilotNative/30.0.440505001-prod (Android 14; Google; Pixel 8 Pro)'; static const _startUrl = 'https://copilot.microsoft.com/c/api/start'; @override Stream createAsyncGenerator( String model, List messages, { bool stream = false, }) async* { if (kIsWeb) { throw UnsupportedError('CopilotApp requires dart:io (Android/iOS/desktop)'); } final seeds = _fakeCookieSeeds(); _seedFakeCookies(seeds); final prompt = formatPrompt(messages); try { yield await _viaRestAndWs(model, prompt); return; } catch (e) { debugPrint('Copilot REST/WS failed, trying origin WebView: $e'); } yield await _viaWebView(model, prompt, seeds); } Future _viaRestAndWs(String model, String prompt) async { final startReq = http.Request('POST', Uri.parse(_startUrl)); startReq.headers.addAll({ 'content-type': 'application/json', 'user-agent': _ua, 'origin': url, 'referer': '$url/', 'X-Search-UILang': 'en-US', }); startReq.body = jsonEncode({ 'timeZone': 'Europe/Moscow', 'startNewConversation': true, 'teenSupportEnabled': true, 'correctPersonalizationSetting': true, 'deferredDataUseCapable': true, }); final startRes = await g4fHttp.send(startReq).timeout(const Duration(seconds: 15)); if (startRes.statusCode != 200) { final err = await startRes.stream.bytesToString(); throw Exception('Copilot start ${startRes.statusCode}: $err'); } final startBody = jsonDecode(utf8.decode(await startRes.stream.toBytes())); final conversationId = (startBody['currentConversationId'] ?? '').toString(); if (conversationId.isEmpty) { throw Exception('Copilot: no conversation id'); } final cookieHeader = g4fCookies.headerFor(Uri.parse(url)); final sessionId = const Uuid().v4(); final wsUrl = 'wss://copilot.microsoft.com/c/api/chat?api-version=2&clientSessionId=$sessionId'; final ws = await WebSocket.connect( wsUrl, headers: { 'User-Agent': _ua, 'Origin': url, 'Pragma': 'no-cache', 'Cache-Control': 'no-cache', if (cookieHeader.isNotEmpty) 'Cookie': cookieHeader, }, ).timeout(const Duration(seconds: 12)); try { final mode = _modeFor(model); var connected = false; final collected = StringBuffer(); await for (final raw in ws.timeout(const Duration(seconds: 35))) { if (raw is! String) continue; Map data; try { data = jsonDecode(raw) as Map; } catch (_) { continue; } final event = data['event']?.toString(); if (event == 'connected' || event == 'received') { if (!connected) { connected = true; ws.add(jsonEncode({ 'event': 'send', 'conversationId': conversationId, 'content': [ {'type': 'text', 'text': prompt}, ], 'mode': mode, })); } continue; } if (event == 'appendText' || event == 'replaceText') { final text = data['text']?.toString() ?? ''; if (text.isNotEmpty) collected.write(text); } else if (event == 'done') { break; } else if (event == 'error') { throw Exception('Copilot error: $data'); } } final text = collected.toString().trim(); if (!looksLikeModelOutput(text)) { throw Exception('Copilot returned empty response'); } return text; } finally { await ws.close(); } } Future _viaWebView( String model, String prompt, List seeds, ) async { final promptJson = jsonEncode(prompt); final modeJson = jsonEncode(_modeFor(model)); final raw = await runOriginWebView( originUrl: url, timeout: requestTimeout, seeds: seeds, functionBody: ''' const prompt = $promptJson; const mode = $modeJson; const start = await fetch('/c/api/start', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'include', body: JSON.stringify({ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', startNewConversation: true, teenSupportEnabled: true, correctPersonalizationSetting: true, deferredDataUseCapable: true }) }); if (!start.ok) { throw new Error('start ' + start.status + ' ' + (await start.text()).slice(0, 160)); } const started = await start.json(); const conversationId = started.currentConversationId; if (!conversationId) throw new Error('no conversation id'); const sessionId = (crypto.randomUUID && crypto.randomUUID()) || String(Math.random()).slice(2); return await new Promise((resolve, reject) => { const ws = new WebSocket('wss://copilot.microsoft.com/c/api/chat?api-version=2&clientSessionId=' + sessionId); let text = ''; let sent = false; const timer = setTimeout(() => { try { ws.close(); } catch (e) {} reject(new Error('ws timeout')); }, 45000); ws.onmessage = (e) => { let data; try { data = JSON.parse(e.data); } catch (err) { return; } const event = data.event; if ((event === 'connected' || event === 'received') && !sent) { sent = true; ws.send(JSON.stringify({ event: 'send', conversationId, content: [{ type: 'text', text: prompt }], mode })); } if (event === 'appendText' || event === 'replaceText') text += data.text || ''; if (event === 'done') { clearTimeout(timer); ws.close(); resolve(text); } if (event === 'error') { clearTimeout(timer); reject(new Error(JSON.stringify(data))); } }; ws.onerror = () => { clearTimeout(timer); reject(new Error('ws error')); }; }); ''', ); final text = raw.trim(); if (!looksLikeModelOutput(text)) { throw Exception('Copilot WebView unusable: ${text.substring(0, text.length.clamp(0, 180))}'); } return text; } void _seedFakeCookies(List seeds) { final uri = Uri.parse(url); for (final seed in seeds) { g4fCookies.set( name: seed.name, value: seed.value, domain: seed.domain ?? 'microsoft.com', path: seed.path, secure: seed.isSecure, hostOnly: false, ); } // Touch the jar host as well as the parent domain. g4fCookies.set( name: '_C_ETH', value: '1', domain: uri.host, secure: true, ); } List _fakeCookieSeeds() { return [ CookieSeed(name: '_C_ETH', value: '1', domain: '.microsoft.com'), CookieSeed(name: '_C_Auth', value: '', domain: '.microsoft.com'), CookieSeed(name: 'MUID', value: _hex(32), domain: '.microsoft.com'), CookieSeed(name: 'MUIDB', value: _hex(32), domain: '.microsoft.com'), CookieSeed( name: '_EDGE_S', value: 'F=1&SID=${_hex(32)}', domain: '.microsoft.com', ), CookieSeed(name: '_EDGE_V', value: '1', domain: '.microsoft.com'), ]; } String _modeFor(String model) { final m = model.toLowerCase(); if (m.contains('reason') || m.contains('think') || m.contains('o1')) { return 'reasoning'; } if (m.contains('chat') || m.contains('gpt-4')) return 'chat'; return 'smart'; } String _hex(int len) { const hex = '0123456789ABCDEF'; final r = Random(); return String.fromCharCodes( Iterable.generate(len, (_) => hex.codeUnitAt(r.nextInt(16))), ); } }