forked from Damianonymous/MFCAuto
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathClient.ts
More file actions
3131 lines (2938 loc) · 148 KB
/
Client.ts
File metadata and controls
3131 lines (2938 loc) · 148 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { CookieJar, Cookie } from "request";
import { LogLevel, logWithLevelInternal as logl, findDependentExe, spawnOutput, parseJsObj, loadFromWeb, createFormInput } from "./Utils";
import { Model } from "./Model";
import { Packet } from "./Packet";
import { RefinedEventEmitter } from "./RefinedEventEmitter";
import * as _ from "lodash";
import * as assert from "assert";
import * as cheerio from "cheerio";
import * as constants from "./Constants";
import * as messages from "./sMessages";
import * as moment from "moment";
import * as net from "net";
import * as path from "path";
import * as request from "request-promise-native";
import * as WebSocket from "ws";
/**
* Connection state of the client
* @access private
*/
export const ClientState = {
/** Not currently connected to MFC and not trying to connect */
IDLE: "IDLE",
/** Actively trying to connect to MFC but not currently connected */
PENDING: "PENDING",
/** Currently connected to MFC */
ACTIVE: "ACTIVE",
} as { IDLE: "IDLE", PENDING: "PENDING", ACTIVE: "ACTIVE" };
/**
* Creates and maintains a connection to MFC chat servers
*
* Client instances are [NodeJS EventEmitters](https://nodejs.org/api/all.html#events_class_eventemitter)
* and will emit an event every time a Packet is received from the server. The
* event will be named after the FCType of the Packet. See FCTYPE in
* ./src/main/Constants.ts for the complete list of possible events.
*
* Listening for Client events is an advanced feature and requires some
* knowledge of MFC's chat server protocol, which will not be documented here.
* Where possible, listen for events on [Model](#Model) instead.
*/
export class Client extends RefinedEventEmitter<ClientEventName, ClientEventCallback, Packet | Boolean> {
/** Session ID assigned to this client by the server after login */
public sessionId: number;
/**
* username used to log in to MFC, or, if the username was
* left as "guest" then the server will have randomly generated
* a new name for us like "Guest12345" and this value will
* be updated to reflect that
*/
public username: string;
/** unhashed password used by this client to log in */
public password: string;
/** User ID assigned to the currently logged in user */
public uid: number | undefined;
// Starting in March 2018, every 5 minutes each connected
// client is given a trio of auth tokens that are required
// to access the high def, OBS, video streams. This is very
// likely the way all streams will be encoded in the near
// future. These values change every ~5 minutes.
public stream_cxid?: number;
public stream_password?: string;
public stream_vidctx?: string;
/** MFC generated 32-character hash of password */
private _passcode?: string;
private _passcode_password?: string;
private _webApiToken?: string;
private _tokens: number = 0;
private _state: ClientStates;
private _choseToLogIn: boolean = false;
private _completedModels: boolean = false;
private _completedTags: boolean = false;
private _shareHasLoggedIn: boolean = false;
private _shareCookieJar?: CookieJar;
private readonly _roomHelperStatus: Map<number, boolean> = new Map();
private readonly _availableClubShows = new Set();
private readonly _options: ClientOptions;
private readonly _baseUrl: string;
private readonly _shareUrl: string;
public serverConfig: ServerConfig | undefined;
private _streamBuffer: Buffer;
private _streamWebSocketBuffer: string;
private _streamPosition: number;
private _emoteParser: EmoteParser | undefined;
private _client: net.Socket | WebSocket | undefined;
private _keepAliveTimer: NodeJS.Timer | undefined;
private _manualDisconnect: boolean;
private _reconnectTimer?: NodeJS.Timer;
private static _userQueryId: number;
private _currentConnectionStartTime?: number;
private _lastPacketTime?: number;
private _lastStatePacketTime?: number;
private static _connectedClientCount = 0;
private static readonly _initialReconnectSeconds = 5;
private static readonly _reconnectBackOffMultiplier = 1.5;
private static readonly _maximumReconnectSeconds = 2400; // 40 Minutes
private static _currentReconnectSeconds = 5;
private static readonly webSocketNoiseFilter = /^\d{4}\d+ \d+ \d+ \d+ \d+/;
/**
* Client constructor
* @param [username] Either "guest" or a real MFC member account name, default is "guest"
* @param [password] Either "guest" or the account's password.
*
* This can be either the real password or the password hash as MFCAuto has always taken
* historically. Client will attempt to auto-detect which type of password you have specified.
*
* If your real password looks like a hashed password (exactly 32 alphanumeric characters
* with no spaces or special characters), it will be incorrectly detected as a hashed
* password. In which case, you can override the auto-detection of password type and
* force Client to treat it as the real password by specifying `{ forceUnhashedPassword: true }`
* as part of the constructor options.
*
* If you wish to use the hashed password, you can discover it by checking your browser
* cookies after logging in via your browser. In Firefox, go to Options->Privacy
* and then "Show Cookies..." and search for "myfreecams". You will see one
* cookie named "passcode". Select it and copy the value listed as "Content".
* It will be a long string of lower case letters that looks like gibberish.
* @param [options] A ClientOptions object detailing several optional Client settings
* like whether to use WebSockets or traditional TCP sockets and whether to connect
* to MyFreeCams.com or CamYou.com
* @example
* const mfc = require("MFCAuto");
* const guestMFCClient = new mfc.Client();
* const premiumMFCClient = new mfc.Client(premiumUsername, premiumPasswordHash);
* const guestMFCFlashClient = new mfc.Client("guest", "guest", {useWebSockets: false});
* const guestCamYouClient = new mfc.Client("guest", "guest", {camYou: true});
* const guestCamYouFlashClient = new mfc.Client("guest", "guest", {useWebSockets: false, camYou: true});
*/
constructor(username: string = "guest", password: string = "guest", options: boolean | ClientOptions = {}) {
super();
const defaultOptions: ClientOptions = {
useWebSockets: true,
camYou: false,
useCachedServerConfig: false,
silenceTimeout: 90000,
stateSilenceTimeout: 120000,
loginTimeout: 30000,
modernLogin: false,
preserveHtml: false,
forceUnhashedPassword: false,
};
// v4.1.0 supported a third constructor parameter that was a boolean controlling whether to use
// WebSockets (true) or not (false, the default). For backward compat reasons, we'll still handle
// that case gracefully. New consumers should move to the options bag syntax.
if (typeof options === "boolean") {
logl(LogLevel.WARNING, `WARNING: Client useWebSockets as a boolean third constructor parameter is being deprecated, please see the release notes for v4.2.0 for the current way to use a websocket server connection`);
options = { useWebSockets: options };
}
this._options = Object.assign({}, defaultOptions, options);
this._baseUrl = this._options.camYou ? "camyou.com" : "myfreecams.com";
this._shareUrl = constants.SHARE_URL;
this.username = username;
this.password = password;
this.sessionId = 0;
this._streamBuffer = Buffer.alloc(0);
this._streamWebSocketBuffer = "";
this._streamPosition = 0;
this._manualDisconnect = false;
this._state = ClientState.IDLE;
logl(LogLevel.DEBUG, () => `[CLIENT] Constructed, State: ${this._state}`);
}
/**
* Current server connection state:
* - IDLE: Not currently connected to MFC and not trying to connect
* - PENDING: Actively trying to connect to MFC but not currently connected
* - ACTIVE: Currently connected to MFC
*
* If this client is PENDING and you wish to wait for it to enter ACTIVE,
* use [client.ensureConnected](#clientensureconnectedtimeout).
*/
public get state(): ClientStates {
return this._state;
}
/**
* How long the current client has been connected to a server
* in milliseconds. Or 0 if this client is not currently connected
*/
public get uptime(): number {
if (this._state === ClientState.ACTIVE
&& this._currentConnectionStartTime) {
return Date.now() - this._currentConnectionStartTime;
} else {
return 0;
}
}
/**
* Returns headers required to authenticate an HTTP request to
* MFC's web servers.
* @deprecated
*/
public get httpHeaders(): object {
logl(LogLevel.WARNING, `WARNING: Client.httpHeaders has been deprecated. Please switch to Client.getHttpHeaders(), an asynchronous method that supports usage of raw/unhashed password.`);
return {
Cookie: `passcode=${this.password}; username=${this.username}`,
Origin: `https://www.${this._baseUrl}`,
Referer: `https://www.${this._baseUrl}/`,
};
}
/**
* Returns headers required to authenticate an HTTP request to
* MFC's web servers.
*/
public async getHttpHeaders(): Promise<object> {
return {
Cookie: `passcode=${await this.getPassCode()}; username=${this.username}`,
Origin: `https://www.${this._baseUrl}`,
Referer: `https://www.${this._baseUrl}/`,
};
}
/**
* Tokens available on this account
*/
public get tokens(): number {
return this._tokens;
}
/**
* Internal MFCAuto use only
*
* Reads data from the socket as quickly as possible and stores it in an internal buffer
* readData is invoked by the "on data" event of the net.Socket object currently handling
* the TCP connection to the MFC servers.
* @param buf New Buffer to read from
* @access private
*/
private _readData(buf: Buffer): void {
this._streamBuffer = Buffer.concat([this._streamBuffer, buf]);
// The new buffer might contain a complete packet, try to read to find out...
this._readPacket();
}
/**
* Internal MFCAuto use only
*
* Reads data from the websocket as quickly as possible and stores it in an internal string
* readWebSocketData is invoked by the "message" event of the WebSocket object currently
* handling the connection to the MFC servers.
* @param buf New string to read from
* @access private
*/
private _readWebSocketData(buf: string): void {
this._streamWebSocketBuffer += buf;
// The new buffer might contain a complete packet, try to read to find out...
this._readWebSocketPacket();
}
/**
* Internal MFCAuto use only
*
* Called with a single, complete, packet. This function processes the packet,
* handling some special packets like FCTYPE_LOGIN, which gives our user name and
* session ID when first logging in to mfc. It then calls out to any registered
* event handlers.
* @param packet Packet to be processed
* @access private
*/
private _packetReceived(packet: Packet): void {
this._lastPacketTime = Date.now();
logl(LogLevel.TRACE, () => packet.toString());
// Special case some packets to update and maintain internal state
switch (packet.FCType) {
case constants.FCTYPE.DETAILS:
case constants.FCTYPE.ROOMHELPER:
case constants.FCTYPE.SESSIONSTATE:
case constants.FCTYPE.ADDFRIEND:
case constants.FCTYPE.ADDIGNORE:
case constants.FCTYPE.CMESG:
case constants.FCTYPE.PMESG:
case constants.FCTYPE.TXPROFILE:
case constants.FCTYPE.USERNAMELOOKUP:
case constants.FCTYPE.MYCAMSTATE:
case constants.FCTYPE.MYWEBCAM:
case constants.FCTYPE.JOINCHAN:
// According to the site code, these packets can all trigger a user state update
this._lastStatePacketTime = this._lastPacketTime;
// This case updates our available tokens (yes the logic is insane, but it's lifted right from MFC code...)
if (packet.FCType === constants.FCTYPE.DETAILS && packet.nTo === this.sessionId) {
this._tokens = (packet.nArg1 > 2147483647) ? ((4294967297 - packet.nArg1) * -1) : packet.nArg1;
}
// And these specific cases don't update state...
if ((packet.FCType === constants.FCTYPE.DETAILS && packet.nFrom === constants.FCTYPE.TOKENINC) ||
// 100 here is taken directly from MFC's top.js and has no additional
// explanation. My best guess is that it is intended to reference the
// constant: USER.ID_START. But since I'm not certain, I'll leave this
// "magic" number here.
(packet.FCType === constants.FCTYPE.ROOMHELPER && packet.nArg2 < 100) ||
(packet.FCType === constants.FCTYPE.JOINCHAN && packet.nArg2 === constants.FCCHAN.PART)) {
break;
}
if (packet.FCType === constants.FCTYPE.ROOMHELPER) {
if (packet.nArg2 >= 100 || packet.nArg2 === constants.FCRESPONSE.SUCCESS) {
this._roomHelperStatus.set(packet.nArg1, true);
}
if (packet.nArg2 === constants.FCRESPONSE.SUSPEND) {
this._roomHelperStatus.set(packet.nArg1, false);
}
}
// Ok, we're good, merge if there's anything to merge
if (packet.sMessage !== undefined) {
const msg = packet.sMessage as messages.Message;
const lv = msg.lv;
const sid = msg.sid;
let uid = msg.uid;
if (uid === 0 && sid > 0) {
uid = sid;
}
if (uid === undefined && packet.aboutModel !== undefined) {
uid = packet.aboutModel.uid;
}
// Only merge models (when we can tell). Unfortunately not every SESSIONSTATE
// packet has a user level property. So this is no worse than we had been doing
// before in terms of merging non-models...
if (uid !== undefined && uid !== -1 && (lv === undefined || lv === constants.FCLEVEL.MODEL)) {
// If we know this is a model, get her instance and create it
// if it does not exist. Otherwise, don't create an instance
// for someone that might not be a model.
const possibleModel = Model.getModel(uid, lv === constants.FCLEVEL.MODEL);
if (possibleModel !== undefined) {
possibleModel.merge(msg);
}
}
}
break;
case constants.FCTYPE.TAGS:
const tagPayload = packet.sMessage as messages.FCTypeTagsResponse;
if (typeof tagPayload === "object") {
for (const key in tagPayload) {
if (tagPayload.hasOwnProperty(key)) {
const possibleModel = Model.getModel(key);
if (possibleModel !== undefined) {
possibleModel.mergeTags(tagPayload[key]);
}
}
}
}
break;
case constants.FCTYPE.BOOKMARKS:
const bmMsg = packet.sMessage as messages.BookmarksMessage;
if (Array.isArray(bmMsg.bookmarks)) {
bmMsg.bookmarks.forEach((b) => {
const possibleModel = Model.getModel(b.uid);
if (possibleModel !== undefined) {
possibleModel.merge(b);
}
});
}
break;
case constants.FCTYPE.EXTDATA:
if (packet.nTo === this.sessionId && packet.nArg2 === constants.FCWOPT.REDIS_JSON) {
this._handleExtData(packet.sMessage as messages.ExtDataMessage).catch((reason) => {
logl(LogLevel.WARNING, () => `WARNING: _packetReceived caught rejection from _handleExtData: ${reason}`);
});
}
break;
case constants.FCTYPE.METRICS:
// For METRICS, nTO is an FCTYPE indicating the type of data that's
// starting or ending, nArg1 is the count of data received so far, and nArg2
// is the total count of data, so when nArg1 === nArg2, we're done for that data
// Note that after MFC server updates on 2017-04-18, Metrics packets are rarely,
// or possibly never, sent
break;
case constants.FCTYPE.MANAGELIST:
if (packet.nArg2 > 0 && packet.sMessage !== undefined && (packet.sMessage as messages.ManageListMessage).rdata !== undefined) {
const rdata = this.processListData((packet.sMessage as messages.ManageListMessage).rdata);
const nType: constants.FCL = packet.nArg2;
switch (nType) {
case constants.FCL.ROOMMATES:
if (Array.isArray(rdata)) {
rdata.forEach((viewer: messages.Message) => {
if (viewer !== undefined) {
const possibleModel = Model.getModel(viewer.uid, viewer.lv === constants.FCLEVEL.MODEL);
if (possibleModel !== undefined) {
possibleModel.merge(viewer);
}
}
});
}
break;
case constants.FCL.CAMS:
if (Array.isArray(rdata)) {
rdata.forEach((model: messages.Message) => {
if (model !== undefined) {
const possibleModel = Model.getModel(model.uid, model.lv === constants.FCLEVEL.MODEL);
if (possibleModel !== undefined) {
possibleModel.merge(model);
}
}
});
if (!this._completedModels) {
this._completedModels = true;
if (this._completedTags) {
logl(LogLevel.DEBUG, `[CLIENT] emitting: CLIENT_MODELSLOADED`);
this.emit("CLIENT_MODELSLOADED");
}
}
}
break;
case constants.FCL.FRIENDS:
if (Array.isArray(rdata)) {
rdata.forEach((model: messages.Message) => {
if (model !== undefined) {
const possibleModel = Model.getModel(model.uid, model.lv === constants.FCLEVEL.MODEL);
if (possibleModel !== undefined) {
possibleModel.merge(model);
}
}
});
}
break;
case constants.FCL.IGNORES:
if (Array.isArray(rdata)) {
rdata.forEach((user: messages.Message) => {
if (user !== undefined) {
const possibleModel = Model.getModel(user.uid, user.lv === constants.FCLEVEL.MODEL);
if (possibleModel !== undefined) {
possibleModel.merge(user);
}
}
});
}
break;
case constants.FCL.TAGS:
const tagPayload2 = rdata as messages.FCTypeTagsResponse;
if (tagPayload2 !== undefined) {
for (const key in tagPayload2) {
if (tagPayload2.hasOwnProperty(key)) {
const possibleModel = Model.getModel(key);
if (possibleModel !== undefined) {
possibleModel.mergeTags(tagPayload2[key]);
}
}
}
if (!this._completedTags) {
this._completedTags = true;
if (this._completedModels) {
logl(LogLevel.DEBUG, `[CLIENT] emitting: CLIENT_MODELSLOADED`);
this.emit("CLIENT_MODELSLOADED");
}
}
}
break;
case constants.FCL.SHARE_CLUBS:
// @TODO
break;
case constants.FCL.SHARE_CLUBMEMBERSHIPS:
// @TODO
break;
case constants.FCL.SHARE_CLUBSHOWS:
if (Array.isArray(rdata)) {
rdata.forEach((message) => {
this._packetReceived(new Packet(
constants.FCTYPE.CLUBSHOW,
// tslint:disable-next-line:no-any
(message as any as messages.ClubShowMessage).model,
packet.nTo,
packet.nArg1,
packet.nArg2,
0,
message,
));
});
}
break;
default:
logl(LogLevel.WARNING, () => `WARNING: _packetReceived unhandled list type on MANAGELIST packet: ${nType}`);
}
}
break;
case constants.FCTYPE.ROOMDATA:
if (packet.nArg1 === 0 && packet.nArg2 === 0) {
if (Array.isArray(packet.sMessage)) {
const sizeOfModelSegment = 2;
for (let i = 0; i < packet.sMessage.length; i = i + sizeOfModelSegment) {
const possibleModel = Model.getModel(packet.sMessage[i]);
if (possibleModel !== undefined) {
possibleModel.merge({ "sid": possibleModel.bestSessionId, "m": { "rc": packet.sMessage[i + 1] } } as messages.Message);
}
}
} else if (typeof (packet.sMessage) === "object") {
for (const key in packet.sMessage) {
if (packet.sMessage.hasOwnProperty(key)) {
const rdmsg = packet.sMessage as messages.RoomDataUserCountObjectMessage;
const possibleModel = Model.getModel(key);
if (possibleModel !== undefined) {
possibleModel.merge({ "sid": possibleModel.bestSessionId, "m": { "rc": rdmsg[key] } } as messages.Message);
}
}
}
}
}
break;
case constants.FCTYPE.TKX:
const auth = packet.sMessage as messages.TKXMessage;
if (auth && auth.cxid && auth.tkx && auth.ctxenc) {
this.stream_cxid = auth.cxid;
this.stream_password = auth.tkx;
const pwParts = auth.ctxenc.split("/");
this.stream_vidctx = pwParts.length > 1 ? pwParts[1] : auth.ctxenc;
}
break;
case constants.FCTYPE.TOKENINC:
if (packet.sMessage === undefined) {
this._tokens = packet.nArg1;
}
break;
case constants.FCTYPE.CLUBSHOW:
const showDetails = packet.sMessage as messages.ClubShowMessage;
if (showDetails.op === constants.FCCHAN.WELCOME && showDetails.tksid !== undefined) {
this._availableClubShows.add(showDetails.model);
} else {
this._availableClubShows.delete(showDetails.model);
}
break;
default:
break;
}
// Fire this packet's event for any listeners
this.emit(constants.FCTYPE[packet.FCType] as ClientEventName, packet);
this.emit(constants.FCTYPE[constants.FCTYPE.ANY] as ClientEventName, packet);
}
/**
* Internal MFCAuto use only
*
* Parses the incoming MFC stream buffer from a socket connection. For each
* complete individual packet parsed, it will call packetReceived. Because
* of the single-threaded async nature of node.js, there will often be partial
* packets and this needs to handle that gracefully, only calling packetReceived
* once we've parsed out a complete response.
* @access private
*/
private _readPacket(): void {
let pos: number = this._streamPosition;
const intParams: number[] = [];
let strParam: string | undefined;
try {
// Each incoming packet is initially tagged with 7 int32 values, they look like this:
// 0 = "Magic" value that is *always* -2027771214
// 1 = "FCType" that identifies the type of packet this is (FCType being a MyFreeCams defined thing)
// 2 = nFrom
// 3 = nTo
// 4 = nArg1
// 5 = nArg2
// 6 = sPayload, the size of the payload
// 7 = sMessage, the actual payload. This is not an int but is the actual buffer
// Any read here could throw a RangeError exception for reading beyond the end of the buffer. In theory we could handle this
// better by checking the length before each read, but that would be a bit ugly. Instead we handle the RangeErrors and just
// try to read again the next time the buffer grows and we have more data
// Parse out the first 7 integer parameters (Magic, FCType, nFrom, nTo, nArg1, nArg2, sPayload)
const countOfIntParams = 7;
const sizeOfInt32 = 4;
for (let i = 0; i < countOfIntParams; i++) {
intParams.push(this._streamBuffer.readInt32BE(pos));
pos += sizeOfInt32;
}
const [magic, fcType, nFrom, nTo, nArg1, nArg2, sPayload] = intParams;
// If the first integer is MAGIC, we have a valid packet
if (magic === constants.MAGIC) {
// If there is a JSON payload to this packet
if (sPayload > 0) {
// If we don't have the complete payload in the buffer already, bail out and retry after we get more data from the network
if (pos + sPayload > this._streamBuffer.length) {
throw new RangeError(); // This is needed because streamBuffer.toString will not throw a rangeerror when the last param is out of the end of the buffer
}
// We have the full packet, store it and move our buffer pointer to the next packet
strParam = this._streamBuffer.toString("utf8", pos, pos + sPayload);
pos = pos + sPayload;
}
} else {
// Magic value did not match? In that case, all bets are off. We no longer understand the MFC stream and cannot recover...
// This is usually caused by a mis-alignment error due to incorrect buffer management (bugs in this code or the code that writes the buffer from the network)
this._disconnected(`Invalid packet received! - ${magic} Length == ${this._streamBuffer.length}`);
return;
}
// At this point we have the full packet in the intParams and strParam values, but intParams is an unstructured array
// Let's clean it up before we delegate to this.packetReceived. (Leaving off the magic int, because it MUST be there always
// and doesn't add anything to the understanding)
let sMessage: messages.AnyMessage | undefined;
if (strParam !== undefined && strParam !== "") {
try {
sMessage = JSON.parse(strParam) as messages.AnyMessage;
} catch (e) {
sMessage = strParam;
}
}
this._packetReceived(new Packet(
fcType,
nFrom,
nTo,
nArg1,
nArg2,
sPayload,
sMessage,
));
// If there's more to read, keep reading (which would be the case if the network sent >1 complete packet in a single transmission)
if (pos < this._streamBuffer.length) {
this._streamPosition = pos;
this._readPacket();
} else {
// We read the full buffer, clear the buffer cache so that we can
// read cleanly from the beginning next time (and save memory)
this._streamBuffer = Buffer.alloc(0);
this._streamPosition = 0;
}
} catch (e) {
// RangeErrors are expected because sometimes the buffer isn't complete. Other errors are not...
if (!(e instanceof RangeError)) {
this._disconnected(`Unexpected error while reading socket stream: ${e}`);
} else {
// this.log("Expected exception (?): " + e);
}
}
}
/**
* Internal MFCAuto use only
*
* Parses the incoming MFC data string from a WebSocket connection. For each
* complete individual packet parsed, it will call packetReceived.
* @access private
*/
private _readWebSocketPacket(): void {
const sizeTagLength = 6;
const minimumPacketLength = sizeTagLength + 9; // tag chars + 5 possibly single digit numbers + 4 spaces
while (this._streamWebSocketBuffer.length >= minimumPacketLength) {
// Occasionally there is noise in the WebSocket buffer
// it really should start with 7-8 digits followed by a
// space. Where the first 6 digits are the size of the
// total message and the last digits of that first 7-8
// are the FCType of the first Packet in the buffer
// We'll clean it up by shifting the buffer until we
// find that pattern
while (!Client.webSocketNoiseFilter.test(this._streamWebSocketBuffer) && this._streamWebSocketBuffer.length > (minimumPacketLength * 10)) {
// If this happens too often it likely represents a bug
logl(LogLevel.WARNING, () => `WARNING: _readWebSocketPacket handling noise: '${this._streamWebSocketBuffer.slice(0, 30)}...'`);
this._streamWebSocketBuffer = this._streamWebSocketBuffer.slice(1);
}
if (this._streamWebSocketBuffer.length < minimumPacketLength) {
return;
}
const messageLength = parseInt(this._streamWebSocketBuffer.slice(0, sizeTagLength), 10);
if (isNaN(messageLength)) {
// If this packet is invalid we can possibly recover by continuing to shift
// the buffer to the next packet. If that doesn't ever line up and work
// we should still be able to recover eventually through silence timeouts.
logl(LogLevel.WARNING, () => `WARNING: _readWebSocketPacket received invalid packet: '${this._streamWebSocketBuffer}'`);
return;
}
if (this._streamWebSocketBuffer.length < messageLength) {
return;
}
this._streamWebSocketBuffer = this._streamWebSocketBuffer.slice(sizeTagLength);
let currentMessage = this._streamWebSocketBuffer.slice(0, messageLength);
this._streamWebSocketBuffer = this._streamWebSocketBuffer.slice(messageLength);
const countOfIntParams = 5;
const intParamsLength = currentMessage.split(" ", countOfIntParams).reduce((p, c) => p + c.length, 0) + countOfIntParams;
const intParams = currentMessage.split(" ", countOfIntParams).map(s => parseInt(s, 10));
const [FCType, nFrom, nTo, nArg1, nArg2] = intParams;
currentMessage = currentMessage.slice(intParamsLength);
let sMessage: messages.AnyMessage | undefined;
if (currentMessage.length > 0) {
try {
sMessage = JSON.parse(decodeURIComponent(currentMessage)) as messages.AnyMessage;
} catch (e) {
// Guess it wasn't a JSON blob. OK, just use it raw.
sMessage = currentMessage;
}
}
this._packetReceived(new Packet(
FCType,
nFrom,
nTo,
nArg1,
nArg2,
currentMessage.length,
currentMessage.length === 0 ? undefined : sMessage,
));
}
}
/**
* Internal MFCAuto use only
*
* Incoming FCTYPE.EXTDATA messages are signals to request additional
* data from an external REST API. This helper function handles that task
* and invokes packetReceived with the results of the REST call
* @param extData An ExtDataMessage
* @returns A promise that resolves when data has been retrieves from
* the web API and packetReceived has completed
* @access private
*/
private async _handleExtData(extData: messages.ExtDataMessage) {
if (extData !== undefined && extData.respkey !== undefined) {
const url = `https://www.${this._baseUrl}/php/FcwExtResp.php?respkey=${extData.respkey}&type=${extData.type}&opts=${extData.opts}&serv=${extData.serv}&`;
logl(LogLevel.TRACE, () => `[CLIENT] _handleExtData: ${JSON.stringify(extData)} - '${url}'`);
const contentLogLimit = 80;
let contents = "";
try {
contents = await request(url).promise() as string;
logl(LogLevel.TRACE, () => `[CLIENT] _handleExtData response: ${JSON.stringify(extData)} - '${url}'\n\t${contents.slice(0, contentLogLimit)}...`);
// tslint:disable-next-line:no-unsafe-any
const p = new Packet(extData.msg.type, extData.msg.from, extData.msg.to, extData.msg.arg1, extData.msg.arg2, extData.msglen, JSON.parse(contents));
this._packetReceived(p);
} catch (e) {
logl(LogLevel.WARNING, () => `WARNING: _handleExtData error: ${e} - ${JSON.stringify(extData)} - '${url}'\n\t${contents.slice(0, contentLogLimit)}...`);
}
}
}
/**
* Processes the .rdata component of an FCTYPE.MANAGELIST server packet
*
* MANAGELIST packets are used by MFC for bulk dumps of data. For instance,
* they're used when you first log in to send the initial lists of online
* models, and when you first join a room to send the initial lists of
* other members in the room.
*
* If an MFCAuto consumer script wants to intercept and interpret details
* like that, it will need to listen for "MANAGELIST" events emitted from
* the client instance and process the results using this function.
*
* Most of the details are encoded in the .rdata element of the ManageListMessage
* and its format is cumbersome to deal with. This function handles the insanity.
* @param rdata rdata property off a received ManageListMessage
* @returns Either a list of Message elements, most common, or an
* FCTypeTagsResponse, which is an object containing tag information for
* one or more models.
* @access private
*/
public processListData(rdata: Array<Array<string | number | object>> | messages.FCTypeTagsResponse): Array<messages.Message> | messages.FCTypeTagsResponse {
// Really MFC? Really?? Ok, commence the insanity...
if (Array.isArray(rdata) && rdata.length > 0) {
const result: Array<messages.Message> = [];
const schema = rdata[0] as Array<string | { [index: string]: Array<string> }>;
const schemaMap: Array<string | [string, string]> = [];
logl(LogLevel.DEBUG, () => `[CLIENT] _processListData, processing schema: ${JSON.stringify(schema)}`);
if (Array.isArray(schema)) {
// Build a map of array index -> property path from the schema
schema.forEach((prop) => {
if (typeof prop === "object") {
Object.keys(prop).forEach((key) => {
if (Array.isArray(prop[key])) {
prop[key].forEach((prop2: string) => {
schemaMap.push([key, prop2]);
});
} else {
logl(LogLevel.WARNING, () => `_processListData. N-level deep schemas? ${JSON.stringify(schema)}`);
}
});
} else {
schemaMap.push(prop);
}
});
logl(LogLevel.DEBUG, () => `[CLIENT] _processListData. Calculated schema map: ${JSON.stringify(schemaMap)}`);
rdata.slice(1).forEach((record) => {
if (Array.isArray(record)) {
// Now apply the schema
const msg: messages.Message = {} as messages.Message;
for (let i = 0; i < record.length; i++) {
if (schemaMap.length > i) {
let schemaPath = schemaMap[i];
if (Array.isArray(schemaPath)) {
schemaPath = schemaPath.join(".");
}
_.set(msg, schemaPath, record[i]);
} else {
logl(LogLevel.WARNING, () => `WARNING: _processListData. Not enough elements in schema\n\tSchema: ${JSON.stringify(schema)}\n\tSchemaMap: ${JSON.stringify(schemaMap)}\n\tData: ${JSON.stringify(record)}`);
}
}
result.push(msg);
} else {
result.push(record);
}
});
} else {
// tslint:disable-next-line:no-any
return (rdata as any) as Array<messages.Message>;
}
return result;
} else {
return rdata as Array<messages.Message> | messages.FCTypeTagsResponse;
}
}
/**
* Encodes raw chat text strings into a format the MFC servers understand
* @param rawMsg A chat string like `I am happy :mhappy`
* @returns A promise that resolve with the translated text like
* `I am happy #~ue,2c9d2da6.gif,mhappy~#`
* @access private
*/
public async encodeRawChat(rawMsg: string): Promise<string> {
// On MFC, this code is part of the ParseEmoteInput function in
// https://www.myfreecams.com/_js/mfccore.js, and it is especially convoluted
// code involving ajax requests back to the server depending on the text you're
// sending and a giant hashtable of known emotes.
return new Promise<string>((resolve, reject) => {
// Pre-filters mostly taken from player.html's SendChat method
if (rawMsg.match(/^\s*$/) !== null || rawMsg.match(/:/) === null) {
resolve(rawMsg);
return;
}
rawMsg = rawMsg.replace(/`/g, "'");
rawMsg = rawMsg.replace(/<~/g, "'");
rawMsg = rawMsg.replace(/~>/g, "'");
this._ensureEmoteParserIsLoaded()
.then(() => (this._emoteParser as EmoteParser).Process(rawMsg, resolve))
.catch((reason) => reject(reason));
});
}
/**
* Internal MFCAuto use only
*
* Loads the emote parsing code from the MFC web site directly, if it's not
* already loaded, and then invokes the given callback. This is useful because
* most scripts won't actually need the emote parsing capabilities, so lazy
* loading it can speed up the common case.
*
* We're loading this code from the live site instead of re-coding it ourselves
* here because of the complexity of the code and the fact that it has changed
* several times in the past.
* @returns A promise that resolves when this.emoteParser has been initialized
* @access private
*/
private async _ensureEmoteParserIsLoaded(): Promise<void> {
if (this._emoteParser === undefined) {
const obj = await loadFromWeb(`https://www.${this._baseUrl}/_js/mfccore.js`, (content) => {
// Massager....Yes this is vulnerable to site breaks, but then
// so is this entire module.
// First, pull out only the ParseEmoteInput function
const startIndex = content.indexOf("// js_build_core: MfcJs/ParseEmoteInput/ParseEmoteInput.js");
const endIndex = content.indexOf("// js_build_core: ", startIndex + 1);
assert.ok(startIndex !== -1 && endIndex !== -1 && startIndex < endIndex, "mfccore.js layout has changed, don't know what to do now");
content = content.substr(startIndex, endIndex - startIndex);
// Then massage the function somewhat and prepend some prerequisites
content = `var document = {cookie: '', domain: '${this._baseUrl}', location: { protocol: 'https:' }};
var g_hPlatform = {
"id": 01,
"domain": "${this._baseUrl}",
"name": "MyFreeCams",
"code": "mfc",
"image_url": "https://img.mfcimg.com/",
"performer": "model",
"Performer": "Model",
"avatar_prefix": "avatar",
};
var XMLHttpRequest = require('xmlhttprequest').XMLHttpRequest;
function bind(that,f){return f.bind(that);}` + content;
content = content.replace(/this.createRequestObject\(\)/g, "new XMLHttpRequest()");
content = content.replace(/new MfcImageHost\(\)/g, "{host: function(){return '';}}");
content = content.replace(/this\.Reset\(\);/g, "this.Reset();this.oReq = new XMLHttpRequest();");
content = content.replace(/MfcClientRes/g, "undefined");
return content;
});
// tslint:disable-next-line:no-unsafe-any
this._emoteParser = new obj.ParseEmoteInput() as EmoteParser;
this._emoteParser.setUrl(`https://api.${this._baseUrl}/parseEmote`);
}
}
/**
* Internal MFCAuto use only
*
* Loads the latest server information from MFC, if it's not already loaded
* @returns A promise that resolves when this.serverConfig has been initialized
* @access private
*/
private async _ensureServerConfigIsLoaded() {
if (this.serverConfig === undefined) {
if (this._options.useCachedServerConfig) {
this.serverConfig = constants.CACHED_SERVERCONFIG;
} else {
const mfcConfig = await request(`https://www.${this._baseUrl}/_js/serverconfig.js?nc=${Math.random()}`).promise() as string;
try {
this.serverConfig = JSON.parse(mfcConfig) as ServerConfig;
} catch (e) {
logl(LogLevel.ERROR, `Error parsing serverconfig: '${mfcConfig}'`);
throw e;
}
}
}
}
/**
* Sends a command to the MFC chat server. Don't use this unless
* you really know what you're doing.
* @param nType FCTYPE of the message
* @param nTo Number representing the channel or entity the
* message is for. This is often left as 0.
* @param nArg1 First argument of the message. Its meaning varies
* depending on the FCTYPE of the message. Often left as 0.
* @param nArg2 Second argument of the message. Its meaning
* varies depending on the FCTYPE of the message. Often left as 0.
* @param sMsg Payload of the message. Its meaning varies depending
* on the FCTYPE of the message and is sometimes is stringified JSON.
* Most often this should remain undefined.
*/
public TxCmd(nType: constants.FCTYPE, nTo: number = 0, nArg1: number = 0, nArg2: number = 0, sMsg?: string): void {
logl(LogLevel.DEBUG, () => `[CLIENT] TxCmd Sending - nType: ${constants.FCTYPE[nType]}, nTo: ${nTo}, nArg1: ${nArg1}, nArg2: ${nArg2}, sMsg:${sMsg}`);
if (this.state === ClientState.IDLE) {
throw new Error("Client is not connected. Please call 'connect' before attempting this.");
}
if (this.state === ClientState.PENDING && nType !== constants.FCTYPE.LOGIN) {
throw new Error("Client is trying to connect and cannot send server commands yet. Please ensure the client is active by checking Client.state or Client.ensureConnected before attempting this.");
}
if (this._client === undefined) {
// Should not be possible to hit this condition as our state should be idle
// or pending whenever _client is undefined. This is only defense-in-depth.
throw new Error("Client is not ready to process commands, undefined _client");
}
if (this._client instanceof net.Socket) {
const msgLength = (sMsg ? sMsg.length : 0);
const buf = Buffer.alloc((7 * 4) + msgLength);
buf.writeInt32BE(constants.MAGIC, 0);
buf.writeInt32BE(nType, 4);
buf.writeInt32BE(this.sessionId, 8); // Session id, this is always our nFrom value
buf.writeInt32BE(nTo, 12);
buf.writeInt32BE(nArg1, 16);
buf.writeInt32BE(nArg2, 20);
buf.writeInt32BE(msgLength, 24);
if (sMsg) {
buf.write(sMsg, 28);
}
this._client.write(buf);
} else {
this._client.send(`${nType} ${this.sessionId} ${nTo} ${nArg1} ${nArg2}${sMsg ? " " + sMsg : ""}\n\0`);
}
// @TODO - Consider converting TxCmd to return a promise and catching any
// exceptions in client.send. In those cases, we could call ._disconnected()
// and wait on the CLIENT_CONNECTED event before trying to send the message
// again and then only resolve when we finally do send the message (or until
// manual disconnect() is called)
//
// On the other hand, during periods of long disconnect, that could cause a
// swarm of pending commands that would flood the server when we finally
// do get a connection, possibly causing MFC to drop and/or block us. So
// we'd need to handle it gracefully.
}
/**
* Sends a command to the MFC chat server. Don't use this unless
* you really know what you're doing.
* @param packet Packet instance encapsulating the command to be sent
*/
public TxPacket(packet: Packet): void {
this.TxCmd(packet.FCType, packet.nTo, packet.nArg1, packet.nArg2, JSON.stringify(packet.sMessage));
}
/**