diff --git a/CHANGELOG b/CHANGELOG index efc87cf..c6c7b3d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,10 @@ 1.24 +* Add /dropautoreact command +* Add /listautoreact command +* Add /listannoy command +* Add /dropannoy command +* Store annoy and autoreact in state, so they are preserved +* Allow unlimited timeout for annoy and autoreact 1.23 * Add /autoreact command to automatically put reactions diff --git a/irc.py b/irc.py index 07cb474..5cf2887 100644 --- a/irc.py +++ b/irc.py @@ -128,7 +128,6 @@ def __init__( self._usersent = False # Used to hold all events until the IRC client sends the initial USER message self._held_events: list[slack.SlackEvent] = [] self._mentions_regex_cache: dict[str, Optional[re.Pattern]] = {} # Cache for the regexp to perform mentions. Key is channel id - self._annoy_users: dict[str, int] = {} # Users to annoy pretending to type when they type def get_mention_str(self) -> str: ''' @@ -174,7 +173,11 @@ async def _userhandler(self, cmd: bytes) -> None: await self._sendreply(Replies.RPL_LUSERCLIENT, 'There are 1 users and 0 services on 1 server') await self._sendreply(2, '============= Extra IRC commands supported =============') await self._sendreply(2, '/annoy') + await self._sendreply(2, '/dropannoy') + await self._sendreply(2, '/listannoy') await self._sendreply(2, '/autoreact') + await self._sendreply(2, '/dropautoreact') + await self._sendreply(2, '/listautoreact') await self._sendreply(2, '/sendfile') if self.settings.autojoin and not self.settings.nouserlist: @@ -348,8 +351,11 @@ async def _autoreacthandler(self, cmd: bytes) -> None: else: duration = 10 + if duration < 1 and duration != -1: + raise ValueError('Duration must be >0 or = -1') + # async def add_autoreact(self, username: str, reaction: str, probability: float, expiration: int) -> None: - await self.sl_client.add_autoreact(username, reaction, probability, time.time() + duration * 60 ) + await self.sl_client.add_autoreact(username, reaction, probability, time.time() + duration * 60 if duration != -1 else -1) except Exception as e: await self._sendreply(Replies.ERR_UNKNOWNCOMMAND, 'Syntax: /autoreact user probability [reaction] [duration]') await self._sendreply(Replies.ERR_UNKNOWNCOMMAND, f'error: {e}') @@ -364,22 +370,58 @@ async def _annoyhandler(self, cmd: bytes) -> None: try: user = params.pop(0).decode('utf8') if params: - duration = abs(int(params.pop())) + duration = int(params.pop()) else: duration = 10 # 10 minutes default + + if duration < 1 and duration != -1: + raise ValueError("Duration must be positive or -1") + + await self.sl_client.add_annoy(user, time.time() + (duration * 60) if duration > 0 else duration) + + except KeyError: + await self._sendreply(Replies.ERR_NOSUCHCHANNEL, f'Unable to find user: {user}') + return except Exception: await self._sendreply(Replies.ERR_UNKNOWNCOMMAND, 'Syntax: /annoy user [duration]') return + await self._sendreply(0, f'Will annoy {user} for {duration} minutes') + async def _dropannoyhandler(self, cmd: bytes) -> None: try: - user_id = (await self.sl_client.get_user_by_name(user)).id + user = cmd.split(b' ', 1)[1].decode('utf8') + await self.sl_client.drop_annoy(user) + except KeyError: await self._sendreply(Replies.ERR_NOSUCHCHANNEL, f'Unable to find user: {user}') return + except Exception: + await self._sendreply(Replies.ERR_UNKNOWNCOMMAND, 'Syntax: /dropannoy user') + return + await self._sendreply(0, f'No longer annoying {user}') - self._annoy_users[user_id] = int(time.time()) + (duration * 60) - await self._sendreply(0, f'Will annoy {user} for {duration} minutes') + async def _dropautoreacthandler(self, cmd: bytes) -> None: + try: + user = cmd.split(b' ', 1)[1].decode('utf8') + await self.sl_client.drop_autoreact(user) + except KeyError: + await self._sendreply(Replies.ERR_NOSUCHCHANNEL, f'Unable to find user: {user}') + return + except Exception: + await self._sendreply(Replies.ERR_UNKNOWNCOMMAND, 'Syntax: /dropautoreact user') + return + await self._sendreply(0, f'No longer reacting to {user}') + + async def _listannoyhandler(self, _: bytes) -> None: + for i in await self.sl_client.get_annoy(): + await self._sendreply(0, f'Annoying {i}') + + async def _listautoreacthandler(self, _: bytes) -> None: + for k, v in (await self.sl_client.get_autoreact()).items(): + await self._sendreply(0, f'Reactions for {k}') + for i in v: + await self._sendreply(0, str(i)) async def _sendfilehandler(self, cmd: bytes) -> None: #/sendfile #destination filename @@ -838,15 +880,6 @@ async def slack_event(self, sl_ev: slack.SlackEvent) -> None: elif isinstance(sl_ev, slack.GroupJoined): channel_name = '#%s' % sl_ev.channel.name_normalized await self._send_chan_info(channel_name.encode('utf-8'), sl_ev.channel) - elif isinstance(sl_ev, slack.UserTyping): - if sl_ev.user not in self._annoy_users: - return - if time.time() > self._annoy_users[sl_ev.user]: - del self._annoy_users[sl_ev.user] - await self._sendreply(0, f'No longer annoying {(await self.sl_client.get_user(sl_ev.user)).name}') - return - await self.sl_client.typing(sl_ev.channel) - async def command(self, cmd: bytes) -> None: if b' ' in cmd: @@ -873,7 +906,11 @@ async def command(self, cmd: bytes) -> None: b'INVITE': self._invitehandler, b'SENDFILE': self._sendfilehandler, b'ANNOY': self._annoyhandler, + b'LISTANNOY': self._listannoyhandler, + b'DROPANNOY': self._dropannoyhandler, b'AUTOREACT': self._autoreacthandler, + b'LISTAUTOREACT': self._listautoreacthandler, + b'DROPAUTOREACT': self._dropautoreacthandler, b'QUIT': self._quithandler, #CAP LS b'USERHOST': self._userhosthandler, diff --git a/man/localslackirc.1 b/man/localslackirc.1 index b5fd1f2..8a52958 100644 --- a/man/localslackirc.1 +++ b/man/localslackirc.1 @@ -1,4 +1,4 @@ -.TH localslackirc 1 "Nov 9, 2023" "IRC gateway for slack" +.TH localslackirc 1 "Nov 22, 2023" "IRC gateway for slack" .SH NAME localslackirc \- Creates an IRC server running locally, which acts as a gateway to slack for one user. @@ -201,9 +201,30 @@ probability is a number between 0 and 1, to decide how much to react. .br reaction is the reaction to use. The default is "thumbsup". .br -duration indicates when to stop doing it, in minutes. Defaults to 10. +duration indicates when to stop doing it, in minutes. Defaults to 10. Setting it to -1 makes it never expire. .SS +.TP +.B /dropautoreact user +Deletes all the automatic reacts for a given user +.SS + +.TP +.B /dropannoy user +Stops annoying the given user +.SS + +.TP +.B /listannoy +Lists the users that are currently being annoyed +.SS + +.TP +.B /listautoreact +Lists the automatic reactions +.SS + + .SH "SEE ALSO" .BR lsi-send (1), lsi-write (1) diff --git a/slack.py b/slack.py index d1026b5..24c628e 100644 --- a/slack.py +++ b/slack.py @@ -331,30 +331,36 @@ class Conversations(NamedTuple): ) -@dataclass -class SlackStatus: - """ - Not related to the slack API. - This is a structure used internally by this module to - save the status on disk. - """ - last_timestamp: float = 0.0 - - class Autoreaction(NamedTuple): - user_id: str reaction: str probability: float expiration: float @property def expired(self) -> bool: + if self.expiration == -1: + return False return time() > self.expiration def random_reaction(self) -> bool: import random return random.random() < self.probability + def __str__(self): + return f'{self.reaction} at {self.probability * 100}%' + + +@dataclass +class SlackStatus: + """ + Not related to the slack API. + This is a structure used internally by this module to + save the status on disk. + """ + last_timestamp: float = 0.0 + autoreactions: dict[str, list[Autoreaction]] = field(default_factory=dict) + annoy: dict[str, float] = field(default_factory=dict) + class Slack: def __init__(self, token: str, cookie: Optional[str], previous_status: Optional[bytes]) -> None: @@ -369,7 +375,6 @@ def __init__(self, token: str, cookie: Optional[str], previous_status: Optional[ self.client = SlackClient(token, cookie) self._usercache: dict[str, User] = {} self._usermapcache: dict[str, User] = {} - self._usermapcache_keys: list[str] self._imcache: dict[str, str] = {} self._channelscache: list[Channel] = [] self._joinedchannelscache: list[Channel] = [] @@ -380,7 +385,6 @@ def __init__(self, token: str, cookie: Optional[str], previous_status: Optional[ self._wsblock: int = 0 # Semaphore to block the socket and avoid events being received before their API call ended. self.login_info: Optional[LoginInfo] = None self.loader = dataloader.Loader() - self._autoreactions: list[Autoreaction] = [] if previous_status is None: self._status = SlackStatus() @@ -563,6 +567,33 @@ async def add_reaction(self, msg: Message, reaction: str) -> None: if not response.ok: raise ResponseException(response.error) + async def add_annoy(self, username, expiration: float) -> None: + user_id = (await self.get_user_by_name(username)).id + self._status.annoy[user_id] = expiration + + async def drop_annoy(self, username: str) -> None: + user_id = (await self.get_user_by_name(username)).id + del self._status.annoy[user_id] + + async def drop_autoreact(self, username: str) -> None: + user_id = (await self.get_user_by_name(username)).id + del self._status.autoreactions[user_id] + + async def get_annoy(self) -> list[str]: + r = [] + for i in self._status.annoy.keys(): + try: + u = await self.get_user(i) + r.append(u.name) + except KeyError: + # The user is gone, expire it + self._status.annoy[i] = 1 + r.sort() + return r + + async def get_autoreact(self) -> dict[str, list[Autoreaction]]: + return {(await self.get_user(k)).name: v for k, v in self._status.autoreactions.items()} + async def add_autoreact(self, username: str, reaction: str, probability: float, expiration: float) -> None: if probability > 1 or probability < 0: @@ -570,7 +601,6 @@ async def add_autoreact(self, username: str, reaction: str, probability: float, user_id = (await self.get_user_by_name(username)).id a = Autoreaction( - user_id=user_id, reaction=reaction, probability=probability, expiration=expiration, @@ -579,24 +609,31 @@ async def add_autoreact(self, username: str, reaction: str, probability: float, if a.expired: raise ValueError('Expired') - self._autoreactions.append(a) + if user_id not in self._status.autoreactions: + self._status.autoreactions[user_id] = [] + self._status.autoreactions[user_id].append(a) + + async def _annoy(self, typing: UserTyping) -> None: + if typing.user not in self._status.annoy: + return + expiration = self._status.annoy[typing.user] + if expiration > 0 and time() > expiration: + del self._status.annoy[typing.user] + await self.typing(typing.channel) async def _autoreact(self, msg: Message) -> None: - for i in self._autoreactions: + for i in (rlist := self._status.autoreactions.get(msg.user, [])): # Clean up if i.expired: - self._autoreactions.remove(i) + rlist.remove(i) return - if i.user_id != msg.user: - continue - if i.random_reaction(): try: await self.add_reaction(msg, i.reaction) except: # Remove reactions that fail - self._autoreactions.remove(i) + rlist.remove(i) return async def topic(self, channel: Channel, topic: str) -> None: @@ -828,7 +865,6 @@ async def prefetch_users(self) -> None: for user in self.tload(r['members'], list[User]): self._usercache[user.id] = user self._usermapcache[user.name] = user - self._usermapcache_keys = list() async def get_user(self, id_: str) -> User: """ @@ -844,8 +880,6 @@ async def get_user(self, id_: str) -> User: if response.ok: u = self.tload(r['user'], User) self._usercache[id_] = u - if u.name not in self._usermapcache: - self._usermapcache_keys = list() self._usermapcache[u.name] = u return u else: @@ -999,6 +1033,9 @@ async def event(self) -> Optional[SlackEvent]: self._get_members_cache[ev.channel].add(ev.user) else: self._get_members_cache[ev.channel].discard(ev.user) + elif isinstance(ev, UserTyping): + await self._annoy(ev) + continue if ev: return ev