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.
@@ -0,0 +1,9 @@
1
+ compile_commands.json
2
+ __pycache__
3
+ venv/
4
+ ~/.config/tchat/config.toml
5
+ .gitignore
6
+ .env
7
+ dist/
8
+ *.egg-info/
9
+ ONBOARDING.md
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: tchat-client
3
+ Version: 0.12.0
4
+ Summary: tchat — client
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: prompt-toolkit>=3.0.52
7
+ Requires-Dist: typer>=0.24.1
@@ -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,10 @@
1
+ from enum import Enum
2
+
3
+ class Colors( Enum ):
4
+ BLUE = "\033[34m"
5
+ GREEN = "\033[32m"
6
+ WHITE = "\033[37m"
7
+ YELLOW = "\033[33m"
8
+ RED = "\033[31m"
9
+ RESET = "\033[0m"
10
+ BOLD = "\033[1m"
@@ -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" )
@@ -0,0 +1,10 @@
1
+ import time
2
+ import sys
3
+
4
+
5
+ def write( msg: str, delay: float = 0.1 ) -> None:
6
+ for char in msg:
7
+ sys.stdout.write( char )
8
+ sys.stdout.flush()
9
+ time.sleep( delay )
10
+ print()
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,10 @@
1
+ from enum import Enum
2
+
3
+
4
+ class MessageType( Enum ):
5
+ CHAT = "chat"
6
+ COMMAND = "command"
7
+ JOIN = "join"
8
+ LEAVE = "leave"
9
+ VERSION = "version"
10
+ TYPING = "typing"
@@ -0,0 +1 @@
1
+ VERSION = "v0.12.0"