diff --git a/CHANGELOG b/CHANGELOG index 00021d4..7011b5e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,6 @@ 1.23 +* Add /autoreact command to automatically put reactions + to messages. 1.22 * Fetch only the list of joined channels if the full list diff --git a/README.md b/README.md index 2cdf613..62710b7 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,9 @@ They are mapped as irc channels that get automatically joined when a message is Until a thread has some activity you can't write to it. They are only tested for channels, not private groups or chats. +## Reacting to messages +Since I don't feel like manually wasting time to do it, a very nice `/autoreact` command is available to automate reacting. + ## Instructions for irssi If you need to refresh your memory about connecting in general, this is a good guide: https://pthree.org/2010/02/02/irssis-channel-network-server-and-connect-what-it-means/ diff --git a/irc.py b/irc.py index 4099882..7bc0f19 100644 --- a/irc.py +++ b/irc.py @@ -172,6 +172,10 @@ async def _userhandler(self, cmd: bytes) -> None: await self._sendreply(2, 'Your nickname must be: %s' % self.sl_client.login_info.self.name) await self._sendreply(2, f'Version: {VERSION}') 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, '/autoreact') + await self._sendreply(2, '/sendfile') if self.settings.autojoin and not self.settings.nouserlist: # We're about to load many users for each chan; instead of requesting each @@ -326,6 +330,33 @@ async def _modehandler(self, cmd: bytes) -> None: params = cmd.split(b' ', 2) await self._sendreply(Replies.RPL_CHANNELMODEIS, '', [params[1], '+']) + async def _autoreacthandler(self, cmd: bytes) -> None: + params = cmd.split(b' ') + params.pop(0) + + try: + username = params.pop(0).decode('utf8') + probability = float(params.pop(0)) + + if params: + reaction = msgparsing.get_emoji_code(params.pop(0).decode('utf8')) + else: + reaction = 'thumbsup' + + if params: + duration = int(params.pop(0)) + else: + duration = 10 + + # 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 ) + 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}') + return + await self._sendreply(0, f'Will react to {username} for {duration} minutes') + + async def _annoyhandler(self, cmd: bytes) -> None: params = cmd.split(b' ') params.pop(0) @@ -683,7 +714,8 @@ async def _messageedit(self, sl_ev: slack.MessageEdit) -> None: text=seddiff(sl_ev.previous.text, sl_ev.current.text), channel=sl_ev.channel, user=sl_ev.previous.user, - thread_ts=sl_ev.previous.thread_ts + thread_ts=sl_ev.previous.thread_ts, + ts=sl_ev.previous.ts, ) await self._message(diffmsg) @@ -841,6 +873,7 @@ async def command(self, cmd: bytes) -> None: b'INVITE': self._invitehandler, b'SENDFILE': self._sendfilehandler, b'ANNOY': self._annoyhandler, + b'AUTOREACT': self._autoreacthandler, b'QUIT': self._quithandler, #CAP LS b'USERHOST': self._userhosthandler, diff --git a/man/localslackirc.1 b/man/localslackirc.1 index 56b290b..b5fd1f2 100644 --- a/man/localslackirc.1 +++ b/man/localslackirc.1 @@ -191,6 +191,18 @@ This means that whenever a typing event is received from that user, on any chann .br duration is the duration of the annoyance in minutes. It defaults to 10. .SS +.TP +.B /autoreact user probability [reaction] [duration] +To automate reacting to messages, this nice feature is available. +.br +user is the username of the user that we want to react to. +.br +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. +.SS .SH "SEE ALSO" .BR lsi-send (1), lsi-write (1) diff --git a/msgparsing.py b/msgparsing.py index a1e0234..aeb4297 100644 --- a/msgparsing.py +++ b/msgparsing.py @@ -20,10 +20,12 @@ from typing import Iterable, NamedTuple, Optional try: - from emoji import emojize # type: ignore + from emoji import emojize, demojize # type: ignore except ModuleNotFoundError: def emojize(string:str, *args, **kwargs) -> str: # type: ignore return string + def demojize(string, str, delimiters: tuple[str, str]) -> str: # type: ignore + return string SLACK_SUBSTITUTIONS = [ @@ -38,9 +40,17 @@ def emojize(string:str, *args, **kwargs) -> str: # type: ignore 'Itemkind', 'PreBlock', 'SpecialItem', + 'get_emoji_code', ] +def get_emoji_code(msg: str) -> str: + ''' + Pass a single emoji and get the code without delimiters. + ''' + return demojize(msg, ('', '')) + + def preblocks(msg: str) -> Iterable[tuple[str, bool]]: """ Iterates the preformatted and normal text blocks diff --git a/slack.py b/slack.py index 444e5fd..d1026b5 100644 --- a/slack.py +++ b/slack.py @@ -143,6 +143,7 @@ class Message: channel: str # The channel id user: str # The user id text: str + ts: float thread_ts: Optional[str] = None files: list[File] = field(default_factory=list) @@ -150,6 +151,7 @@ class Message: class NoChanMessage(NamedTuple): user: str text: str + ts: float thread_ts: Optional[str] = None @@ -338,6 +340,22 @@ class SlackStatus: """ last_timestamp: float = 0.0 + +class Autoreaction(NamedTuple): + user_id: str + reaction: str + probability: float + expiration: float + + @property + def expired(self) -> bool: + return time() > self.expiration + + def random_reaction(self) -> bool: + import random + return random.random() < self.probability + + class Slack: def __init__(self, token: str, cookie: Optional[str], previous_status: Optional[bytes]) -> None: """ @@ -362,6 +380,7 @@ 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() @@ -484,6 +503,7 @@ async def _history(self) -> None: user=msg.user, thread_ts=msg.thread_ts, files=msg.files, + ts=msg.ts, )) elif isinstance(msg, HistoryBotMessage): self._internalevents.append(MessageBot( @@ -532,6 +552,53 @@ async def typing(self, channel: Channel|str) -> None: ch_id = channel await self.client.wspacket(type='typing', channel=ch_id) + async def add_reaction(self, msg: Message, reaction: str) -> None: + r = await self.client.api_call( + 'reactions.add', + channel=msg.channel, + timestamp=msg.ts, + name=reaction, + ) + response = self.tload(r, Response) + if not response.ok: + raise ResponseException(response.error) + + async def add_autoreact(self, username: str, reaction: str, probability: float, expiration: float) -> None: + + if probability > 1 or probability < 0: + raise ValueError(f'Probability must be comprised between 0 and 1') + user_id = (await self.get_user_by_name(username)).id + + a = Autoreaction( + user_id=user_id, + reaction=reaction, + probability=probability, + expiration=expiration, + ) + + if a.expired: + raise ValueError('Expired') + + self._autoreactions.append(a) + + async def _autoreact(self, msg: Message) -> None: + for i in self._autoreactions: + # Clean up + if i.expired: + self._autoreactions.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) + return + async def topic(self, channel: Channel, topic: str) -> None: r = await self.client.api_call('conversations.setTopic', channel=channel.id, topic=topic) response: Response = self.tload(r, Response) @@ -914,10 +981,6 @@ async def event(self) -> Optional[SlackEvent]: if ts > self._status.last_timestamp: self._status.last_timestamp = ts - if ts in self._sent_by_self: - self._sent_by_self.remove(ts) - continue - if t in USELESS_EVENTS: continue @@ -951,7 +1014,14 @@ async def event(self) -> Optional[SlackEvent]: # the other user, and prepend them with "I say: " im = await self.get_im(msg.channel) if im and im.user != msg.user: - msg = Message(user=im.user, text='I say: ' + msg.text, channel=im.id, thread_ts=msg.thread_ts) + msg = Message(user=im.user, text='I say: ' + msg.text, channel=im.id, thread_ts=msg.thread_ts, ts=msg.ts) + + await self._autoreact(msg) + + if ts in self._sent_by_self: + self._sent_by_self.remove(ts) + continue + if subt == 'me_message': return ActionMessage(*msg) # type: ignore else: