From 245249eee8cced7b5b4ff56ddba022552a4100b6 Mon Sep 17 00:00:00 2001 From: Dimitri Date: Sat, 30 Apr 2022 00:22:10 +0200 Subject: [PATCH] Matrix capability integration To support Matrix as an outlet for ghi I: -edited ghi/configuration.py to read, check and use the (optional) Matrix-settings from the .yml -created ghi/ghimatrix.py to facilitate creating credential-files, logging onto a Matrix server and sending messages to one or more rooms on it by using the matrix-nio module -changed some naming and matrix-specific things in most of the files -changed the README.md en .ghy.yml.example to include the new Matrix-support -added relevant exception-handlers No changes to the config-file are necessary when using IRC. It works quite well with server.py, haven't tested it with AWS though, but should work as well. Closes #27 --- README.md | 128 ++++++++++++++++++--------- ghi/configuration.py | 171 +++++++++++++++++++++++++++++++++++-- ghi/events/pull_request.py | 17 +++- ghi/events/push.py | 59 ++++++++++--- ghi/ghimatrix.py | 118 +++++++++++++++++++++++++ ghi/github.py | 6 +- ghi/index.py | 65 +++++++++----- requirements-server.txt | 1 + 8 files changed, 485 insertions(+), 80 deletions(-) create mode 100644 ghi/ghimatrix.py diff --git a/README.md b/README.md index dbc55c7..9e2d7db 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ **G**it**H**ub **I**RC Notification Service -Ghi (pronounced 'ghee') is a relay between GitHub and IRC and/or Mastodon. It was created to take the place of the [now depreciated](https://developer.github.com/changes/2018-04-25-github-services-deprecation/) [GitHub IRC Service](https://github.com/github/github-services/blob/master/lib/services/irc.rb). Ghi receives events from GitHub for a specified repository via a webhook. Then it parses the event and sends the relevant information to your configured IRC channels and/or Mastodon timeline. Ghi was written to be very configuration driven. Therefore, Ghi is set up with a `.ghi.yml` file and can listen for multiple repositories and send to multiple IRC channels. Most of the features in the original GitHub Service are supported in Ghi as well. +Ghi (pronounced 'ghee') is a relay between GitHub and IRC and/or Mastodon. It was created to take the place of the [now depreciated](https://developer.github.com/changes/2018-04-25-github-services-deprecation/) [GitHub IRC Service](https://github.com/github/github-services/blob/master/lib/services/irc.rb). Ghi receives events from GitHub for a specified repository via a webhook. Then it parses the event and sends the relevant information to your configured IRC channels and/or Mastodon timeline and/or Matrix rooms. Ghi was written to be very configuration driven. Therefore, Ghi is set up with a `.ghi.yml` file and can listen for multiple repositories and send to multiple IRC channels. Most of the features in the original GitHub Service are supported in Ghi as well. # Getting Started -Ghi was designed and written to be ran in [AWS Lambda](https://aws.amazon.com/lambda/) with [API Gateway](https://aws.amazon.com/api-gateway/). However, I've also created a very simple HTTP server implementation so Ghi can be ran on any server if desired. Ghi is configured entirely with the `.ghi.yml` file. In this file you will set all necessary information including repositories, IRC nick, IRC host, channels, Mastodon instance, Mastodon user, etc. +Ghi was designed and written to be ran in [AWS Lambda](https://aws.amazon.com/lambda/) with [API Gateway](https://aws.amazon.com/api-gateway/). However, I've also created a very simple HTTP server implementation so Ghi can be ran on any server if desired. Ghi is configured entirely with the `.ghi.yml` file. In this file you will set all necessary information including repositories, IRC nick, IRC host, channels, Mastodon instance, Mastodon user, Matrix homeserver, Matrix rooms, etc. ## Deployment @@ -38,6 +38,14 @@ Ghi supports pushing messages to Mastodon. Since Ghi, as the name implies, is ma $ pip3 install mastodon-py ``` +### Matrix + +Ghi also supports pushing messages to Matrix. Again, since Ghi, as the name implies, is mainly focused on IRC the module requirement for Mastodon is optional. If you want to use Matrix as one of the outlets the matrix-nio module is required: + +``` +$ pip3 install matrix-nio +``` + ## Setting Configuration ### .ghi.yml @@ -56,7 +64,7 @@ To explain, if I have a `~/.ghi.yml` file and a `./.ghi.yml` file in my current #### Contents -The Ghi file is where you specify things like repositories, branches, channels, IRC details, etc. Ghi uses something called a "Pool" to determine which events do what. A Pool can have 1 or more repositories and 1 or more channels. You can also list multiple pools in a single Ghi instance. So you could have both `gkrizek/repo1` and `gkrizek/repo2` sending messages to `#my-cool-channel` while also having `gkrizek/repo3` sending messages to `#other-cool-channel` and `#last-cool-channel` (and of course many more variations of that). +The Ghi file is where you specify things like repositories, branches, channels, IRC/Mastodon/Matrix details, etc. Ghi uses something called a "Pool" to determine which events do what. A Pool can have 1 or more repositories and 1 or more channels. You can also list multiple pools in a single Ghi instance. So you could have both `gkrizek/repo1` and `gkrizek/repo2` sending messages to `#my-cool-channel` while also having `gkrizek/repo3` sending messages to `#other-cool-channel` and `#last-cool-channel` (and of course many more variations of that). The top two required parameters of the Ghi file are `version` and `pools`. Currently there is only a version `1` of the Ghi file, but `pools` will be a list of Pool configurations. Each Pool is required to define some GitHub information like repository names and validation secrets. They will also need to specify IRC data like nick, host, password, and channels. @@ -81,11 +89,20 @@ pools: instance: https://mstdn.social user: happy@place.net password: myBotPassword123! - secretspath: /home/thatsme/my/secrets/ + secretspath: /home/thatsme/my/secrets/ # can be the same as matrix' credentialspath appname: my-mastodon-bot + matrix: + homeserver: https://a.matrix.srv + user: @ghibot:matrix.srv + password: anotherGreatPassword456! + credentialspath: /home/thatsme/my/credentials/ # can be the same as mastodon's secretpath + device_id: Ghi-Matrix-Bot + rooms: + - "#room:matrix.srv" outlets: - irc - - mastodon + - mastodon + - matrix ``` _More Ghi file examples in [`examples/.ghi.yml.md`](examples/.ghi.yml.md)._ @@ -148,45 +165,56 @@ Ghi is configurable and supports lots of combinations of repositories, channels, **Global Configuration Object** -| Name | Default | Required | Description | -|:-------------------- |:-----------------------------------------:|:--------:| ----------------------------------------------------:| -| outlets | ["irc"] | No | List of outlets to send messages to | -| github:shorten_url | False | No | Shorten all GitHub links with git.io | -| github:verify | True | No | Verify the payload with the `X-Hub-Signature` header | -| irc:host | None | No | Hostname for IRC Server | -| irc:port | 6697 if SSL enabled, 6667 if SSL disabled | No | Port for IRC Server | -| irc:ssl | True | No | Connect to IRC Server with SSL | -| irc:nick | None | No | IRC Nickname | -| irc:password | None | No | IRC Password | -| mastodon:instance | None | No | Hostname for Mastodon Instance | -| mastodon:user | None | No | Mastodon User (registered E-mail address) | -| mastodon:password | None | No | Mastodon Password | -| mastodon:secretspath | None | No | Path to Client and User Credential-Files (.secret) | -| mastodon:appname | None | No | Name of the App for registration at the Instance | -| mastodon:merges_only | True | No | Only toot merges to the Instance | +| Name | Default | Required | Description | +|:---------------------- |:-----------------------------------------:|:--------:| ----------------------------------------------------:| +| outlets | ["irc"] | No | List of outlets to send messages to | +| github:shorten_url | False | No | Shorten all GitHub links with git.io | +| github:verify | True | No | Verify the payload with the `X-Hub-Signature` header | +| irc:host | None | No | Hostname for IRC Server | +| irc:port | 6697 if SSL enabled, 6667 if SSL disabled | No | Port for IRC Server | +| irc:ssl | True | No | Connect to IRC Server with SSL | +| irc:nick | None | No | IRC Nickname | +| irc:password | None | No | IRC Password | +| mastodon:instance | None | No | Hostname for Mastodon Instance | +| mastodon:user | None | No | Mastodon User (registered E-mail address) | +| mastodon:password | None | No | Mastodon Password | +| mastodon:secretspath | None | No | Path to Client and User Credential-Files (.secret) | +| mastodon:appname | None | No | Name of the App for registration at the Instance | +| mastodon:merges_only | True | No | Only toot merges to the Instance | +| matrix:homeserver | None | No | Hostname for Matrix Server | +| matrix:user | None | No | Matrix User | +| matrix:password | None | No | Matrix Password | +| matrix:credentialspath | None | No | Path to Matrix` User Credential-Files (.json) | +| matrix:device_id | "Ghi-Matrix-Bot" | No | Name of the device(/app) being shown at M's userinfo | **Pool Configuration Object** -| Name | Default | Required~ | Description | -|:-------------------- |:-----------------------------------------:|:---------:| ----------------------------------------------------:| -| name | None | Yes | Name of the Pool | -| outlets | ["irc"] | No | List of outlets to send messages to | -| github:repos | None | Yes | List of Repository Configuration Objects | -| github:shorten_url | False | No | Shorten all GitHub links with git.io | -| irc:host | None | Yes | Hostname for IRC Server | -| irc:port | 6697 if SSL enabled, 6667 if SSL disabled | No | Port for IRC Server | -| irc:ssl | True | No | Connect to IRC Server with SSL | -| irc:nick | None | Yes | IRC Nickname | -| irc:password | None | No | IRC Password | -| irc:channels | None | Yes | List of channels to send messages to | -| mastodon:instance | None | Yes | Hostname for Mastodon Instance | -| mastodon:user | None | Yes | Mastodon User (registered E-mail address) | -| mastodon:password | None | Yes | Mastodon Password | -| mastodon:secretspath | None | Yes | Path to Client and User Credential-Files (.secret) | -| mastodon:appname | None | Yes | Name of the App for registration at the Instance | -| mastodon:merges_only | True | No | Only toot merges to the Instance | - -~ For irc:* and mastodon:* : if they're one of the configured outlets. +| Name | Default | Required~ | Description | +|:---------------------- |:-----------------------------------------:|:---------:| ----------------------------------------------------:| +| name | None | Yes | Name of the Pool | +| outlets | ["irc"] | No | List of outlets to send messages to | +| github:repos | None | Yes | List of Repository Configuration Objects | +| github:shorten_url | False | No | Shorten all GitHub links with git.io | +| irc:host | None | Yes | Hostname for IRC Server | +| irc:port | 6697 if SSL enabled, 6667 if SSL disabled | No | Port for IRC Server | +| irc:ssl | True | No | Connect to IRC Server with SSL | +| irc:nick | None | Yes | IRC Nickname | +| irc:password | None | No | IRC Password | +| irc:channels | None | Yes | List of channels to send messages to | +| mastodon:instance | None | Yes | Hostname for Mastodon Instance | +| mastodon:user | None | Yes | Mastodon User (registered E-mail address) | +| mastodon:password | None | Yes | Mastodon Password | +| mastodon:secretspath | None | Yes | Path to Client and User Credential-Files (.secret) | +| mastodon:appname | None | Yes | Name of the App for registration at the Instance | +| mastodon:merges_only | True | No | Only toot merges to the Instance | +| matrix:homeserver | None | Yes | Hostname for Matrix Server | +| matrix:user | None | Yes | Matrix User | +| matrix:password | None | Yes | Matrix Password | +| matrix:credentialspath | None | Yes | Path to Matrix' User Credential-Files (.json) | +| matrix:device_id | "Ghi-Matrix-Bot" | No | Name of the device(/app) being shown at M's userinfo | +| matrix:rooms | None | Yes | List of rooms to send messages to | + +~ For all irc, mastodon, and matrix settings: if they're one of the configured outlets. **Repository Configuration Object** @@ -220,9 +248,16 @@ global: # optional secretspath: /home/thatsme/my/secrets/ # optional, but must be set in pool if not here and needed (i.e. Mastodon is one of the outlets) appname: my-mastodon-bot # optional, but must be set in pool if not here and needed (i.e. Mastodon is one of the outlets) merges_only: true # optional, default is true + matrix: # optional + homeserver: https://a.matrix.srv # optional, but must be set in pool if not here and needed (i.e. Matrix is one of the outlets) + user: @ghibot:matrix.srv # optional, but must be set in pool if not here and needed (i.e. Matrix is one of the outlets) + password: anotherGreatPassword456! # optional, but must be set in pool if not here and needed (i.e. Matrix is one of the outlets) + credentialspath: /home/thatsme/my/credentials/ # optional, but must be set in pool if not here and needed (i.e. Matrix is one of the outlets) + device_id: Ghi-Matrix-Bot # optional, default is "Ghi-Matrix-Bot" outlets: # optional, default is irc - irc - mastodon + - matrix pools: # required - name: my-pool # required @@ -247,12 +282,21 @@ pools: # required instance: https://mstdn.social # required user: happy@place.net # required password: myBotPassword123! # required - secretspath: /home/thatsme/my/secrets/ # required + secretspath: /home/thatsme/my/secrets/ # required, can be the same as matrix' credentialspath appname: my-mastodon-bot # required merges_only: true # optional, default is true + matrix: + homeserver: https://a.matrix.srv # required + user: @ghibot:matrix.srv # required + password: anotherGreatPassword456! # required + credentialspath: /home/thatsme/my/credentials/ # required, can be the same as mastodon's secretpath + device_id: Ghi-Matrix-Bot # optional, default is "Ghi-Matrix-Bot" + rooms: # required + - "#room:matrix.srv" # at least 1 room is required outlets: # optional, default is irc - irc - mastodon + - matrix ``` If you define a parameter in the Global section and in your pool, the value in the pool will be used. diff --git a/ghi/configuration.py b/ghi/configuration.py index 901749d..d002a80 100644 --- a/ghi/configuration.py +++ b/ghi/configuration.py @@ -3,13 +3,15 @@ import os import yaml -SUPPORTED_OUTLETS = ["irc", "mastodon"] +SUPPORTED_OUTLETS = ["irc", "mastodon", "matrix"] +MATRIX_DEVICE_ID = "Ghi-Matrix-Bot" class Pool(object): def __init__(self, name, outlets, repos, shorten, ircHost, ircPort, ircSsl, ircNick, ircPassword, ircChannels,\ - mastInstance, mastUser, mastPassword, mastSecPath, mastAppName, mastMergeFilter): + mastInstance, mastUser, mastPassword, mastSecPath, mastAppName, mastMergeFilter,\ + matrixUser, matrixPassword, matrixServer, matrixRooms, matrixCredPath, matrixDevId): self.name = name self.outlets = outlets self.repos = repos @@ -26,10 +28,17 @@ def __init__(self, name, outlets, repos, shorten, ircHost, ircPort, ircSsl, ircN self.mastSecPath = mastSecPath self.mastAppName = mastAppName self.mastMergeFilter = mastMergeFilter + self.matrixUser = matrixUser + self.matrixPassword = matrixPassword + self.matrixServer = matrixServer + self.matrixRooms = matrixRooms + self.matrixCredPath = matrixCredPath + self.matrixDevId = matrixDevId def containsRepo(self, repo): - for configRepo in self.repos: + for configRepo in self.repos: + print(configRepo) if repo == configRepo["name"]: return True return False @@ -44,7 +53,8 @@ class GlobalConfig(object): def __init__(self, ircHost, ircPort, ircSsl, ircNick, ircPassword, mastInstance, mastUser,\ - mastPassword, mastSecPath, mastAppName, mastMergeFilter, shorten, verify, outlets): + mastPassword, mastSecPath, mastAppName, mastMergeFilter, shorten, verify, outlets,\ + matrixUser, matrixPassword, matrixServer, matrixCredPath, matrixDevId): self.ircHost = ircHost self.ircPort = ircPort self.ircSsl = ircSsl @@ -59,6 +69,11 @@ def __init__(self, ircHost, ircPort, ircSsl, ircNick, ircPassword, mastInstance, self.shorten = shorten self.verify = verify self.outlets = outlets + self.matrixUser = matrixUser + self.matrixPassword = matrixPassword + self.matrixServer = matrixServer + self.matrixCredPath = matrixCredPath + self.matrixDevId = matrixDevId def getConfiguration(): @@ -242,6 +257,48 @@ def getConfiguration(): globalMastAppName = None globalMastMergeFilter = None + if "matrix" in globalConfig: + if "user" in globalConfig["matrix"]: + globalMatrixUser = globalConfig["matrix"]["user"] + if type(globalMatrixUser) is not str: + raise TypeError("'user' is not a string") + else: + globalMatrixUser = None + + if "password" in globalConfig["matrix"]: + globalMatrixPassword = globalConfig["matrix"]["password"] + if type(globalMatrixPassword) is not str: + raise TypeError("'password' is not a string") + else: + globalMatrixPassword = None + + if "homeserver" in globalConfig["matrix"]: + globalMatrixServer = globalConfig["matrix"]["homeserver"] + if type(globalMatrixServer) is not str: + raise TypeError("'homeserver' is not a string") + else: + globalMatrixServer = None + + if "credentialspath" in globalConfig["matrix"]: + globalMatrixCredPath = globalConfig["matrix"]["credentialspath"] + if type(globalMatrixCredPath) is not str: + raise TypeError("'credentialspath' is not a string") + else: + globalMatrixCredPath = None + + if "device_id" in globalConfig["matrix"]: + globalMatrixDevId = globalConfig["matrix"]["device_id"] + if type(globalMatrixDevId) is not str: + raise TypeError("'device_id' is not a string") + else: + globalMatrixDevId = MATRIX_DEVICE_ID + else: + globalMatrixUser = None + globalMatrixPassword = None + globalMatrixServer = None + globalMatrixCredPath = None + globalMatrixDevId = None + if "github" in globalConfig: if "shorten_url" in globalConfig["github"]: globalShorten = globalConfig["github"]["shorten_url"] @@ -292,6 +349,11 @@ def getConfiguration(): mastSecPath = globalMastSecPath, mastAppName = globalMastAppName, mastMergeFilter = globalMastMergeFilter, + matrixUser = globalMatrixUser, + matrixPassword = globalMatrixPassword, + matrixServer = globalMatrixServer, + matrixCredPath = globalMatrixCredPath, + matrixDevId = globalMatrixDevId, shorten = globalShorten, verify = globalVerify, outlets = globalGeneratedOutlets @@ -575,6 +637,91 @@ def getConfiguration(): else: mastMergeFilter = True + if "matrix" in generatedOutlets and "matrix" in pool: + if "user" in pool["matrix"]: + matrixUser = pool["matrix"]["user"] + elif globalSettings.matrixUser: + matrixUser = globalSettings.matrixUser + else: + raise KeyError("user") + if type(matrixUser) is not str: + raise TypeError("'user' is not a string") + + if "password" in pool["matrix"]: + matrixPassword = pool["matrix"]["password"] + elif globalSettings.matrixPassword: + matrixPassword = globalSettings.matrixPassword + else: + raise KeyError("password") + if type(matrixPassword) is not str: + raise TypeError("'password' is not a string") + + if "homeserver" in pool["matrix"]: + matrixServer = pool["matrix"]["homeserver"] + elif globalSettings.matrixServer: + matrixServer = globalSettings.matrixServer + else: + raise KeyError("homeserver") + if type(matrixServer) is not str: + raise TypeError("'homeserver' is not a string") + + if "credentialspath" in pool["matrix"]: + matrixCredPath = pool["matrix"]["credentialspath"] + elif globalSettings.matrixCredPath: + matrixCredPath = globalSettings.matrixCredPath + else: + raise KeyError("credentialspath") + if type(matrixCredPath) is not str: + raise TypeError("'credentialspath' is not a string") + + if "device_id" in pool["matrix"]: + matrixDevId = pool["matrix"]["device_id"] + elif globalSettings.matrixDevId: + matrixDevId = globalSettings.matrixDevId + else: + matrixDevId = MATRIX_DEVICE_ID + if type(matrixDevId) is not str: + raise TypeError("'device_id' is not a string") + + if "rooms" in pool["matrix"]: + matrixRooms = pool["matrix"]["rooms"] + else: + raise KeyError("rooms") + if type(matrixRooms) is not list: + raise TypeError("'rooms' is not a list") + if len(matrixRooms) < 1: + raise TypeError("'rooms' must contain at least 1 item") + + generatedMatrixRooms = list() + for room in matrixRooms: + generatedMatrixRooms.append(room)#"+room) + + elif "matrix" in generatedOutlets and "matrix" in globalConfig: + if globalSettings.matrixUser: + matrixUser = globalSettings.matrixUser + else: + raise KeyError("user") + + if globalSettings.matrixPassword: + matrixPassword = globalSettings.matrixPassword + else: + raise KeyError("password") + + if globalSettings.matrixServer: + matrixServer = globalSettings.matrixServer + else: + raise KeyError("homeserver") + + if globalSettings.matrixCredPath: + matrixCredPath = globalSettings.matrixCredPath + else: + raise KeyError("credentialspath") + + if globalSettings.matrixDevId: + matrixDevId = globalSettings.matrixDevId + else: + matrixDevId = MATRIX_DEVICE_ID + except (KeyError, TypeError) as e: errorMessage = "Missing or invalid parameter in configuration file: %s" % e logging.error(errorMessage) @@ -602,6 +749,14 @@ def getConfiguration(): mastAppName = None mastMergeFilter = None + if "matrix" not in generatedOutlets: + matrixUser = None + matrixPassword = None + matrixServer = None + generatedMatrixRooms = None + matrixCredPath = None + matrixDevId = None + pools.append( Pool( name=name, @@ -619,7 +774,13 @@ def getConfiguration(): mastPassword=mastPassword, mastSecPath=mastSecPath, mastAppName=mastAppName, - mastMergeFilter=mastMergeFilter + mastMergeFilter=mastMergeFilter, + matrixUser=matrixUser, + matrixPassword=matrixPassword, + matrixServer=matrixServer, + matrixRooms=generatedMatrixRooms, + matrixCredPath=matrixCredPath, + matrixDevId=matrixDevId ) ) diff --git a/ghi/events/pull_request.py b/ghi/events/pull_request.py index 1047e1b..b617bea 100644 --- a/ghi/events/pull_request.py +++ b/ghi/events/pull_request.py @@ -53,10 +53,25 @@ def PullRequest(payload, shorten): url = url ) + matrixMessage = ( + '[{repo}] {user} {action} pull request #{number}: {title} ' + '({baseBranch}...{headBranch}) {url}' + ).format( + repo = payload["pull_request"]["base"]["repo"]["name"], + user = payload["sender"]["login"], + action = action, + number = payload["number"], + title = payload["pull_request"]["title"], + baseBranch = payload["pull_request"]["base"]["ref"], + headBranch = payload["pull_request"]["head"]["ref"], + url = url + ) + return { "statusCode": 200, "ircMessages": [ircMessage], - "mastMessages": [mastMessage] + "mastMessages": [mastMessage], + "matrixMessages": [matrixMessage] } else: diff --git a/ghi/events/push.py b/ghi/events/push.py index bc4952d..3f3c599 100644 --- a/ghi/events/push.py +++ b/ghi/events/push.py @@ -54,22 +54,35 @@ def Push(payload, poolRepos, shorten): compareUrl = url ) + matrixMessage = ( + '[{repo}] {user} {action} ' + 'tag {tag}: {compareUrl}' + ).format( + repo = payload["repository"]["name"], + user = payload["pusher"]["name"], + action = action, + tag = ref.split("/", maxsplit=2)[2], + compareUrl = url + ) + return { "statusCode": 200, "ircMessages": [ircMessage], - "mastMessages": [mastMessage] + "mastMessages": [mastMessage], + "matrixMessages": [matrixMessage] } else: # Commits were pushed - ircMessages = [] - mastMessages = [] - commits = payload["commits"] - repo = payload["repository"]["name"] - fullName = payload["repository"]["full_name"] - user = payload["pusher"]["name"] - length = len(commits) - branch = ref.split("/", maxsplit=2)[2] + ircMessages = [] + mastMessages = [] + matrixMessages = [] + commits = payload["commits"] + repo = payload["repository"]["name"] + fullName = payload["repository"]["full_name"] + user = payload["pusher"]["name"] + length = len(commits) + branch = ref.split("/", maxsplit=2)[2] # Check if the pool has allowed branches set. # If they do, make sure that this branch is included @@ -143,6 +156,20 @@ def Push(payload, poolRepos, shorten): ) ) + matrixMessages.append( + '[{repo}] {user} {action} ' + '{length} commit{plural} to {branch}: ' + '{compareUrl}'.format( + repo = repo, + user = user, + action = action, + length = length, + branch = branch, + compareUrl = url, + plural = plural + ) + ) + # First 3 individual commits num = 0 for commit in commits: @@ -179,10 +206,22 @@ def Push(payload, poolRepos, shorten): ) ) + matrixMessages.append( + '{repo}/{branch} ' + '{shortCommit} {user}: {message}'.format( + repo = repo, + branch = branch, + shortCommit = commit["id"][0:7], + user = author, + message = commitMessage + ) + ) + num += 1 return { "statusCode": 200, "ircMessages": ircMessages, - "mastMessages": mastMessages + "mastMessages": mastMessages, + "matrixMessages": matrixMessages } diff --git a/ghi/ghimatrix.py b/ghi/ghimatrix.py new file mode 100644 index 0000000..fe4d151 --- /dev/null +++ b/ghi/ghimatrix.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 + +import asyncio +import json +import os +import sys +import logging + +try: + from nio import AsyncClient, LoginResponse +except ImportError: + pass + + +CRED_FILE = "ghi_matrix_credentials.json" + + +def createCreds(resp: LoginResponse, homeserver, matrixCredFile) -> None: + """Writes the required login details to disk so we can log in later without + using a password. + + Arguments: + resp {LoginResponse} -- the successful client login response. + homeserver -- URL of homeserver, e.g. "https://matrix.example.org" + """ + # open the config file in write-mode + with open(matrixCredFile, "w") as f: + # write the login details to disk + json.dump( + { + "homeserver": homeserver, + "user_id": resp.user_id, + "device_id": resp.device_id, + "access_token": resp.access_token, + }, + f, + ) + + +def sendMessages(*args, **kwargs) -> None: + return asyncio.run(_sendMessages(*args, **kwargs)) + + +async def _sendMessages(pool, messages) -> None: + homeserver = pool.matrixServer + user_id = pool.matrixUser + password = pool.matrixPassword + deviceName = pool.matrixDevId + credPath = pool.matrixCredPath + rooms = pool.matrixRooms + + matrixCredFile = os.path.join(credPath, CRED_FILE) + # If there are no previously-saved credentials, we'll use the password + if not os.path.exists(matrixCredFile): + print( + "First time use. Did not find credential file. Asking for " + "homeserver, user, and password to create credential file." + ) + + if not (homeserver.startswith("https://") or homeserver.startswith("http://")): + homeserver = "https://" + homeserver + + client = AsyncClient(homeserver, user_id) + + resp = await client.login(password, device_name=deviceName) + + # check that we logged in succesfully + if isinstance(resp, LoginResponse): + createCreds(resp, homeserver, matrixCredFile) + else: + print(f'homeserver = "{homeserver}"; user = "{user_id}"') + print(f"Failed to log in: {resp}") + sys.exit(1) + + print( + "Logged in using a password. Credentials were stored.", + "Try running the script again to login with credentials.", + ) + + await client.close() + + with open(matrixCredFile, "r") as f: + config = json.load(f) + client = AsyncClient(config["homeserver"]) + + client.access_token = config["access_token"] + client.user_id = config["user_id"] + client.device_id = config["device_id"] + + for room_id in rooms: + if room_id[0] != "!": + response = await client.room_resolve_alias(room_id) + + room_id = response.room_id + + await client.join(room_id) + for message in messages: + await client.room_send( + room_id, + message_type="m.room.message", + content={"msgtype": "m.text", "body": "", "format": "org.matrix.custom.html", "formatted_body":message}, + ) + + await client.close() + + if len(messages) == 1: + resultMessage = "Matrix - Successfully sent 1 message." + else: + resultMessage = "Matrix - Successfully sent {} messages.".format(len(messages)) + + logging.info(resultMessage) + return { + "statusCode": 200, + "body": json.dumps({ + "success": True, + "message": resultMessage + }) + } diff --git a/ghi/github.py b/ghi/github.py index 7373c05..14bb5a9 100644 --- a/ghi/github.py +++ b/ghi/github.py @@ -82,7 +82,8 @@ def parsePayload(event, payload, repos, shorten): return { "statusCode": 200, "ircMessages": push["ircMessages"], - "mastMessages": push["mastMessages"] + "mastMessages": push["mastMessages"], + "matrixMessages": push["matrixMessages"] } @@ -95,7 +96,8 @@ def parsePayload(event, payload, repos, shorten): return { "statusCode": 200, "ircMessages": pullRequest["ircMessages"], - "mastMessages": pullRequest["mastMessages"] + "mastMessages": pullRequest["mastMessages"], + "matrixMessages": pullRequest["matrixMessages"] } diff --git a/ghi/index.py b/ghi/index.py index 0539ede..45c6106 100644 --- a/ghi/index.py +++ b/ghi/index.py @@ -3,15 +3,33 @@ sys.path.append(os.path.dirname(os.path.realpath(__file__))) import json import logging + from configuration import getConfiguration from github import getPool, parsePayload -from irc import sendMessages from ghilogging import setup_server_logging +from irc import sendMessages as sendIrcMessages from ghimastodon import sendToots +from ghimatrix import sendMessages as sendMatrixMessages from validation import validatePayload from __init__ import __version__ +def composeResult(outlets): + result = "Succesfully notified " + + if len(outlets) == 1: + result += outlets[0] + "." + elif len(outlets) == 2: + result += "both " + outlets[0] + " " + outlets[1] + "." + else: + result += outlets[0] + for outlet in outlets[1:]: + result += ", and " + outlet + result += "." + + return result + + def handler(event, context=None, sysd=None): # ensure it's a valid request if event and "body" in event and "headers" in event: @@ -96,24 +114,22 @@ def handler(event, context=None, sysd=None): if getMessages["statusCode"] != 200: return getMessages - ircCheck = False - mastCheck = False + outletChecks = list() failure = False - + if "irc" in pool["pool"].outlets: logging.debug("IRC Messages:") logging.debug(getMessages["ircMessages"]) # Send messages to the designated IRC channel(s) - sendToIrc = sendMessages(pool["pool"], getMessages["ircMessages"]) + sendToIrc = sendIrcMessages(pool["pool"], getMessages["ircMessages"]) if sendToIrc["statusCode"] != 200: failure = True ircResult = "Something went wrong while trying to notify IRC." else: ircResult = "Successfully notified IRC." - ircCheck = True + outletChecks.append("IRC") logging.info(ircResult) - githubPayload = json.loads(githubPayload) @@ -136,21 +152,30 @@ def handler(event, context=None, sysd=None): mastResult = "Something went wrong while trying to notify Mastodon." else: mastResult = "Succesfully notified Mastodon." - mastCheck = True + outletChecks.append("Mastodon") logging.info(mastResult) - - if ircCheck or not mastAppliedMergeFilter and not failure: - result = "Succesfully notified {both0}{IRC}{both1}{Mastodon}.".format( - both0 = "both " if ircCheck and mastCheck else "", - both1 = " and " if ircCheck and mastCheck else "", - IRC = "IRC" if ircCheck else "", - Mastodon = "Mastodon" if mastCheck else "" - ) + + if "matrix" in pool["pool"].outlets: + logging.debug("Matrix Messages:") + logging.debug(getMessages["matrixMessages"]) + + # Send messages to the designated Matrix' room(s) + sendToMatrix = sendMatrixMessages(pool["pool"], getMessages["matrixMessages"]) + if sendToMatrix["statusCode"] != 200: + failure = True + matrixResult = "Something went wrong while trying to notify Matrix." + else: + matrixResult = "Succesfully notified Matrix." + outletChecks.append("Matrix") + logging.info(matrixResult) + + if "IRC" in outletChecks or not mastAppliedMergeFilter or "Matrix" in outletChecks and not failure: + result = composeResult(outletChecks) if "mastodon" in pool["pool"].outlets and mastAppliedMergeFilter: mastResult = "Didn't toot because of the merge filter." logging.info(mastResult) - result = result[:-1] + ", but not Mastodon because of the merge filter." - elif "mastodon" in pool["pool"].outlets and mastAppliedMergeFilter: + result = result[:-1] + ", but not Mastodon because of the merge filter." + elif "mastodon" in pool["pool"].outlets and mastAppliedMergeFilter and not failure: mastResult = "Event received, but didn't toot because of the merge filter." logging.info(mastResult) result = "Event received, but didn't toot because of the merge filter." @@ -164,7 +189,7 @@ def handler(event, context=None, sysd=None): "message": result }) } - else: + else: return { "statusCode": 200, "body": json.dumps({ @@ -180,4 +205,4 @@ def handler(event, context=None, sysd=None): "success": False, "message": "bad event data" }) - } \ No newline at end of file + } diff --git a/requirements-server.txt b/requirements-server.txt index 1a1a99d..b130ef3 100644 --- a/requirements-server.txt +++ b/requirements-server.txt @@ -2,3 +2,4 @@ PyYAML~=5.4 requests~=2.26 tornado~=6.1 mastodon-py~=1.5 +matrix-nio~=0.19