Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ChatMediator for GitHub issues & pull requests #127

Merged
merged 5 commits into from
Feb 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions social-bot-manager/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,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