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 +3 -0
- zapchat/cli.py +332 -0
- zapchat/client.py +337 -0
- zapchat/discovery.py +289 -0
- zapchat/protocol.py +256 -0
- zapchat-3.0.0.dist-info/METADATA +103 -0
- zapchat-3.0.0.dist-info/RECORD +11 -0
- zapchat-3.0.0.dist-info/WHEEL +5 -0
- zapchat-3.0.0.dist-info/entry_points.txt +2 -0
- zapchat-3.0.0.dist-info/licenses/LICENSE +21 -0
- zapchat-3.0.0.dist-info/top_level.txt +1 -0
zapchat/__init__.py
ADDED
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,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
|