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.
- agentirc/__init__.py +1 -0
- agentirc/cli.py +651 -0
- agentirc/clients/__init__.py +0 -0
- agentirc/clients/claude/__init__.py +0 -0
- agentirc/clients/claude/__main__.py +93 -0
- agentirc/clients/claude/agent_runner.py +167 -0
- agentirc/clients/claude/config.py +162 -0
- agentirc/clients/claude/daemon.py +422 -0
- agentirc/clients/claude/ipc.py +38 -0
- agentirc/clients/claude/irc_transport.py +146 -0
- agentirc/clients/claude/message_buffer.py +46 -0
- agentirc/clients/claude/skill/SKILL.md +202 -0
- agentirc/clients/claude/skill/__init__.py +0 -0
- agentirc/clients/claude/skill/irc_client.py +281 -0
- agentirc/clients/claude/socket_server.py +106 -0
- agentirc/clients/claude/supervisor.py +139 -0
- agentirc/clients/claude/webhook.py +59 -0
- agentirc/observer.py +228 -0
- agentirc/pidfile.py +49 -0
- agentirc/protocol/__init__.py +0 -0
- agentirc/protocol/commands.py +33 -0
- agentirc/protocol/extensions/federation.md +94 -0
- agentirc/protocol/extensions/history.md +112 -0
- agentirc/protocol/message.py +58 -0
- agentirc/protocol/protocol-index.md +9 -0
- agentirc/protocol/replies.py +44 -0
- agentirc/server/__init__.py +0 -0
- agentirc/server/__main__.py +61 -0
- agentirc/server/channel.py +56 -0
- agentirc/server/client.py +742 -0
- agentirc/server/config.py +21 -0
- agentirc/server/ircd.py +208 -0
- agentirc/server/remote_client.py +42 -0
- agentirc/server/server_link.py +537 -0
- agentirc/server/skill.py +45 -0
- agentirc/server/skills/__init__.py +0 -0
- agentirc/server/skills/history.py +152 -0
- agentirc_cli-0.2.1.dist-info/METADATA +183 -0
- agentirc_cli-0.2.1.dist-info/RECORD +42 -0
- agentirc_cli-0.2.1.dist-info/WHEEL +4 -0
- agentirc_cli-0.2.1.dist-info/entry_points.txt +2 -0
- 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)
|
agentirc/server/skill.py
ADDED
|
@@ -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
|