-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathLTS.py
More file actions
2726 lines (2401 loc) · 104 KB
/
LTS.py
File metadata and controls
2726 lines (2401 loc) · 104 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
# TouchFish LTS Client/Server Unified Program (Final Release, Version 4)
"""
# TouchFish 协议文档
本协议文档版本:v2.4.0
本协议分为三个部分:`Gate`,`Chat`,`Misc`。
协议均使用 NDJSON(JSON 格式,相邻两个 JSON 以换行符分隔)格式进行发送。
---
# 协议更新日志
- Protocol v2.4.0 (TouchFish v4.7.0)
- 在 MISC.START 添加 stamp 字段
- Protocol v2.3.0 (TouchFish v4.6.0)
- 将 SERVER 部分整体更名为 MISC 部分
- 将 SERVER.STOP 部分整体更名为 MISC.SERVER_STOP 部分
- 将 MISC.START 部分的 server_version 字段更名为 version 字段
- 增加 MISC.COMMAND 和 MISC.CLIENT_STOP 协议
- Protocol v2.2.0 (TouchFish v4.4.0)
- 更改 CHAT.RECEIVE 和 CHAT.LOG 协议中 order 的定义
- Protocol v2.1.0 (TouchFish v4.1.0)
- 将 SERVER.STOP 更名为 SERVER.STOP.LOG
- 增加 SERVER.STOP.ANNOUNCE 协议
- Protocol v2.0.0 (TouchFish v4.0.0)
- 完整重构了协议
由于 v2 完整重构了协议,v1 部分的协议更新日志不再列出。
---
# 1 Gate
这个部分是关于进入聊天室的请求、审核等操作的协议内容。
## 1.1 Request
`{ type: "GATE.REQUEST", username: string }`
客户端连接时向服务端发送此消息,用于申请加入。
- `type`: `"GATE.REQUEST"`(所有协议的 `type` 字段均为固定值,下同)
- `username`: 字符串,表示用户希望使用的用户名。(所有 `username` 字段必须非空,下同)
## 1.2 Response
`{ type: "GATE.RESPONSE", result: "Accepted" | "Pending review" | "IP is banned" | "Room is full" | "Duplicate usernames" | "Username consists of banned words" }`
服务端对 `1.1 Request` 的直接响应,告知客户端其请求的处理结果。
- `type`: `"GATE.RESPONSE"`
- `result`: 字符串,可能取值包括:(下同)
- `"Accepted"`:立即允许加入;
- `"Pending review"`:需人工审核;
- `"IP is banned"`:当前 IP 被封禁;
- `"Room is full"`:服务端用户数已达上限;
- `"Duplicate usernames"`:用户名已被使用;
- `"Username consists of banned words"`:用户名包含违禁词。
## 1.3 Review Result
`{ type: "GATE.REVIEW_RESULT", accepted: boolean, operator: { username: string, uid: number } }`
当请求状态为 `"Pending review"` 时,管理员审核完成后,服务端向该客户端发送此消息。
- `type`: `"GATE.REVIEW_RESULT"`
- `accepted`: 布尔值,`true` 表示通过,`false` 表示拒绝。
- `operator`: 审核者信息:
- `username`: 操作者用户名;
- `uid`: 操作者的用户 ID。(除特殊说明外,用户 ID 为非负整数,下同)
## 1.4 Incorrect Protocol
`{ type: "GATE.INCORRECT_PROTOCOL", time: time, ip: ip }`
当出现通信不符合协议规范的连接时,服务端向日志写入记录。
- `type`: `"GATE.INCORRECT_PROTOCOL"`
- `time`: 表示事件发生的时间。(精确到微秒,下同)
- `ip`: 字符串,表示连接的网络地址。(格式为 `IPv4:port`,下同)
## 1.5 Client Request
服务端对客户端连接请求的响应。
### 1.5.1 Announce
`{ type: "GATE.CLIENT_REQUEST.ANNOUNCE", username: string, uid: number, result: "Accepted" | "Pending review" | "IP is banned" | "Room is full" | "Duplicate usernames" | "Username consists of banned words" }`
服务端向所有已连接的客户端广播该客户端的连接请求。
- `type`: `"GATE.CLIENT_REQUEST.ANNOUNCE"`
- `username`: 用户名。
- `uid`: 服务端为该用户分配的用户 ID。
- `result`: 服务端对该请求的处理结果。
### 1.5.2 Log
`{ type: "GATE.CLIENT_REQUEST.LOG", time: time, ip: ip, username: string, uid: number }`
服务端向接收到的连接请求写入日志。
- `type`: `"GATE.CLIENT_REQUEST.LOG"`
- `time`: 同上。
- `ip`: 客户端 IP 地址与端口。
- `username`: 用户名。
- `uid`: 用户 ID。
## 1.6 Status Change
关于用户状态变更事件的协议。
### 1.6.1 Request
`{ type: "GATE.STATUS_CHANGE.REQUEST", status: "Rejected" | "Kicked" | "Offline" | "Pending" | "Online" | "Admin" | "Root", uid: number }`
由管理员向服务端发起的用户状态变更请求。
- `type`: `"GATE.STATUS_CHANGE.REQUEST"`
- `status`: 字符串,表示目标状态,可能取值包括:(下同)
- `"Rejected"`:连接被拒绝的用户;(本协议中表示管理员拒绝加入请求)
- `"Kicked"`:被踢出聊天室的用户;(本协议中表示管理员主动踢出用户)
- `"Offline"`:主动离开聊天室的用户;(本协议中不会出现)
- `"Pending"`:等待加入审核的用户;(本协议中不会出现)
- `"Online"`:在线用户;(本协议中表示管理员通过加入请求)
- `"Admin"`:在线管理员;(本协议中不会出现)
- `"Root"`:聊天室房主。(本协议中不会出现)
- `uid`: 被操作用户的用户 ID。
### 1.6.2 Announce
`{ type: "GATE.STATUS_CHANGE.ANNOUNCE", status: "Rejected" | "Kicked" | "Offline" | "Pending" | "Online" | "Admin" | "Root", uid: number }`
当用户状态变更时,服务端进行广播。
- `type`: `"GATE.STATUS_CHANGE.ANNOUNCE"`
- `status`: 新状态。
- `uid`: 被变更状态的用户 ID。
### 1.6.3 Log
`{ type: "GATE.STATUS_CHANGE.LOG", time: time, status: "Rejected" | "Kicked" | "Offline" | "Pending" | "Online" | "Admin" | "Root", uid: number, operator: number }`
服务端将用户状态变更事件写入日志。
- `type`: 固定为 `"GATE.STATUS_CHANGE.LOG"`。
- `time`: 同上。
- `status`: 新状态。(`Pending` 状态和 `Root` 状态不会出现)
- `uid`: 被操作用户的用户 ID。
- `operator`: 操作者的用户 ID。
---
# 2 Chat
这个部分是关于在聊天室内收发消息和文件的协议内容。
## 2.1 Send
`{ type: "CHAT.SEND", filename: string, content: string, to: number | -1 | -2 }`
客户端发送消息或文件。
- `type`: `"CHAT.SEND"`
- `filename`: 文件名。若发送的是普通文本消息,则为空字符串 `""`;若发送文件,则为原始文件名。(下同)
- `content`: 若为消息,则为原始文本内容;若为文件,则为文件内容的 Base64 编码字符串。(下同)
- `to`: 目标接收者,可能取值包括:(下同)
- `-2`:广播给所有在线用户(相较于普通发送有特殊提示);
- `-1`:发送给所有在线用户;
- 非负整数:私聊给拥有相应用户 ID 的用户。
## 2.2 Receive
`{ type: "CHAT.RECEIVE", from: number, order: signed number, filename: string, content: string, to: number | -1 | -2 }`
服务端将消息转发给目标客户端。
- `type`: `"CHAT.RECEIVE"`
- `from`: 发送者的用户 ID。(下同)
- `order`: 消息编号,可能取值包括:(下同)
- 正整数:普通文本消息;
- 负整数:文件编号。
- `filename`: 同上。
- `content`: 同上。
- `to`: 同上。
## 2.3 Log
`{ type: "CHAT.LOG", time: time, from: number, order: signed number, filename: string, content: string, to: number | -1 | -2 }`
服务端将收到的聊天记录写入日志。
- `type`: `"CHAT.LOG"`
- `time`: 同上。
- `from`: 同上。
- `order`: 同上。
- `filename`: 同上。
- `content`: 若为消息,则为原始文本内容;若为文件,则为空字符串 `""`。(为了防止日志文件过大,具体的文件内容会单独存储)
- `to`: 同上。
---
# 3 Misc
这个部分是关于程序运行情况的协议内容。
## 3.1 Start
`{ type: "MISC.START", time: time, stamp: number, server_version: string, config: JSON }`
程序将启动时的启动参数写入日志。
- `type`: `"MISC.START"`
- `time`: 同上。
- `stamp`: 用于指定文件保存路径,取启动时的 UNIX 时间戳乘 `10 ** 6` 后向下取整。
- `server_version`: 字符串,表示服务端程序版本。(下同)
- `config`: JSON 对象,表示启动参数。(具体格式详见代码,下同)
## 3.2 Data
`{ type: "MISC.DATA", server_version: string, uid: number, config: JSON, users: [JSON, ...], chat_history: [JSON, ...] }`
用于向新连接的客户端提供完整上下文。
- `type`: `"MISC.DATA"`
- `server_version`: 同上。
- `uid`: 表示服务端分配给该用户的用户 ID。
- `config`: 同上。
- `users`: 用户列表,每个元素为:
- `username`: 用户名;
- `status`: 同上。
- `chat_history`: 历史聊天记录,每条记录包含:(不包含私聊记录和文件发送记录)
- `time`: 同上;
- `order`:同上;
- `from`: 同上;
- `content`: 同上;
- `to`: 同上。
## 3.3 Command
`{ type: "MISC.COMMAND", time: time, command: string }`
程序将用户输入的指令写入日志。
- `type`: `"MISC.COMMAND"`
- `time`: 同上。
- `command`: 输入的指令。
## 3.4 Client Stop
`{ type: "MISC.CLIENT_STOP", time: time }`
客户端正常关闭时将事件写入日志。
- `type`: `"MISC.MISC.CLIENT_STOP"`
- `time`: 同上。
## 3.5 Server Stop
服务端正常关闭时的协议。
### 3.5.1 Announce
`{ type: "MISC.SERVER_STOP.ANNOUNCE" }`
服务端正常关闭时,向全体客户端进行广播。
- `type`: `"MISC.SERVER_STOP.ANNOUNCE"`
### 3.5.2 Log
`{ type: "MISC.SERVER_STOP.LOG", time: time }`
服务端正常关闭时将事件写入日志。
- `type`: `"MISC.SERVER_STOP.LOG"`
- `time`: 同上。
## 3.6 Config
### 3.6.1 Post
`{ type: "MISC.CONFIG.POST", key: string, value: any }`
管理员向服务端发送配置修改请求。
- `type`: `"MISC.CONFIG.POST"`
- `key`: 配置项名称。(下同)
- `value`: 配置值。(下同)
### 3.6.2 Change
`{ type: "MISC.CONFIG.CHANGE", key: string, value: any, operator: number }`
服务端向客户端广播配置修改事件。
- `type`: `"MISC.CONFIG.CHANGE"`
- `key`: 同上。
- `value`: 同上。
- `operator`: 执行修改操作的用户 ID。
### 3.6.3 Save
`{ type: "MISC.CONFIG.SAVE", time: time }`
服务端将聊天室房主导出配置的事件写入日志。
- `type`: `"MISC.CONFIG.SAVE"`
- `time`: 同上。
### 3.6.4 Log
`{ type: "MISC.CONFIG.LOG", time: time, key: string, value: any, operator: number }`
服务端将配置修改事件写入日志。
- `type`: `"MISC.CONFIG.LOG"`
- `time`: 同上。
- `key`: 同上。
- `value`: 同上。
- `operator`: 执行修改操作的用户 ID。
"""
# ============================== 第一部分:常量和变量定义 ==============================
# 所有的模块依赖
import base64
import datetime
import json
import os
import platform
import queue
import re
import socket
import sys
import threading
import time
# 程序版本
VERSION = "v4.7.0"
# 用于客户端解析协议 1.2
RESULTS = \
{
"IP is banned": "您的 IP 被服务端封禁。",
"Room is full": "服务端连接数已满,无法加入。",
"Duplicate usernames": "该用户名已被占用,请更换用户名。",
"Username consists of banned words": "用户名中包含违禁词,请更换用户名。"
}
# 用于输出 ANSI 颜色转义字符 \033[1;3*m,具体如下:
"""
灰色 (black):
- 输入模式中的所有输入输出文本(除了 help 指令的输出)
- 消息头中用户名前的 @ 符号,行末的 : 符号,以及时间戳
- 启动成功时输出的聊天室信息(本质为模拟 dashboard 指令调用)
红色 (red):
- [加入提示],[公告],[广播],[文件] 标签的颜色
- 启动阶段的失败提示
绿色 (green):
- [私聊] 标签的颜色
- 私聊消息的消息头中中发送方和接收方之间的 -> 连接符
- 启动阶段的成功提示
蓝色 (blue):
- [发给您的],[您发送的] 标签的颜色
- help 指令显示的帮助消息中的第三段和第六段
白色 (white):
- 普通消息和加入提示的文本
- 启动阶段的参数输入提示
- 启动阶段中创建 TouchFishFiles 目录(客户端用于存放
接收到的文件,服务端用于存放所有经服务端传输的文件)
失败(例如,目录已经存在)时的系统提示
黄色 (yellow):
- 消息头中的用户名
- 启动阶段中上面没有提到的所有文本
青色 (cyan):
- 所有除普通消息和加入提示以外的消息的文本
- help 指令显示的帮助消息中的其余段落
- 程序关闭时的「再见!」文本
特别说明:
- shell 指令的输出文本颜色为系统默认颜色
- 洋红色 (magenta) 目前没有使用过
"""
COLORS = \
{
"black": 0,
"red": 1,
"green": 2,
"yellow": 3,
"blue": 4,
"magenta": 5,
"cyan": 6,
"white": 7
}
# 默认客户端配置(必须在启动时指定):
"""
side 角色 (Client)
ip 服务端地址
port 服务端端口
username 连接时使用的用户名
"""
# 需要指出的是,第五部分中会给 username 字段
# 的默认值后面加上一个随机六位数作为后缀,
# 形成形如 "user123456" 的用户名
DEFAULT_CLIENT_CONFIG = {"side": "Client", "ip": "touchfish.xin", "port": 7001, "username": "user"}
# 默认服务端配置(side 和 general.* 必须在启动时指定):
"""
side 角色 (Server)
general.server_ip 服务端地址
general.server_port 服务端端口
general.server_username 服务端用户使用的用户名
general.max_connections 最大允许连接数,
参见下面对 online_count 变量的说明
... 其余参数不必在启动时指定,
具体内容及含义参见 CONFIG_LIST 常量
"""
DEFAULT_SERVER_CONFIG = \
{
"side": "Server",
"general": {"server_ip": "127.0.0.1", "server_port": 8080, "server_username": "root", "max_connections": 128},
"ban": {"ip": [], "words": []},
"gate": {"enter_hint": "", "enter_check": False},
"message": {"allow_private": True, "max_length": 16384},
"file": {"allow_any": True, "allow_private": True, "max_size": 1048576}
}
# 服务端配置中的期望数据类型,额外限制如下:
"""
side 必须为 "Server"(此处没有列出)
general.server_ip 必须为合法 IPv4
general.server_port 必须在 [1, 65535] 中取值
general.server_username 不能为空串
general.max_connections 必须在 [1, 128] 中取值
ban.ip 必须全部为合法 IPv4,不能有重复项
ban.words 不能为空串,不能包含 \r 或 \n,不能有重复项
message.max_length 必须在 [1, 16384] 中取值
file.max_size 必须在 [1, 1073741824] (1 Byte - 1 GiB) 中取值,
实际判定时向下取整到 3 的倍数
"""
# general.server_ip 中给出的 IPv4 可以在 ban.ip 列表中出现,
# 且启动服务端时不会进行相关检查;
# ban.words 的检查范围包括消息文本,所发送的文件的文件名和
# 客户端连接时使用的用户名,但不包含 general.server_port
# 中指定的服务端用户名和所发送的文件的内容;
# message.max_length 参数以「字符 (Unicode 码点) 个数」为准,
# 例如「你好」算作 2 个字符;
# message.max_length 参数以「字节个数」为准,
# 例如 UTF-8 格式的文本「你好」算作 6 个字节
CONFIG_TYPE_CHECK_TABLE = \
{
"general.server_ip": "str", "general.server_port": "int", "general.server_username": "str", "general.max_connections": "int",
"ban.ip": "list", "ban.words": "list",
"gate.enter_hint": "str", "gate.enter_check": "bool",
"message.allow_private": "bool", "message.max_length": "int",
"file.allow_any": "bool", "file.allow_private": "bool", "file.max_size": "int"
}
# 客户端配置中的期望数据类型如下,此处没有单独编写代码:
"""
side 必须为 "Client"
ip 不能为空串
port 必须在 [1, 65535] 中取值
username 不能为空串
"""
# 用于 dashboard 指令中列出参数列表
CONFIG_LIST = \
"""
参数名称 当前值 修改示例 描述
ban.ip <1> ["8.8.8.8"] IP 黑名单
ban.words <2> ["a", "b"] 屏蔽词列表
gate.enter_hint <3> "Hi there!\\n" 进入提示
gate.enter_check {!s:<12}True 加入是否需要人工放行
message.allow_private {!s:<12}False 是否允许私聊
message.max_length {:<12}256 最大消息长度(字符个数)
file.allow_any {!s:<12}False 是否允许发送文件
file.allow_private {!s:<12}False 是否允许发送私有文件
file.max_size {:<12}16384 最大文件大小(字节数)
为了防止尖括号处的内容写不下,此处单独列出:
<1>:
{}
<2>:
{}
<3>:
{}
"""[1:-1]
# 指令列表
COMMAND_LIST = ["admin", "ban", "broadcast", "config", "dashboard", "distribute", "doorman", "evaluate", "exit", "flood", "help", "kick", "save", "send", "shell", "transfer", "whisper"]
# 缩写表
ABBREVIATION_TABLE = \
{
"D": "dashboard", "F": "distribute", "Q": "evaluate", "E": "exit", "L": "flood",
"H": "help", "S": "send", "J": "shell", "T": "transfer", "P": "whisper",
"I+": "ban ip add", "I-": "ban ip remove", "W+": "ban words add", "W-": "ban words remove",
"B": "broadcast", "C": "config", "G+": "doorman accept", "G-": "doorman reject", "K": "kick",
"A+": "admin add", "A-": "admin remove", "V": "save",
"d": "dashboard", "f": "distribute", "q": "evaluate", "e": "exit", "l": "flood",
"h": "help", "s": "send", "j": "shell", "t": "transfer", "p": "whisper",
"i+": "ban ip add", "i-": "ban ip remove", "w+": "ban words add", "w-": "ban words remove",
"b": "broadcast", "c": "config", "g+": "doorman accept", "g-": "doorman reject", "k": "kick",
"a+": "admin add", "a-": "admin remove", "v": "save"
}
# flood 指令开启的简易命令行模式的进入提示
SIMPLE_COMMAND_LINE_HINT_CONTENT = \
"""
您已经进入简易命令行模式。
在简易命令行模式中,您只需要执行以下三个步骤,即可完成单行公开消息的发送:
1. 按下 Enter 进入输入模式
2. 直接输入想要发送的单行消息(不需要显式执行 send 指令)
3. 再按下 Enter 返回输出模式
本模式下发送结果不会进行显式反馈,而是根据下面的特性间接反馈:
发送成功的消息能够在输出模式中看到(带有响铃),发送失败的消息则不会。
在任何模式下按下 Ctrl + {} 以退出简易命令行模式。
"""[1:-1]
# help 指令显示的帮助消息(分为 8 段)
HELP_HINT_CONTENT = \
[
"""
聊天室界面分为输出模式和输入模式,默认为输出模式,此时行首没有符号。
按下回车键即可从输出模式转为输入模式,此时行首有一个 > 符号。
按下 Enter(或输入任意指令)即可从输入模式转换回输出模式。
输出模式下,输入的指令将被忽略,且不会显示在屏幕上。
输入模式下,新的消息将等待到退出输入模式才会显示。
聊天室内可用的指令有:
"""[1:-1],
"""
聊天室内用户的状态分为以下 7 种:
"""[1:-1],
"""
Root 聊天室房主
Admin 聊天室管理员
Online 聊天室普通用户
Pending 等待加入审核的用户
Offline 主动离开聊天室的用户
Kicked 被踢出聊天室的用户
Rejected 连接被拒绝的用户
""",
"""
有且只有状态为 Root,Admin,Online 和 Pending 的用户会被计入在线用户数。
需要指出的是,状态为 Admin 和 Root 有权查看别人的私聊消息和私有文件。
"""[1:-1],
"""
聊天室内可用的指令分为以下 17 条 25 项:
"""[1:-1],
"""
[D] dashboard 展示聊天室各项数据
[F] distribute <filename> 发送文件
[Q] evaluate <input> 像 Python IDLE 那样计算输入数据
[E] exit 退出或关闭聊天室
[L] flood 开启简易命令行模式
[H] help 显示本帮助文本
[S] send 发送多行消息
[S] send <message> 发送单行消息
[J] shell <command> 执行 Shell 指令
[T] transfer <user> <filename> 向某个用户发送私有文件
[P] whisper <user> 向某个用户发送多行私聊消息
[P] whisper <user> <message> 向某个用户发送单行私聊消息
[I+] * ban ip add <ip> 封禁 IP 或 IP 段
[I-] * ban ip remove <ip> 解除封禁 IP 或 IP 段
[W+] * ban words add <word> 屏蔽某个词语
[W-] * ban words remove <word> 解除屏蔽某个词语
[B] * broadcast 向全体用户广播多行消息
[B] * broadcast <message> 向全体用户广播单行消息
[C] * config <key> <value> 修改聊天室配置项
[G+] * doorman accept <user> 通过某个用户的加入申请
[G-] * doorman reject <user> 拒绝某个用户的加入申请
[K] * kick <user> 踢出某个用户
[A+] ** admin add <uid> 添加管理员
[A-] ** admin remove <uid> 移除管理员
[V] ** save 保存聊天室配置项信息
""",
"""
缩略表示形式不区分大小写,其他字段区分大小写。
支持用左边方括号内的内容缩略表示右边所有没有用尖括号括起来的字段。
所有 <user> 字段可以输入 UID 或用户名均可,优先解析为 UID。
解析用户名遇到冲突时采纳 UID 最小的合法解析结果。
简易命令行模式允许您直接输入并发送单行消息而省略 send,但会禁用其他指令。
标注 * 的指令只有状态为 Admin 或 Root 的用户可以使用。
标注 ** 的指令只有状态为 Root 的用户可以使用。
对于 dashboard 指令,状态为 Root 的用户可以看到所有用户的 IP 地址,其他用户不能。
对于 evaluate 指令,该指令直接使用 eval() 函数实现,其中二进制发行版的 Python 版本为 3.6。
对于 evaluate 指令,请不要注入恶意代码(典型的有 globals(), locals() 等),否则后果自负。
对于 shell 指令,请不要试图执行危害本程序(或您的设备)的指令(此处从略),否则后果自负。
对于 ban ip 指令,支持输入形如 a.b.c.d/e 的 IP 段,但前缀长度 (e 值) 不得小于 24。
对于 config 指令,<key> 的格式以 dashboard 指令输出的参数名称为准。
对于 config 指令,<value> 的格式以 dashboard 指令输出的修改示例为准。
对于 kick 指令,状态为 Root 的用户可以踢出状态为 Admin 或 Online 的用户。
对于 kick 指令,状态为 Admin 的用户只能踢出状态为 Online 的用户。
对于 kick 指令,状态为 Admin 的用户只能踢出状态为 Online 的用户。
"""[1:-1],
"""
您可以在 TouchFish 的官方 Github 仓库页面获取更多联机帮助:
https://github.com/2044-space-elevator/TouchFish
"""[1:-1]
]
HELP_HINT_COLORS = ["cyan", "cyan", "blue", "cyan", "cyan", "blue", "cyan", "cyan"]
HELP_HINT = [{"content": HELP_HINT_CONTENT[i], "color": HELP_HINT_COLORS[i]} for i in range(len(HELP_HINT_CONTENT))]
# 当服务端收到不遵守 TouchFish v4 协议的连接时,
# 假设其为浏览器访问公共聊天室,显示 HTML 提示页面
WEBPAGE_CONTENT = \
"""
HTTP/1.1 405 Method Not Allowed
Content-Type: text/html; charset=utf-8
my_socket: close
<html>
<head><meta name="color-scheme" content="light dark"><meta charset="utf-8"><title>警告 Warning</title></head>
<body>
<h1>405 Method Not Allowed</h1>
<pre style="word-wrap: break-word; white-space: pre-wrap; color: red; font-weight: bold;">
您似乎正在使用浏览器或类似方法向 TouchFish 服务器发送请求。
此类请求可能会危害 TouchFish 服务器的正常运行,因此请不要继续使用此访问方法,否则我们可能会封禁您的 IP 地址。
正确的访问方法是,使用 TouchFish 生态下任意兼容的 TouchFish Client 登录 TouchFish Server。
欲了解更多有关 TouchFish 聊天室的信息,请访问 TouchFish 聊天室的官方 Github 仓库:
<a href="https://github.com/2044-space-elevator/TouchFish">github.com/2044-space-elevator/TouchFish</a>
Seemingly you are sending requests to TouchFish Server via something like Web browsers.
Such requests are HAZARDOUS to the server and will result in a BAN if you insist on this access method.
To use the TouchFish chatroom service correctly, you might need a dedicated TouchFish Client.
For more information, please visit the official Github repository of this project:
<a href="https://github.com/2044-space-elevator/TouchFish">github.com/2044-space-elevator/TouchFish</a>
</pre>
</body>
</html>
"""[1:]
"""
以下是在服务端和客户端都启用的变量:
config 服务端参数(对于客户端,启动前存储客户端参数,
启动后存储服务端参数)
blocked True 表示 HELP_HINT 第 1 段提到的「输入模式」,
False 表示「输出模式」
stamp 自身保存文件时的路径,具体为
./TouchFishFiles/<stamp>/<file_order>.file
(<file_order> 取绝对值),取值为启动时的
UNIX 时间戳乘上 (10 ** 6) 后向下取整的值
my_username 自身连接的用户名
my_uid 自身的用户 ID(服务端为 0,客户端从 1 开始分配)
my_socket 自身的 TCP socket 连接(服务端也有连接,
但 my_socket 一端只读取不发送)
users 用户信息(JSON 格式列表,
但服务端和客户端中 JSON 结构不同,详见下文)
side 角色(服务端取 "Server",客户端取 "Client")
server_version 服务端版本
online_count 在线人数(包括状态为 Root,Admin,
Online 和 Pending 的用户,这些状态的含义
参见 HELP_HINT 第 3 段,下同)
buffer my_socket 读取时模拟的缓冲区
(发送的数据都是 NDJSON,因此遇到换行符则清空)
exit_flag 默认为 False,程序终止改为 True,通知所有线程终止
log_queue 记录需要写入日志的信息,数据格式为 str(JSON)
print_queue 用于输入模式下记录被阻塞的输出内容(每行一条),
切换到输出模式后一并输出
以下是服务端启用而客户端不启用的变量:
file_order 目前服务端已经传送的文件个数,
用于从 -1 开始分配文件 ID (-1, -2, -3, ...),区分文件
message_order 目前服务端已经传送的消息个数,
用于从 1 开始分配消息 ID (1, 2, 3, ...),区分消息
server_socket 服务端向客户端暴露用于连接的 TCP socket
history 用于记录聊天上下文,在新客户端建立连接时
通过协议 3.2 发送给客户端
send_queue 记录需要发送给客户端的信息,
数据格式为 str({ "to": UID, "content": JSON })
receive_queue 记录从客户端读取到的(符合协议的)信息,
数据格式为 str({ "from": UID, "content": JSON })
对于 users 列表的每个 JSON 项,以下字段在服务端和客户端中均存在:
(index) 每个用户的用户 ID 与对应 JSON 项在列表中的下标相等
username 用户名
status 状态,参见上面对 online_count 变量的说明
对于 users 列表的每个 JSON 项,以下字段在服务端中存在,在客户端中不存在:
body 连接到该用户的 TCP socket
buffer body 读取时模拟的缓冲区(发送的数据都是 NDJSON,
因此遇到换行符则清空)
ip 数据格式为 [str, int],
ip[0] 为 IPv4 地址,ip[1] 为端口
busy bool 类型变量,表示服务端是否在向该客户端发送文件:
若取值为 True,则阻止第四部分的 thread_check 线程
向该用户发送心跳数据(单个换行符),
防止 NDJSON 被意外截断
"""
config = DEFAULT_CLIENT_CONFIG
blocked = False
stamp = int(time.time() * (10 ** 6))
my_username = "user"
my_uid = 0
file_order = 0
message_order = 0
my_socket = None
users = []
server_socket = socket.socket()
side = "Server"
server_version = VERSION
history = []
online_count = 1
buffer = ""
exit_flag = False
log_queue = queue.Queue()
receive_queue = queue.Queue()
send_queue = queue.Queue()
print_queue = queue.Queue()
# ================================ 第二部分:功能性函数 ================================
# 响铃
def ring():
print("\a", end="", flush=True)
# 清屏
def clear_screen():
if platform.system() == "Windows":
os.system("cls")
else:
os.system("clear")
# 多行输入
def enter():
if platform.system() == "Windows":
shortcut = "C"
else:
shortcut = "D"
print("请输入要发送的消息。")
print("输入结束后,先按下 Enter,然后按下 Ctrl + {}。".format(shortcut))
message = ""
while True:
try:
message += input() + "\n"
except EOFError:
break
if message:
message = message[:-1]
return message
# 利用 ANSI 转义序列给文本染色,
# 其中 \033[8;30m 用于输出模式下隐藏输入的文本;
# color_code 传入 None 时不额外染色(下同)
def dye(text, color_code):
if color_code:
return "\033[0m\033[1;3{}m{}\033[8;30m".format(COLORS[color_code], text)
return text
# 转换到输出模式时使用,一次性输出所有被阻塞的输出内容
def flush():
global print_queue
if not blocked:
while not print_queue.empty():
print(print_queue.get())
# 受 blocked 变量控制的文本输出(用于输出一般信息)
def prints(text, color_code=None):
global print_queue
if not blocked:
print(dye(text, color_code))
else:
print_queue.put(dye(text, color_code))
# 不受 blocked 变量控制的强制文本输出:
# 只用于 dashboard 指令、flood 指令(部分)
# 和 help 指令输出信息
def printf(text, color_code=None):
print(dye(text, color_code))
# 用于第三部分的指令函数输出提示信息,
# 根据传入的 verbose 变量决定是否输出指令执行结果提示:
# 用户执行指令时 verbose 变量为 True,输出相应提示信息;
# 随后请求会发给服务端,服务端调用同样的函数进行二次检查,
# 此时 verbose 变量为 False,因此服务端不会看到奇怪的提示信息
def printc(verbose, text):
if verbose:
print(dye(text, "black"))
# 解析用户名
def parse_username(arg, expected_status):
try:
uid = int(arg.split()[0])
if uid >= 0 and uid < len(users) and users[uid]["status"] in expected_status:
return arg
raise
except:
for i in range(len(users)):
if users[i]["status"] in expected_status:
if arg.startswith(users[i]["username"] + " ") or arg == users[i]["username"]:
return str(i) + arg[len(users[i]["username"]):]
return ""
# 检查 element 是不是合法 IP
def check_ip(element):
pattern = r"^(\d+)\.(\d+)\.(\d+)\.(\d+)$" # int.int.int.int
match = re.search(pattern, element)
if not match:
return False
for i in range(1, 5):
if int(match.group(i)) < 0 or int(match.group(i)) > 255: # x.x.x.x 中 0 <= x <= 255
return False
return True
# 检查 element 是不是合法 IP 段,要求前缀长度不小于 24
def check_ip_segment(element):
if not check_ip(element.split("/")[0]): # 先检查 IP 段前半部分的 IP
return [] # 返回 [] 表明 IP 段本身不合法
if not "/" in element:
element = element + "/32" # 将 x.x.x.x 转换为 x.x.x.x/32 再继续解析
pattern = r"^(\d+)\.(\d+)\.(\d+)\.(\d+)/(\d+)$" # int.int.int.int/int
match = re.search(pattern, element)
if not match:
return []
numbers = [0] + [int(match.group(i)) for i in range(1, 6)]
if numbers[5] < 0 or numbers[5] > 32: # 0 <= 前缀长度 <= 32
return []
if numbers[4] % (2 ** (32 - numbers[5])): # 把 x.x.x.x 转换为网络地址
numbers[5] -= numbers[4] % (2 ** (32 - numbers[5]))
if numbers[5] < 24: # 前缀长度 >= 24
return [""] # 返回 [""] 表明 IP 段本身是合法的,只是前缀长度需要 >= 24
result = [] # 枚举网络中的所有 IPv4,逐个添加进 ban.ip 参数
for i in range(numbers[4], numbers[4] + 2 ** (32 - numbers[5])):
result.append("{}.{}.{}.{}".format(numbers[1], numbers[2], numbers[3], i))
return result
# 返回一个 "xxxx-xx-xx xx:xx:xx.xxxxxx" 格式的字符串表示当前时区的当前时间;
# 下面的 announce 和 print_message 函数对其切片 [11:19],
# 可以得到其中的 "xx:xx:xx"
def time_str():
return str(datetime.datetime.now())
# 公告消息的消息头
def announce(uid):
first_line = dye("[" + time_str()[11:19] + "]", "black")
if uid == my_uid:
first_line += dye(" [您发送的]", "blue")
first_line += dye(" [公告]", "red")
first_line += " "
first_line += dye("@", "black")
first_line += dye(users[uid]["username"], "yellow")
first_line += dye(":", "black")
prints(first_line)
# 其他消息的消息头(根据协议 2.2 进行解析)
def print_message(message):
first_line = dye("[" + message["time"][11:19] + "]", "black")
if message["from"] == my_uid:
first_line += dye(" [您发送的]", "blue")
if message["to"] == my_uid:
first_line += dye(" [发给您的]", "blue")
try:
if message["filename"]:
first_line += dye(" [文件]", "red")
except KeyError:
pass
if message["to"] == -2:
first_line += dye(" [广播]", "red")
if message["to"] >= 0:
first_line += dye(" [私聊]", "green")
first_line += " "
first_line += dye("@", "black")
first_line += dye(users[message["from"]]["username"], "yellow")
# 对于私聊消息,上面的代码输出发送方,下面的代码输出接收方
if message["to"] >= 0:
first_line += dye(" -> ", "green")
first_line += dye("@", "black")
first_line += dye(users[message["to"]]["username"], "yellow")
first_line += dye(":", "black")
prints(first_line)
try:
# 对于文件消息,保存到 TouchFishFiles/<order>.file,
# 其中 <order> 的定义参见第一部分对 file_order 变量
# 用途的介绍和协议 2.2 的协议文档,取绝对值
if message["filename"]:
# 服务端的文件保存工作已经在第三部分的
# do_distribute 函数和 do_transfer 函数
# 完成,因此这里只在角色为客户端时保存文件
if side == "Client":
try:
# 以二进制格式输出 base64 解密后的结果,
# Windows 下子目录用反斜杠,其他用正斜杠(下同)
if platform.system() == "Windows":
with open("TouchFishFiles\\{}\\{}.file".format(stamp, -message["order"]), "wb") as f:
f.write(base64.b64decode(message["content"]))
else:
with open("TouchFishFiles/{}/{}.file".format(stamp, -message["order"]), "wb") as f:
f.write(base64.b64decode(message["content"]))
except:
pass
prints("发送了文件 {},已经保存到:TouchFishFiles/{}/{}.file".format(message["filename"], stamp, -message["order"]), "cyan")
else:
prints(message["content"], "white")
except KeyError:
prints(message["content"], "white") # 对于普通消息,直接显示消息内容
# 处理 my_socket 收到的信息
def process(message):
global users
global online_count
global exit_flag
ring() # 响铃
if message["type"] == "CHAT.RECEIVE": # 收到消息 (协议 2.2)
message["time"] = time_str()
print_message(message)
return
if message["type"] == "GATE.CLIENT_REQUEST.ANNOUNCE": # 新的客户端连接到聊天室 (协议 1.5.1)
announce(0)
prints("用户 {} (UID = {}) 请求加入聊天室,请求结果:".format(message["username"], message["uid"]) + message["result"], "cyan")
if side == "Client": # 同上,客户端已经在别处更新
users.append({"username": message["username"], "status": "Rejected"})
if message["result"] == "Pending review":
users[message["uid"]]["status"] = "Pending"
if message["result"] == "Accepted":
users[message["uid"]]["status"] = "Online"
if side == "Client" and message["result"] in ["Pending review", "Accepted"]: # 同上
online_count += 1
return
if message["type"] == "GATE.STATUS_CHANGE.ANNOUNCE": # 用户状态变更 (协议 1.6.2)
announce(message["operator"])
prints("用户 {} (UID = {}) 的状态变更为:".format(users[message["uid"]]["username"], message["uid"]) + message["status"], "cyan")
if side == "Client": # 同上
users[message["uid"]]["status"] = message["status"]
if message["status"] in ["Offline", "Kicked", "Rejected"]:
online_count -= 1
if message["uid"] == my_uid and message["status"] == "Kicked": # 特殊情况:自己被踢出
while blocked:
pass
my_socket.close() # 关闭相应 TCP socket
prints("您被踢出了聊天室。", "cyan")
# 此处不能调用 dye 函数,因为需要使用 \033[0m
# 来清除 ANSI 文本序列带来的显示效果,
# 防止干扰用户后续的终端使用
prints("\033[0m\033[1;36m再见!\033[0m")
exit_flag = True
return
if message["type"] == "MISC.CONFIG.CHANGE": # 服务端参数变更 (协议 3.6.2)
announce(message["operator"])
prints("配置项 {} 变更为:".format(message["key"]) + str(message["value"]), "cyan")
if side == "Client": # 同上
if isinstance(message["value"], list):
additions = [item for item in message["value"] if not item in config[message["key"].split(".")[0]][message["key"].split(".")[1]]]
deletions = [item for item in config[message["key"].split(".")[0]][message["key"].split(".")[1]] if not item in message["value"]]
prints("该配置项相比修改前增加了:{}".format(str(additions)), "cyan")
prints("该配置项相比修改前移除了:{}".format(str(deletions)), "cyan")
config[message["key"].split(".")[0]][message["key"].split(".")[1]] = message["value"]
return
if message["type"] == "MISC.SERVER_STOP.ANNOUNCE": # 服务端关闭 (协议 3.5.1)
if side == "Client": # 同上
announce(0)
prints("聊天室服务端已经关闭。", "cyan")
prints("\033[0m\033[1;36m再见!\033[0m")
exit_flag = True
return
# 从 my_socket 读取数据,每次 128 KiB,读完为止
def read():
global my_socket
global buffer
while True: