Skip to content

Commit 9d22391

Browse files
authored
Merge pull request #173 from TotallyNotRobots/channel-keys
Add official support for channel keys
2 parents c58f209 + 2dd13d2 commit 9d22391

18 files changed

+784
-307
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
### Added
99
- Add Python 3.8 to testing matrix
1010
- Add support for channel keys (#95)
11+
- Officially support channel keys across the whole bot
1112
### Changed
1213
- Refactor tests to remove dependency on mock library
1314
- Change link_announcer.py to only warn on connection errors

cloudbot/clients/irc.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,35 @@ def __init__(self, bot, _type, name, nick, *, channels=None, config=None):
146146

147147
self._connecting = False
148148

149+
self._channel_keys = {}
150+
151+
def set_channel_key(self, channel: str, key: str, *, override: bool = True) -> None:
152+
if override or channel not in self._channel_keys:
153+
self._channel_keys[channel] = key
154+
155+
def clear_channel_keys(self) -> None:
156+
self._channel_keys.clear()
157+
158+
def clear_channel_key(self, channel: str) -> bool:
159+
if channel in self._channel_keys:
160+
del self._channel_keys[channel]
161+
return True
162+
163+
return False
164+
165+
def get_channel_key(
166+
self, channel: str, default: Optional[str] = None, *, set_key: bool = True
167+
) -> Optional[str]:
168+
if channel in self._channel_keys:
169+
key = self._channel_keys[channel]
170+
if key is not None:
171+
return key
172+
173+
if set_key:
174+
self._channel_keys[channel] = default
175+
176+
return default
177+
149178
def make_ssl_context(self, conn_config):
150179
if self.use_ssl:
151180
ssl_context = ssl.create_default_context()
@@ -304,6 +333,7 @@ def set_nick(self, nick):
304333
self.cmd("NICK", nick)
305334

306335
def join(self, channel, key=None):
336+
key = self.get_channel_key(channel, key)
307337
if key:
308338
self.cmd("JOIN", channel, key)
309339
else:

cloudbot/util/irc.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import logging
2+
from enum import Enum
3+
from typing import List, Mapping, Optional
4+
5+
import attr
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
class ModeType(Enum):
11+
A = "A"
12+
B = "B"
13+
C = "C"
14+
D = "D"
15+
Status = 1
16+
17+
18+
PARAM_MODE_TYPES = (ModeType.A, ModeType.B, ModeType.Status)
19+
20+
21+
@attr.s(hash=True)
22+
class ChannelMode:
23+
"""
24+
An IRC channel mode
25+
"""
26+
27+
character = attr.ib(type=str)
28+
type = attr.ib(type=ModeType)
29+
30+
def has_param(self, adding: bool) -> bool:
31+
return self.type in PARAM_MODE_TYPES or (self.type is ModeType.C and adding)
32+
33+
34+
@attr.s(hash=True)
35+
class ModeChange:
36+
"""
37+
Represents a single change of a mode
38+
"""
39+
40+
char = attr.ib(type=str)
41+
adding = attr.ib(type=bool)
42+
param = attr.ib(type=Optional[str])
43+
info = attr.ib(type=ChannelMode)
44+
45+
@property
46+
def is_status(self):
47+
return self.info.type is ModeType.Status
48+
49+
50+
@attr.s(hash=True)
51+
class StatusMode(ChannelMode):
52+
"""
53+
An IRC status mode
54+
"""
55+
56+
prefix = attr.ib(type=str)
57+
level = attr.ib(type=int)
58+
59+
@classmethod
60+
def make(cls, prefix: str, char: str, level: int) -> "StatusMode":
61+
return cls(prefix=prefix, level=level, character=char, type=ModeType.Status)
62+
63+
64+
def parse_mode_string(
65+
modes: str, params: List[str], server_modes: Mapping[str, ChannelMode]
66+
) -> List[ModeChange]:
67+
new_modes = []
68+
params = params.copy()
69+
adding = True
70+
for c in modes:
71+
if c == "+":
72+
adding = True
73+
elif c == "-":
74+
adding = False
75+
else:
76+
mode_info = server_modes.get(c)
77+
if mode_info and mode_info.has_param(adding):
78+
param = params.pop(0)
79+
else:
80+
param = None
81+
82+
new_modes.append(
83+
ModeChange(char=c, adding=adding, param=param, info=mode_info)
84+
)
85+
86+
return new_modes

plugins/core/chan_key_db.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""
2+
Store and retrieve channel keys in a database table
3+
4+
Author:
5+
- linuxdaemon
6+
"""
7+
from itertools import zip_longest
8+
from typing import Any, Dict, List, Optional
9+
10+
from irclib.parser import Message
11+
from sqlalchemy import Column, PrimaryKeyConstraint, String, Table, and_, select
12+
from sqlalchemy.orm import Session
13+
from sqlalchemy.sql.elements import BooleanClauseList, ClauseElement
14+
15+
from cloudbot import hook
16+
from cloudbot.client import Client
17+
from cloudbot.clients.irc import IrcClient
18+
from cloudbot.util import database
19+
from cloudbot.util.irc import parse_mode_string
20+
from plugins.core.server_info import get_channel_modes, get_server_info
21+
22+
table = Table(
23+
"channel_keys",
24+
database.metadata,
25+
Column("conn", String),
26+
Column("chan", String),
27+
Column("key", String),
28+
PrimaryKeyConstraint("conn", "chan"),
29+
)
30+
31+
32+
@hook.connect(clients=["irc"])
33+
def load_keys(conn: IrcClient, db) -> None:
34+
"""
35+
Load channel keys to the client
36+
"""
37+
query = select([table.c.chan, table.c.key], table.c.conn == conn.name.lower())
38+
conn.clear_channel_keys()
39+
for row in db.execute(query):
40+
conn.set_channel_key(row["chan"], row["key"])
41+
42+
43+
@hook.irc_raw("MODE")
44+
def handle_modes(irc_paramlist: List[str], conn: IrcClient, db, chan: str) -> None:
45+
"""
46+
Handle mode changes
47+
"""
48+
modes = irc_paramlist[1]
49+
mode_params = list(irc_paramlist[2:])
50+
serv_info = get_server_info(conn)
51+
mode_changes = parse_mode_string(modes, mode_params, get_channel_modes(serv_info))
52+
updated = False
53+
for change in mode_changes:
54+
if change.char == "k":
55+
updated = True
56+
if change.adding:
57+
set_key(db, conn, chan, change.param)
58+
else:
59+
clear_key(db, conn, chan)
60+
61+
if updated:
62+
load_keys(conn, db)
63+
64+
65+
def insert_or_update(
66+
db: Session, tbl: Table, data: Dict[str, Any], query: ClauseElement
67+
) -> None:
68+
"""
69+
Insert a new row or update an existing matching row
70+
"""
71+
result = db.execute(tbl.update().where(query).values(**data))
72+
if not result.rowcount:
73+
db.execute(tbl.insert().values(**data))
74+
75+
db.commit()
76+
77+
78+
def make_clause(conn: Client, chan: str) -> BooleanClauseList:
79+
"""
80+
Generate a WHERE clause to match keys for this conn+channel
81+
"""
82+
return and_(table.c.conn == conn.name.lower(), table.c.chan == chan.lower(),)
83+
84+
85+
def clear_key(db: Session, conn, chan: str) -> None:
86+
"""
87+
Remove a channel's key from the DB
88+
"""
89+
db.execute(table.delete().where(make_clause(conn, chan)))
90+
91+
92+
def set_key(db: Session, conn: IrcClient, chan: str, key: Optional[str]) -> None:
93+
"""
94+
Set the key for a channel
95+
"""
96+
insert_or_update(
97+
db,
98+
table,
99+
{"conn": conn.name.lower(), "chan": chan.lower(), "key": key or None},
100+
make_clause(conn, chan),
101+
)
102+
conn.set_channel_key(chan, key)
103+
104+
105+
@hook.irc_out()
106+
def check_send_key(conn: IrcClient, parsed_line: Message, db: Session) -> Message:
107+
"""
108+
Parse outgoing JOIN messages and store used channel keys
109+
"""
110+
if parsed_line.command == "JOIN":
111+
if len(parsed_line.parameters) > 1:
112+
keys = parsed_line.parameters[1]
113+
else:
114+
keys = ""
115+
116+
for chan, key in zip_longest(
117+
*map(lambda s: s.split(","), (parsed_line.parameters[0], keys))
118+
):
119+
if key:
120+
set_key(db, conn, chan, key)
121+
122+
return parsed_line

0 commit comments

Comments
 (0)