Skip to content

Commit d9a1a2a

Browse files
committed
Make IrcBot.start() signal connection loss via ConnectionDown
1 parent 68fe696 commit d9a1a2a

2 files changed

Lines changed: 163 additions & 6 deletions

File tree

irc_bot.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from command_router import CommandRouter
1010
from config import Configuration
11-
from irc_connection import IrcConnection
11+
from irc_connection import ConnectionDown, IrcConnection
1212

1313

1414
class IrcBot:
@@ -36,6 +36,9 @@ def __init__(self, config_obj):
3636
self._logger = logging.getLogger("IRCMainLoop")
3737

3838
async def start(self):
39+
self.shutdown = False
40+
self.nickserv_auth = False
41+
self._handler_tasks = set()
3942
self.conn = IrcConnection()
4043

4144
local_addr = None
@@ -96,6 +99,7 @@ async def start(self):
9699
task.add_done_callback(self._handler_tasks.discard)
97100
except asyncio.CancelledError:
98101
self._logger.info("Main loop cancelled (shutdown)")
102+
self.shutdown = True
99103
finally:
100104
self._logger.info("Main loop has been stopped")
101105
self.command_router.task_pool.cancel_all()
@@ -105,17 +109,21 @@ async def start(self):
105109
with contextlib.suppress(asyncio.CancelledError):
106110
await timer_task
107111
await self.command_router.close()
108-
await self.conn.send_msg("QUIT :Shutting down")
109-
try:
110-
await self.conn.flush(timeout=5)
111-
except TimeoutError:
112-
self._logger.warning("Timed out flushing write queue")
112+
if self.shutdown:
113+
await self.conn.send_msg("QUIT :Shutting down")
114+
try:
115+
await self.conn.flush(timeout=5)
116+
except TimeoutError:
117+
self._logger.warning("Timed out flushing write queue")
113118
write_task.cancel()
114119
with contextlib.suppress(asyncio.CancelledError):
115120
await write_task
116121
await self.conn.close()
117122
self._logger.info("Connection closed.")
118123

124+
if not self.shutdown:
125+
raise ConnectionDown(self.host, datetime.datetime.now())
126+
119127
def _parse_message(self, msg):
120128
msg_parts = msg.split(" ", 2)
121129
prefix = msg_parts[0][1:] if msg_parts[0][0] == ":" else None

tests/test_irc_bot.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1+
import asyncio
2+
from unittest.mock import AsyncMock, MagicMock, patch
3+
4+
import pytest
5+
16
from irc_bot import IrcBot
7+
from irc_connection import ConnectionDown
28

39

410
class TestParseMessage:
@@ -52,3 +58,146 @@ def test_numeric_command(self):
5258
assert prefix == "server"
5359
assert command == "353"
5460
assert params == "bot = #chan :@user"
61+
62+
63+
def _make_bot():
64+
"""Create an IrcBot with minimal fake config."""
65+
config = {
66+
"connection": {
67+
"server": "irc.example.com",
68+
"port": 6667,
69+
"nickname": "TestBot",
70+
"password": "",
71+
"ident": "testbot",
72+
"realname": "Test",
73+
},
74+
"administration": {
75+
"operators": [],
76+
"channels": [],
77+
"command_prefix": "!",
78+
"logging_level": "INFO",
79+
},
80+
"networking": {"force_ipv6": False, "bind_address": ""},
81+
}
82+
83+
class FakeConfig:
84+
pass
85+
86+
cfg = FakeConfig()
87+
cfg.config = config
88+
return IrcBot(cfg)
89+
90+
91+
def _mock_conn(lines=None):
92+
"""Create a mock IrcConnection that yields the given lines then stops."""
93+
conn = MagicMock()
94+
conn.ready = True
95+
conn.error = None
96+
conn.send_msg = AsyncMock()
97+
conn.flush = AsyncMock()
98+
conn.close = AsyncMock()
99+
conn.connect = AsyncMock()
100+
conn.write_loop = AsyncMock()
101+
102+
async def fake_read_lines():
103+
if lines:
104+
for line in lines:
105+
yield line
106+
conn.ready = False
107+
108+
conn.read_lines = fake_read_lines
109+
return conn
110+
111+
112+
def _mock_router():
113+
"""Create a mock CommandRouter."""
114+
router = MagicMock()
115+
router._handler_lock = asyncio.Lock()
116+
router.handle = AsyncMock()
117+
router.close = AsyncMock()
118+
router.check_timer_events = AsyncMock()
119+
router.task_pool = MagicMock()
120+
router.task_pool.cancel_all = MagicMock()
121+
return router
122+
123+
124+
class TestStartReconnectSignaling:
125+
async def test_raises_connection_down_on_connection_loss(self):
126+
bot = _make_bot()
127+
conn = _mock_conn()
128+
router = _mock_router()
129+
130+
with (
131+
patch("irc_bot.IrcConnection", return_value=conn),
132+
patch("irc_bot.CommandRouter", return_value=router),
133+
pytest.raises(ConnectionDown),
134+
):
135+
await bot.start()
136+
137+
async def test_no_connection_down_on_shutdown(self):
138+
bot = _make_bot()
139+
conn = _mock_conn(lines=[":server PING :test"])
140+
router = _mock_router()
141+
142+
async def fake_handle(send, prefix, command, params, auth):
143+
bot.shutdown = True
144+
145+
router.handle = AsyncMock(side_effect=fake_handle)
146+
147+
with (
148+
patch("irc_bot.IrcConnection", return_value=conn),
149+
patch("irc_bot.CommandRouter", return_value=router),
150+
):
151+
await bot.start() # Should return normally, no exception
152+
153+
async def test_quit_sent_on_shutdown(self):
154+
bot = _make_bot()
155+
conn = _mock_conn(lines=[":server PING :test"])
156+
router = _mock_router()
157+
158+
async def fake_handle(send, prefix, command, params, auth):
159+
bot.shutdown = True
160+
161+
router.handle = AsyncMock(side_effect=fake_handle)
162+
163+
with (
164+
patch("irc_bot.IrcConnection", return_value=conn),
165+
patch("irc_bot.CommandRouter", return_value=router),
166+
):
167+
await bot.start()
168+
169+
# QUIT should have been sent (one of the send_msg calls)
170+
quit_calls = [c for c in conn.send_msg.call_args_list if "QUIT" in str(c)]
171+
assert len(quit_calls) > 0
172+
173+
async def test_quit_not_sent_on_connection_loss(self):
174+
bot = _make_bot()
175+
conn = _mock_conn() # No lines, simulates connection drop
176+
router = _mock_router()
177+
178+
with (
179+
patch("irc_bot.IrcConnection", return_value=conn),
180+
patch("irc_bot.CommandRouter", return_value=router),
181+
pytest.raises(ConnectionDown),
182+
):
183+
await bot.start()
184+
185+
# QUIT should NOT have been sent
186+
quit_calls = [c for c in conn.send_msg.call_args_list if "QUIT" in str(c)]
187+
assert len(quit_calls) == 0
188+
189+
async def test_nickserv_auth_reset_on_start(self):
190+
bot = _make_bot()
191+
bot.nickserv_auth = "SomeNick" # Simulate previous session's auth
192+
193+
conn = _mock_conn()
194+
router = _mock_router()
195+
196+
with (
197+
patch("irc_bot.IrcConnection", return_value=conn),
198+
patch("irc_bot.CommandRouter", return_value=router),
199+
pytest.raises(ConnectionDown),
200+
):
201+
await bot.start()
202+
203+
assert bot.nickserv_auth is False

0 commit comments

Comments
 (0)