agentirc-cli 0.2.1__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.
Files changed (42) hide show
  1. agentirc/__init__.py +1 -0
  2. agentirc/cli.py +651 -0
  3. agentirc/clients/__init__.py +0 -0
  4. agentirc/clients/claude/__init__.py +0 -0
  5. agentirc/clients/claude/__main__.py +93 -0
  6. agentirc/clients/claude/agent_runner.py +167 -0
  7. agentirc/clients/claude/config.py +162 -0
  8. agentirc/clients/claude/daemon.py +422 -0
  9. agentirc/clients/claude/ipc.py +38 -0
  10. agentirc/clients/claude/irc_transport.py +146 -0
  11. agentirc/clients/claude/message_buffer.py +46 -0
  12. agentirc/clients/claude/skill/SKILL.md +202 -0
  13. agentirc/clients/claude/skill/__init__.py +0 -0
  14. agentirc/clients/claude/skill/irc_client.py +281 -0
  15. agentirc/clients/claude/socket_server.py +106 -0
  16. agentirc/clients/claude/supervisor.py +139 -0
  17. agentirc/clients/claude/webhook.py +59 -0
  18. agentirc/observer.py +228 -0
  19. agentirc/pidfile.py +49 -0
  20. agentirc/protocol/__init__.py +0 -0
  21. agentirc/protocol/commands.py +33 -0
  22. agentirc/protocol/extensions/federation.md +94 -0
  23. agentirc/protocol/extensions/history.md +112 -0
  24. agentirc/protocol/message.py +58 -0
  25. agentirc/protocol/protocol-index.md +9 -0
  26. agentirc/protocol/replies.py +44 -0
  27. agentirc/server/__init__.py +0 -0
  28. agentirc/server/__main__.py +61 -0
  29. agentirc/server/channel.py +56 -0
  30. agentirc/server/client.py +742 -0
  31. agentirc/server/config.py +21 -0
  32. agentirc/server/ircd.py +208 -0
  33. agentirc/server/remote_client.py +42 -0
  34. agentirc/server/server_link.py +537 -0
  35. agentirc/server/skill.py +45 -0
  36. agentirc/server/skills/__init__.py +0 -0
  37. agentirc/server/skills/history.py +152 -0
  38. agentirc_cli-0.2.1.dist-info/METADATA +183 -0
  39. agentirc_cli-0.2.1.dist-info/RECORD +42 -0
  40. agentirc_cli-0.2.1.dist-info/WHEEL +4 -0
  41. agentirc_cli-0.2.1.dist-info/entry_points.txt +2 -0
  42. agentirc_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,537 @@
