lantalk 2.0.0__py3-none-any.whl

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.
lantalk/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """LanTalk — terminal-based LAN chat."""
2
+ __version__ = "1.1.0"
lantalk/cli.py ADDED
@@ -0,0 +1,54 @@
1
+ """
2
+ lantalk.cli
3
+ -----------
4
+ Entry point for the `lantalk` command.
5
+ """
6
+
7
+ import sys
8
+ from lantalk.server import run_server, DEFAULT_PORT as SERVER_PORT
9
+ from lantalk.client import run_client, DEFAULT_PORT as CLIENT_PORT
10
+
11
+ VERSION = "2.0.0"
12
+
13
+ BANNER = r"""
14
+ _ _____ _ _
15
+ | | |_ _| | | |
16
+ | | __ _ _ __ | | __ _| | | __
17
+ | | / _` | '_ \ | |/ _` | | |/ /
18
+ | |___| (_| | | | || | (_| | | <
19
+ |______\__,_|_| |_\_/ \__,_|_|_|\_\
20
+
21
+ LAN chat — instant, private, no internet needed.
22
+ """
23
+
24
+
25
+ def _color(text: str, code: str) -> str:
26
+ return f"\033[{code}m{text}\033[0m"
27
+
28
+
29
+ def main() -> None:
30
+ print(_color(BANNER, "1;35"))
31
+ print(_color(f" v{VERSION} • pure asyncio • zero runtime dependencies\n", "35"))
32
+
33
+ print(" [1] Start a Server")
34
+ print(" [2] Join as Client")
35
+ print()
36
+
37
+ while True:
38
+ choice = input("Choose (1 or 2): ").strip()
39
+ if choice in ("1", "2"):
40
+ break
41
+ print(_color("Please enter 1 or 2.", "31"))
42
+
43
+ print()
44
+
45
+ if choice == "1":
46
+ port_in = input(f"Port (default {SERVER_PORT}): ").strip()
47
+ port = int(port_in) if port_in.isdigit() else SERVER_PORT
48
+ run_server(port=port)
49
+ else:
50
+ run_client(default_port=CLIENT_PORT)
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
lantalk/client.py ADDED
@@ -0,0 +1,269 @@
1
+ """
2
+ lantalk.client
3
+ --------------
4
+ Async LAN chat client. Features:
5
+ - UDP auto-discovery (lists servers on LAN)
6
+ - JSON protocol with validation
7
+ - Password support
8
+ - /pm, /users, /quit commands
9
+ - Polished prompt: [username | lantalk] >
10
+ - Connection + read timeouts
11
+ """
12
+
13
+ import asyncio
14
+ import json
15
+ import socket
16
+ import sys
17
+ from datetime import datetime
18
+ from typing import Optional
19
+
20
+ DEFAULT_PORT = 5050
21
+ DISCOVERY_PORT = 5051
22
+ ENCODING = "utf-8"
23
+ DISCOVERY_TIMEOUT = 3.0
24
+ CONNECT_TIMEOUT = 5.0
25
+ AUTH_TIMEOUT = 10.0
26
+ VERSION = "2.0.0"
27
+
28
+
29
+ # ── Helpers ────────────────────────────────────────────────────────────────── #
30
+
31
+ def _ts() -> str:
32
+ return datetime.now().strftime("%H:%M")
33
+
34
+
35
+ def _color(text: str, code: str) -> str:
36
+ return f"\033[{code}m{text}\033[0m"
37
+
38
+
39
+ def _make_msg(**kwargs) -> str:
40
+ return json.dumps(kwargs) + "\n"
41
+
42
+
43
+ # ── UDP Discovery ──────────────────────────────────────────────────────────── #
44
+
45
+ def discover_servers(timeout: float = DISCOVERY_TIMEOUT) -> list[tuple[str, int]]:
46
+ """Listen for UDP beacons and return list of (ip, port) tuples."""
47
+ found: dict[str, int] = {}
48
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
49
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
50
+ sock.settimeout(timeout)
51
+ try:
52
+ sock.bind(("", DISCOVERY_PORT))
53
+ except OSError:
54
+ return []
55
+
56
+ import time
57
+ deadline = time.time() + timeout
58
+ while time.time() < deadline:
59
+ try:
60
+ data, _ = sock.recvfrom(256)
61
+ text = data.decode()
62
+ if text.startswith("LANTALK_SERVER:"):
63
+ _, ip, port = text.split(":", 2)
64
+ found[ip] = int(port)
65
+ except socket.timeout:
66
+ break
67
+ except (ValueError, UnicodeDecodeError):
68
+ continue
69
+
70
+ return list(found.items())
71
+
72
+
73
+ # ── Client ─────────────────────────────────────────────────────────────────── #
74
+
75
+ class LanTalkClient:
76
+ def __init__(self, host: str, port: int = DEFAULT_PORT,
77
+ username: str = "User", password: str = ""):
78
+ self.host = host
79
+ self.port = port
80
+ self.username = username
81
+ self.password = password
82
+ self._reader: Optional[asyncio.StreamReader] = None
83
+ self._writer: Optional[asyncio.StreamWriter] = None
84
+ self._running = False
85
+
86
+ def _prompt(self) -> str:
87
+ return _color(f"[{self.username} | lantalk] > ", "1;36")
88
+
89
+ async def _send(self, **kwargs):
90
+ if self._writer:
91
+ self._writer.write(_make_msg(**kwargs).encode(ENCODING))
92
+ await self._writer.drain()
93
+
94
+ # ── Receive loop ─────────────────────────────────────────────────────── #
95
+
96
+ async def _receive_loop(self):
97
+ while self._running:
98
+ try:
99
+ line = await self._reader.readline()
100
+ except (asyncio.IncompleteReadError, OSError, ConnectionResetError):
101
+ break
102
+ if not line:
103
+ break
104
+
105
+ try:
106
+ msg = json.loads(line.decode(ENCODING).strip())
107
+ except (json.JSONDecodeError, UnicodeDecodeError):
108
+ continue
109
+
110
+ self._render(msg)
111
+
112
+ if self._running:
113
+ sys.stdout.write(
114
+ "\n" + _color("[Disconnected from server]\n", "31")
115
+ )
116
+ sys.stdout.flush()
117
+ self._running = False
118
+
119
+ def _render(self, msg: dict):
120
+ type_ = msg.get("type")
121
+ ts = msg.get("ts", _ts())
122
+
123
+ sys.stdout.write("\r\033[K") # clear prompt line
124
+
125
+ if type_ == "message":
126
+ user = msg.get("user", "?")
127
+ text = msg.get("text", "")
128
+ line = f"[{ts}] {_color(user, '36')}: {text}"
129
+ elif type_ == "pm":
130
+ from_user = msg.get("from_user", "?")
131
+ text = msg.get("text", "")
132
+ line = _color(f"[{ts}] 🔒 PM from {from_user}: {text}", "33")
133
+ elif type_ == "system":
134
+ line = _color(f"[{ts}] *** {msg.get('text', '')} ***", "32")
135
+ elif type_ in ("auth_ok", "auth_fail"):
136
+ line = _color(f"[{ts}] {msg.get('reason') or msg.get('text', '')}", "33")
137
+ else:
138
+ line = str(msg)
139
+
140
+ sys.stdout.write(line + "\n")
141
+ sys.stdout.write(self._prompt())
142
+ sys.stdout.flush()
143
+
144
+ # ── Send loop ─────────────────────────────────────────────────────────── #
145
+
146
+ async def _send_loop(self):
147
+ loop = asyncio.get_event_loop()
148
+ while self._running:
149
+ try:
150
+ text = await loop.run_in_executor(None, self._blocking_input)
151
+ except (EOFError, KeyboardInterrupt):
152
+ break
153
+
154
+ if not self._running:
155
+ break
156
+ text = text.strip()
157
+ if not text:
158
+ sys.stdout.write(self._prompt())
159
+ sys.stdout.flush()
160
+ continue
161
+
162
+ if text.lower() in ("/quit", "/exit", "/q"):
163
+ break
164
+
165
+ await self._send(type="message", text=text)
166
+
167
+ self._running = False
168
+
169
+ def _blocking_input(self) -> str:
170
+ sys.stdout.write(self._prompt())
171
+ sys.stdout.flush()
172
+ return sys.stdin.readline()
173
+
174
+ # ── Connect & handshake ───────────────────────────────────────────────── #
175
+
176
+ async def _connect(self):
177
+ try:
178
+ self._reader, self._writer = await asyncio.wait_for(
179
+ asyncio.open_connection(self.host, self.port),
180
+ timeout=CONNECT_TIMEOUT,
181
+ )
182
+ except (ConnectionRefusedError, OSError):
183
+ print(_color(f"[Error] Cannot connect to {self.host}:{self.port}", "31"))
184
+ sys.exit(1)
185
+ except asyncio.TimeoutError:
186
+ print(_color("[Error] Connection timed out.", "31"))
187
+ sys.exit(1)
188
+
189
+ # Auth handshake
190
+ line = await asyncio.wait_for(self._reader.readline(), timeout=AUTH_TIMEOUT)
191
+ req = json.loads(line.decode().strip())
192
+ if req.get("type") != "auth_request":
193
+ print(_color("[Error] Unexpected handshake.", "31"))
194
+ sys.exit(1)
195
+
196
+ self._writer.write(
197
+ _make_msg(username=self.username, password=self.password).encode(ENCODING)
198
+ )
199
+ await self._writer.drain()
200
+
201
+ line = await asyncio.wait_for(self._reader.readline(), timeout=AUTH_TIMEOUT)
202
+ result = json.loads(line.decode().strip())
203
+ if result.get("type") == "auth_fail":
204
+ print(_color(f"[Auth failed] {result.get('reason', '')}", "31"))
205
+ sys.exit(1)
206
+
207
+ self._running = True
208
+
209
+ print(_color("=" * 54, "36"))
210
+ print(_color(f" LanTalk Client v{VERSION} — {self.username}", "1;36"))
211
+ print(_color(f" Connected to {self.host}:{self.port}", "36"))
212
+ print(_color(" Commands: /users /pm <user> <msg> /quit", "36"))
213
+ print(_color("=" * 54, "36"))
214
+ sys.stdout.write(self._prompt())
215
+ sys.stdout.flush()
216
+
217
+ await asyncio.gather(
218
+ self._receive_loop(),
219
+ self._send_loop(),
220
+ )
221
+
222
+ try:
223
+ self._writer.close()
224
+ await self._writer.wait_closed()
225
+ except OSError:
226
+ pass
227
+ print(_color("Disconnected.", "31"))
228
+
229
+ def connect(self):
230
+ try:
231
+ asyncio.run(self._connect())
232
+ except KeyboardInterrupt:
233
+ print(_color("\nDisconnected.", "31"))
234
+
235
+
236
+ # ── CLI entry ──────────────────────────────────────────────────────────────── #
237
+
238
+ def run_client(default_port: int = DEFAULT_PORT) -> None:
239
+ print(_color("Scanning for LanTalk servers…", "33"))
240
+ servers = discover_servers()
241
+
242
+ if servers:
243
+ print(_color(f"Found {len(servers)} server(s):\n", "32"))
244
+ for i, (ip, port) in enumerate(servers, 1):
245
+ print(f" [{i}] {ip}:{port}")
246
+ print(" [m] Enter manually\n")
247
+ choice = input("Choose server: ").strip()
248
+ if choice.isdigit() and 1 <= int(choice) <= len(servers):
249
+ host, port = servers[int(choice) - 1]
250
+ else:
251
+ host = input("Server IP: ").strip()
252
+ port_in = input(f"Port (default {default_port}): ").strip()
253
+ port = int(port_in) if port_in.isdigit() else default_port
254
+ else:
255
+ print(_color("No servers found via auto-discovery.\n", "33"))
256
+ host = input("Server IP: ").strip()
257
+ port_in = input(f"Port (default {default_port}): ").strip()
258
+ port = int(port_in) if port_in.isdigit() else default_port
259
+
260
+ if not host:
261
+ print("IP address is required.")
262
+ sys.exit(1)
263
+
264
+ username = input("Display name: ").strip() or "User"
265
+ password = input("Password (leave blank if none): ").strip()
266
+
267
+ LanTalkClient(
268
+ host=host, port=port, username=username, password=password
269
+ ).connect()
lantalk/protocol.py ADDED
@@ -0,0 +1,72 @@
1
+ """
2
+ lantalk.protocol
3
+ ----------------
4
+ Shared message construction, parsing, and validation utilities.
5
+ """
6
+
7
+ import json
8
+ from datetime import datetime
9
+
10
+ ENCODING = "utf-8"
11
+
12
+ # Message type constants
13
+ AUTH_REQUEST = "auth_request"
14
+ AUTH_OK = "auth_ok"
15
+ AUTH_FAIL = "auth_fail"
16
+ MESSAGE = "message"
17
+ PM = "pm"
18
+ SYSTEM = "system"
19
+
20
+ VALID_TYPES = frozenset({
21
+ AUTH_REQUEST, AUTH_OK, AUTH_FAIL, MESSAGE, PM, SYSTEM
22
+ })
23
+
24
+ # Per-field constraints
25
+ MAX_TEXT_LEN = 2000
26
+ MAX_USERNAME_LEN = 24
27
+
28
+
29
+ def ts() -> str:
30
+ return datetime.now().strftime("%H:%M")
31
+
32
+
33
+ def make(type_: str, **kwargs) -> str:
34
+ """Return a JSON-encoded protocol message terminated with newline."""
35
+ return json.dumps({"type": type_, "ts": ts(), **kwargs}) + "\n"
36
+
37
+
38
+ def parse(line: str | bytes) -> dict:
39
+ """Parse a protocol line. Returns {} on error."""
40
+ if isinstance(line, bytes):
41
+ line = line.decode(ENCODING, errors="replace")
42
+ try:
43
+ return json.loads(line.strip())
44
+ except json.JSONDecodeError:
45
+ return {}
46
+
47
+
48
+ def validate(msg: dict) -> tuple[bool, str]:
49
+ """
50
+ Validate a parsed message dict.
51
+ Returns (True, "") on success, (False, reason) on failure.
52
+ """
53
+ if not isinstance(msg, dict):
54
+ return False, "not a dict"
55
+ msg_type = msg.get("type")
56
+ if not isinstance(msg_type, str) or not msg_type:
57
+ return False, "missing type"
58
+ # Text field
59
+ text = msg.get("text")
60
+ if text is not None:
61
+ if not isinstance(text, str):
62
+ return False, "text must be a string"
63
+ if len(text) > MAX_TEXT_LEN:
64
+ return False, f"text too long (max {MAX_TEXT_LEN})"
65
+ # Username field
66
+ username = msg.get("username")
67
+ if username is not None:
68
+ if not isinstance(username, str):
69
+ return False, "username must be a string"
70
+ if len(username) > MAX_USERNAME_LEN:
71
+ return False, f"username too long (max {MAX_USERNAME_LEN})"
72
+ return True, ""
lantalk/server.py ADDED
@@ -0,0 +1,522 @@
1
+ """
2
+ lantalk.server
3
+ --------------
4
+ Async LAN chat server (pure asyncio). Features:
5
+ - UDP broadcast for auto-discovery (pure asyncio, no threads)
6
+ - Optional password authentication (SHA-256)
7
+ - Duplicate username rejection
8
+ - JSON message protocol with full validation
9
+ - Rate limiting (kicks spammers)
10
+ - Connection timeouts via asyncio.wait_for
11
+ - /kick, /ban, /stats server commands
12
+ - Private messages (/pm)
13
+ - /users command
14
+ - Structured logging
15
+ - Persistent bans (bans.json)
16
+ """
17
+
18
+ import asyncio
19
+ import hashlib
20
+ import json
21
+ import logging
22
+ import os
23
+ import socket
24
+ import sys
25
+ from collections import deque
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ HOST = "0.0.0.0"
31
+ DEFAULT_PORT = 5050
32
+ DISCOVERY_PORT = 5051
33
+ BUFFER = 8192
34
+ ENCODING = "utf-8"
35
+ VERSION = "2.0.0"
36
+
37
+ BANS_FILE = Path("bans.json")
38
+ RATE_LIMIT = 10 # max messages per window
39
+ RATE_WINDOW = 5.0 # seconds
40
+ READ_TIMEOUT = 300.0 # idle disconnect (5 min)
41
+ AUTH_TIMEOUT = 15.0 # handshake timeout
42
+
43
+
44
+ # ── Logging ────────────────────────────────────────────────────────────────── #
45
+
46
+ def _setup_logging() -> logging.Logger:
47
+ logger = logging.getLogger("lantalk")
48
+ if not logger.handlers:
49
+ logger.setLevel(logging.DEBUG)
50
+ fmt = logging.Formatter("[%(asctime)s] %(levelname)s %(message)s",
51
+ datefmt="%H:%M:%S")
52
+ ch = logging.StreamHandler(sys.stdout)
53
+ ch.setFormatter(fmt)
54
+ ch.setLevel(logging.INFO)
55
+ fh = logging.FileHandler("lantalk.log", encoding="utf-8")
56
+ fh.setFormatter(fmt)
57
+ fh.setLevel(logging.DEBUG)
58
+ logger.addHandler(ch)
59
+ logger.addHandler(fh)
60
+ return logger
61
+
62
+ log = _setup_logging()
63
+
64
+
65
+ # ── Helpers ────────────────────────────────────────────────────────────────── #
66
+
67
+ def _ts() -> str:
68
+ return datetime.now().strftime("%H:%M")
69
+
70
+
71
+ def _color(text: str, code: str) -> str:
72
+ return f"\033[{code}m{text}\033[0m"
73
+
74
+
75
+ def _hash(password: str) -> str:
76
+ return hashlib.sha256(password.encode()).hexdigest()
77
+
78
+
79
+ def _make_msg(type_: str, **kwargs) -> str:
80
+ return json.dumps({"type": type_, "ts": _ts(), **kwargs}) + "\n"
81
+
82
+
83
+ def _get_lan_ip() -> str:
84
+ try:
85
+ probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
86
+ probe.connect(("8.8.8.8", 80))
87
+ ip = probe.getsockname()[0]
88
+ probe.close()
89
+ return ip
90
+ except OSError:
91
+ return "127.0.0.1"
92
+
93
+
94
+ # ── Message validation ─────────────────────────────────────────────────────── #
95
+
96
+ VALID_TYPES = {"message", "pm", "system", "auth_request", "auth_ok", "auth_fail"}
97
+
98
+ def _validate(msg: dict) -> bool:
99
+ """Return True if msg has the minimum required fields and sensible values."""
100
+ if not isinstance(msg, dict):
101
+ return False
102
+ if "type" not in msg:
103
+ return False
104
+ if not isinstance(msg.get("type"), str):
105
+ return False
106
+ # Text field sanity
107
+ text = msg.get("text")
108
+ if text is not None and not isinstance(text, str):
109
+ return False
110
+ if isinstance(text, str) and len(text) > 2000:
111
+ return False
112
+ return True
113
+
114
+
115
+ # ── Persistent bans ────────────────────────────────────────────────────────── #
116
+
117
+ def _load_bans() -> set:
118
+ if BANS_FILE.exists():
119
+ try:
120
+ return set(json.loads(BANS_FILE.read_text(encoding="utf-8")))
121
+ except Exception:
122
+ pass
123
+ return set()
124
+
125
+
126
+ def _save_bans(banned: set) -> None:
127
+ try:
128
+ BANS_FILE.write_text(json.dumps(sorted(banned), indent=2), encoding="utf-8")
129
+ except Exception as e:
130
+ log.warning("Could not save bans.json: %s", e)
131
+
132
+
133
+ # ── Pure-asyncio UDP discovery beacon ─────────────────────────────────────── #
134
+
135
+ class _DiscoveryProtocol(asyncio.DatagramProtocol):
136
+ """Minimal UDP sink — we send, we don't receive."""
137
+ def error_received(self, exc):
138
+ log.debug("Discovery UDP error: %s", exc)
139
+ def connection_lost(self, exc):
140
+ pass
141
+
142
+
143
+ async def _discovery_beacon(lan_ip: str, port: int, stop_event: asyncio.Event):
144
+ """Broadcast LANTALK_SERVER beacon every 2 s without spawning a thread."""
145
+ message = f"LANTALK_SERVER:{lan_ip}:{port}".encode()
146
+ loop = asyncio.get_running_loop()
147
+ transport = None
148
+ try:
149
+ transport, _ = await loop.create_datagram_endpoint(
150
+ _DiscoveryProtocol,
151
+ local_addr=("0.0.0.0", 0),
152
+ family=socket.AF_INET,
153
+ allow_broadcast=True,
154
+ )
155
+ while not stop_event.is_set():
156
+ try:
157
+ transport.sendto(message, ("<broadcast>", DISCOVERY_PORT))
158
+ except Exception as e:
159
+ log.debug("Beacon send error: %s", e)
160
+ try:
161
+ await asyncio.wait_for(stop_event.wait(), timeout=2)
162
+ except asyncio.TimeoutError:
163
+ pass
164
+ finally:
165
+ if transport:
166
+ transport.close()
167
+
168
+
169
+ # ── Client state ───────────────────────────────────────────────────────────── #
170
+
171
+ class _RateLimiter:
172
+ """Sliding-window rate limiter."""
173
+ def __init__(self, limit: int, window: float):
174
+ self._limit = limit
175
+ self._window = window
176
+ self._timestamps: deque = deque()
177
+
178
+ def is_allowed(self) -> bool:
179
+ import time as _time
180
+ now = _time.monotonic()
181
+ while self._timestamps and now - self._timestamps[0] > self._window:
182
+ self._timestamps.popleft()
183
+ if len(self._timestamps) >= self._limit:
184
+ return False
185
+ self._timestamps.append(_time.monotonic())
186
+ return True
187
+
188
+
189
+ class Client:
190
+ def __init__(self, username: str, reader: asyncio.StreamReader,
191
+ writer: asyncio.StreamWriter):
192
+ self.username = username
193
+ self.reader = reader
194
+ self.writer = writer
195
+ self.addr = writer.get_extra_info("peername", ("?", 0))
196
+ self.joined_at = datetime.now()
197
+ self.rate = _RateLimiter(RATE_LIMIT, RATE_WINDOW)
198
+
199
+ async def send(self, payload: str):
200
+ try:
201
+ self.writer.write(payload.encode(ENCODING))
202
+ await self.writer.drain()
203
+ except (OSError, ConnectionResetError):
204
+ pass
205
+
206
+
207
+ # ── Server ─────────────────────────────────────────────────────────────────── #
208
+
209
+ class LanTalkServer:
210
+ def __init__(self, port: int = DEFAULT_PORT, username: str = "Server",
211
+ password: Optional[str] = None):
212
+ self.port = port
213
+ self.username = username
214
+ self.password_hash: Optional[str] = _hash(password) if password else None
215
+ self.lan_ip = _get_lan_ip()
216
+
217
+ self._clients: dict[str, Client] = {}
218
+ self._banned: set[str] = _load_bans()
219
+ self._lock = asyncio.Lock()
220
+ self._server: Optional[asyncio.AbstractServer] = None
221
+ self._stop_beacon = asyncio.Event()
222
+
223
+ # ── Broadcast / send helpers ──────────────────────────────────────────── #
224
+
225
+ async def _broadcast(self, payload: str, exclude: Optional[str] = None):
226
+ async with self._lock:
227
+ targets = list(self._clients.values())
228
+ for c in targets:
229
+ if c.username != exclude:
230
+ await c.send(payload)
231
+
232
+ async def _send_to(self, username: str, payload: str) -> bool:
233
+ async with self._lock:
234
+ c = self._clients.get(username)
235
+ if c:
236
+ await c.send(payload)
237
+ return True
238
+ return False
239
+
240
+ async def _remove(self, username: str, announce: bool = True):
241
+ async with self._lock:
242
+ c = self._clients.pop(username, None)
243
+ if c:
244
+ try:
245
+ c.writer.close()
246
+ await c.writer.wait_closed()
247
+ except OSError:
248
+ pass
249
+ if announce:
250
+ msg = _make_msg("system", text=f"{username} left the chat.")
251
+ log.info("%s left.", username)
252
+ await self._broadcast(msg)
253
+
254
+ # ── Client handler ────────────────────────────────────────────────────── #
255
+
256
+ async def _handle(self, reader: asyncio.StreamReader,
257
+ writer: asyncio.StreamWriter):
258
+ addr = writer.get_extra_info("peername", ("?", 0))
259
+ ip = addr[0]
260
+
261
+ if ip in self._banned:
262
+ writer.close()
263
+ log.info("Rejected banned IP %s", ip)
264
+ return
265
+
266
+ async def send_raw(payload: str):
267
+ writer.write(payload.encode(ENCODING))
268
+ await writer.drain()
269
+
270
+ # ① Auth handshake
271
+ await send_raw(_make_msg("auth_request",
272
+ needs_password=self.password_hash is not None))
273
+ try:
274
+ line = await asyncio.wait_for(reader.readline(), timeout=AUTH_TIMEOUT)
275
+ except asyncio.TimeoutError:
276
+ writer.close()
277
+ log.debug("Auth timeout from %s", ip)
278
+ return
279
+
280
+ try:
281
+ auth = json.loads(line.decode(ENCODING).strip())
282
+ except (json.JSONDecodeError, UnicodeDecodeError):
283
+ writer.close()
284
+ return
285
+
286
+ username = (auth.get("username") or "").strip()[:24]
287
+ given_pw = auth.get("password") or ""
288
+
289
+ # Username validation
290
+ if not username or not username.replace("_", "").replace("-", "").isalnum():
291
+ await send_raw(_make_msg("auth_fail", reason="Invalid username."))
292
+ writer.close()
293
+ return
294
+
295
+ async with self._lock:
296
+ taken = username in self._clients
297
+ if taken:
298
+ await send_raw(_make_msg("auth_fail", reason="Username already taken."))
299
+ writer.close()
300
+ return
301
+
302
+ if self.password_hash and _hash(given_pw) != self.password_hash:
303
+ await send_raw(_make_msg("auth_fail", reason="Wrong password."))
304
+ log.warning("Wrong password attempt for '%s' from %s", username, ip)
305
+ writer.close()
306
+ return
307
+
308
+ # ② Register client
309
+ client = Client(username, reader, writer)
310
+ async with self._lock:
311
+ self._clients[username] = client
312
+
313
+ await send_raw(_make_msg("auth_ok", username=username,
314
+ server_name=self.username))
315
+ log.info("%s joined from %s", username, ip)
316
+ await self._broadcast(
317
+ _make_msg("system", text=f"{username} joined from {ip}."),
318
+ exclude=username,
319
+ )
320
+
321
+ # ③ Message loop
322
+ try:
323
+ while True:
324
+ try:
325
+ line = await asyncio.wait_for(
326
+ reader.readline(), timeout=READ_TIMEOUT
327
+ )
328
+ except asyncio.TimeoutError:
329
+ await client.send(
330
+ _make_msg("system", text="Idle timeout — disconnecting.")
331
+ )
332
+ break
333
+
334
+ if not line:
335
+ break
336
+
337
+ try:
338
+ msg = json.loads(line.decode(ENCODING).strip())
339
+ except (json.JSONDecodeError, UnicodeDecodeError):
340
+ log.debug("Malformed message from %s — ignored", username)
341
+ continue
342
+
343
+ if not _validate(msg):
344
+ log.debug("Invalid message structure from %s — ignored", username)
345
+ continue
346
+
347
+ if msg.get("type") == "message":
348
+ text = str(msg.get("text", "")).strip()
349
+ if not text:
350
+ continue
351
+
352
+ # Rate limiting
353
+ if not client.rate.is_allowed():
354
+ await client.send(
355
+ _make_msg("system",
356
+ text="⚠ Slow down — you're sending too fast.")
357
+ )
358
+ log.warning("%s hit rate limit", username)
359
+ continue
360
+
361
+ if text.startswith("/"):
362
+ await self._handle_client_command(client, text)
363
+ else:
364
+ log.debug("<%s> %s", username, text)
365
+ await self._broadcast(
366
+ _make_msg("message", user=username, text=text),
367
+ exclude=username,
368
+ )
369
+ finally:
370
+ await self._remove(username)
371
+
372
+ async def _handle_client_command(self, client: Client, text: str):
373
+ parts = text.split(maxsplit=2)
374
+ cmd = parts[0].lower()
375
+
376
+ if cmd == "/users":
377
+ async with self._lock:
378
+ names = list(self._clients.keys())
379
+ await client.send(
380
+ _make_msg("system", text="Online: " + ", ".join(names))
381
+ )
382
+ elif cmd == "/pm" and len(parts) >= 3:
383
+ target, pm_text = parts[1], parts[2]
384
+ payload = _make_msg("pm", from_user=client.username, text=pm_text)
385
+ if not await self._send_to(target, payload):
386
+ await client.send(
387
+ _make_msg("system", text=f"User '{target}' not found.")
388
+ )
389
+ elif cmd in ("/quit", "/exit", "/q"):
390
+ await client.send(_make_msg("system", text="Goodbye!"))
391
+ else:
392
+ await client.send(
393
+ _make_msg("system", text=f"Unknown command: {cmd}")
394
+ )
395
+
396
+ # ── Server console commands ───────────────────────────────────────────── #
397
+
398
+ async def _server_console(self):
399
+ loop = asyncio.get_event_loop()
400
+ while True:
401
+ try:
402
+ text = await loop.run_in_executor(None, input, "")
403
+ except (EOFError, KeyboardInterrupt):
404
+ break
405
+ text = text.strip()
406
+ if not text:
407
+ continue
408
+
409
+ parts = text.split(maxsplit=1)
410
+ cmd = parts[0].lower()
411
+
412
+ if cmd in ("/quit", "/exit", "/q"):
413
+ log.info("Server shutting down.")
414
+ self._stop_beacon.set()
415
+ if self._server:
416
+ self._server.close()
417
+ break
418
+
419
+ elif cmd == "/users":
420
+ async with self._lock:
421
+ names = list(self._clients.keys())
422
+ print(_color("Online: " + (", ".join(names) or "(none)"), "36"))
423
+
424
+ elif cmd == "/stats":
425
+ async with self._lock:
426
+ count = len(self._clients)
427
+ print(_color(
428
+ f"Connected: {count} | Banned IPs: {len(self._banned)}", "36"
429
+ ))
430
+
431
+ elif cmd == "/kick" and len(parts) == 2:
432
+ target = parts[1].strip()
433
+ if target in self._clients:
434
+ await self._send_to(
435
+ target, _make_msg("system", text="You have been kicked.")
436
+ )
437
+ await self._remove(target)
438
+ log.info("Kicked %s", target)
439
+ else:
440
+ print(_color(f"User '{target}' not found.", "31"))
441
+
442
+ elif cmd == "/ban" and len(parts) == 2:
443
+ target = parts[1].strip()
444
+ async with self._lock:
445
+ c = self._clients.get(target)
446
+ if c:
447
+ self._banned.add(c.addr[0])
448
+ _save_bans(self._banned)
449
+ await self._send_to(
450
+ target, _make_msg("system", text="You have been banned.")
451
+ )
452
+ await self._remove(target)
453
+ log.info("Banned %s (%s)", target, c.addr[0])
454
+ else:
455
+ print(_color(f"User '{target}' not found.", "31"))
456
+
457
+ elif cmd == "/unban" and len(parts) == 2:
458
+ ip = parts[1].strip()
459
+ if ip in self._banned:
460
+ self._banned.discard(ip)
461
+ _save_bans(self._banned)
462
+ log.info("Unbanned %s", ip)
463
+ print(_color(f"Unbanned {ip}", "32"))
464
+ else:
465
+ print(_color(f"IP {ip} not in ban list.", "31"))
466
+
467
+ elif cmd == "/bans":
468
+ if self._banned:
469
+ print(_color("Banned IPs: " + ", ".join(sorted(self._banned)), "36"))
470
+ else:
471
+ print(_color("No banned IPs.", "36"))
472
+
473
+ elif cmd == "/help":
474
+ print(_color(
475
+ "/users /stats /kick <user> /ban <user> "
476
+ "/bans /unban <ip> /quit",
477
+ "36"
478
+ ))
479
+
480
+ else:
481
+ # Server chat
482
+ msg = _make_msg("message", user=self.username + " (server)", text=text)
483
+ log.debug("[server] %s", text)
484
+ await self._broadcast(msg)
485
+
486
+ # ── Start ─────────────────────────────────────────────────────────────── #
487
+
488
+ async def _run(self):
489
+ self._server = await asyncio.start_server(
490
+ self._handle, HOST, self.port
491
+ )
492
+
493
+ print(_color("=" * 54, "35"))
494
+ print(_color(f" LanTalk Server v{VERSION} — {self.username}", "1;35"))
495
+ print(_color(f" Listening on {self.lan_ip}:{self.port}", "35"))
496
+ print(_color(f" Discovery beacon active (UDP {DISCOVERY_PORT})", "35"))
497
+ if self.password_hash:
498
+ print(_color(" Password protection: ON", "35"))
499
+ if self._banned:
500
+ print(_color(f" Loaded {len(self._banned)} persistent ban(s)", "35"))
501
+ print(_color(" /help for server commands", "35"))
502
+ print(_color("=" * 54, "35"))
503
+ log.info("Server started on %s:%s", self.lan_ip, self.port)
504
+
505
+ async with self._server:
506
+ await asyncio.gather(
507
+ self._server.serve_forever(),
508
+ self._server_console(),
509
+ _discovery_beacon(self.lan_ip, self.port, self._stop_beacon),
510
+ )
511
+
512
+ def start(self):
513
+ try:
514
+ asyncio.run(self._run())
515
+ except KeyboardInterrupt:
516
+ pass
517
+
518
+
519
+ def run_server(port: int = DEFAULT_PORT) -> None:
520
+ username = input("Server display name: ").strip() or "Server"
521
+ pw = input("Set password (leave blank for none): ").strip() or None
522
+ LanTalkServer(port=port, username=username, password=pw).start()
@@ -0,0 +1,300 @@
1
+ Metadata-Version: 2.4
2
+ Name: lantalk
3
+ Version: 2.0.0
4
+ Summary: Terminal-based LAN chat — async, discoverable, secure
5
+ Author-email: Ezra Destaw <ezradestaw@email.com>
6
+ Project-URL: Homepage, https://github.com/Ezradestaw/lantalk
7
+ Project-URL: Repository, https://github.com/Ezradestaw/lantalk
8
+ Project-URL: Issues, https://github.com/Ezradestaw/lantalk/issues
9
+ Keywords: chat,lan,wifi,terminal,networking,broadcast,asyncio
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Communications :: Chat
22
+ Classifier: Topic :: Internet
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENCE
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8.0; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # LanTalk
32
+
33
+ > Terminal-based LAN chat — pure asyncio, auto-discoverable, zero runtime dependencies.
34
+
35
+ ```
36
+ _ _____ _ _
37
+ | | |_ _| | | |
38
+ | | __ _ _ __ | | __ _| | | __
39
+ | | / _` | '_ \ | |/ _` | | |/ /
40
+ | |___| (_| | | | || | (_| | | <
41
+ |______\__,_|_| |_\_/ \__,_|_|_|\_\
42
+ ```
43
+
44
+ [![PyPI version](https://img.shields.io/pypi/v/lantalk)](https://pypi.org/project/lantalk/)
45
+ [![Python](https://img.shields.io/pypi/pyversions/lantalk)](https://pypi.org/project/lantalk/)
46
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
47
+
48
+ ---
49
+
50
+ ## What is LanTalk?
51
+
52
+ LanTalk lets people on the **same Wi-Fi or LAN** chat instantly — no accounts, no internet, no servers in the cloud. One person runs `lantalk` and starts a server; everyone else runs `lantalk` and joins. That's it.
53
+
54
+ - Works on school networks, home networks, offices, hackathons
55
+ - Pure Python stdlib — nothing to `pip install` beyond the package itself
56
+ - Clients auto-discover the server via UDP broadcast (no IP typing required)
57
+
58
+ ---
59
+
60
+ ## Features
61
+
62
+ | | |
63
+ |---|---|
64
+ | 🔍 **Auto-discovery** | UDP beacon finds servers on your LAN automatically |
65
+ | 🔐 **Password auth** | Optional server password with SHA-256 hashing |
66
+ | 👤 **Username dedup** | Two people can't use the same name simultaneously |
67
+ | 💬 **Private messages** | `/pm <user> <message>` — end-to-end on the wire |
68
+ | 🛡 **Rate limiting** | Spammers get warned then kicked (10 msg / 5 s) |
69
+ | ⏱ **Idle timeouts** | Dead sockets cleaned up after 5 minutes of silence |
70
+ | 🚫 **Persistent bans** | Banned IPs survive server restarts via `bans.json` |
71
+ | 🔇 **Full validation** | Every message checked for type and field constraints |
72
+ | 📝 **Structured logging** | Console + `lantalk.log` file, levels DEBUG/INFO/WARNING |
73
+ | ⚡ **Pure asyncio** | Single event loop — no threads, no race conditions |
74
+
75
+ ---
76
+
77
+ ## Install
78
+
79
+ ```bash
80
+ pip install lantalk
81
+ ```
82
+
83
+ Python 3.10+ required. No third-party dependencies.
84
+
85
+ ### From source
86
+
87
+ ```bash
88
+ git clone https://github.com/yourusername/lantalk
89
+ cd lantalk
90
+ pip install -e ".[dev]"
91
+ ```
92
+
93
+ ---
94
+
95
+ ## Quick start
96
+
97
+ ### 1 — Start a server (one person does this)
98
+
99
+ ```
100
+ $ lantalk
101
+
102
+ [1] Start a Server
103
+ [2] Join as Client
104
+
105
+ Choose (1 or 2): 1
106
+ Port (default 5050):
107
+ Server display name: Alice
108
+ Set password (leave blank for none): secret
109
+
110
+ ══════════════════════════════════════════════════════
111
+ LanTalk Server v2.0.0 — Alice
112
+ Listening on 192.168.1.5:5050
113
+ Discovery beacon active (UDP 5051)
114
+ Password protection: ON
115
+ ══════════════════════════════════════════════════════
116
+ ```
117
+
118
+ ### 2 — Join as a client (everyone else)
119
+
120
+ ```
121
+ $ lantalk
122
+
123
+ [1] Start a Server
124
+ [2] Join as Client
125
+
126
+ Choose (1 or 2): 2
127
+ Scanning for LanTalk servers…
128
+ Found 1 server(s):
129
+
130
+ [1] 192.168.1.5:5050
131
+ [m] Enter manually
132
+
133
+ Choose server: 1
134
+ Display name: Bob
135
+ Password: secret
136
+
137
+ ══════════════════════════════════════════════════════
138
+ LanTalk Client v2.0.0 — Bob
139
+ Connected to 192.168.1.5:5050
140
+ Commands: /users /pm <user> <msg> /quit
141
+ ══════════════════════════════════════════════════════
142
+ [Bob | lantalk] > Hey everyone!
143
+ [14:22] Alice: Welcome Bob!
144
+ [Bob | lantalk] > /pm Alice just between us 👋
145
+ [Bob | lantalk] > /users
146
+ [14:23] *** Online: Alice, Bob, Carol ***
147
+ [Bob | lantalk] >
148
+ ```
149
+
150
+ ---
151
+
152
+ ## Commands
153
+
154
+ ### Client commands
155
+
156
+ | Command | Description |
157
+ |---------|-------------|
158
+ | `/users` | List all connected users |
159
+ | `/pm <user> <message>` | Send a private message |
160
+ | `/quit` | Disconnect and exit |
161
+
162
+ ### Server console commands
163
+
164
+ | Command | Description |
165
+ |---------|-------------|
166
+ | `/users` | List connected users |
167
+ | `/stats` | Show connection count and ban list size |
168
+ | `/kick <user>` | Disconnect a user |
169
+ | `/ban <user>` | Ban a user's IP (persisted to `bans.json`) |
170
+ | `/bans` | Show all banned IPs |
171
+ | `/unban <ip>` | Remove an IP from the ban list |
172
+ | `/help` | Show this list |
173
+ | `/quit` | Shut down the server |
174
+ | *(anything else)* | Broadcast as a server message to all clients |
175
+
176
+ ---
177
+
178
+ ## Architecture
179
+
180
+ ```
181
+ ┌──────────────────────────────────────────────────────────────┐
182
+ │ LAN (e.g. 192.168.1.0/24) │
183
+ │ │
184
+ │ ┌────────────────────────────────────┐ │
185
+ │ │ LanTalkServer (pure asyncio) │ │
186
+ │ │ │◄─── TCP :5050 ───┐ │
187
+ │ │ _discovery_beacon() ─────────────┼──── UDP bcast │ │
188
+ │ │ asyncio coroutine, no threads │ every 2 s │ │
189
+ │ │ │ │ │
190
+ │ │ per-client _RateLimiter │ │ │
191
+ │ │ _validate() on every message │ │ │
192
+ │ │ asyncio.wait_for() timeouts │ │ │
193
+ │ │ bans.json (persistent) │ │ │
194
+ │ │ lantalk.log (structured) │ │ │
195
+ │ └────────────────────────────────────┘ │ │
196
+ │ │ │
197
+ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┴─┐ │
198
+ │ │ Client A │ │ Client B │ │ Client C │ │
199
+ │ │ UDP discover │ │ UDP discover │ │ manual IP entry │ │
200
+ │ │ asyncio │ │ asyncio │ │ asyncio │ │
201
+ │ └──────────────┘ └──────────────┘ └────────────────────┘ │
202
+ └──────────────────────────────────────────────────────────────┘
203
+
204
+ Message flow (post-auth):
205
+ Client ──JSON──► Server ──broadcast JSON──► all other Clients
206
+ └──(PM)──────────► one Client
207
+ ```
208
+
209
+ ### Concurrency model
210
+
211
+ Everything runs in **one asyncio event loop** — no `threading.Thread`, no locks between threads, no shared mutable state across OS threads. The only `asyncio.Lock` is used to guard the `_clients` dict between concurrent coroutines.
212
+
213
+ ### JSON wire protocol
214
+
215
+ Every message is a single UTF-8 JSON line terminated with `\n`.
216
+
217
+ ```jsonc
218
+ // Server → Client: auth challenge
219
+ {"type": "auth_request", "ts": "14:22", "needs_password": true}
220
+
221
+ // Client → Server: credentials
222
+ {"username": "Bob", "password": "secret"}
223
+
224
+ // Server → Client: success / failure
225
+ {"type": "auth_ok", "ts": "…", "username": "Bob", "server_name": "Alice"}
226
+ {"type": "auth_fail", "ts": "…", "reason": "Username already taken."}
227
+
228
+ // Client → Server: chat message
229
+ {"type": "message", "text": "Hello!"}
230
+
231
+ // Server → all clients: broadcast
232
+ {"type": "message", "ts": "14:22", "user": "Bob", "text": "Hello!"}
233
+
234
+ // Server → one client: private message
235
+ {"type": "pm", "ts": "14:22", "from_user": "Alice", "text": "hey"}
236
+
237
+ // Server → clients: system event
238
+ {"type": "system", "ts": "14:22", "text": "Bob joined from 192.168.1.10."}
239
+ ```
240
+
241
+ ---
242
+
243
+ ## Security notes
244
+
245
+ - Passwords are **never stored or sent in plaintext**. The server stores a SHA-256 hash; clients send the raw password only during the auth handshake (upgrade to TLS in v3 is planned).
246
+ - Banned IPs are written to `bans.json` in the server's working directory and reloaded on every start.
247
+ - Rate limiting: each client is capped at **10 messages per 5 seconds**. Exceeding this sends a warning; repeat offences kick the user.
248
+ - Usernames are limited to 24 alphanumeric/underscore/hyphen characters.
249
+ - All incoming messages are schema-validated before processing; malformed JSON is silently dropped.
250
+
251
+ ---
252
+
253
+ ## Logging
254
+
255
+ LanTalk writes structured logs to both the terminal and `lantalk.log`:
256
+
257
+ ```
258
+ [14:22:01] INFO Server started on 192.168.1.5:5050
259
+ [14:22:15] INFO Bob joined from 192.168.1.10
260
+ [14:23:44] WARNING Carol hit rate limit
261
+ [14:25:01] INFO Kicked Dave
262
+ [14:25:03] INFO Banned Dave (192.168.1.22)
263
+ ```
264
+
265
+ Set `LANTALK_LOG_LEVEL=DEBUG` to see per-message traces.
266
+
267
+ ---
268
+
269
+ ## Running tests
270
+
271
+ ```bash
272
+ pip install -e ".[dev]"
273
+ pytest -v
274
+ ```
275
+
276
+ ---
277
+
278
+ ## Changelog
279
+
280
+ ```
281
+ 2.0.0 — pure asyncio (no threads), validation, rate limiting,
282
+ timeouts, persistent bans, structured logging, v2 prompt
283
+ 1.1.0 — asyncio rewrite, UDP discovery, JSON protocol, auth, commands, pytest
284
+ 1.0.0 — initial: threading, basic TCP, plain-text protocol
285
+ ```
286
+
287
+ ---
288
+
289
+ ## Roadmap
290
+
291
+ - [ ] TLS encryption (v3)
292
+ - [ ] File transfer (`/send file.pdf`)
293
+ - [ ] Web UI mode (`http://localhost:5050`)
294
+ - [ ] Plugin system (`@lantalk.plugin`)
295
+
296
+ ---
297
+
298
+ ## License
299
+
300
+ MIT © Ezra
@@ -0,0 +1,11 @@
1
+ lantalk/__init__.py,sha256=eA0Hf6CMeVoA1eMZAcKSE6Rsv2E6MGzYxZDQyA6Ek4E,65
2
+ lantalk/cli.py,sha256=dsVKrcy_9EkFMNzWVw19Bj4xhVhBSNgtMdK5h2LG6MQ,1307
3
+ lantalk/client.py,sha256=Xx74SBCccoZsw-yFP6GLf5qbQjucnasuYdWc6gK7HTs,9689
4
+ lantalk/protocol.py,sha256=mb_MrfRfSzyXK2AQL8Ugp1FKhk02XnmMudeWPV1oG9s,1961
5
+ lantalk/server.py,sha256=Enrv23LPpjGwXi_QnSBeUTErqBO5r1Gi8Rd810XVcP0,19363
6
+ lantalk-2.0.0.dist-info/licenses/LICENCE,sha256=5LD6OPAP1Fr90OIiWhFsVm1iWY4TR5rICgyNebC5n_k,1087
7
+ lantalk-2.0.0.dist-info/METADATA,sha256=Z3DyPbsheed2RTR8bYNU0yLVsteDyVUo52GcaSlcQ7U,11181
8
+ lantalk-2.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ lantalk-2.0.0.dist-info/entry_points.txt,sha256=D7PNHGH90TMA-5XYPKTt2z1MUv6YcjbuLA-i4_r2Hn8,45
10
+ lantalk-2.0.0.dist-info/top_level.txt,sha256=RvuhHjamhH-V0tKW2wCkJtOxfEL3tnjI0-bp9tYnlXI,8
11
+ lantalk-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lantalk = lantalk.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ezra Destaw
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ lantalk