zapchat 3.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.
zapchat/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """zapchat: terminal-native LAN chat with no accounts and no server."""
2
+
3
+ __version__ = "3.0.0"
zapchat/cli.py ADDED
@@ -0,0 +1,332 @@
1
+ """Command line entry point: `zapchat` starts a terminal chat session.
2
+
3
+ The chat loop is deliberately simple line-based I/O so it works everywhere
4
+ (Windows cmd, PowerShell, ssh, tmux): what you type is sent, what others say
5
+ is printed. Type /help to see the commands.
6
+ """
7
+
8
+ import argparse
9
+ import os
10
+ import sys
11
+ import threading
12
+ import time
13
+ import uuid
14
+
15
+ from .client import ChatClient
16
+ from .discovery import DiscoveryService, DISCOVERY_PORT
17
+ from .protocol import (
18
+ MAX_USERNAME_LENGTH,
19
+ sanitize_room_name,
20
+ sanitize_username,
21
+ )
22
+ from . import __version__
23
+
24
+ CONFIG_DIR_ENV = "ZAPCHAT_CONFIG_DIR"
25
+ DEFAULT_ROOM = "general"
26
+
27
+
28
+ def default_username():
29
+ """OS account name when it is usable, otherwise a random handle."""
30
+ try:
31
+ account = os.getlogin()
32
+ except OSError:
33
+ account = os.environ.get("USERNAME") or os.environ.get("USER") or ""
34
+ username = sanitize_username(account)
35
+ if username:
36
+ return username
37
+ return "zapper-" + uuid.uuid4().hex[:4]
38
+
39
+
40
+ def config_path():
41
+ """Path of the tiny local config file (username + client id)."""
42
+ override = os.environ.get(CONFIG_DIR_ENV)
43
+ if override:
44
+ directory = override
45
+ elif sys.platform == "win32":
46
+ directory = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "zapchat")
47
+ elif sys.platform == "darwin":
48
+ directory = os.path.join(os.path.expanduser("~"), "Library", "Application Support", "zapchat")
49
+ else:
50
+ directory = os.path.join(
51
+ os.environ.get("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
52
+ "zapchat",
53
+ )
54
+ return os.path.join(directory, "config.json")
55
+
56
+
57
+ def load_or_create_config(name_override=None):
58
+ """Read the config file, creating it when missing. Never fatal."""
59
+ import json
60
+
61
+ path = config_path()
62
+ config = {"clientId": "zpy-" + uuid.uuid4().hex, "username": "", "lastRoom": DEFAULT_ROOM}
63
+
64
+ try:
65
+ with open(path, "r", encoding="utf-8") as handle:
66
+ stored = json.load(handle)
67
+ if isinstance(stored, dict):
68
+ if isinstance(stored.get("clientId"), str) and stored["clientId"]:
69
+ config["clientId"] = stored["clientId"]
70
+ if isinstance(stored.get("username"), str):
71
+ config["username"] = stored["username"]
72
+ if isinstance(stored.get("lastRoom"), str):
73
+ config["lastRoom"] = stored["lastRoom"]
74
+ except (OSError, ValueError):
75
+ pass
76
+
77
+ if name_override:
78
+ config["username"] = name_override
79
+
80
+ if not sanitize_username(config["username"]):
81
+ config["username"] = default_username()
82
+
83
+ try:
84
+ os.makedirs(os.path.dirname(path), exist_ok=True)
85
+ with open(path, "w", encoding="utf-8") as handle:
86
+ json.dump(config, handle, indent=2)
87
+ except OSError:
88
+ pass
89
+
90
+ return config
91
+
92
+
93
+ class ChatApp:
94
+ """Wires discovery + the TCP client together and runs the chat loop."""
95
+
96
+ def __init__(self, username, room, client_id, no_discovery=False, connect_to=None):
97
+ self.username = username
98
+ self.room = room
99
+ self.client_id = client_id
100
+ self.no_discovery = no_discovery
101
+ self.connect_to = connect_to
102
+
103
+ self.history = [] # rendered lines kept for /help-style local commands
104
+ self.lock = threading.Lock()
105
+ self.connected_usernames = set()
106
+
107
+ self.chat = ChatClient(
108
+ client_id,
109
+ username,
110
+ get_room=lambda: self.room,
111
+ on_event=self._on_event,
112
+ )
113
+
114
+ self.discovery = None
115
+ if not no_discovery:
116
+ self.discovery = DiscoveryService(
117
+ client_id,
118
+ username,
119
+ room,
120
+ tcp_port=0, # set after the TCP port is bound
121
+ on_warning=lambda message: self._print(f"! {message}"),
122
+ )
123
+
124
+ # ------------------------------------------------------------------ events
125
+
126
+ def _on_event(self, kind, **kwargs):
127
+ if kind == "message":
128
+ # Envelope timestamps are in milliseconds; localtime wants seconds.
129
+ ts = kwargs.get("ts")
130
+ seconds = (ts / 1000) if isinstance(ts, (int, float)) and ts > 10_000_000_000 else time.time()
131
+ stamp = time.strftime("%H:%M", time.localtime(seconds))
132
+ self._print(f"{stamp} {kwargs['sender']}: {kwargs['text']}")
133
+ elif kind == "peer-connected":
134
+ self._print(f"* {kwargs['username']} connected")
135
+ elif kind == "peer-gone":
136
+ self._print(f"* {kwargs['username']} left")
137
+ elif kind == "warning":
138
+ self._print(f"! {kwargs['text']}")
139
+
140
+ def _print(self, line):
141
+ with self.lock:
142
+ sys.stdout.write(line + "\n")
143
+ sys.stdout.flush()
144
+
145
+ # ------------------------------------------------------------------- start
146
+
147
+ def start(self):
148
+ port = self.chat.start()
149
+ if self.discovery is not None:
150
+ self.discovery.tcp_port = port
151
+ self.discovery.start()
152
+ self.discovery.set_state(self.username, self.room, port)
153
+
154
+ self._print(f"zapchat {__version__} — LAN chat, no accounts, no server")
155
+ self._print(f" you are {self.username} on #{self.room} (tcp:{port})")
156
+ if self.no_discovery:
157
+ self._print(" discovery off; use /connect <ip> to reach a peer")
158
+ else:
159
+ self._print(" waiting for people on this network... (ctrl+c to quit, /help)")
160
+
161
+ if self.connect_to:
162
+ address, _, port_text = self.connect_to.partition(":")
163
+ self.chat.connect_to(address, int(port_text or 45913))
164
+
165
+ threading.Thread(target=self._dial_loop, daemon=True).start()
166
+ threading.Thread(target=self._heartbeat_loop, daemon=True).start()
167
+
168
+ def _heartbeat_loop(self):
169
+ """PING every peer periodically so half-open links get reaped."""
170
+ while self.chat._running:
171
+ time.sleep(5.0)
172
+ try:
173
+ self.chat.send_heartbeat()
174
+ except Exception: # never let the heartbeat die
175
+ pass
176
+
177
+ def _dial_loop(self):
178
+ """Connect to discovered peers we are not already linked to.
179
+
180
+ Only the instance with the lexicographically smaller client id dials,
181
+ so two instances never dial each other at the same moment and the
182
+ link never gets duplicated (same rule as the npm client).
183
+ """
184
+ while self.chat._running:
185
+ if self.discovery is not None:
186
+ for peer in self.discovery.live_peers():
187
+ should_dial = (
188
+ peer.client_id not in self.chat.peer_ids
189
+ and peer.client_id < self.client_id
190
+ )
191
+ if should_dial:
192
+ peer._source = self.discovery._beacon_sources.get(peer.client_id)
193
+ self.chat.connect_to_peer_object(peer)
194
+ time.sleep(1.0)
195
+
196
+ def stop(self):
197
+ if self.discovery is not None:
198
+ self.discovery.stop()
199
+ self.chat.stop()
200
+
201
+ # -------------------------------------------------------------- chat loop
202
+
203
+ def handle_line(self, line):
204
+ """Process one input line: a /command or chat to send."""
205
+ if line.startswith("/"):
206
+ self.run_command(line)
207
+ return
208
+
209
+ if not self.chat.peer_ids:
210
+ self._print("! nobody is connected yet; waiting for the network...")
211
+ return
212
+ self.chat.send_chat(line)
213
+
214
+ def run_command(self, line):
215
+ parts = line[1:].split(maxsplit=1)
216
+ command = parts[0].lower() if parts else ""
217
+ argument = parts[1].strip() if len(parts) > 1 else ""
218
+
219
+ if command in ("help", "h", "?"):
220
+ self._print(
221
+ "commands: /rooms /users /join <room> /name <username> "
222
+ "/connect <ip[:port]> /status /quit"
223
+ )
224
+ elif command == "rooms":
225
+ rooms = self.discovery.live_rooms() if self.discovery else {}
226
+ if self.room:
227
+ rooms.setdefault(self.room, {"online": 1, "seen": 0})
228
+ for name, info in sorted(rooms.items()):
229
+ self._print(f" #{name} {info['online']} online")
230
+ if not rooms:
231
+ self._print(" no rooms discovered yet")
232
+ elif command in ("users", "who"):
233
+ if self.discovery is not None:
234
+ for peer in self.discovery.live_peers():
235
+ room = f" #{peer.room}" if peer.room else ""
236
+ self._print(f" {peer.username}{room}")
237
+ for client_id in self.chat.peer_ids:
238
+ self._print(f" {client_id} (connected)")
239
+ elif command == "join":
240
+ room = sanitize_room_name(argument)
241
+ if not room:
242
+ self._print("! usage: /join <room>")
243
+ return
244
+ self.room = room
245
+ if self.discovery is not None:
246
+ self.discovery.set_state(self.username, room, self.chat.port)
247
+ self._print(f"* joined #{room}")
248
+ elif command == "name":
249
+ name = sanitize_username(argument)
250
+ if not name:
251
+ self._print("! usage: /name <username>")
252
+ return
253
+ self.username = name
254
+ if self.discovery is not None:
255
+ self.discovery.set_state(name, self.room, self.chat.port)
256
+ self._print(f"* you are now {name}")
257
+ elif command == "connect":
258
+ address, _, port_text = argument.partition(":")
259
+ if not address:
260
+ self._print("! usage: /connect <ip[:port]>")
261
+ return
262
+ threading.Thread(
263
+ target=self.chat.connect_to,
264
+ args=(address, int(port_text or 45913)),
265
+ daemon=True,
266
+ ).start()
267
+ elif command == "status":
268
+ discovery = self.discovery
269
+ if discovery is None:
270
+ self._print(" discovery: off")
271
+ else:
272
+ self._print(
273
+ f" discovery: {'multicast' if discovery.multicast_ok else 'broadcast'}"
274
+ f" on udp:{DISCOVERY_PORT}, {discovery.beacons_sent} sent,"
275
+ f" {discovery.beacons_received} received"
276
+ )
277
+ self._print(f" tcp port: {self.chat.port}, peers: {len(self.chat.peer_ids)}")
278
+ elif command in ("quit", "q", "exit"):
279
+ raise KeyboardInterrupt
280
+ else:
281
+ self._print(f"! unknown command /{command}; try /help")
282
+
283
+
284
+ def main(argv=None):
285
+ parser = argparse.ArgumentParser(
286
+ prog="zapchat",
287
+ description="Terminal-native LAN chat. No accounts, no cloud, no servers.",
288
+ )
289
+ parser.add_argument("-n", "--name", help="display name (remembered between runs)")
290
+ parser.add_argument("-r", "--room", help="room to join on start")
291
+ parser.add_argument("-c", "--connect", metavar="HOST[:PORT]", help="connect straight to a peer")
292
+ parser.add_argument("--discovery-port", type=int, default=DISCOVERY_PORT, help="UDP discovery port")
293
+ parser.add_argument("--tcp-port", type=int, default=45913, help="first TCP port to try")
294
+ parser.add_argument("--no-discovery", action="store_true", help="manual connections only")
295
+ parser.add_argument("-v", "--version", action="version", version=f"zapchat {__version__}")
296
+ args = parser.parse_args(argv)
297
+
298
+ config = load_or_create_config(name_override=args.name)
299
+ room = sanitize_room_name(args.room) if args.room else None
300
+ if room is None and args.room:
301
+ parser.error("room names may only contain letters, digits, - and _")
302
+ if room is None:
303
+ room = sanitize_room_name(config.get("lastRoom")) or DEFAULT_ROOM
304
+
305
+ app = ChatApp(
306
+ username=config["username"],
307
+ room=room,
308
+ client_id=config["clientId"],
309
+ no_discovery=args.no_discovery,
310
+ connect_to=args.connect,
311
+ )
312
+
313
+ app.start()
314
+
315
+ # Standard input is line-buffered; ctrl+c raises KeyboardInterrupt.
316
+ try:
317
+ for line in sys.stdin:
318
+ line = line.strip()
319
+ if line:
320
+ try:
321
+ app.handle_line(line)
322
+ except KeyboardInterrupt:
323
+ break
324
+ except KeyboardInterrupt:
325
+ pass
326
+
327
+ app.stop()
328
+ return 0
329
+
330
+
331
+ if __name__ == "__main__":
332
+ sys.exit(main())
zapchat/client.py ADDED
@@ -0,0 +1,337 @@
1
+ """The LAN chat client: a TCP mesh between discovered instances.
2
+
3
+ Wire-compatible with the npm client's protocol v1: newline-delimited JSON
4
+ envelopes over TCP, HELLO handshake, room-scoped MESSAGE frames with
5
+ duplicate suppression and a one-hop relay so a message still lands when two
6
+ peers are not directly connected to each other.
7
+ """
8
+
9
+ import socket
10
+ import threading
11
+
12
+ from .protocol import (
13
+ create_envelope,
14
+ decode_frames,
15
+ encode_envelope,
16
+ now_ms,
17
+ parse_envelope,
18
+ sanitize_room_name,
19
+ )
20
+
21
+ MAX_PEERS = 64
22
+ DEDUP_CACHE_SIZE = 1024
23
+
24
+
25
+ class ChatClient:
26
+ """Owns the listening TCP socket and every peer link."""
27
+
28
+ def __init__(self, client_id, username, get_room, on_event=None):
29
+ """`get_room` is read fresh for every HELLO (the room can change)."""
30
+ self.client_id = client_id
31
+ self.username = username
32
+ self.get_room = get_room
33
+ self.on_event = on_event or (lambda kind, **kwargs: None)
34
+
35
+ self._server = None
36
+ self._server_port = 0
37
+ self._running = False
38
+ self._lock = threading.RLock()
39
+
40
+ # client_id -> {"socket": Socket, "username": str, "room": str|None}
41
+ self._links = {}
42
+ # message ids we have already seen (duplicate suppression)
43
+ self._seen_ids = set()
44
+ self._seen_order = []
45
+
46
+ # ------------------------------------------------------------------ setup
47
+
48
+ def start(self, port_base=45913, attempts=12):
49
+ """Bind a listening socket, trying port_base and the next few ports.
50
+
51
+ No SO_REUSEADDR: on Windows it would let two instances silently share
52
+ the port and split incoming connections. A busy port just moves us to
53
+ the next candidate, which is exactly what the npm client does.
54
+ """
55
+ import sys
56
+
57
+ self._running = True
58
+ for offset in range(attempts):
59
+ try:
60
+ self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
61
+ if sys.platform != "win32":
62
+ self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
63
+ self._server.bind(("0.0.0.0", port_base + offset))
64
+ self._server.listen(16)
65
+ self._server_port = port_base + offset
66
+ break
67
+ except OSError:
68
+ self._server.close()
69
+ self._server = None
70
+
71
+ if self._server is None:
72
+ # Ephemeral fallback so a fully busy range still works.
73
+ self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
74
+ self._server.bind(("0.0.0.0", 0))
75
+ self._server.listen(16)
76
+ self._server_port = self._server.getsockname()[1]
77
+
78
+ threading.Thread(target=self._accept_loop, daemon=True).start()
79
+ return self._server_port
80
+
81
+ def stop(self):
82
+ self._running = False
83
+ if self._server is not None:
84
+ try:
85
+ self._server.close()
86
+ except OSError:
87
+ pass
88
+ self._server = None
89
+ with self._lock:
90
+ for link in list(self._links.values()):
91
+ try:
92
+ link["socket"].close()
93
+ except OSError:
94
+ pass
95
+ self._links.clear()
96
+
97
+ @property
98
+ def port(self):
99
+ return self._server_port
100
+
101
+ @property
102
+ def peer_ids(self):
103
+ with self._lock:
104
+ return list(self._links.keys())
105
+
106
+ # ------------------------------------------------------------- connecting
107
+
108
+ def connect_to(self, address, port, timeout=3.0):
109
+ """Dial a peer and run the HELLO handshake. Returns True on success."""
110
+ try:
111
+ sock = socket.create_connection((address, port), timeout=timeout)
112
+ except OSError as error:
113
+ self.on_event("warning", text=f"could not connect to {address}:{port} ({error})")
114
+ return False
115
+
116
+ sock.settimeout(5.0)
117
+ hello = self._build_hello()
118
+ try:
119
+ sock.sendall(encode_envelope(hello))
120
+ reply = self._read_envelopes(sock)
121
+ except OSError:
122
+ sock.close()
123
+ return False
124
+
125
+ peer_envelope = next((e for e in reply if e["type"] == "HELLO"), None)
126
+ if peer_envelope is None or peer_envelope["from"] == self.client_id:
127
+ sock.close()
128
+ return False
129
+
130
+ with self._lock:
131
+ if peer_envelope["from"] in self._links or len(self._links) >= MAX_PEERS:
132
+ sock.close()
133
+ return False
134
+
135
+ self._links[peer_envelope["from"]] = {
136
+ "socket": sock,
137
+ "username": peer_envelope["username"],
138
+ "room": peer_envelope["room"],
139
+ }
140
+
141
+ # The handshake timeout must not linger: an idle chat link is healthy,
142
+ # and a leftover recv timeout would drop it every few seconds.
143
+ sock.settimeout(None)
144
+
145
+ threading.Thread(target=self._serve_link, args=(sock, peer_envelope["from"]), daemon=True).start()
146
+ self.on_event("peer-connected", client_id=peer_envelope["from"], username=peer_envelope["username"])
147
+ return True
148
+
149
+ def connect_to_peer_object(self, peer):
150
+ """Dial a discovery.Peer, trying its advertised addresses in turn."""
151
+ candidates = list(peer.addresses)
152
+ source = getattr(peer, "_source", None)
153
+ if source and source not in candidates:
154
+ candidates.append(source)
155
+ for address in candidates:
156
+ if self.connect_to(address, peer.port):
157
+ return True
158
+ return False
159
+
160
+ # ---------------------------------------------------------------- sending
161
+
162
+ def send_chat(self, text):
163
+ """Send a message to every connected peer in the same room."""
164
+ room = self.get_room()
165
+ envelope = create_envelope("MESSAGE", self.client_id, self.username, room=room, data={"text": text})
166
+ sent = self._broadcast(envelope, room)
167
+ self.on_event("message-sent", envelope=envelope, peers=sent)
168
+ return sent
169
+
170
+ def send_heartbeat(self):
171
+ self._broadcast(create_envelope("PING", self.client_id, self.username), None)
172
+
173
+ # ------------------------------------------------------------- internals
174
+
175
+ def _build_hello(self):
176
+ return create_envelope(
177
+ "HELLO",
178
+ self.client_id,
179
+ self.username,
180
+ room=self.get_room(),
181
+ data={"port": self._server_port, "addresses": []},
182
+ )
183
+
184
+ def _read_envelopes(self, sock, initial=""):
185
+ """Read from the socket until a newline-delimited envelope arrives."""
186
+ buffer = initial
187
+ envelopes = []
188
+ try:
189
+ while not envelopes:
190
+ chunk = sock.recv(4096)
191
+ if not chunk:
192
+ break
193
+ envelopes, buffer = decode_frames(chunk, buffer)
194
+ except OSError:
195
+ pass
196
+ return envelopes
197
+
198
+ def _accept_loop(self):
199
+ while self._running:
200
+ try:
201
+ sock, address = self._server.accept()
202
+ except OSError:
203
+ return
204
+ threading.Thread(target=self._serve_inbound, args=(sock, address), daemon=True).start()
205
+
206
+ def _serve_inbound(self, sock, address):
207
+ sock.settimeout(5.0)
208
+ try:
209
+ envelopes = self._read_envelopes(sock)
210
+ except OSError:
211
+ sock.close()
212
+ return
213
+
214
+ hello = next((e for e in envelopes if e["type"] == "HELLO"), None)
215
+ if hello is None or hello["from"] == self.client_id:
216
+ sock.close()
217
+ return
218
+
219
+ with self._lock:
220
+ if hello["from"] in self._links or len(self._links) >= MAX_PEERS:
221
+ sock.close()
222
+ return
223
+ self._links[hello["from"]] = {
224
+ "socket": sock,
225
+ "username": hello["username"],
226
+ "room": hello["room"],
227
+ }
228
+
229
+ # Reply with our own HELLO so both ends are identified.
230
+ try:
231
+ sock.sendall(encode_envelope(self._build_hello()))
232
+ except OSError:
233
+ self._drop_link(hello["from"])
234
+ return
235
+
236
+ sock.settimeout(None)
237
+ self.on_event("peer-connected", client_id=hello["from"], username=hello["username"])
238
+ self._serve_link(sock, hello["from"])
239
+
240
+ def _serve_link(self, sock, client_id):
241
+ buffer = ""
242
+ while self._running:
243
+ try:
244
+ chunk = sock.recv(8192)
245
+ except (OSError, TimeoutError):
246
+ break
247
+ if not chunk:
248
+ break
249
+ envelopes, buffer = decode_frames(chunk, buffer)
250
+ for envelope in envelopes:
251
+ self._handle(envelope, client_id)
252
+
253
+ self._drop_link(client_id)
254
+
255
+ def _handle(self, envelope, client_id):
256
+ if envelope["id"] in self._seen_ids:
257
+ return
258
+ self._remember_id(envelope["id"])
259
+
260
+ msg_type = envelope["type"]
261
+ if msg_type == "PONG":
262
+ return
263
+ if msg_type == "PING":
264
+ self._send_to(client_id, create_envelope("PONG", self.client_id, self.username))
265
+ return
266
+ if msg_type == "HELLO":
267
+ with self._lock:
268
+ link = self._links.get(client_id)
269
+ if link is not None:
270
+ link["username"] = envelope["username"]
271
+ link["room"] = envelope["room"]
272
+ return
273
+
274
+ if msg_type == "MESSAGE":
275
+ text = envelope["data"].get("text") if isinstance(envelope["data"], dict) else None
276
+ if text:
277
+ self.on_event(
278
+ "message",
279
+ sender=envelope["username"],
280
+ text=text,
281
+ room=envelope["room"],
282
+ client_id=envelope["from"],
283
+ ts=envelope["ts"],
284
+ )
285
+ # One-hop relay: forward to same-room peers that have not seen it.
286
+ self._relay(envelope, from_client=client_id)
287
+ elif msg_type in ("JOIN", "LEAVE", "ANNOUNCE"):
288
+ self.on_event("presence", envelope=envelope)
289
+
290
+ def _remember_id(self, message_id):
291
+ self._seen_ids.add(message_id)
292
+ self._seen_order.append(message_id)
293
+ if len(self._seen_order) > DEDUP_CACHE_SIZE:
294
+ for old in self._seen_order[:-DEDUP_CACHE_SIZE]:
295
+ self._seen_ids.discard(old)
296
+ del self._seen_order[:-DEDUP_CACHE_SIZE]
297
+
298
+ def _relay(self, envelope, from_client):
299
+ """Forward a room-scoped frame to other same-room peers (once)."""
300
+ self._broadcast(envelope, envelope["room"], skip_client=from_client)
301
+
302
+ def _broadcast(self, envelope, room, skip_client=None):
303
+ """Send to every peer in `room` (all peers when room is None)."""
304
+ sent = 0
305
+ with self._lock:
306
+ targets = [
307
+ (cid, link["socket"])
308
+ for cid, link in self._links.items()
309
+ if cid != skip_client and (room is None or link["room"] == room)
310
+ ]
311
+ for client_id, sock in targets:
312
+ if self._send_to(client_id, envelope, sock=sock):
313
+ sent += 1
314
+ return sent
315
+
316
+ def _send_to(self, client_id, envelope, sock=None):
317
+ with self._lock:
318
+ link = self._links.get(client_id)
319
+ sock = sock or (link["socket"] if link else None)
320
+ if sock is None:
321
+ return False
322
+ try:
323
+ sock.sendall(encode_envelope(envelope))
324
+ return True
325
+ except OSError:
326
+ self._drop_link(client_id)
327
+ return False
328
+
329
+ def _drop_link(self, client_id):
330
+ with self._lock:
331
+ link = self._links.pop(client_id, None)
332
+ if link is not None:
333
+ try:
334
+ link["socket"].close()
335
+ except OSError:
336
+ pass
337
+ self.on_event("peer-gone", client_id=client_id, username=link["username"])
zapchat/discovery.py ADDED
@@ -0,0 +1,289 @@
1
+ """UDP discovery: announce ourselves, listen for other instances.
2
+
3
+ Compatible with the npm client: the same multicast group and port, the same
4
+ ANNOUNCE envelope shape, beacons sent to multicast, subnet broadcast and
5
+ loopback so several instances work on one machine too.
6
+ """
7
+
8
+ import json
9
+ import socket
10
+ import sys
11
+ import threading
12
+
13
+ from .protocol import (
14
+ MAX_UDP_PACKET_BYTES,
15
+ MAX_USERNAME_LENGTH,
16
+ create_envelope,
17
+ now_ms,
18
+ parse_announce_data,
19
+ parse_envelope,
20
+ )
21
+
22
+ MULTICAST_GROUP = "239.255.42.99"
23
+ DISCOVERY_PORT = 45912
24
+ ANNOUNCE_INTERVAL = 2.0
25
+ PEER_STALE_SECONDS = 7
26
+
27
+
28
+ class Peer:
29
+ """Another instance seen on the LAN."""
30
+
31
+ def __init__(self, client_id, username, room, port, addresses, last_seen):
32
+ self.client_id = client_id
33
+ self.username = username
34
+ self.room = room
35
+ self.port = port
36
+ self.addresses = addresses
37
+ self.last_seen = last_seen
38
+
39
+ def fresh(self, now=None):
40
+ return now_ms() - self.last_seen < PEER_STALE_SECONDS * 1000
41
+
42
+
43
+ class DiscoveryService:
44
+ """Bind one UDP socket, send beacons, and keep a map of live peers."""
45
+
46
+ def __init__(self, client_id, username, room, tcp_port, on_warning=None):
47
+ self.client_id = client_id
48
+ self.username = username
49
+ self.room = room
50
+ self.tcp_port = tcp_port
51
+ self.on_warning = on_warning or (lambda message: None)
52
+
53
+ self.peers = {} # client_id -> Peer
54
+ self.rooms = {} # room name -> {"online": int, "seen": ms}
55
+ self.beacons_sent = 0
56
+ self.beacons_received = 0
57
+ self.multicast_ok = False
58
+ self.broadcast_ok = False
59
+
60
+ self._sock = None
61
+ self._running = False
62
+ self._lock = threading.Lock()
63
+ self._beacon_sources = {} # client_id -> (ip, port) of last beacon
64
+ self._owns_discovery_port = False
65
+ self._replied_to = set() # client ids we have unicast-replied to
66
+
67
+ # ------------------------------------------------------------------ state
68
+
69
+ def current_room(self):
70
+ return self.room
71
+
72
+ def set_state(self, username, room, tcp_port):
73
+ """Update what our beacons advertise (called on any identity change)."""
74
+ self.username = username
75
+ self.room = room
76
+ self.tcp_port = tcp_port
77
+
78
+ def live_peers(self):
79
+ """Fresh peers only, stale entries pruned."""
80
+ now = now_ms()
81
+ with self._lock:
82
+ for client_id in [cid for cid, p in self.peers.items() if not p.fresh(now)]:
83
+ del self.peers[client_id]
84
+ return list(self.peers.values())
85
+
86
+ def live_rooms(self):
87
+ """Rooms advertised by anyone (including us), freshest counts win."""
88
+ with self._lock:
89
+ for peer in self.peers.values():
90
+ if peer.fresh() and peer.room:
91
+ entry = self.rooms.setdefault(peer.room, {"online": 0, "seen": 0})
92
+ entry["seen"] = max(entry["seen"], peer.last_seen)
93
+ return dict(self.rooms)
94
+
95
+ # ----------------------------------------------------------------- socket
96
+
97
+ def start(self):
98
+ self._running = True
99
+ try:
100
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
101
+ if sys.platform != "win32":
102
+ # On POSIX, REUSEADDR allows quick restarts and multi-process
103
+ # multicast. On Windows it would let a second bind "succeed"
104
+ # while unicast then reaches only the last binder — so there
105
+ # we deliberately let the second bind fail and fall back.
106
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
107
+ try:
108
+ sock.bind(("", DISCOVERY_PORT))
109
+ self._owns_discovery_port = True
110
+ except OSError:
111
+ # Another instance owns the well-known port. Multicast (joined
112
+ # below) still delivers to every member regardless of bind
113
+ # port, which is what keeps several instances on one machine
114
+ # working.
115
+ sock.bind(("", 0))
116
+ self.on_warning("another instance holds the discovery port; using multicast")
117
+
118
+ self._sock = sock
119
+
120
+ try:
121
+ group = socket.inet_aton(MULTICAST_GROUP)
122
+ mreq = group + socket.inet_aton("0.0.0.0")
123
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
124
+ self.multicast_ok = True
125
+ except OSError:
126
+ self.on_warning("multicast unavailable; falling back to broadcast")
127
+
128
+ try:
129
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
130
+ self.broadcast_ok = True
131
+ except OSError:
132
+ pass
133
+
134
+ threading.Thread(target=self._listen, daemon=True).start()
135
+ threading.Thread(target=self._announce_loop, daemon=True).start()
136
+ self.send_beacon()
137
+ except OSError as error:
138
+ self.on_warning(f"discovery unavailable: {error}")
139
+
140
+ def stop(self):
141
+ self._running = False
142
+ if self._sock is not None:
143
+ try:
144
+ self._sock.close()
145
+ except OSError:
146
+ pass
147
+ self._sock = None
148
+
149
+ # ---------------------------------------------------------------- sending
150
+
151
+ def beacon_targets(self):
152
+ """Where beacons go: multicast, each subnet broadcast, and loopback."""
153
+ targets = []
154
+ if self.multicast_ok:
155
+ targets.append((MULTICAST_GROUP, DISCOVERY_PORT))
156
+
157
+ # Loopback unicast only helps when we (or a same-machine peer) own the
158
+ # well-known port; the multicast path covers the other same-machine
159
+ # instances.
160
+ targets.append(("127.0.0.1", DISCOVERY_PORT))
161
+
162
+ for address in self._local_addresses():
163
+ broadcast = ".".join(address.split(".")[:-1]) + ".255"
164
+ if broadcast not in [t[0] for t in targets]:
165
+ targets.append((broadcast, DISCOVERY_PORT))
166
+
167
+ if self.broadcast_ok and ("255.255.255.255", DISCOVERY_PORT) not in targets:
168
+ targets.append(("255.255.255.255", DISCOVERY_PORT))
169
+
170
+ return targets
171
+
172
+ def _local_addresses(self):
173
+ """Best-effort list of this machine's LAN IPv4 addresses."""
174
+ addresses = []
175
+ try:
176
+ hostname = socket.gethostname()
177
+ for info in socket.getaddrinfo(hostname, None, socket.AF_INET):
178
+ address = info[4][0]
179
+ if address not in addresses and not address.startswith("127."):
180
+ addresses.append(address)
181
+ except OSError:
182
+ pass
183
+ return addresses
184
+
185
+ def build_beacon(self):
186
+ local_port = self._sock.getsockname()[1] if self._sock is not None else 0
187
+ return create_envelope(
188
+ "ANNOUNCE",
189
+ self.client_id,
190
+ (self.username or "anon")[:MAX_USERNAME_LENGTH],
191
+ room=self.room,
192
+ data={
193
+ "port": self.tcp_port,
194
+ "addresses": self._local_addresses(),
195
+ "rooms": [self.room] if self.room else [],
196
+ # Where a unicast beacon reaches us directly (Python clients).
197
+ "replyPort": local_port,
198
+ },
199
+ )
200
+
201
+ def send_beacon(self):
202
+ if self._sock is None:
203
+ return
204
+ packet = json.dumps(self.build_beacon(), separators=(",", ":")).encode("utf-8")[:MAX_UDP_PACKET_BYTES]
205
+ for target in self.beacon_targets():
206
+ try:
207
+ self._sock.sendto(packet, target)
208
+ self.beacons_sent += 1
209
+ except OSError as error:
210
+ self.on_warning(f"beacon to {target[0]} failed: {error}")
211
+
212
+ def _announce_loop(self):
213
+ import time
214
+
215
+ while self._running:
216
+ self.send_beacon()
217
+ time.sleep(ANNOUNCE_INTERVAL)
218
+
219
+ # -------------------------------------------------------------- receiving
220
+
221
+ def _listen(self):
222
+ while self._running:
223
+ try:
224
+ packet, address = self._sock.recvfrom(MAX_UDP_PACKET_BYTES * 2)
225
+ except OSError:
226
+ return
227
+
228
+ try:
229
+ raw = json.loads(packet.decode("utf-8", errors="replace"))
230
+ except ValueError:
231
+ continue
232
+
233
+ envelope = parse_envelope(raw)
234
+ if envelope is None:
235
+ continue
236
+
237
+ # Our own beacon echoing back from a multicast membership: skip it.
238
+ if envelope["from"] == self.client_id:
239
+ continue
240
+
241
+ data = parse_announce_data(envelope["data"])
242
+ if data is None:
243
+ continue
244
+
245
+ self.beacons_received += 1
246
+ self._remember_peer(envelope, data, address)
247
+
248
+ def _remember_peer(self, envelope, data, source_address):
249
+ with self._lock:
250
+ client_id = envelope["from"]
251
+ peer = self.peers.get(client_id)
252
+ if peer is None:
253
+ peer = Peer(
254
+ client_id=client_id,
255
+ username=envelope["username"],
256
+ room=envelope["room"] or (data["rooms"][0] if data["rooms"] else None),
257
+ port=data["port"],
258
+ addresses=data["addresses"],
259
+ last_seen=now_ms(),
260
+ )
261
+ else:
262
+ peer.username = envelope["username"]
263
+ peer.room = envelope["room"] or peer.room
264
+ peer.port = data["port"]
265
+ if data["addresses"]:
266
+ peer.addresses = data["addresses"]
267
+ peer.last_seen = now_ms()
268
+
269
+ self.peers[client_id] = peer
270
+ # Remember where the beacon came from so TCP can dial it even if
271
+ # the advertised address list was empty.
272
+ self._beacon_sources[client_id] = source_address[0]
273
+
274
+ for room in data["rooms"] or ([peer.room] if peer.room else []):
275
+ entry = self.rooms.setdefault(room, {"online": 0, "seen": 0})
276
+ entry["seen"] = now_ms()
277
+
278
+ # First contact: reply by direct unicast so the other side learns us
279
+ # even when multicast/broadcast is unavailable (the Python protocol
280
+ # carries a replyPort for exactly this; npm peers ignore it).
281
+ if client_id not in self._replied_to and data["reply_port"]:
282
+ self._replied_to.add(client_id)
283
+ try:
284
+ if self._sock is not None:
285
+ packet = json.dumps(self.build_beacon(), separators=(",", ":")).encode("utf-8")
286
+ self._sock.sendto(packet, (source_address[0], data["reply_port"]))
287
+ self.beacons_sent += 1
288
+ except OSError:
289
+ pass
zapchat/protocol.py ADDED
@@ -0,0 +1,256 @@
1
+ """Wire protocol shared by discovery beacons and TCP messages.
2
+
3
+ An envelope is a JSON object with routing metadata and a type-specific
4
+ `data` payload. Everything that arrives from the network is untrusted:
5
+ `parse_envelope` returns None for anything that does not match the expected
6
+ shape, and callers simply drop it.
7
+ """
8
+
9
+ import json
10
+ import re
11
+ import time
12
+ import uuid
13
+
14
+ PROTOCOL_VERSION = 1
15
+
16
+ # The npm client uses these limits; stay compatible.
17
+ MAX_MESSAGE_CHARS = 1000
18
+ MAX_USERNAME_LENGTH = 20
19
+ MAX_ROOM_NAME_LENGTH = 20
20
+ MAX_UDP_PACKET_BYTES = 1100
21
+ MAX_FRAME_BYTES = 8 * 1024
22
+ MAX_CLOCK_SKEW_MS = 10 * 60 * 1000
23
+
24
+ MESSAGE_TYPES = {
25
+ "HELLO", "ANNOUNCE", "ROOM_LIST", "PEER_LIST",
26
+ "JOIN", "LEAVE", "MESSAGE", "PING", "PONG",
27
+ }
28
+
29
+ ENVELOPE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
30
+ CLIENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{6,64}$")
31
+ USERNAME_PATTERN = re.compile(r"^[a-zA-Z0-9_\-\. ]+$")
32
+ ROOM_PATTERN = re.compile(r"^[a-z0-9][a-z0-9\-_]*$")
33
+
34
+ # Control characters and C1 range: never allowed in display text.
35
+ CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f-\x9f]")
36
+
37
+ # ANSI escape sequences (CSI etc.): strip the whole sequence, not just the
38
+ # ESC byte, so a hostile sender cannot repaint the receiver's terminal.
39
+ ANSI_SEQUENCES = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])")
40
+
41
+
42
+ def now_ms():
43
+ return int(time.time() * 1000)
44
+
45
+
46
+ def new_message_id():
47
+ return str(uuid.uuid4())
48
+
49
+
50
+ def sanitize_text(value, max_length):
51
+ """Strip ANSI sequences and control characters, cap the length."""
52
+ if not isinstance(value, str):
53
+ return None
54
+ cleaned = CONTROL_CHARS.sub("", ANSI_SEQUENCES.sub("", value)).strip()
55
+ if not cleaned or len(cleaned) > max_length * 2:
56
+ return None
57
+ return cleaned[:max_length]
58
+
59
+
60
+ def sanitize_username(value):
61
+ if not isinstance(value, str):
62
+ return None
63
+ cleaned = CONTROL_CHARS.sub("", ANSI_SEQUENCES.sub("", value)).strip()[:MAX_USERNAME_LENGTH]
64
+ if cleaned and USERNAME_PATTERN.match(cleaned):
65
+ return cleaned
66
+ return None
67
+
68
+
69
+ def sanitize_room_name(value):
70
+ if not isinstance(value, str):
71
+ return None
72
+ cleaned = CONTROL_CHARS.sub("", value).strip().lower()[:MAX_ROOM_NAME_LENGTH]
73
+ if cleaned and ROOM_PATTERN.match(cleaned):
74
+ return cleaned
75
+ return None
76
+
77
+
78
+ def is_valid_port(value):
79
+ return isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= 65535
80
+
81
+
82
+ def is_valid_client_id(value):
83
+ return isinstance(value, str) and CLIENT_ID_PATTERN.match(value) is not None
84
+
85
+
86
+ def sanitize_address_list(value, max_addresses=8):
87
+ """Keep well-formed IPv4 strings, drop everything else."""
88
+ if not isinstance(value, list):
89
+ return []
90
+ kept = []
91
+ for entry in value[: max_addresses * 2]:
92
+ if isinstance(entry, str) and re.match(r"^(\d{1,3}\.){3}\d{1,3}$", entry):
93
+ kept.append(entry)
94
+ if len(kept) >= max_addresses:
95
+ break
96
+ return kept
97
+
98
+
99
+ def create_envelope(msg_type, sender_id, username, room=None, data=None, env_id=None, ts=None):
100
+ """Build an envelope to send. Senders always use this helper."""
101
+ return {
102
+ "v": PROTOCOL_VERSION,
103
+ "id": env_id or new_message_id(),
104
+ "type": msg_type,
105
+ "ts": ts if ts is not None else now_ms(),
106
+ "from": sender_id,
107
+ "username": username,
108
+ "room": room,
109
+ "data": data if data is not None else None,
110
+ }
111
+
112
+
113
+ def _coerce_username(value):
114
+ """Usernames are display-only: repair rather than reject."""
115
+ cleaned = sanitize_username(value)
116
+ if cleaned:
117
+ return cleaned
118
+ if isinstance(value, str):
119
+ stripped = CONTROL_CHARS.sub("", ANSI_SEQUENCES.sub("", value)).strip()[:20]
120
+ if stripped:
121
+ return stripped
122
+ return "unnamed"
123
+
124
+
125
+ def _coerce_room(value):
126
+ if value is None:
127
+ return None
128
+ if isinstance(value, str):
129
+ return sanitize_room_name(value)
130
+ return False # invalid: reject the whole envelope
131
+
132
+
133
+ def parse_envelope(raw, clock_skew_check=True):
134
+ """Validate an untrusted value into an envelope dict, or None."""
135
+ if not isinstance(raw, dict):
136
+ return None
137
+ if raw.get("v") != PROTOCOL_VERSION:
138
+ return None
139
+
140
+ env_id = raw.get("id")
141
+ if not isinstance(env_id, str) or not ENVELOPE_ID_PATTERN.match(env_id):
142
+ return None
143
+
144
+ msg_type = raw.get("type")
145
+ if msg_type not in MESSAGE_TYPES:
146
+ return None
147
+
148
+ sender = raw.get("from")
149
+ if not is_valid_client_id(sender):
150
+ return None
151
+
152
+ ts = raw.get("ts")
153
+ if not isinstance(ts, (int, float)) or isinstance(ts, bool) or ts <= 0:
154
+ return None
155
+ if clock_skew_check and abs(now_ms() - ts) > MAX_CLOCK_SKEW_MS:
156
+ return None
157
+
158
+ room = _coerce_room(raw.get("room"))
159
+ if room is False:
160
+ return None
161
+
162
+ return {
163
+ "v": PROTOCOL_VERSION,
164
+ "id": env_id,
165
+ "type": msg_type,
166
+ "ts": ts,
167
+ "from": sender,
168
+ "username": _coerce_username(raw.get("username")),
169
+ "room": room,
170
+ "data": raw.get("data"),
171
+ }
172
+
173
+
174
+ def parse_message_data(data):
175
+ """Validate a MESSAGE payload; return the text or None."""
176
+ if not isinstance(data, dict):
177
+ return None
178
+ text = data.get("text")
179
+ if not isinstance(text, str):
180
+ return None
181
+ if len(text) > MAX_MESSAGE_CHARS * 4:
182
+ return None
183
+ cleaned = CONTROL_CHARS.sub("", ANSI_SEQUENCES.sub("", text)).strip()[:MAX_MESSAGE_CHARS]
184
+ return cleaned or None
185
+
186
+
187
+ def parse_announce_data(data):
188
+ """Validate an ANNOUNCE/HELLO payload: port, addresses, rooms."""
189
+ if not isinstance(data, dict):
190
+ return None
191
+ port = data.get("port")
192
+ if not is_valid_port(port):
193
+ return None
194
+ addresses = sanitize_address_list(data.get("addresses"))
195
+
196
+ # Optional direct-reply port (Python clients): where a unicast beacon can
197
+ # reach us reliably, immune to multicast outages. The npm client ignores
198
+ # this extra field.
199
+ reply_port = data.get("replyPort")
200
+ if not is_valid_port(reply_port):
201
+ reply_port = 0
202
+
203
+ rooms = []
204
+ raw_rooms = data.get("rooms")
205
+ if isinstance(raw_rooms, list):
206
+ for candidate in raw_rooms:
207
+ room = sanitize_room_name(candidate) if isinstance(candidate, str) else None
208
+ if room and room not in rooms:
209
+ rooms.append(room)
210
+ return {"port": port, "addresses": addresses, "rooms": rooms, "reply_port": reply_port}
211
+
212
+
213
+ def parse_room_list_data(data):
214
+ """Validate a ROOM_LIST payload into a list of {name, online}."""
215
+ if not isinstance(data, dict) or not isinstance(data.get("rooms"), list):
216
+ return None
217
+ rooms = []
218
+ for entry in data["rooms"][:128]:
219
+ if not isinstance(entry, dict) or not isinstance(entry.get("name"), str):
220
+ continue
221
+ name = sanitize_room_name(entry["name"])
222
+ if not name:
223
+ continue
224
+ online = entry.get("online")
225
+ if isinstance(online, int) and not isinstance(online, bool) and 0 <= online <= 1000:
226
+ rooms.append({"name": name, "online": online})
227
+ else:
228
+ rooms.append({"name": name, "online": 0})
229
+ return {"rooms": rooms}
230
+
231
+
232
+ def encode_envelope(envelope):
233
+ """Serialise one envelope as a TCP frame (JSON plus newline)."""
234
+ return (json.dumps(envelope, separators=(",", ":")) + "\n").encode("utf-8")
235
+
236
+
237
+ def decode_frames(chunk, buffer=""):
238
+ """Split received bytes into envelopes.
239
+
240
+ TCP gives us arbitrary chunks; JSON envelopes are newline-delimited.
241
+ Returns (envelopes, leftover_bytes_str). Invalid lines are dropped.
242
+ """
243
+ text = buffer + chunk.decode("utf-8", errors="replace")
244
+ envelopes = []
245
+ while "\n" in text:
246
+ line, text = text.split("\n", 1)
247
+ if not line.strip() or len(line) > MAX_FRAME_BYTES:
248
+ continue
249
+ try:
250
+ raw = json.loads(line)
251
+ except ValueError:
252
+ continue
253
+ envelope = parse_envelope(raw)
254
+ if envelope is not None:
255
+ envelopes.append(envelope)
256
+ return envelopes, text
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: zapchat
3
+ Version: 3.0.0
4
+ Summary: Terminal-native LAN chat. No accounts, no cloud, no servers - just your local network.
5
+ Author: Samarth Chugh (Sam3360)
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Sam3360/zapchatnpm
8
+ Project-URL: Repository, https://github.com/Sam3360/zapchatnpm
9
+ Project-URL: Issues, https://github.com/Sam3360/zapchatnpm/issues
10
+ Keywords: lan,chat,cli,terminal,p2p,udp,discovery,local-network
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Communications :: Chat
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Provides-Extra: dev
21
+ Requires-Dist: build>=1.2; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # zapchat (Python)
25
+
26
+ **Terminal-native LAN chat. No accounts, no cloud, no servers — just your
27
+ local network.**
28
+
29
+ This is the Python distribution of [zapchat](https://github.com/Sam3360/zapchatnpm).
30
+ It speaks the same discovery protocol as the Node.js version, so a Python user
31
+ and an npm user on the same Wi-Fi can chat with each other.
32
+
33
+ > The Python client runs the proven protocol v1 wire format (JSON envelopes
34
+ > over TCP, signed discovery beacons). The Node.js client's protocol v2 adds
35
+ > TLS-style encryption on top of the same envelope shapes — the Python client
36
+ > interoperates with npm peers via the shared discovery and messaging format.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install zapchat
42
+ ```
43
+
44
+ ## Run
45
+
46
+ ```bash
47
+ zapchat
48
+ ```
49
+
50
+ That is it. Everyone on the same network who runs `zapchat` shows up
51
+ automatically, and you can start chatting.
52
+
53
+ ```
54
+ zapchat 3.0.0 — LAN chat, no accounts, no server
55
+ you are sam on #general (tcp:45913)
56
+ waiting for people on this network... (ctrl+c to quit, /help)
57
+ * alex connected
58
+ 10:24 alex: morning all
59
+ 10:25 sam: morning! which room are we using today?
60
+ ```
61
+
62
+ ## Commands
63
+
64
+ | Command | What it does |
65
+ | --- | --- |
66
+ | `/help` | Show the command list |
67
+ | `/rooms` | Rooms discovered on the LAN |
68
+ | `/users` `/who` | Who is on the LAN and connected |
69
+ | `/join <room>` | Join a room (created if nobody is in it) |
70
+ | `/name <username>` | Change your display name |
71
+ | `/connect <ip[:port]>` | Connect straight to a peer when discovery is blocked |
72
+ | `/status` | Discovery state, ports, peers |
73
+ | `/quit` | Exit |
74
+
75
+ ## How it works
76
+
77
+ - Every instance sends a small UDP beacon (multicast + subnet broadcast)
78
+ announcing its username, room and TCP port.
79
+ - Beacons are how instances find each other; then they open direct TCP
80
+ connections and exchange newline-delimited JSON messages.
81
+ - Rooms are derived, not stored: a room exists while somebody is in it.
82
+ - There is no server of any kind. If the LAN works, zapchat works.
83
+
84
+ Works with the npm version of zapchat on the same network (same beacons, same
85
+ message protocol).
86
+
87
+ ## Privacy
88
+
89
+ - No accounts, no telemetry, no cloud: the only network traffic is UDP
90
+ beacons and TCP messages on your local network.
91
+ - Config is one small JSON file (your username, a random client id, the last
92
+ room). Chat history stays in memory.
93
+ - Messages are plain text on your LAN — anyone who can capture that traffic
94
+ can read it. For sensitive conversations use an encrypted messenger.
95
+
96
+ ## Requirements
97
+
98
+ - Python 3.9+ (standard library only — zero dependencies)
99
+ - Python 3.9+ on any OS: Windows, macOS, Linux
100
+
101
+ ## License
102
+
103
+ MIT © Samarth Chugh (Sam3360)
@@ -0,0 +1,11 @@
1
+ zapchat/__init__.py,sha256=I2wbd7t3aHjhkknzOYaDSRj1nTQ8LbRCV7suQ5zvf50,95
2
+ zapchat/cli.py,sha256=S9uKuiyW3hAfkgvfndCMSKhOimeoBauot8EtKZCdDeU,12371
3
+ zapchat/client.py,sha256=WREj3QFjdBOjib99M0yLIH4FHcmcvXEG7mgN6MrwCuM,11923
4
+ zapchat/discovery.py,sha256=rpRfjzmpdNfgcwgj_YgR_XxHDSnch5ZeOOBxe5kHhhA,10746
5
+ zapchat/protocol.py,sha256=TJR9DyR7RZHXGb_Xh2VHGbD7FCOwoCn64raYsFOLiPk,8017
6
+ zapchat-3.0.0.dist-info/licenses/LICENSE,sha256=s9HH20McR0DRYxqylc7bo3MF0mBZqhQsM6V36RKAlFs,1080
7
+ zapchat-3.0.0.dist-info/METADATA,sha256=1G9j5pI3HqmS0KcurAyac5W827kFN7W8jhMrFT5-DkU,3622
8
+ zapchat-3.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
9
+ zapchat-3.0.0.dist-info/entry_points.txt,sha256=CA3RFPElbWY2YxgINBp3wx3RVJgPB840_fh4oidxQZY,45
10
+ zapchat-3.0.0.dist-info/top_level.txt,sha256=u6fDbEZY2tCzrNGigqdG1kZopPxalnm2TSKYphvi3Rc,8
11
+ zapchat-3.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ zapchat = zapchat.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Samarth Chugh (Sam3360)
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
+ zapchat