2022-02-27 17:55:58 +01:00
|
|
|
|
#!/usr/bin/python3
|
2018-09-05 15:38:58 +02:00
|
|
|
|
|
2022-02-27 11:11:44 +01:00
|
|
|
|
import socket
|
|
|
|
|
import sys
|
|
|
|
|
import re
|
|
|
|
|
import select
|
|
|
|
|
import os.path
|
|
|
|
|
import configparser
|
2022-02-27 15:15:18 +01:00
|
|
|
|
import logging
|
2022-02-27 11:11:44 +01:00
|
|
|
|
from datetime import datetime, timedelta, timezone
|
2022-02-27 15:15:18 +01:00
|
|
|
|
from typing import Generator, Tuple, List
|
2021-12-05 18:30:01 +01:00
|
|
|
|
|
2018-09-05 15:38:58 +02:00
|
|
|
|
import feedparser
|
|
|
|
|
|
2022-02-27 15:15:18 +01:00
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
|
logging.basicConfig(level=logging.INFO,
|
2023-05-10 13:35:57 +02:00
|
|
|
|
format="%(levelname)s %(threadName)s %(name)s %(message)s")
|
2022-02-27 15:15:18 +01:00
|
|
|
|
|
2019-08-22 01:33:25 +02:00
|
|
|
|
# Help configuration.
|
2022-02-27 11:11:44 +01:00
|
|
|
|
HELP_LIST = ["help", "faq", "aide"]
|
|
|
|
|
HELLO_LIST = ["hello", "yo", "bonjour", "salut"]
|
2019-08-22 01:33:25 +02:00
|
|
|
|
|
|
|
|
|
# Load configuration.
|
2022-02-27 11:11:44 +01:00
|
|
|
|
CONFIG_FILENAME = "/etc/redminebot/redminebot.conf"
|
2020-02-05 10:29:53 +01:00
|
|
|
|
if len(sys.argv) == 2:
|
2022-02-27 11:11:44 +01:00
|
|
|
|
CONFIG_FILENAME = sys.argv[1]
|
2020-02-05 10:29:53 +01:00
|
|
|
|
|
2022-02-27 11:11:44 +01:00
|
|
|
|
if os.path.isfile(CONFIG_FILENAME):
|
2022-02-27 15:15:18 +01:00
|
|
|
|
LOGGER.info('Using configuration file: %s', CONFIG_FILENAME)
|
2022-02-27 11:11:44 +01:00
|
|
|
|
CONFIG = configparser.ConfigParser()
|
|
|
|
|
CONFIG.read(CONFIG_FILENAME)
|
2019-08-22 01:33:25 +02:00
|
|
|
|
|
2022-02-27 11:11:44 +01:00
|
|
|
|
DEFAULT_SERVER = CONFIG.get("IRCSection", "irc.server")
|
|
|
|
|
DEFAULT_NICKNAME = CONFIG.get("IRCSection", "irc.nickname")
|
|
|
|
|
REGISTERED = CONFIG.get("IRCSection", "irc.registered")
|
|
|
|
|
PASSWORD = CONFIG.get("IRCSection", "irc.password")
|
2019-08-25 19:42:54 +02:00
|
|
|
|
|
2022-02-27 11:11:44 +01:00
|
|
|
|
PROJECT_ID_1 = CONFIG.get("IRCSection", "irc.projects.1.id")
|
|
|
|
|
PROJECT_CHANNEL_1 = CONFIG.get("IRCSection", "irc.projects.1.channel")
|
|
|
|
|
PROJECT_ID_2 = CONFIG.get("IRCSection", "irc.projects.2.id")
|
|
|
|
|
PROJECT_CHANNEL_2 = CONFIG.get("IRCSection", "irc.projects.2.channel")
|
|
|
|
|
PROJECT_ID_3 = CONFIG.get("IRCSection", "irc.projects.3.id")
|
|
|
|
|
PROJECT_CHANNEL_3 = CONFIG.get("IRCSection", "irc.projects.3.channel")
|
2019-08-22 02:05:21 +02:00
|
|
|
|
|
2019-08-22 01:33:25 +02:00
|
|
|
|
else:
|
2022-02-27 15:15:18 +01:00
|
|
|
|
LOGGER.error("Missing configuration file.")
|
2019-08-22 01:33:25 +02:00
|
|
|
|
sys.exit()
|
|
|
|
|
|
2018-09-05 15:38:58 +02:00
|
|
|
|
|
2022-02-27 11:11:44 +01:00
|
|
|
|
class Project:
|
2018-09-05 15:38:58 +02:00
|
|
|
|
|
2022-02-27 11:11:44 +01:00
|
|
|
|
def __init__(self, project: str, channel: str):
|
2018-09-05 15:38:58 +02:00
|
|
|
|
self.name = project
|
2021-12-05 18:30:01 +01:00
|
|
|
|
self.channel = channel
|
2022-02-27 11:11:44 +01:00
|
|
|
|
self.redmine_next = datetime.now(tz=timezone.utc)
|
|
|
|
|
self.redmine_latest = datetime.now(tz=timezone.utc)
|
|
|
|
|
|
2022-02-27 15:15:18 +01:00
|
|
|
|
def generate_messages(self) -> Generator[str, None, None]:
|
2023-05-09 14:05:04 +02:00
|
|
|
|
print("Generating messages for project", self.name)
|
2022-02-27 11:11:44 +01:00
|
|
|
|
now = datetime.now(tz=timezone.utc)
|
|
|
|
|
if now < self.redmine_next:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
latest_new = self.redmine_latest
|
|
|
|
|
self.redmine_next = self.redmine_next + timedelta(seconds=10)
|
2023-05-09 14:05:04 +02:00
|
|
|
|
redmine = feedparser.parse(f"https://agir.april.org/projects/{self.name}/activity.atom?show_issues=1&show_changesets=1")
|
2022-02-27 11:11:44 +01:00
|
|
|
|
for i in reversed(range(len(redmine.entries))):
|
|
|
|
|
redmine_entry = redmine.entries[i]
|
|
|
|
|
entry_updated = parse_redmine_datetime(redmine_entry.updated)
|
|
|
|
|
|
|
|
|
|
if entry_updated > self.redmine_latest:
|
|
|
|
|
yield f"Redmine: ({redmine_entry.link}): {redmine_entry.title}"
|
|
|
|
|
|
|
|
|
|
# find the new latest time
|
|
|
|
|
if entry_updated > latest_new:
|
|
|
|
|
latest_new = entry_updated
|
|
|
|
|
|
|
|
|
|
self.redmine_latest = latest_new
|
|
|
|
|
|
|
|
|
|
|
2022-02-27 15:15:18 +01:00
|
|
|
|
def generate_command_regex(options: List[str]) -> str:
|
2022-02-27 11:11:44 +01:00
|
|
|
|
pattern = "("
|
|
|
|
|
for option in options:
|
|
|
|
|
pattern += option
|
|
|
|
|
pattern += '|'
|
|
|
|
|
pattern = pattern[:-1]
|
|
|
|
|
pattern += ")"
|
|
|
|
|
return pattern
|
|
|
|
|
|
|
|
|
|
|
2022-02-27 15:15:18 +01:00
|
|
|
|
def parse_irc_messages(irc_msg: str) -> Tuple[str, str, str]:
|
2022-02-27 11:11:44 +01:00
|
|
|
|
try:
|
|
|
|
|
actor = irc_msg.split(":")[1].split("!")[0]
|
|
|
|
|
try:
|
|
|
|
|
target = irc_msg.split(":")[1].split(" ")[2]
|
2022-02-27 15:15:18 +01:00
|
|
|
|
except Exception:
|
2023-05-10 14:57:47 +02:00
|
|
|
|
LOGGER.warning("Failed to parse target for actor='{}' in the following incoming irc message: {}".format(actor, irc_msg))
|
2022-02-27 11:11:44 +01:00
|
|
|
|
target = None
|
|
|
|
|
return " ".join(irc_msg.split()), actor, target
|
2022-02-27 15:15:18 +01:00
|
|
|
|
except Exception:
|
2023-05-10 14:57:47 +02:00
|
|
|
|
LOGGER.exception("Failed to parse the following incoming irc message: {}".format(irc_msg))
|
2022-02-27 11:11:44 +01:00
|
|
|
|
return None, None, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_redmine_datetime(redmine_date: str) -> datetime:
|
|
|
|
|
date_with_iso_tz = redmine_date[:-1] + '+00:00'
|
|
|
|
|
return datetime.fromisoformat(date_with_iso_tz)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Bot:
|
|
|
|
|
|
2022-02-27 15:15:18 +01:00
|
|
|
|
def __init__(self, server: str, bot_nick: str):
|
2022-02-27 11:11:44 +01:00
|
|
|
|
self.bot_nick = bot_nick
|
|
|
|
|
self.hello_regex = re.compile(generate_command_regex(HELLO_LIST), re.I)
|
|
|
|
|
self.help_regex = re.compile(generate_command_regex(HELP_LIST), re.I)
|
2018-09-05 15:38:58 +02:00
|
|
|
|
self.server = server
|
2021-12-05 18:30:01 +01:00
|
|
|
|
self.projects = []
|
2022-02-27 11:11:44 +01:00
|
|
|
|
self.irc_socket = None
|
|
|
|
|
|
2022-02-27 15:15:18 +01:00
|
|
|
|
def send_raw(self, msg: str) -> None:
|
2022-02-27 11:11:44 +01:00
|
|
|
|
self.irc_socket.send(msg.encode("utf-8", "ignore"))
|
2018-09-05 15:38:58 +02:00
|
|
|
|
|
2022-02-27 15:15:18 +01:00
|
|
|
|
def send_privmsg(self, channel: str, message: str) -> None:
|
|
|
|
|
self.send_raw(f"PRIVMSG {channel} :{message}\n")
|
|
|
|
|
|
|
|
|
|
def connect(self) -> None:
|
2022-02-27 11:11:44 +01:00
|
|
|
|
self.irc_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
|
|
|
self.irc_socket.connect((self.server, 6667))
|
|
|
|
|
|
2022-02-27 15:15:18 +01:00
|
|
|
|
self.send_raw(f"USER {self.bot_nick} {self.bot_nick} {self.bot_nick} :Robot Agir April"
|
|
|
|
|
f".\n") # bot authentication
|
|
|
|
|
self.send_raw(f"NICK {self.bot_nick}\n") # Assign the nick to the bot.
|
2022-02-27 11:11:44 +01:00
|
|
|
|
|
|
|
|
|
if not PASSWORD.strip() and REGISTERED is True:
|
2022-02-27 15:15:18 +01:00
|
|
|
|
self.send_privmsg("NickServ", f"IDENTIFY {self.bot_nick} {PASSWORD}")
|
2018-09-05 15:38:58 +02:00
|
|
|
|
|
2023-05-09 14:05:04 +02:00
|
|
|
|
def add_project(self, project: Project) -> None:
|
2022-02-27 15:15:18 +01:00
|
|
|
|
self.send_raw("JOIN {} \n".format(project.channel)) # Joins channel
|
2018-09-05 15:38:58 +02:00
|
|
|
|
self.projects.append(project)
|
|
|
|
|
|
2023-05-09 14:16:01 +02:00
|
|
|
|
def get_project(self, name: str) -> Project:
|
2018-09-05 15:38:58 +02:00
|
|
|
|
for project in self.projects:
|
2022-02-27 11:11:44 +01:00
|
|
|
|
if name[0] != '#' and project.name == name:
|
2018-09-05 15:38:58 +02:00
|
|
|
|
return project
|
2022-02-27 11:11:44 +01:00
|
|
|
|
if name[0] == '#' and project.channel == name:
|
2018-09-05 15:38:58 +02:00
|
|
|
|
return project
|
2022-02-27 11:11:44 +01:00
|
|
|
|
return None
|
2018-09-05 15:38:58 +02:00
|
|
|
|
|
|
|
|
|
# Main loop
|
2022-02-27 15:15:18 +01:00
|
|
|
|
def loop_forever(self) -> None:
|
2022-02-27 11:11:44 +01:00
|
|
|
|
last_read = datetime.utcnow()
|
|
|
|
|
|
|
|
|
|
while True: # Loop forever
|
|
|
|
|
ready_to_read, _fd2, _fd3 = select.select([self.irc_socket], [], [], 1)
|
|
|
|
|
|
2018-09-05 15:38:58 +02:00
|
|
|
|
if ready_to_read:
|
2022-02-27 11:11:44 +01:00
|
|
|
|
last_read = datetime.utcnow()
|
|
|
|
|
irc_msg = self.msg_handler()
|
2023-05-10 13:37:30 +02:00
|
|
|
|
|
|
|
|
|
# If the server pings us then we've got to respond!
|
|
|
|
|
if irc_msg.find("PING :") != -1:
|
|
|
|
|
self.pong(irc_msg)
|
|
|
|
|
LOGGER.info("Responding to ping message: {}".format(irc_msg))
|
|
|
|
|
continue
|
|
|
|
|
|
2022-02-27 11:11:44 +01:00
|
|
|
|
irc_msg, actor, channel = parse_irc_messages(irc_msg)
|
|
|
|
|
|
|
|
|
|
if irc_msg is not None:
|
|
|
|
|
self.message_response(irc_msg, actor, channel)
|
|
|
|
|
|
|
|
|
|
if datetime.utcnow() - last_read > timedelta(minutes=10):
|
|
|
|
|
raise Exception('timeout: nothing to read on socket since 10 minutes')
|
2018-09-05 15:38:58 +02:00
|
|
|
|
|
|
|
|
|
# Responds to server Pings.
|
2022-02-27 15:15:18 +01:00
|
|
|
|
def pong(self, irc_msg: str) -> None:
|
2022-02-27 11:11:44 +01:00
|
|
|
|
response = f"PONG :{irc_msg.split('PING :')[1]}\n"
|
2022-02-27 15:15:18 +01:00
|
|
|
|
self.send_raw(response)
|
2022-02-27 11:11:44 +01:00
|
|
|
|
self._check_global_updates()
|
|
|
|
|
|
2022-02-27 15:15:18 +01:00
|
|
|
|
def _check_global_updates(self) -> None:
|
|
|
|
|
LOGGER.info("Checking all projects updates...")
|
2018-09-05 15:38:58 +02:00
|
|
|
|
|
|
|
|
|
for project in self.projects:
|
2022-02-27 11:11:44 +01:00
|
|
|
|
|
2022-02-27 15:15:18 +01:00
|
|
|
|
for message in project.generate_messages():
|
|
|
|
|
self.send_privmsg(project.channel, message)
|
2022-02-27 11:11:44 +01:00
|
|
|
|
|
2022-02-27 15:15:18 +01:00
|
|
|
|
def _check_project_updates(self, channel_name: str) -> None:
|
|
|
|
|
LOGGER.info("Checking project of channel %s updates...", channel_name)
|
2022-02-27 11:11:44 +01:00
|
|
|
|
project = self.get_project(channel_name)
|
|
|
|
|
|
|
|
|
|
if not project:
|
2022-02-27 15:15:18 +01:00
|
|
|
|
LOGGER.error("Cannot find project for channel %s", channel_name)
|
2022-02-27 11:11:44 +01:00
|
|
|
|
return
|
2023-05-09 14:05:04 +02:00
|
|
|
|
print(project)
|
2022-02-27 11:11:44 +01:00
|
|
|
|
|
2023-05-09 14:05:04 +02:00
|
|
|
|
try:
|
|
|
|
|
for message in project.generate_messages():
|
|
|
|
|
self.send_privmsg(project.channel, message)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
LOGGER.error("Error while checking project %s updates: %s", project.name, e)
|
2018-09-05 15:38:58 +02:00
|
|
|
|
|
|
|
|
|
# Parses messages and responds to them appropriately.
|
2022-02-27 15:15:18 +01:00
|
|
|
|
def message_response(self, irc_msg: str, actor: str, channel: str) -> None:
|
2018-09-05 15:38:58 +02:00
|
|
|
|
# If someone talks to (or refers to) the bot.
|
2022-02-27 11:11:44 +01:00
|
|
|
|
if (':!' in irc_msg or self.bot_nick.lower() in irc_msg.lower()) and \
|
|
|
|
|
actor != self.bot_nick and \
|
|
|
|
|
"PRIVMSG".lower() in irc_msg.lower():
|
|
|
|
|
if self.hello_regex.search(irc_msg):
|
|
|
|
|
self.bot_hello(channel, 'Yo!')
|
|
|
|
|
if self.help_regex.search(irc_msg):
|
2018-09-05 15:38:58 +02:00
|
|
|
|
self.bot_help(channel)
|
2022-02-27 11:11:44 +01:00
|
|
|
|
if 'refresh' in irc_msg.lower():
|
2022-02-27 15:15:18 +01:00
|
|
|
|
self.send_privmsg(channel, "Raffraîchissement en cours")
|
2022-02-27 11:11:44 +01:00
|
|
|
|
self._check_project_updates(channel)
|
2022-02-27 15:15:18 +01:00
|
|
|
|
self.send_privmsg(channel, "Fait !")
|
2018-09-05 15:38:58 +02:00
|
|
|
|
|
|
|
|
|
# Responds to a user that inputs "Hello Mybot".
|
2022-02-27 15:15:18 +01:00
|
|
|
|
def bot_hello(self, channel: str, greeting: str) -> None:
|
|
|
|
|
self.send_privmsg(channel, greeting)
|
2018-09-05 15:38:58 +02:00
|
|
|
|
|
|
|
|
|
# Explains what the bot is when queried.
|
2022-02-27 15:15:18 +01:00
|
|
|
|
def bot_help(self, channel: str) -> None:
|
|
|
|
|
self.send_privmsg(channel, "Bonjour, je suis un bot qui reconnaît les options !help, !refresh et !bonjour")
|
|
|
|
|
project = self.get_project(channel)
|
|
|
|
|
|
|
|
|
|
if not project:
|
|
|
|
|
LOGGER.warning("Project of channel %s not found", channel)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self.send_privmsg(channel, f"Périodiquement, je vais afficher les actualités "
|
|
|
|
|
f"de http://agir.april.org/projects/{project.name}.")
|
2022-02-27 11:11:44 +01:00
|
|
|
|
|
|
|
|
|
# Reads the messages from the server and adds them to the Queue and prints
|
2018-09-05 15:38:58 +02:00
|
|
|
|
# them to the console. This function will be run in a thread, see below.
|
2022-02-27 15:15:18 +01:00
|
|
|
|
def msg_handler(self) -> str: # pragma: no cover (this excludes this function from testing)
|
2022-02-27 11:11:44 +01:00
|
|
|
|
new_msg = self.irc_socket.recv(2048) # receive data from the server
|
2020-07-04 18:24:58 +02:00
|
|
|
|
if not new_msg:
|
2022-02-27 15:15:18 +01:00
|
|
|
|
LOGGER.error("Empty recv. It seems I’ve lost my mind. I stop to be reborn.")
|
2022-02-27 11:11:44 +01:00
|
|
|
|
sys.exit(7)
|
2020-07-04 18:24:58 +02:00
|
|
|
|
else:
|
2022-02-27 11:11:44 +01:00
|
|
|
|
new_msg = new_msg.decode("utf-8").strip('\n\r') # removing any unnecessary linebreaks
|
|
|
|
|
if new_msg != '' and new_msg.find("PING :") == -1:
|
2022-02-27 15:15:18 +01:00
|
|
|
|
LOGGER.debug(new_msg)
|
2018-09-05 15:38:58 +02:00
|
|
|
|
return new_msg
|
|
|
|
|
|
2021-12-05 18:30:01 +01:00
|
|
|
|
|
2022-02-27 15:15:18 +01:00
|
|
|
|
def main() -> None:
|
|
|
|
|
LOGGER.info("redmine bot starting…")
|
2022-02-27 11:11:44 +01:00
|
|
|
|
redmine_bot = Bot(DEFAULT_SERVER, DEFAULT_NICKNAME)
|
2018-09-05 15:38:58 +02:00
|
|
|
|
redmine_bot.connect()
|
2023-05-09 14:05:04 +02:00
|
|
|
|
LOGGER.info("redmine bot connected")
|
2022-02-27 11:11:44 +01:00
|
|
|
|
if PROJECT_ID_1 and PROJECT_CHANNEL_1:
|
|
|
|
|
redmine_bot.add_project(Project(PROJECT_ID_1, PROJECT_CHANNEL_1))
|
|
|
|
|
if PROJECT_ID_2 and PROJECT_CHANNEL_2:
|
|
|
|
|
redmine_bot.add_project(Project(PROJECT_ID_2, PROJECT_CHANNEL_2))
|
|
|
|
|
if PROJECT_ID_3 and PROJECT_CHANNEL_3:
|
|
|
|
|
redmine_bot.add_project(Project(PROJECT_ID_3, PROJECT_CHANNEL_3))
|
2022-02-27 15:15:18 +01:00
|
|
|
|
redmine_bot.loop_forever()
|
2021-12-05 18:30:01 +01:00
|
|
|
|
|
2018-09-05 15:38:58 +02:00
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2022-02-27 15:15:18 +01:00
|
|
|
|
main()
|