1
+ # server/server_link.py
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from agentirc.protocol.message import Message
9
+ from agentirc.server.remote_client import RemoteClient
10
+ from agentirc.server.skill import Event, EventType
11
+
12
+ if TYPE_CHECKING:
13
+ from agentirc.server.ircd import IRCd
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class ServerLink:
19
+ """A server-to-server link to a peer IRCd."""
20
+
21
+ def __init__(
22
+ self,
23
+ reader: asyncio.StreamReader,
24
+ writer: asyncio.StreamWriter,
25
+ server: IRCd,
26
+ password: str,
27
+ *,
28
+ initiator: bool = False,
29
+ ):
30
+ self.reader = reader
31
+ self.writer = writer
32
+ self.server = server
33
+ self.password = password
34
+ self.initiator = initiator
35
+ self.peer_name: str | None = None
36
+ self.peer_description: str = ""
37
+ self._authenticated = False
38
+ self._got_pass = False
39
+ self._got_server = False
40
+ self._peer_pass: str | None = None
41
+ self.last_seen_seq: int = 0
42
+
43
+ async def send_raw(self, line: str) -> None:
44
+ try:
45
+ self.writer.write(f"{line}\r\n".encode("utf-8"))
46
+ await self.writer.drain()
47
+ except (ConnectionError, BrokenPipeError, OSError):
48
+ pass
49
+
50
+ async def send(self, message: Message) -> None:
51
+ try:
52
+ self.writer.write(message.format().encode("utf-8"))
53
+ await self.writer.drain()
54
+ except (ConnectionError, BrokenPipeError, OSError):
55
+ pass
56
+
57
+ async def handle(self, initial_msg: str | None = None) -> None:
58
+ """Main S2S connection loop."""
59
+ try:
60
+ if self.initiator:
61
+ await self._send_handshake()
62
+
63
+ buffer = ""
64
+ if initial_msg:
65
+ buffer = initial_msg + "\n"
66
+
67
+ while True:
68
+ if "\n" not in buffer:
69
+ data = await self.reader.read(4096)
70
+ if not data:
71
+ break
72
+ buffer += data.decode("utf-8", errors="replace")
73
+ buffer = buffer.replace("\r\n", "\n").replace("\r", "\n")
74
+
75
+ while "\n" in buffer:
76
+ line, buffer = buffer.split("\n", 1)
77
+ if line.strip():
78
+ msg = Message.parse(line)
79
+ if msg.command:
80
+ await self._dispatch(msg)
81
+ except (ConnectionError, asyncio.IncompleteReadError):
82
+ pass
83
+ finally:
84
+ self.server._remove_link(self)
85
+ self.writer.close()
86
+ try:
87
+ await self.writer.wait_closed()
88
+ except (ConnectionError, BrokenPipeError):
89
+ pass
90
+
91
+ async def _send_handshake(self) -> None:
92
+ await self.send_raw(f"PASS {self.password}")
93
+ await self.send_raw(
94
+ f"SERVER {self.server.config.name} 1 :{self.server.config.name} IRC"
95
+ )
96
+
97
+ async def _dispatch(self, msg: Message) -> None:
98
+ handler = getattr(self, f"_handle_{msg.command.lower()}", None)
99
+ if handler:
100
+ await handler(msg)
101
+
102
+ # --- Handshake handlers ---
103
+
104
+ async def _handle_pass(self, msg: Message) -> None:
105
+ if not msg.params:
106
+ return
107
+ self._peer_pass = msg.params[0]
108
+ self._got_pass = True
109
+ await self._try_complete_handshake()
110
+
111
+ async def _handle_server(self, msg: Message) -> None:
112
+ if not msg.params:
113
+ return
114
+ self.peer_name = msg.params[0]
115
+ if len(msg.params) >= 3:
116
+ self.peer_description = msg.params[2]
117
+ self._got_server = True
118
+ await self._try_complete_handshake()
119
+
120
+ async def _try_complete_handshake(self) -> None:
121
+ if not (self._got_pass and self._got_server):
122
+ return
123
+
124
+ # For inbound links, look up expected password by peer name
125
+ if not self.initiator and self.password is None:
126
+ link_config = None
127
+ for lc in self.server.config.links:
128
+ if lc.name == self.peer_name:
129
+ link_config = lc
130
+ break
131
+ if not link_config:
132
+ logger.warning("No link config for peer %s", self.peer_name)
133
+ await self.send_raw(f"ERROR :No link configured for {self.peer_name}")
134
+ raise ConnectionError(f"No link config for {self.peer_name}")
135
+ self.password = link_config.password
136
+
137
+ if self._peer_pass != self.password:
138
+ logger.warning("Bad password from peer %s", self.peer_name)
139
+ await self.send_raw(f"ERROR :Bad password")
140
+ raise ConnectionError("Bad S2S password")
141
+
142
+ # Check for duplicate server name
143
+ if self.peer_name in self.server.links:
144
+ logger.warning("Duplicate server name %s", self.peer_name)
145
+ await self.send_raw(f"ERROR :Server name {self.peer_name} already linked")
146
+ raise ConnectionError("Duplicate server name")
147
+
148
+ if self.peer_name == self.server.config.name:
149
+ logger.warning("Peer has same name as us: %s", self.peer_name)
150
+ await self.send_raw(f"ERROR :Cannot link to self")
151
+ raise ConnectionError("Cannot link to self")
152
+
153
+ self._authenticated = True
154
+ self.server.links[self.peer_name] = self
155
+
156
+ # Restore last seen seq from previous link sessions
157
+ self.last_seen_seq = self.server._peer_acked_seq.get(self.peer_name, 0)
158
+
159
+ if not self.initiator:
160
+ await self._send_handshake()
161
+
162
+ await self.send_burst()
163
+ await self._send_backfill_request()
164
+
165
+ # --- Burst handlers ---
166
+
167
+ async def send_burst(self) -> None:
168
+ """Send our local state to the peer."""
169
+ # Send all local clients
170
+ for client in self.server.clients.values():
171
+ await self.send_raw(
172
+ f"SNICK {client.nick} {client.user} {client.host} :{client.realname}"
173
+ )
174
+
175
+ # Send channel membership
176
+ for channel in self.server.channels.values():
177
+ local_nicks = [
178
+ m.nick for m in channel.members
179
+ if not isinstance(m, RemoteClient)
180
+ ]
181
+ if local_nicks:
182
+ nicks_str = " ".join(local_nicks)
183
+ await self.send_raw(f"SJOIN {channel.name} {nicks_str}")
184
+
185
+ # Send channel topics
186
+ for channel in self.server.channels.values():
187
+ if channel.topic:
188
+ # Find who set it (use first local member as setter)
189
+ local_members = [
190
+ m for m in channel.members
191
+ if not isinstance(m, RemoteClient)
192
+ ]
193
+ setter = local_members[0].nick if local_members else self.server.config.name
194
+ await self.send_raw(
195
+ f"STOPIC {channel.name} {setter} :{channel.topic}"
196
+ )
197
+
198
+ async def _handle_snick(self, msg: Message) -> None:
199
+ if len(msg.params) < 4:
200
+ return
201
+ nick, user, host = msg.params[0], msg.params[1], msg.params[2]
202
+ realname = msg.params[3]
203
+
204
+ # Validate nick conforms to <peer_name>-<agent> format
205
+ expected_prefix = f"{self.peer_name}-"
206
+ if not nick.startswith(expected_prefix):
207
+ logger.warning(
208
+ "Rejected remote nick %s: must start with %s", nick, expected_prefix
209
+ )
210
+ return
211
+
212
+ if nick in self.server.clients or nick in self.server.remote_clients:
213
+ return # Already known
214
+
215
+ rc = RemoteClient(
216
+ nick=nick,
217
+ user=user,
218
+ host=host,
219
+ realname=realname,
220
+ server_name=self.peer_name,
221
+ link=self,
222
+ )
223
+ self.server.remote_clients[nick] = rc
224
+
225
+ async def _handle_sjoin(self, msg: Message) -> None:
226
+ if len(msg.params) < 2:
227
+ return
228
+
229
+ channel_name = msg.params[0]
230
+ nicks = msg.params[1:]
231
+
232
+ channel = self.server.get_or_create_channel(channel_name)
233
+
234
+ for nick in nicks:
235
+ rc = self.server.remote_clients.get(nick)
236
+ if rc and rc not in channel.members:
237
+ channel.members.add(rc)
238
+ rc.channels.add(channel)
239
+
240
+ if self._authenticated:
241
+ # Notify local members about the join
242
+ join_msg = Message(
243
+ prefix=rc.prefix, command="JOIN", params=[channel_name]
244
+ )
245
+ for member in list(channel.members):
246
+ if not isinstance(member, RemoteClient):
247
+ await member.send(join_msg)
248
+
249
+ async def _handle_stopic(self, msg: Message) -> None:
250
+ if len(msg.params) < 3:
251
+ return
252
+ channel_name = msg.params[0]
253
+ nick = msg.params[1]
254
+ topic = msg.params[2]
255
+
256
+ channel = self.server.channels.get(channel_name)
257
+ if channel:
258
+ channel.topic = topic
259
+
260
+ # --- Real-time relay handlers (incoming from peer) ---
261
+
262
+ async def _handle_smsg(self, msg: Message) -> None:
263
+ """Handle relayed PRIVMSG from peer."""
264
+ if len(msg.params) < 3:
265
+ return
266
+ target = msg.params[0]
267
+ sender_nick = msg.params[1]
268
+ text = msg.params[2]
269
+
270
+ relay = Message(
271
+ prefix=f"{sender_nick}!*@*",
272
+ command="PRIVMSG",
273
+ params=[target, text],
274
+ )
275
+
276
+ # Build the sender prefix from remote client if available
277
+ rc = self.server.remote_clients.get(sender_nick)
278
+ if rc:
279
+ relay = Message(
280
+ prefix=rc.prefix,
281
+ command="PRIVMSG",
282
+ params=[target, text],
283
+ )
284
+
285
+ if target.startswith("#"):
286
+ channel = self.server.channels.get(target)
287
+ if channel:
288
+ for member in list(channel.members):
289
+ if not isinstance(member, RemoteClient):
290
+ await member.send(relay)
291
+ # Emit event for skills (e.g., history) with _origin to prevent re-relay
292
+ await self.server.emit_event(
293
+ Event(
294
+ type=EventType.MESSAGE,
295
+ channel=target,
296
+ nick=sender_nick,
297
+ data={"text": text, "_origin": self.peer_name},
298
+ )
299
+ )
300
+ # Notify mentions for remote messages
301
+ await self._notify_remote_mentions(target, sender_nick, text)
302
+ else:
303
+ # DM to a local client
304
+ local = self.server.clients.get(target)
305
+ if local:
306
+ await local.send(relay)
307
+ await self.server.emit_event(
308
+ Event(
309
+ type=EventType.MESSAGE,
310
+ channel=None,
311
+ nick=sender_nick,
312
+ data={"text": text, "_origin": self.peer_name},
313
+ )
314
+ )
315
+ await self._notify_remote_mentions(None, sender_nick, text)
316
+
317
+ async def _handle_snotice(self, msg: Message) -> None:
318
+ """Handle relayed NOTICE from peer."""
319
+ if len(msg.params) < 3:
320
+ return
321
+ target = msg.params[0]
322
+ sender_nick = msg.params[1]
323
+ text = msg.params[2]
324
+
325
+ rc = self.server.remote_clients.get(sender_nick)
326
+ prefix = rc.prefix if rc else f"{sender_nick}!*@*"
327
+ relay = Message(prefix=prefix, command="NOTICE", params=[target, text])
328
+
329
+ if target.startswith("#"):
330
+ channel = self.server.channels.get(target)
331
+ if channel:
332
+ for member in list(channel.members):
333
+ if not isinstance(member, RemoteClient):
334
+ await member.send(relay)
335
+ await self.server.emit_event(
336
+ Event(
337
+ type=EventType.MESSAGE,
338
+ channel=target,
339
+ nick=sender_nick,
340
+ data={"text": text, "_origin": self.peer_name},
341
+ )
342
+ )
343
+ else:
344
+ local = self.server.clients.get(target)
345
+ if local:
346
+ await local.send(relay)
347
+
348
+ async def _handle_spart(self, msg: Message) -> None:
349
+ """Handle relayed PART from peer."""
350
+ if len(msg.params) < 2:
351
+ return
352
+ channel_name = msg.params[0]
353
+ nick = msg.params[1]
354
+ reason = msg.params[2] if len(msg.params) > 2 else ""
355
+
356
+ rc = self.server.remote_clients.get(nick)
357
+ if not rc:
358
+ return
359
+
360
+ channel = self.server.channels.get(channel_name)
361
+ if not channel:
362
+ return
363
+
364
+ # Notify local members
365
+ part_params = [channel_name, reason] if reason else [channel_name]
366
+ part_msg = Message(prefix=rc.prefix, command="PART", params=part_params)
367
+ for member in list(channel.members):
368
+ if not isinstance(member, RemoteClient):
369
+ await member.send(part_msg)
370
+
371
+ channel.members.discard(rc)
372
+ rc.channels.discard(channel)
373
+
374
+ if not channel.members:
375
+ del self.server.channels[channel_name]
376
+
377
+ async def _handle_squituser(self, msg: Message) -> None:
378
+ """Handle relayed client QUIT from peer."""
379
+ if len(msg.params) < 1:
380
+ return
381
+ nick = msg.params[0]
382
+ reason = msg.params[1] if len(msg.params) > 1 else "Remote client quit"
383
+
384
+ rc = self.server.remote_clients.get(nick)
385
+ if not rc:
386
+ return
387
+
388
+ quit_msg = Message(prefix=rc.prefix, command="QUIT", params=[reason])
389
+
390
+ # Notify local members in shared channels
391
+ notified = set()
392
+ for channel in list(rc.channels):
393
+ for member in list(channel.members):
394
+ if not isinstance(member, RemoteClient) and member not in notified:
395
+ await member.send(quit_msg)
396
+ notified.add(member)
397
+ channel.members.discard(rc)
398
+ if not channel.members:
399
+ del self.server.channels[channel.name]
400
+
401
+ rc.channels.clear()
402
+ del self.server.remote_clients[nick]
403
+
404
+ async def _handle_squit(self, msg: Message) -> None:
405
+ """Handle peer announcing it's delinking."""
406
+ raise ConnectionError("Peer sent SQUIT")
407
+
408
+ # --- Backfill ---
409
+
410
+ async def _send_backfill_request(self) -> None:
411
+ """Request missed events from peer since our last known seq."""
412
+ await self.send_raw(
413
+ f"BACKFILL {self.server.config.name} {self.last_seen_seq}"
414
+ )
415
+
416
+ async def _handle_backfill(self, msg: Message) -> None:
417
+ """Peer is requesting backfill from a given sequence."""
418
+ if len(msg.params) < 2:
419
+ return
420
+ try:
421
+ from_seq = int(msg.params[1])
422
+ except ValueError:
423
+ return
424
+
425
+ # Use the higher of: what peer claims, or what we know they acked
426
+ # (during real-time relay, peer saw everything up to our _seq at link drop)
427
+ acked = self.server._peer_acked_seq.get(self.peer_name, 0)
428
+ effective_seq = max(from_seq, acked)
429
+
430
+ # Replay events from our log that are after effective_seq
431
+ for seq, event in self.server._event_log:
432
+ if seq <= effective_seq:
433
+ continue
434
+ # Only replay events that originated locally
435
+ if event.data.get("_origin"):
436
+ continue
437
+ await self._replay_event(seq, event)
438
+
439
+ await self.send_raw(
440
+ f":{self.server.config.name} BACKFILLEND {self.server._seq}"
441
+ )
442
+
443
+ async def _handle_backfillend(self, msg: Message) -> None:
444
+ """Peer finished backfilling."""
445
+ if msg.params:
446
+ try:
447
+ self.last_seen_seq = int(msg.params[0])
448
+ except ValueError:
449
+ pass
450
+
451
+ async def _replay_event(self, seq: int, event: Event) -> None:
452
+ """Replay a single event to the peer as S2S wire format."""
453
+ origin = self.server.config.name
454
+ if event.type == EventType.MESSAGE:
455
+ target = event.channel or event.data.get("target", "")
456
+ text = event.data.get("text", "")
457
+ cmd = event.data.get("notice") and "SNOTICE" or "SMSG"
458
+ await self.send_raw(
459
+ f":{origin} {cmd} {target} {event.nick} :{text}"
460
+ )
461
+
462
+ # --- Relay outbound ---
463
+
464
+ async def relay_event(self, event: Event) -> None:
465
+ """Relay a local event to the peer in S2S wire format."""
466
+ origin = self.server.config.name
467
+ seq = self.server._seq
468
+
469
+ if event.type == EventType.MESSAGE:
470
+ target = event.channel or event.data.get("target", "")
471
+ text = event.data.get("text", "")
472
+ if event.data.get("notice"):
473
+ await self.send_raw(
474
+ f":{origin} SNOTICE {target} {event.nick} :{text}"
475
+ )
476
+ else:
477
+ await self.send_raw(
478
+ f":{origin} SMSG {target} {event.nick} :{text}"
479
+ )
480
+ elif event.type == EventType.JOIN:
481
+ channel_name = event.channel
482
+ await self.send_raw(
483
+ f":{origin} SJOIN {channel_name} {event.nick}"
484
+ )
485
+ elif event.type == EventType.PART:
486
+ channel_name = event.channel
487
+ reason = event.data.get("reason", "")
488
+ await self.send_raw(
489
+ f":{origin} SPART {channel_name} {event.nick} :{reason}"
490
+ )
491
+ elif event.type == EventType.QUIT:
492
+ reason = event.data.get("reason", "Quit")
493
+ await self.send_raw(
494
+ f":{origin} SQUITUSER {event.nick} :{reason}"
495
+ )
496
+ elif event.type == EventType.TOPIC:
497
+ channel_name = event.channel
498
+ topic = event.data.get("topic", "")
499
+ await self.send_raw(
500
+ f":{origin} STOPIC {channel_name} {event.nick} :{topic}"
501
+ )
502
+
503
+ # --- Mention notifications for remote messages ---
504
+
505
+ async def _notify_remote_mentions(
506
+ self, channel_name: str | None, sender_nick: str, text: str
507
+ ) -> None:
508
+ """Check for @mentions in remote messages and notify local clients."""
509
+ import re
510
+ mentioned_nicks = re.findall(r"@(\S+)", text)
511
+ if not mentioned_nicks:
512
+ return
513
+ seen: set[str] = set()
514
+ channel = (
515
+ self.server.channels.get(channel_name) if channel_name else None
516
+ )
517
+ source = channel_name or "a direct message"
518
+ for raw_nick in mentioned_nicks:
519
+ nick = raw_nick.rstrip(".,;:!?")
520
+ if nick in seen or nick == sender_nick:
521
+ continue
522
+ seen.add(nick)
523
+ # Only notify local clients
524
+ target_client = self.server.clients.get(nick)
525
+ if not target_client:
526
+ continue
527
+ if channel and target_client not in channel.members:
528
+ continue
529
+ notice = Message(
530
+ prefix=self.server.config.name,
531
+ command="NOTICE",
532
+ params=[
533
+ nick,
534
+ f"{sender_nick} mentioned you in {source}: {text}",
535
+ ],
536
+ )
537
+ await target_client.send(notice)
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ if TYPE_CHECKING:
9
+ from agentirc.server.client import Client
10
+ from agentirc.server.ircd import IRCd
11
+ from agentirc.protocol.message import Message
12
+
13
+
14
+ class EventType(Enum):
15
+ MESSAGE = "message"
16
+ JOIN = "join"
17
+ PART = "part"
18
+ QUIT = "quit"
19
+ TOPIC = "topic"
20
+
21
+
22
+ @dataclass
23
+ class Event:
24
+ type: EventType
25
+ channel: str | None
26
+ nick: str
27
+ data: dict[str, Any] = field(default_factory=dict)
28
+ timestamp: float = field(default_factory=time.time)
29
+
30
+
31
+ class Skill:
32
+ name: str = ""
33
+ commands: set[str] = set()
34
+
35
+ async def start(self, server: IRCd) -> None:
36
+ self.server = server
37
+
38
+ async def stop(self) -> None:
39
+ pass
40
+
41
+ async def on_event(self, event: Event) -> None:
42
+ pass
43
+
44
+ async def on_command(self, client: Client, msg: Message) -> None:
45
+ pass
File without changes