Skip to content

Commit

Permalink
Merge pull request #127 from rwth-acis/123-enh-chatmediator-github
Browse files Browse the repository at this point in the history
ChatMediator for GitHub issues & pull requests
  • Loading branch information
AlexanderNeumann authored Feb 5, 2023
2 parents 286af13 + 8767764 commit 794a703
Show file tree
Hide file tree
Showing 9 changed files with 530 additions and 1 deletion.
5 changes: 5 additions & 0 deletions social-bot-manager/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ dependencies {
implementation "commons-codec:commons-codec:1.13"
implementation "com.github.pengrad:java-telegram-bot-api:4.9.0"

// GitHub API
implementation "org.kohsuke:github-api:1.306"
implementation "io.jsonwebtoken:jjwt-impl:0.11.5"
implementation "io.jsonwebtoken:jjwt-jackson:0.11.5"

// javax.websocket-api;version="1.1", jslack;version="1.8.1", rocketchat-common;version="0.7.1, rocketchat-core;version="0.7.1, rocketchat-livechat;version="0.7.1"
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
import i5.las2peer.restMapper.annotations.ServicePath;
import i5.las2peer.security.BotAgent;
import i5.las2peer.services.socialBotManagerService.chat.*;
import i5.las2peer.services.socialBotManagerService.chat.github.GitHubWebhookReceiver;
import i5.las2peer.services.socialBotManagerService.chat.xAPI.ChatStatement;
import i5.las2peer.services.socialBotManagerService.database.SQLDatabase;
import i5.las2peer.services.socialBotManagerService.database.SQLDatabaseType;
Expand Down Expand Up @@ -240,6 +241,7 @@ protected void initResources() {
getResourceConfig().register(BotModelResource.class);
getResourceConfig().register(TrainingResource.class);
getResourceConfig().register(this);
getResourceConfig().register(GitHubWebhookReceiver.class);
}

@POST
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package i5.las2peer.services.socialBotManagerService.chat;

import com.fasterxml.jackson.annotation.JsonProperty;
import i5.las2peer.services.socialBotManagerService.chat.github.GitHubIssueMediator;
import i5.las2peer.services.socialBotManagerService.chat.github.GitHubPRMediator;

/**
* This enum lists all available messenger services. The string value has to
Expand All @@ -23,6 +25,12 @@ public enum ChatService {
@JsonProperty("Moodle Forum")
MOODLE_FORUM("Moodle Forum", MoodleForumMediator.class),

@JsonProperty("GitHub Issues")
GITHUB_ISSUES("GitHub Issues", GitHubIssueMediator.class),

@JsonProperty("GitHub Pull Requests")
GITHUB_PR("GitHub Pull Requests", GitHubPRMediator.class),

UNKNOWN("", null);

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package i5.las2peer.services.socialBotManagerService.chat.github;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.kohsuke.github.GHAppInstallation;
import org.kohsuke.github.GitHub;
import org.kohsuke.github.GitHubBuilder;

import javax.xml.bind.DatatypeConverter;
import java.io.IOException;
import java.security.Key;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Date;

/**
* A GitHub app can be installed multiple times (e.g., within different organizations or repositories).
* To use the GitHub API for an app installation, we need an access token for this app installation.
* For requesting this access token, a JWT is needed. This JWT allows to authenticate as a GitHub app.
* The JWT needs to be signed using the app's private key (from general app settings).
*
* See https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps
*/
public class GitHubAppHelper {

/**
* Id of the GitHub app.
*/
private int gitHubAppId;

/**
* Private key used to sign JWTs.
*/
private Key privateKey;

/**
*
* @param gitHubAppId Id of the GitHub app
* @param pkcs8PrivateKey Private key of GitHub app (already needs to be converted to pkcs8)
* @throws GitHubAppHelperException
*/
public GitHubAppHelper(int gitHubAppId, String pkcs8PrivateKey) throws GitHubAppHelperException {
this.gitHubAppId = gitHubAppId;

byte[] pkcs8PrivateKeyBytes = DatatypeConverter.parseBase64Binary(pkcs8PrivateKey);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(pkcs8PrivateKeyBytes);
try {
this.privateKey = KeyFactory.getInstance("RSA").generatePrivate(keySpec);
} catch (InvalidKeySpecException | NoSuchAlgorithmException e) {
throw new GitHubAppHelperException(e.getMessage());
}
}

/**
* Returns a GitHub object that has access to the given repository.
* @param repositoryFullName Full name of the repository, containing both owner and repository name.
* @return GitHub object that has access to the given repository.
*/
public GitHub getGitHubInstance(String repositoryFullName) {
String ownerName = repositoryFullName.split("/")[0];
String repoName = repositoryFullName.split("/")[1];

try {
// first create GitHub object using a JWT (this is needed to request an access token for an app installation)
GitHub gitHub = new GitHubBuilder().withJwtToken(generateJWT()).build();

// get app installation for given repository (getInstallationByRepository requires a JWT)
GHAppInstallation installation = gitHub.getApp().getInstallationByRepository(ownerName, repoName);

// create a GitHub object with app installation token
return new GitHubBuilder().withAppInstallationToken(installation.createToken().create().getToken()).build();
} catch (IOException e) {
e.printStackTrace();
return null;
}
}

/**
* Generates a JWT and signs it with the app's private key.
* @return JWT
*/
private String generateJWT() {
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
Date expiration = new Date(nowMillis + 60000);
return Jwts.builder()
.setIssuedAt(now) // issue now
.setExpiration(expiration) // expiration time of JWT
.setIssuer(String.valueOf(gitHubAppId)) // app id needs to be used as issuer
.signWith(this.privateKey, SignatureAlgorithm.RS256) // sign with app's private key
.compact();
}

/**
* General exception that is thrown if something related to the GitHubAppHelper is not working.
*/
public class GitHubAppHelperException extends Exception {
public GitHubAppHelperException(String message) {
super(message);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package i5.las2peer.services.socialBotManagerService.chat.github;

import i5.las2peer.services.socialBotManagerService.chat.AuthTokenException;
import i5.las2peer.services.socialBotManagerService.chat.ChatMessage;
import i5.las2peer.services.socialBotManagerService.chat.ChatMessageCollector;
import i5.las2peer.services.socialBotManagerService.chat.EventChatMediator;
import net.minidev.json.JSONObject;
import org.kohsuke.github.GitHub;

import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import java.util.Vector;

/**
* Parent class for GitHub issue and pull request chat mediators.
* <p>
* In the GitHubChatMediator, a channel is a comment section of an issue or a pull request.
* Therefore, the "messenger channel" is defined as follows:
* [Owner name]/[Repo name]#[Issue or PR number]
*/
public abstract class GitHubChatMediator extends EventChatMediator {

/**
* Message collector: New comments are added to the collector in {@link #handleEvent(JSONObject) handleEvent}.
*/
private ChatMessageCollector messageCollector;

/**
* Helper for the GitHub app that is used by the chat mediator.
*/
private GitHubAppHelper gitHubAppHelper;

/**
* Id of the GitHub app that is used by the chat mediator.
*/
private int gitHubAppId;

/**
* Event name for new comments is the same for both issues and pull requests.
*/
private final String eventNameItemComment = "issue_comment";

/**
* The action name for new comments is the same for both issues and pull requests.
*/
private final String actionNameItemComment = "created";

/**
* Event name for a newly opened issue or pull request.
*/
protected String eventNameItemOpened;

/**
* Action name for a newly opened issue or pull request.
*/
private final String actionNameItemOpened = "opened";

/**
* Name of the field that contains the comment information (same for issues and pull requests).
*/
private final String itemNameComment = "issue";

/**
* Name of the field that contains the text of a newly opened issue or pull request.
*/
protected String itemNameOpened;

/**
* Constructor for GitHub chat mediators.
*
* @param authToken Format: [GitHub app id]:[GitHub app private key in pkcs8]
* @throws GitHubAppHelper.GitHubAppHelperException If something related to the GitHubAppHelper is not working.
* @throws AuthTokenException If format of {@code authToken} is incorrect.
*/
public GitHubChatMediator(String authToken) throws GitHubAppHelper.GitHubAppHelperException, AuthTokenException {
super(authToken);

// use default message collector
this.messageCollector = new ChatMessageCollector();

// check that authToken contains app id and private key
String[] parts = authToken.split(":");
if (parts.length != 2) {
throw new AuthTokenException("Incorrect auth information, format should be: " +
"[GitHub app id]:[GitHub app private key in pkcs8]");
}

// get app id and private key
this.gitHubAppId = Integer.parseInt(parts[0]);
String pkcs8PrivateKey = parts[1];

// init GitHub app helper
this.gitHubAppHelper = new GitHubAppHelper(this.gitHubAppId, pkcs8PrivateKey);
}

/**
* Used to filter out events that are not relevant for the chat mediators.
*
* @param parsedEvent Event
* @return Whether the given event is relevant for the chat mediators.
*/
protected boolean isRelevantEvent(JSONObject parsedEvent) {
String event = parsedEvent.getAsString("event");
return List.of(eventNameItemComment, eventNameItemOpened).contains(event);
}

/**
* Adds new comment to {@link GitHubChatMediator#messageCollector messageCollector} (if given event contains one).
*
* @param parsedEvent JSON representation of incoming GitHub event
*/
@Override
public void handleEvent(JSONObject parsedEvent) {
// extract name and payload of given event
String eventName = parsedEvent.getAsString("event");
JSONObject payload = (JSONObject) parsedEvent.get("payload");

String repositoryFullName = this.getRepositoryFullNameOfEvent(parsedEvent);
String action = payload.getAsString("action");

boolean itemComment = eventName.equals(eventNameItemComment) && action.equals(actionNameItemComment);
boolean itemOpened = eventName.equals(eventNameItemOpened) && action.equals(actionNameItemOpened);

if (itemComment || itemOpened) {
String itemName = itemComment ? itemNameComment : itemNameOpened;
JSONObject item = (JSONObject) payload.get(itemName);
String channelName = repositoryFullName + "#" + item.getAsNumber("number");

JSONObject comment;
if (itemComment) comment = (JSONObject) payload.get("comment");
else if (itemOpened) comment = (JSONObject) payload.get(itemName);
else return;

// extract user info from comment
JSONObject user = (JSONObject) comment.get("user");
String username = user.getAsString("login");
String message = comment.getAsString("body");

// dont handle bot messages
if (this.isBotAccount(user)) return;

// add comment to message collector
ChatMessage chatMessage = new ChatMessage(channelName, username, message);
this.messageCollector.addMessage(chatMessage);
}
}

/**
* Comments on an issue or pull request. As in GitHub a pull request also seems to be an issue, this method can
* be shared for both chat mediators.
*
* @param channel Format: [Owner name]/[Repo name]#[Issue or PR number]
* @param text The content of the comment
* @param id
*/
@Override
public void sendMessageToChannel(String channel, String text, Optional<String> id) {
String repositoryFullName = channel.split("#")[0];
int number = Integer.parseInt(channel.split("#")[1]);

try {
GitHub instance = this.gitHubAppHelper.getGitHubInstance(repositoryFullName);
if (instance != null) {
// post comment (in GitHub a pull request also seems to be an issue)
instance.getRepository(repositoryFullName).getIssue(number).comment(text);
}
} catch (IOException e) {
e.printStackTrace();
}
}

@Override
public Vector<ChatMessage> getMessages() {
return this.messageCollector.getMessages();
}

/**
* Returns the id of the GitHub app that the chat mediator is using.
*
* @return Id of the GitHub app that the chat mediator is using.
*/
public int getGitHubAppId() {
return this.gitHubAppId;
}

/**
* Extracts the full repository name from an event JSONObject.
*
* @param parsedEvent Event
* @return Full name of the repository, containing both owner and repository name.
*/
private String getRepositoryFullNameOfEvent(JSONObject parsedEvent) {
JSONObject payload = (JSONObject) parsedEvent.get("payload");
JSONObject repository = (JSONObject) payload.get("repository");
return repository.getAsString("full_name");
}

/**
* Checks if the given user (from GitHub) is a bot.
*
* @param user User JSONObject from GitHub
* @return Whether the given user is a bot.
*/
private boolean isBotAccount(JSONObject user) {
return user.getAsString("type").equals("Bot");
}

@Override
public void editMessage(String channel, String messageId, String message, Optional<String> id) {
}

@Override
public void sendBlocksMessageToChannel(String channel, String blocks, String authToken, Optional<String> id) {
}

@Override
public void updateBlocksMessageToChannel(String channel, String blocks, String authToken, String ts, Optional<String> id) {
}

@Override
public void sendFileMessageToChannel(String channel, File f, String text, Optional<String> id) {
}

@Override
public String getChannelByEmail(String email) {
return null;
}

@Override
public void close() {
}
}
Loading

0 comments on commit 794a703

Please sign in to comment.