tchat-client 0.12.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tchat_client-0.12.0/.gitignore +9 -0
- tchat_client-0.12.0/PKG-INFO +7 -0
- tchat_client-0.12.0/install.sh +53 -0
- tchat_client-0.12.0/pyproject.toml +20 -0
- tchat_client-0.12.0/tchat/__init__.py +0 -0
- tchat_client-0.12.0/tchat/client/__init__.py +0 -0
- tchat_client-0.12.0/tchat/client/connection.py +43 -0
- tchat_client-0.12.0/tchat/client/identity.py +40 -0
- tchat_client-0.12.0/tchat/client/receiver.py +69 -0
- tchat_client-0.12.0/tchat/client/runner.py +41 -0
- tchat_client-0.12.0/tchat/client/sender.py +121 -0
- tchat_client-0.12.0/tchat/client/updater.py +39 -0
- tchat_client-0.12.0/tchat/config/__init__.py +1 -0
- tchat_client-0.12.0/tchat/config/config.py +82 -0
- tchat_client-0.12.0/tchat/config/config.toml +28 -0
- tchat_client-0.12.0/tchat/exceptions.py +26 -0
- tchat_client-0.12.0/tchat/logger/__init__.py +1 -0
- tchat_client-0.12.0/tchat/logger/base.py +24 -0
- tchat_client-0.12.0/tchat/logger/client.py +42 -0
- tchat_client-0.12.0/tchat/logger/colors.py +10 -0
- tchat_client-0.12.0/tchat/logger/server.py +48 -0
- tchat_client-0.12.0/tchat/logger/typewriter.py +10 -0
- tchat_client-0.12.0/tchat/message/__init__.py +0 -0
- tchat_client-0.12.0/tchat/message/framing.py +44 -0
- tchat_client-0.12.0/tchat/message/message.py +38 -0
- tchat_client-0.12.0/tchat/message/types.py +10 -0
- tchat_client-0.12.0/tchat/version.py +1 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Script d'installation de tchat pour les users.
|
|
3
|
+
# Usage: curl -sSL https://raw.githubusercontent.com/TheLeBerton/tchat-client/main/install.sh | bash
|
|
4
|
+
|
|
5
|
+
set -e
|
|
6
|
+
|
|
7
|
+
echo "=== Installation de tchat ==="
|
|
8
|
+
|
|
9
|
+
# Vérifie python3
|
|
10
|
+
if ! command -v python3 &>/dev/null; then
|
|
11
|
+
echo "Erreur : python3 non trouvé."
|
|
12
|
+
echo "Installe Python 3.11+ depuis https://www.python.org/downloads/"
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
# Vérifie version >= 3.11
|
|
17
|
+
if ! python3 -c "import sys; exit(0 if sys.version_info >= (3, 11) else 1)" 2>/dev/null; then
|
|
18
|
+
version=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
|
|
19
|
+
echo "Erreur : Python 3.11+ requis (tu as $version)."
|
|
20
|
+
echo "Mets à jour Python depuis https://www.python.org/downloads/"
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
# Assure que pip est disponible
|
|
25
|
+
python3 -m ensurepip --upgrade 2>/dev/null || true
|
|
26
|
+
|
|
27
|
+
# Installe / met à jour tchat-client
|
|
28
|
+
echo "Installation de tchat-client..."
|
|
29
|
+
python3 -m pip install --upgrade tchat-client --quiet
|
|
30
|
+
|
|
31
|
+
# Trouve le binaire installé
|
|
32
|
+
TCHAT_BIN=$(python3 -c "import sysconfig; print(sysconfig.get_path('scripts'))")/tchat
|
|
33
|
+
USER_BIN=$(python3 -m site --user-base 2>/dev/null)/bin/tchat
|
|
34
|
+
|
|
35
|
+
if command -v tchat &>/dev/null; then
|
|
36
|
+
echo ""
|
|
37
|
+
echo "Terminé ! Lance : tchat"
|
|
38
|
+
elif [ -f "$TCHAT_BIN" ]; then
|
|
39
|
+
echo ""
|
|
40
|
+
echo "Terminé ! Lance : $TCHAT_BIN"
|
|
41
|
+
echo ""
|
|
42
|
+
echo "Astuce — pour utiliser juste 'tchat', ajoute cette ligne à ton ~/.zshrc ou ~/.bashrc :"
|
|
43
|
+
echo " export PATH=\"$(dirname $TCHAT_BIN):\$PATH\""
|
|
44
|
+
elif [ -f "$USER_BIN" ]; then
|
|
45
|
+
echo ""
|
|
46
|
+
echo "Terminé ! Lance : $USER_BIN"
|
|
47
|
+
echo ""
|
|
48
|
+
echo "Astuce — pour utiliser juste 'tchat', ajoute cette ligne à ton ~/.zshrc ou ~/.bashrc :"
|
|
49
|
+
echo " export PATH=\"$(dirname $USER_BIN):\$PATH\""
|
|
50
|
+
else
|
|
51
|
+
echo ""
|
|
52
|
+
echo "Terminé ! Lance : python3 -m tchat"
|
|
53
|
+
fi
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tchat-client"
|
|
7
|
+
version = "0.12.0"
|
|
8
|
+
description = "tchat — client"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"typer>=0.24.1",
|
|
12
|
+
"prompt_toolkit>=3.0.52",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
tchat = "tchat.client.runner:run"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["tchat"]
|
|
20
|
+
include = ["tchat/**/*.toml"]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
|
|
3
|
+
from tchat.config import config
|
|
4
|
+
from tchat.message.message import Message
|
|
5
|
+
from tchat.message.framing import send_framed, receive_framed
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Connection:
|
|
9
|
+
"""
|
|
10
|
+
TCP connection.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__( self, host: str | None = None ) -> None:
|
|
14
|
+
self._host = host or config.client.ip
|
|
15
|
+
self._socket: socket.socket | None = None
|
|
16
|
+
|
|
17
|
+
def connect( self ) -> None:
|
|
18
|
+
"""
|
|
19
|
+
Connect to server.
|
|
20
|
+
"""
|
|
21
|
+
self._socket = socket.socket( socket.AF_INET, socket.SOCK_STREAM )
|
|
22
|
+
self._socket.connect( ( self._host, config.client.port ) )
|
|
23
|
+
|
|
24
|
+
def close( self ) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Close the connection.
|
|
27
|
+
"""
|
|
28
|
+
if self._socket:
|
|
29
|
+
self._socket.close()
|
|
30
|
+
|
|
31
|
+
def send( self, msg: Message ) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Send a message.
|
|
34
|
+
"""
|
|
35
|
+
assert self._socket is not None
|
|
36
|
+
send_framed( self._socket, msg.to_json() )
|
|
37
|
+
|
|
38
|
+
def receive( self ) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Receive a message.
|
|
41
|
+
"""
|
|
42
|
+
assert self._socket is not None
|
|
43
|
+
return receive_framed( self._socket )
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
USERNAME_FILE = Path.home() / ".tchat_username"
|
|
5
|
+
_USERNAME_PATTERN = re.compile( r"^[a-zA-Z0-9_]{2,20}$" )
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _validate( name: str ) -> str | None:
|
|
9
|
+
"""Returns an error message if invalid, None if valid."""
|
|
10
|
+
if len( name ) < 2:
|
|
11
|
+
return "Username must be at least 2 characters."
|
|
12
|
+
if len( name ) > 20:
|
|
13
|
+
return "Username must be at most 20 characters."
|
|
14
|
+
if not _USERNAME_PATTERN.match( name ):
|
|
15
|
+
return "Username can only contain letters, digits, and underscores."
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_username() -> str | None:
|
|
20
|
+
if USERNAME_FILE.exists():
|
|
21
|
+
return USERNAME_FILE.read_text().strip()
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
def save_username( name: str ) -> None:
|
|
25
|
+
USERNAME_FILE.write_text( name )
|
|
26
|
+
|
|
27
|
+
def prompt_username( saved: str | None = None ) -> str:
|
|
28
|
+
if saved:
|
|
29
|
+
print( f"Saved username: \033[1m{ saved }\033[0m — press Enter to use it, or type a new one." )
|
|
30
|
+
while True:
|
|
31
|
+
raw = input( "Username: " ).strip()
|
|
32
|
+
if raw == "" and saved:
|
|
33
|
+
return saved
|
|
34
|
+
name = raw or saved or ""
|
|
35
|
+
error = _validate( name )
|
|
36
|
+
if error:
|
|
37
|
+
print( f"\033[31m{ error }\033[0m" )
|
|
38
|
+
continue
|
|
39
|
+
save_username( name )
|
|
40
|
+
return name
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import signal
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
6
|
+
|
|
7
|
+
from tchat import logger
|
|
8
|
+
from tchat.message.message import Message
|
|
9
|
+
from tchat.message.types import MessageType
|
|
10
|
+
from tchat.client.connection import Connection
|
|
11
|
+
from tchat.exceptions import MessageFramingError, InvalidMessageError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TypingTracker:
|
|
15
|
+
EXPIRY = 2.0
|
|
16
|
+
|
|
17
|
+
def __init__( self ) -> None:
|
|
18
|
+
self._typing: dict[ str, threading.Timer ] = {}
|
|
19
|
+
self._lock = threading.Lock()
|
|
20
|
+
|
|
21
|
+
def set_typing( self, username: str, state: str ) -> None:
|
|
22
|
+
with self._lock:
|
|
23
|
+
if username in self._typing:
|
|
24
|
+
self._typing[ username ].cancel()
|
|
25
|
+
if state == "start":
|
|
26
|
+
t = threading.Timer( self.EXPIRY, self._expire, args=[ username ] )
|
|
27
|
+
t.daemon = True
|
|
28
|
+
t.start()
|
|
29
|
+
self._typing[ username ] = t
|
|
30
|
+
else:
|
|
31
|
+
self._typing.pop( username, None )
|
|
32
|
+
|
|
33
|
+
def _expire( self, username: str ) -> None:
|
|
34
|
+
with self._lock:
|
|
35
|
+
self._typing.pop( username, None )
|
|
36
|
+
|
|
37
|
+
def get_typing_users( self ) -> list[ str ]:
|
|
38
|
+
with self._lock:
|
|
39
|
+
return list( self._typing.keys() )
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ReceiveLoop:
|
|
43
|
+
def __init__( self, connection: Connection ) -> None:
|
|
44
|
+
self._connection = connection
|
|
45
|
+
self._connection_lost = False
|
|
46
|
+
self.typing_tracker = TypingTracker()
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def connection_lost( self ) -> bool:
|
|
50
|
+
return self._connection_lost
|
|
51
|
+
|
|
52
|
+
def start( self ) -> None:
|
|
53
|
+
thread = threading.Thread( target=self._loop, daemon=True )
|
|
54
|
+
thread.start()
|
|
55
|
+
|
|
56
|
+
def _loop( self ) -> None:
|
|
57
|
+
while True:
|
|
58
|
+
try:
|
|
59
|
+
raw = self._connection.receive()
|
|
60
|
+
msg = Message.from_json( raw )
|
|
61
|
+
if msg.type == MessageType.TYPING:
|
|
62
|
+
self.typing_tracker.set_typing( msg.sender, msg.content )
|
|
63
|
+
else:
|
|
64
|
+
logger.client.message( msg )
|
|
65
|
+
except ( MessageFramingError, InvalidMessageError ):
|
|
66
|
+
break
|
|
67
|
+
logger.client.warning( "\n[ Connection closed ]" )
|
|
68
|
+
self._connection_lost = True
|
|
69
|
+
os.kill( os.getpid(), signal.SIGINT )
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from tchat import logger
|
|
4
|
+
from tchat.client.updater import check_and_update
|
|
5
|
+
from tchat.config import config as _config
|
|
6
|
+
from tchat.version import VERSION
|
|
7
|
+
from tchat.message.message import Message
|
|
8
|
+
from tchat.message.types import MessageType
|
|
9
|
+
from tchat.client.connection import Connection
|
|
10
|
+
from tchat.client.identity import load_username, prompt_username
|
|
11
|
+
from tchat.client.receiver import ReceiveLoop
|
|
12
|
+
from tchat.client.sender import InputLoop
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run( host: str | None = None ) -> None:
|
|
16
|
+
check_and_update()
|
|
17
|
+
logger.client.banner()
|
|
18
|
+
username = prompt_username( load_username() )
|
|
19
|
+
while True:
|
|
20
|
+
conn = Connection( host=host )
|
|
21
|
+
try:
|
|
22
|
+
conn.connect()
|
|
23
|
+
except OSError:
|
|
24
|
+
logger.client.info( "Cannot connect. Retrying in 5s..." )
|
|
25
|
+
time.sleep( 5 )
|
|
26
|
+
continue
|
|
27
|
+
version_msg = Message.from_json( conn.receive() )
|
|
28
|
+
if version_msg.content != VERSION:
|
|
29
|
+
logger.client.info( f"Version mismatch — serveur: { version_msg.content }, client: { VERSION }. Telecharge la derniere version." )
|
|
30
|
+
conn.close()
|
|
31
|
+
return
|
|
32
|
+
conn.send( Message.make( MessageType.JOIN, username, "" ) )
|
|
33
|
+
receiver = ReceiveLoop( conn )
|
|
34
|
+
receiver.start()
|
|
35
|
+
should_reconnect = InputLoop( conn, username ).run( receiver )
|
|
36
|
+
conn.close()
|
|
37
|
+
if should_reconnect:
|
|
38
|
+
logger.client.info( "Connection lost. Reconnecting in 5s..." )
|
|
39
|
+
time.sleep( _config.client.reconnect_delay )
|
|
40
|
+
continue
|
|
41
|
+
return
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from prompt_toolkit import PromptSession
|
|
6
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
7
|
+
from prompt_toolkit.history import FileHistory
|
|
8
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
9
|
+
|
|
10
|
+
from tchat.message.message import Message
|
|
11
|
+
from tchat.message.types import MessageType
|
|
12
|
+
from tchat.client.connection import Connection
|
|
13
|
+
from tchat.client.receiver import ReceiveLoop
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
COMMANDS = [ "/whoonline", "/status", "/help", "/quit" ]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CommandCompleter( Completer ):
|
|
20
|
+
def get_completions( self, document, complete_event ):
|
|
21
|
+
text = document.text_before_cursor
|
|
22
|
+
if not text.startswith( "/" ):
|
|
23
|
+
return
|
|
24
|
+
for cmd in COMMANDS:
|
|
25
|
+
if cmd.startswith( text ):
|
|
26
|
+
yield Completion( cmd, start_position=-len( text ) )
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TypingNotifier:
|
|
30
|
+
THROTTLE = 2.5
|
|
31
|
+
IDLE_TIMEOUT = 4.0
|
|
32
|
+
|
|
33
|
+
def __init__( self, connection: Connection, username: str ) -> None:
|
|
34
|
+
self._connection = connection
|
|
35
|
+
self._username = username
|
|
36
|
+
self._last_sent = 0.0
|
|
37
|
+
self._is_typing = False
|
|
38
|
+
self._timer: threading.Timer | None = None
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
|
|
41
|
+
def on_text_changed( self, _buffer ) -> None:
|
|
42
|
+
now = time.monotonic()
|
|
43
|
+
with self._lock:
|
|
44
|
+
if self._timer:
|
|
45
|
+
self._timer.cancel()
|
|
46
|
+
if now - self._last_sent >= self.THROTTLE:
|
|
47
|
+
self._send( "start" )
|
|
48
|
+
self._last_sent = now
|
|
49
|
+
self._is_typing = True
|
|
50
|
+
self._timer = threading.Timer( self.IDLE_TIMEOUT, self._send_stop )
|
|
51
|
+
self._timer.daemon = True
|
|
52
|
+
self._timer.start()
|
|
53
|
+
|
|
54
|
+
def on_message_sent( self ) -> None:
|
|
55
|
+
with self._lock:
|
|
56
|
+
if self._timer:
|
|
57
|
+
self._timer.cancel()
|
|
58
|
+
self._timer = None
|
|
59
|
+
if self._is_typing:
|
|
60
|
+
self._send( "stop" )
|
|
61
|
+
self._is_typing = False
|
|
62
|
+
self._last_sent = 0.0
|
|
63
|
+
|
|
64
|
+
def _send_stop( self ) -> None:
|
|
65
|
+
with self._lock:
|
|
66
|
+
if self._is_typing:
|
|
67
|
+
self._send( "stop" )
|
|
68
|
+
self._is_typing = False
|
|
69
|
+
self._last_sent = 0.0
|
|
70
|
+
|
|
71
|
+
def _send( self, state: str ) -> None:
|
|
72
|
+
try:
|
|
73
|
+
self._connection.send( Message.make( MessageType.TYPING, self._username, state ) )
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class InputLoop:
|
|
79
|
+
def __init__( self, connection: Connection, username: str ) -> None:
|
|
80
|
+
self._connection = connection
|
|
81
|
+
self._username = username
|
|
82
|
+
|
|
83
|
+
def run( self, receiver: ReceiveLoop ) -> bool:
|
|
84
|
+
history_file = Path.home() / ".tchat_history"
|
|
85
|
+
notifier = TypingNotifier( self._connection, self._username )
|
|
86
|
+
|
|
87
|
+
def bottom_toolbar():
|
|
88
|
+
users = receiver.typing_tracker.get_typing_users()
|
|
89
|
+
if not users:
|
|
90
|
+
return ""
|
|
91
|
+
names = ", ".join( users )
|
|
92
|
+
return f" { names } is typing..."
|
|
93
|
+
|
|
94
|
+
session = PromptSession(
|
|
95
|
+
f"[{ self._username }] > ",
|
|
96
|
+
history=FileHistory( str( history_file ) ),
|
|
97
|
+
completer=CommandCompleter(),
|
|
98
|
+
complete_while_typing=True,
|
|
99
|
+
bottom_toolbar=bottom_toolbar,
|
|
100
|
+
refresh_interval=0.5,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def pre_run():
|
|
104
|
+
session.app.current_buffer.on_text_changed += notifier.on_text_changed
|
|
105
|
+
|
|
106
|
+
with patch_stdout( raw=True ):
|
|
107
|
+
while not receiver.connection_lost:
|
|
108
|
+
try:
|
|
109
|
+
text = session.prompt( pre_run=pre_run )
|
|
110
|
+
except ( EOFError, KeyboardInterrupt ):
|
|
111
|
+
if receiver.connection_lost:
|
|
112
|
+
return True
|
|
113
|
+
return False
|
|
114
|
+
notifier.on_message_sent()
|
|
115
|
+
if text == "/quit":
|
|
116
|
+
return False
|
|
117
|
+
elif text.startswith( "/" ):
|
|
118
|
+
self._connection.send( Message.make( MessageType.COMMAND, self._username, text[ 1: ] ) )
|
|
119
|
+
else:
|
|
120
|
+
self._connection.send( Message.make( MessageType.CHAT, self._username, text ) )
|
|
121
|
+
return True
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import urllib.request
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version as pkg_version
|
|
7
|
+
|
|
8
|
+
from tchat.version import VERSION
|
|
9
|
+
|
|
10
|
+
_PYPI_URL = "https://pypi.org/pypi/tchat-client/json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _fetch_remote_version() -> str | None:
|
|
14
|
+
try:
|
|
15
|
+
with urllib.request.urlopen( _PYPI_URL, timeout=3 ) as resp:
|
|
16
|
+
data = json.loads( resp.read() )
|
|
17
|
+
return data[ "info" ][ "version" ]
|
|
18
|
+
except Exception:
|
|
19
|
+
pass
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_and_update() -> None:
|
|
24
|
+
try:
|
|
25
|
+
pkg_version( "tchat-client" )
|
|
26
|
+
except PackageNotFoundError:
|
|
27
|
+
return # running from source (dev), skip
|
|
28
|
+
|
|
29
|
+
remote = _fetch_remote_version()
|
|
30
|
+
if remote is None or remote == VERSION.lstrip( "v" ):
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
print( f"Mise à jour disponible ({ VERSION } → v{ remote }). Installation..." )
|
|
34
|
+
subprocess.run(
|
|
35
|
+
[ sys.executable, "-m", "pip", "install", "--upgrade", "--quiet", "tchat-client" ],
|
|
36
|
+
check=True,
|
|
37
|
+
)
|
|
38
|
+
print( "Mise à jour terminée. Redémarrage..." )
|
|
39
|
+
os.execv( sys.executable, [ sys.executable ] + sys.argv )
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .config import config
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import tomllib
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from tchat.exceptions import ConfigError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_CONFIG_DIR = Path.home() / ".config" / "tchat"
|
|
10
|
+
_CONFIG_PATH = _CONFIG_DIR / "config.toml"
|
|
11
|
+
_TEMPLATE_PATH = Path( __file__ ).parent / "config.toml"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ServerConfig:
|
|
16
|
+
ip: str
|
|
17
|
+
port: int
|
|
18
|
+
waiting_list_size: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ClientConfig:
|
|
23
|
+
ip: str
|
|
24
|
+
port: int
|
|
25
|
+
reconnect_delay: int
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class LoggerConfig:
|
|
29
|
+
typewriter: bool
|
|
30
|
+
typewriter_delay: float
|
|
31
|
+
timestamp_format: str
|
|
32
|
+
log_to_file: bool
|
|
33
|
+
log_file: str
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ChatConfig:
|
|
37
|
+
max_message_length: int
|
|
38
|
+
history_size: int
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ColorsConfig:
|
|
42
|
+
join: str
|
|
43
|
+
leave: str
|
|
44
|
+
server: str
|
|
45
|
+
timestamp: str
|
|
46
|
+
error: str
|
|
47
|
+
info: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Config:
|
|
52
|
+
server: ServerConfig
|
|
53
|
+
client: ClientConfig
|
|
54
|
+
logger: LoggerConfig
|
|
55
|
+
chat: ChatConfig
|
|
56
|
+
colors: ColorsConfig
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _ensure_config() -> None:
|
|
60
|
+
if not _CONFIG_PATH.exists():
|
|
61
|
+
_CONFIG_DIR.mkdir( parents=True, exist_ok=True )
|
|
62
|
+
shutil.copy( _TEMPLATE_PATH, _CONFIG_PATH )
|
|
63
|
+
print( f"Config created at { _CONFIG_PATH }" )
|
|
64
|
+
print( f"--> Edit it to set your server IP ( client.ip )." )
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load_config() -> Config:
|
|
68
|
+
_ensure_config()
|
|
69
|
+
try:
|
|
70
|
+
with open( _CONFIG_PATH, "rb" ) as f:
|
|
71
|
+
data = tomllib.load( f )
|
|
72
|
+
except OSError as e:
|
|
73
|
+
raise ConfigError( f"Cannot read config: { e }" ) from e
|
|
74
|
+
return Config(
|
|
75
|
+
server=ServerConfig( **data[ "server" ] ),
|
|
76
|
+
client=ClientConfig( **data[ "client" ] ),
|
|
77
|
+
logger=LoggerConfig( **data[ "logger" ] ),
|
|
78
|
+
chat=ChatConfig( **data[ "chat" ] ),
|
|
79
|
+
colors=ColorsConfig( **data[ "colors" ] ),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
config = _load_config()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[server]
|
|
2
|
+
ip = "0.0.0.0"
|
|
3
|
+
port = 9999
|
|
4
|
+
waiting_list_size = 1
|
|
5
|
+
|
|
6
|
+
[client]
|
|
7
|
+
ip = "100.81.188.25" # e.g. 132.12.122.1
|
|
8
|
+
port = 9999
|
|
9
|
+
reconnect_delay = 5
|
|
10
|
+
|
|
11
|
+
[logger]
|
|
12
|
+
typewriter = false
|
|
13
|
+
typewriter_delay = 0.02
|
|
14
|
+
timestamp_format = "%H:%M"
|
|
15
|
+
log_to_file = true
|
|
16
|
+
log_file = "~/.local/share/tchat/server.log"
|
|
17
|
+
|
|
18
|
+
[chat]
|
|
19
|
+
max_message_length = 500
|
|
20
|
+
history_size = 50
|
|
21
|
+
|
|
22
|
+
[colors]
|
|
23
|
+
join = "green"
|
|
24
|
+
leave = "red"
|
|
25
|
+
server = "blue"
|
|
26
|
+
timestamp = "white"
|
|
27
|
+
error = "red"
|
|
28
|
+
info = "yellow"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class ChatError( Exception ):
|
|
2
|
+
"""Base class for all chat application errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MessageFramingError( ChatError ):
|
|
6
|
+
"""Raised when a framed message cannot be read ( connection closed mid-frame )."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InvalidMessageError( ChatError ):
|
|
10
|
+
"""Raised when a message cannot be parsed ( invalid JSON or unknown type )."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UnknowUserError( ChatError ):
|
|
14
|
+
"""Raised when an address is not registered in the server state."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CommandError( ChatError ):
|
|
18
|
+
"""Raised when a command is unknow or malformed."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConfigError( ChatError ):
|
|
22
|
+
"""Raised when configuration is missing or invalid."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class JoinError( ChatError ):
|
|
26
|
+
"""Raised when a JOIN request is invalid ( empty or taken username )."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import client, server
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from .colors import Colors
|
|
5
|
+
from . import typewriter
|
|
6
|
+
from tchat.config import config as _config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_lock = threading.Lock()
|
|
10
|
+
_user_colors: dict[ str, Colors ] = {}
|
|
11
|
+
_available_colors = [ Colors.BLUE, Colors.GREEN, Colors.YELLOW, Colors.RED, Colors.WHITE ]
|
|
12
|
+
|
|
13
|
+
def get_user_color( user: str ) -> Colors:
|
|
14
|
+
if user not in _user_colors:
|
|
15
|
+
_user_colors[ user ] = _available_colors[ len( _user_colors ) % len( _available_colors ) ]
|
|
16
|
+
return _user_colors[ user ]
|
|
17
|
+
|
|
18
|
+
def log( msg: str, server_mode: bool = False ) -> None:
|
|
19
|
+
with _lock:
|
|
20
|
+
if not server_mode and _config.logger.typewriter:
|
|
21
|
+
typewriter.write( msg, _config.logger.typewriter_delay )
|
|
22
|
+
else:
|
|
23
|
+
print( msg )
|
|
24
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from . import base
|
|
2
|
+
from .colors import Colors
|
|
3
|
+
from tchat.message.message import Message, MessageType
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _tag( label: str, color: str ) -> str:
|
|
7
|
+
return f"{ Colors.WHITE.value }>{ Colors.RESET.value } { color }[{ label:^5}]{ Colors.RESET.value }"
|
|
8
|
+
|
|
9
|
+
def _emit( msg: str ) -> None:
|
|
10
|
+
base.log( msg, server_mode=False )
|
|
11
|
+
|
|
12
|
+
def info( msg: str ) -> None:
|
|
13
|
+
_emit( f"{ _tag('SYS', Colors.WHITE.value) } { msg }" )
|
|
14
|
+
|
|
15
|
+
def warning( msg: str ) -> None:
|
|
16
|
+
_emit( f"{ _tag('WARN', Colors.YELLOW.value) } { msg }" )
|
|
17
|
+
|
|
18
|
+
def error( msg: str ) -> None:
|
|
19
|
+
_emit( f"{ _tag('ERR', Colors.RED.value) } { msg }" )
|
|
20
|
+
|
|
21
|
+
def message( msg: Message ) -> None:
|
|
22
|
+
if msg.type == MessageType.JOIN:
|
|
23
|
+
_emit( f"{ _tag('JOIN', Colors.GREEN.value) } { msg.sender } has entered the chat" )
|
|
24
|
+
elif msg.type == MessageType.LEAVE:
|
|
25
|
+
_emit( f"{ _tag('LEAVE', Colors.YELLOW.value) } { msg.sender } has left the chat" )
|
|
26
|
+
elif msg.type == MessageType.CHAT:
|
|
27
|
+
color = base.get_user_color( msg.sender ).value
|
|
28
|
+
_emit( f"{ _tag('MSG', Colors.WHITE.value) } { color }{ Colors.BOLD.value }{ msg.sender }{ Colors.RESET.value }: { msg.content }" )
|
|
29
|
+
elif msg.type == MessageType.COMMAND:
|
|
30
|
+
_emit( f"{ _tag('CMD', Colors.BLUE.value) } { msg.content }" )
|
|
31
|
+
|
|
32
|
+
def banner() -> None:
|
|
33
|
+
chat = r"""
|
|
34
|
+
_____ _ _ _____ _ _____ _____ _ _ _ _____ _____ _ _ ___ _____
|
|
35
|
+
|_ _| | | || ___| | | |_ _| _ | \ | ( ) ___| / __ \| | | | / _ \_ _|
|
|
36
|
+
| | | |_| || |__ | | | | | | | | \| |/\ `--. | / \/| |_| |/ /_\ \| |
|
|
37
|
+
| | | _ || __| | | | | | | | | . ` | `--. \ | | | _ || _ || |
|
|
38
|
+
| | | | | || |___ | |_____| |_\ \_/ / |\ | /\__/ / | \__/\| | | || | | || |
|
|
39
|
+
\_/ \_| |_/\____/ \_____/\___/ \___/\_| \_/ \____/ \____/\_| |_/\_| |_/\_/
|
|
40
|
+
|
|
41
|
+
"""
|
|
42
|
+
print( f"{ Colors.BLUE.value }{ chat }{ Colors.RESET.value }" )
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from . import base
|
|
6
|
+
from tchat.message.message import Message, MessageType
|
|
7
|
+
from tchat.config import config as _config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _prefix( level: str ) -> str:
|
|
11
|
+
timestamp = datetime.now().strftime( "%b %d %H:%M:%S" )
|
|
12
|
+
return f"{ timestamp } tchat[{ os.getpid() }]: { level:^7}"
|
|
13
|
+
|
|
14
|
+
def _emit( msg: str ) -> None:
|
|
15
|
+
base.log( msg, server_mode=True )
|
|
16
|
+
_log_to_file( msg )
|
|
17
|
+
|
|
18
|
+
def info( msg: str ) -> None:
|
|
19
|
+
_emit( f"{ _prefix('INFO') } { msg }" )
|
|
20
|
+
|
|
21
|
+
def warning( msg: str ) -> None:
|
|
22
|
+
_emit( f"{ _prefix('WARN') } { msg }" )
|
|
23
|
+
|
|
24
|
+
def error( msg: str ) -> None:
|
|
25
|
+
_emit( f"{ _prefix('ERROR') } { msg }" )
|
|
26
|
+
|
|
27
|
+
def message( msg: Message ) -> None:
|
|
28
|
+
if msg.type == MessageType.JOIN:
|
|
29
|
+
_emit( f"{ _prefix('JOIN') } { msg.sender } joined" )
|
|
30
|
+
elif msg.type == MessageType.LEAVE:
|
|
31
|
+
_emit( f"{ _prefix('LEAVE') } { msg.sender } left" )
|
|
32
|
+
elif msg.type == MessageType.CHAT:
|
|
33
|
+
_emit( f"{ _prefix('CHAT') } { msg.sender }: { msg.content }" )
|
|
34
|
+
elif msg.type == MessageType.COMMAND:
|
|
35
|
+
_emit( f"{ _prefix('CMD') } { msg.content }" )
|
|
36
|
+
|
|
37
|
+
def connected( address: tuple ) -> None:
|
|
38
|
+
_emit( f"{ _prefix('CONN') } { address }" )
|
|
39
|
+
|
|
40
|
+
def disconnected( address: tuple ) -> None:
|
|
41
|
+
_emit( f"{ _prefix('DISC') } { address }" )
|
|
42
|
+
|
|
43
|
+
def _log_to_file( msg: str ) -> None:
|
|
44
|
+
if _config.logger.log_to_file:
|
|
45
|
+
log_path = Path( _config.logger.log_file ).expanduser()
|
|
46
|
+
log_path.parent.mkdir( parents=True, exist_ok=True )
|
|
47
|
+
with open( log_path, "a" ) as f:
|
|
48
|
+
f.write( msg + "\n" )
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import struct
|
|
2
|
+
import socket
|
|
3
|
+
|
|
4
|
+
from tchat.exceptions import MessageFramingError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def send_framed( sock: socket.socket, payload: str ) -> None:
|
|
8
|
+
"""
|
|
9
|
+
Send a framed message through a socket.
|
|
10
|
+
"""
|
|
11
|
+
data = payload.encode( "utf-8" )
|
|
12
|
+
sock.sendall( struct.pack( ">I", len( data ) ) + data )
|
|
13
|
+
|
|
14
|
+
def receive_framed( sock: socket.socket ) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Receive a framed message from a socket.
|
|
17
|
+
|
|
18
|
+
The message format is:
|
|
19
|
+
[ 4 bytes length ][ payload ]
|
|
20
|
+
|
|
21
|
+
The first 4 bytes indicate the size of the payload (big-endian).
|
|
22
|
+
Then the exact number of bytes is read and decoded as UTF-8.
|
|
23
|
+
"""
|
|
24
|
+
header = _receive_exact( sock, 4 )
|
|
25
|
+
length = struct.unpack( ">I", header )[ 0 ]
|
|
26
|
+
body = _receive_exact( sock, length )
|
|
27
|
+
return body.decode( "utf-8" )
|
|
28
|
+
|
|
29
|
+
def _receive_exact( sock: socket.socket, n: int ) -> bytes:
|
|
30
|
+
"""
|
|
31
|
+
This function keeps reading from the socket until n bytes are received.
|
|
32
|
+
If the connection is closed or an error occurs before that, an 'MessageFramingError'
|
|
33
|
+
is raised. Else the received data of length n is returned.
|
|
34
|
+
"""
|
|
35
|
+
buf = b""
|
|
36
|
+
while len( buf ) < n:
|
|
37
|
+
try:
|
|
38
|
+
chunk = sock.recv( n - len( buf ) )
|
|
39
|
+
except OSError:
|
|
40
|
+
raise MessageFramingError( "Socket error or connection closed" )
|
|
41
|
+
if not chunk:
|
|
42
|
+
raise MessageFramingError( "Connection closed mid-frame" )
|
|
43
|
+
buf += chunk
|
|
44
|
+
return buf
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from dataclasses import dataclass, asdict
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from .types import MessageType
|
|
6
|
+
from tchat.exceptions import InvalidMessageError
|
|
7
|
+
from tchat.config import config as _config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Message:
|
|
12
|
+
type: MessageType
|
|
13
|
+
sender: str
|
|
14
|
+
content: str
|
|
15
|
+
timestamp: str
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def make( cls, type: MessageType, sender: str, content: str ) -> "Message":
|
|
19
|
+
return cls(
|
|
20
|
+
type=type,
|
|
21
|
+
sender=sender,
|
|
22
|
+
content=content,
|
|
23
|
+
timestamp=datetime.now().strftime( _config.logger.timestamp_format )
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def to_json( self ) -> str:
|
|
27
|
+
self_dict = asdict( self )
|
|
28
|
+
self_dict[ "type" ] = self.type.value
|
|
29
|
+
return json.dumps( self_dict )
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_json( cls, data: str ) -> "Message":
|
|
33
|
+
try:
|
|
34
|
+
self_dict = json.loads( data )
|
|
35
|
+
self_dict[ "type" ] = MessageType( self_dict[ "type" ] )
|
|
36
|
+
return cls( **self_dict )
|
|
37
|
+
except ( json.JSONDecodeError, KeyError, ValueError ) as e:
|
|
38
|
+
raise InvalidMessageError( f"Cannot parse message: { e }" ) from e
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "v0.12.0"
|