Statically define connection creation and provisioing (#330)

* Statically define connection creation and provisioing

* Tidy up

* Drop JIRA

* Convert other connections to new system

* Small linting fixes

* Fixes

* changelog

* Fix bridge

* Fix JIRA instance naming

* Fix JIRA config

* Drop unnessacery check
This commit is contained in:
Will Hunt 2022-05-06 14:58:39 +01:00 committed by GitHub
parent 2008f2cae0
commit b23e516aa5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 419 additions and 350 deletions

1
changelog.d/330.misc Normal file
View File

@ -0,0 +1 @@
Refactor connection handling logic to improve developer experience.

View File

@ -5,12 +5,11 @@ import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Co
import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi";
import { CommentProcessor } from "./CommentProcessor";
import { ConnectionManager } from "./ConnectionManager";
import { GenericHookConnection } from "./Connections";
import { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types"
import { GithubInstance } from "./Github/GithubInstance";
import { IBridgeStorageProvider } from "./Stores/StorageProvider";
import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace, JiraProjectConnection, GitLabRepoConnection,
GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitLabIssueConnection, FigmaFileConnection, FeedConnection } from "./Connections";
GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitLabIssueConnection, FigmaFileConnection, FeedConnection, GenericHookConnection } from "./Connections";
import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "./Gitlab/WebhookTypes";
import { JiraIssueEvent, JiraIssueUpdatedEvent } from "./Jira/WebhookTypes";
import { JiraOAuthResult } from "./Jira/Types";
@ -39,7 +38,6 @@ import { ListenerService } from "./ListenerService";
import { SetupConnection } from "./Connections/SetupConnection";
import { getAppservice } from "./appservice";
import { JiraOAuthRequestCloud, JiraOAuthRequestOnPrem, JiraOAuthRequestResult } from "./Jira/OAuth";
import { CLOUD_INSTANCE } from "./Jira/Client";
import { GenericWebhookEvent, GenericWebhookEventResult } from "./generic/types";
import { SetupWidget } from "./Widgets/SetupWidget";
import { FeedEntry, FeedError, FeedReader } from "./feeds/FeedReader";
@ -73,7 +71,7 @@ export class Bridge {
}
this.as = getAppservice(this.config, this.registration, this.storage);
Metrics.registerMatrixSdkMetrics(this.as);
this.queue = createMessageQueue(this.config);
this.queue = createMessageQueue(this.config.queue);
this.messageClient = new MessageSenderClient(this.queue);
this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl || this.config.bridge.url);
this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient);
@ -509,18 +507,15 @@ export class Bridge {
}
try {
let tokenInfo: JiraOAuthResult;
let instance;
if ("code" in msg.data) {
tokenInfo = await this.tokenStore.jiraOAuth.exchangeRequestForToken(msg.data.code);
instance = CLOUD_INSTANCE;
} else {
tokenInfo = await this.tokenStore.jiraOAuth.exchangeRequestForToken(msg.data.oauthToken, msg.data.oauthVerifier);
instance = new URL(this.config.jira.url!).host;
}
await this.tokenStore.storeJiraToken(userId, {
access_token: tokenInfo.access_token,
refresh_token: tokenInfo.refresh_token,
instance,
instance: this.config.jira.instanceName,
expires_in: tokenInfo.expires_in,
});
@ -836,9 +831,17 @@ export class Bridge {
try {
await (
new SetupConnection(
roomId, this.as, this.tokenStore, this.config,
roomId,
{
config: this.config,
as: this.as,
tokenStore: this.tokenStore,
commentProcessor: this.commentProcessor,
messageClient: this.messageClient,
storage: this.storage,
getAllConnectionsOfType: this.connectionManager.getAllConnectionsOfType.bind(this),
},
this.getOrCreateAdminRoom.bind(this),
this.github,
)
).onMessageEvent(event, checkPermission);
} catch (ex) {

View File

@ -108,6 +108,8 @@ export interface BridgeConfigJiraYAML {
}
export class BridgeConfigJira implements BridgeConfigJiraYAML {
static CLOUD_INSTANCE_NAME = "api.atlassian.com";
@configKey("Webhook settings for JIRA")
readonly webhook: {
secret: string;
@ -123,13 +125,16 @@ export class BridgeConfigJira implements BridgeConfigJiraYAML {
@hideKey()
readonly instanceUrl?: URL;
@hideKey()
readonly instanceName: string;
constructor(yaml: BridgeConfigJiraYAML) {
assert.ok(yaml.webhook);
assert.ok(yaml.webhook.secret);
this.webhook = yaml.webhook;
this.url = yaml.url;
this.instanceUrl = yaml.url !== undefined ? new URL(yaml.url) : undefined;
this.instanceName = this.instanceUrl?.host || BridgeConfigJira.CLOUD_INSTANCE_NAME;
if (!yaml.oauth) {
return;
}
@ -314,7 +319,7 @@ interface BridgeConfigWebhook {
bindAddress?: string;
}
interface BridgeConfigQueue {
export interface BridgeConfigQueue {
monolithic: boolean;
port?: number;
host?: string;
@ -350,11 +355,11 @@ export interface BridgeConfigRoot {
figma?: BridgeConfigFigma;
feeds?: BridgeConfigFeeds;
generic?: BridgeGenericWebhooksConfigYAML;
github?: BridgeConfigGitHub;
github?: BridgeConfigGitHubYAML;
gitlab?: BridgeConfigGitLabYAML;
permissions?: BridgeConfigActorPermission[];
provisioning?: BridgeConfigProvisioning;
jira?: BridgeConfigJira;
jira?: BridgeConfigJiraYAML;
logging: BridgeConfigLogging;
passFile: string;
queue: BridgeConfigQueue;

View File

@ -7,8 +7,7 @@
import { Appservice, StateEvent } from "matrix-bot-sdk";
import { CommentProcessor } from "./CommentProcessor";
import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config";
import { GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, JiraProjectConnection } from "./Connections";
import { GenericHookAccountData } from "./Connections/GenericHook";
import { ConnectionDeclarations, GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, JiraProjectConnection } from "./Connections";
import { GithubInstance } from "./Github/GithubInstance";
import { GitLabClient } from "./Gitlab/Client";
import { JiraProject } from "./Jira/Types";
@ -17,7 +16,6 @@ import { MessageSenderClient } from "./MatrixSender";
import { GetConnectionTypeResponseItem } from "./provisioning/api";
import { ApiError, ErrCode } from "./api";
import { UserTokenStore } from "./UserTokenStore";
import {v4 as uuid} from "uuid";
import { FigmaFileConnection, FeedConnection } from "./Connections";
import { IBridgeStorageProvider } from "./Stores/StorageProvider";
import Metrics from "./Metrics";
@ -73,80 +71,28 @@ export class ConnectionManager extends EventEmitter {
*/
public async provisionConnection(roomId: string, userId: string, type: string, data: Record<string, unknown>): Promise<IConnection> {
log.info(`Looking to provision connection for ${roomId} ${type} for ${userId} with ${data}`);
const existingConnections = await this.getAllConnectionsForRoom(roomId);
if (JiraProjectConnection.EventTypes.includes(type)) {
if (!this.config.jira) {
throw new ApiError('JIRA integration is not configured', ErrCode.DisabledFeature);
const connectionType = ConnectionDeclarations.find(c => c.EventTypes.includes(type));
if (connectionType?.provisionConnection) {
if (!this.config.checkPermission(userId, connectionType.ServiceCategory, BridgePermissionLevel.manageConnections)) {
throw new ApiError(`User is not permitted to provision connections for this type of service.`, ErrCode.ForbiddenUser);
}
if (existingConnections.find(c => c instanceof JiraProjectConnection)) {
// TODO: Support this.
throw Error("Cannot support multiple connections of the same type yet");
}
if (!this.config.checkPermission(userId, "jira", BridgePermissionLevel.manageConnections)) {
throw new ApiError('User is not permitted to provision connections for Jira', ErrCode.ForbiddenUser);
}
const res = await JiraProjectConnection.provisionConnection(roomId, userId, data, this.as, this.tokenStore);
await this.as.botIntent.underlyingClient.sendStateEvent(roomId, JiraProjectConnection.CanonicalEventType, res.connection.stateKey, res.stateEventContent);
this.push(res.connection);
return res.connection;
}
if (GitHubRepoConnection.EventTypes.includes(type)) {
if (!this.config.github || !this.config.github.oauth || !this.github) {
throw new ApiError('GitHub integration is not configured', ErrCode.DisabledFeature);
}
if (existingConnections.find(c => c instanceof GitHubRepoConnection)) {
// TODO: Support this.
throw Error("Cannot support multiple connections of the same type yet");
}
if (!this.config.checkPermission(userId, "github", BridgePermissionLevel.manageConnections)) {
throw new ApiError('User is not permitted to provision connections for GitHub', ErrCode.ForbiddenUser);
}
const res = await GitHubRepoConnection.provisionConnection(roomId, userId, data, this.as, this.tokenStore, this.github, this.config.github);
await this.as.botIntent.underlyingClient.sendStateEvent(roomId, GitHubRepoConnection.CanonicalEventType, res.connection.stateKey, res.stateEventContent);
this.push(res.connection);
return res.connection;
}
if (GenericHookConnection.EventTypes.includes(type)) {
if (!this.config.generic) {
throw new ApiError('Generic Webhook integration is not configured', ErrCode.DisabledFeature);
}
if (!this.config.checkPermission(userId, "webhooks", BridgePermissionLevel.manageConnections)) {
throw new ApiError('User is not permitted to provision connections for generic webhooks', ErrCode.ForbiddenUser);
}
const res = await GenericHookConnection.provisionConnection(roomId, this.as, data, this.config.generic, this.messageClient);
const existing = this.getAllConnectionsOfType(GenericHookConnection).find(c => c.stateKey === res.connection.stateKey);
if (existing) {
throw new ApiError("A generic webhook with this name already exists", ErrCode.ConflictingConnection, -1, {
existingConnection: existing.getProvisionerDetails()
const { connection } = await connectionType.provisionConnection(roomId, userId, data, {
as: this.as,
config: this.config,
tokenStore: this.tokenStore,
commentProcessor: this.commentProcessor,
messageClient: this.messageClient,
storage: this.storage,
github: this.github,
getAllConnectionsOfType: this.getAllConnectionsOfType.bind(this),
});
}
await GenericHookConnection.ensureRoomAccountData(roomId, this.as, res.connection.hookId, res.connection.stateKey);
await this.as.botIntent.underlyingClient.sendStateEvent(roomId, GenericHookConnection.CanonicalEventType, res.connection.stateKey, res.stateEventContent);
this.push(res.connection);
return res.connection;
}
if (GitLabRepoConnection.EventTypes.includes(type)) {
if (!this.config.gitlab) {
throw new ApiError('GitLab integration is not configured', ErrCode.DisabledFeature);
}
if (!this.config.checkPermission(userId, "gitlab", BridgePermissionLevel.manageConnections)) {
throw new ApiError('User is not permitted to provision connections for GitLab', ErrCode.ForbiddenUser);
}
const res = await GitLabRepoConnection.provisionConnection(roomId, userId, data, this.as, this.tokenStore, data.instance as string, this.config.gitlab);
const existing = this.getAllConnectionsOfType(GitLabRepoConnection).find(c => c.stateKey === res.connection.stateKey);
if (existing) {
throw new ApiError("A GitLab repo connection for this project already exists", ErrCode.ConflictingConnection, -1, {
existingConnection: existing.getProvisionerDetails()
});
}
await this.as.botIntent.underlyingClient.sendStateEvent(roomId, GitLabRepoConnection.CanonicalEventType, res.connection.stateKey, res.stateEventContent);
this.push(res.connection);
return res.connection;
this.push(connection);
return connection;
}
throw new ApiError(`Connection type not known`);
}
private assertStateAllowed(state: StateEvent<any>, serviceType: "github"|"gitlab"|"jira"|"figma"|"webhooks"|"feed") {
private assertStateAllowed(state: StateEvent<any>, serviceType: string) {
if (state.sender === this.as.botUserId) {
return;
}
@ -161,146 +107,21 @@ export class ConnectionManager extends EventEmitter {
log.debug(`${roomId} has disabled state for ${state.type}`);
return;
}
if (GitHubRepoConnection.EventTypes.includes(state.type)) {
if (!this.github || !this.config.github) {
throw Error('GitHub is not configured');
}
this.assertStateAllowed(state, "github");
return new GitHubRepoConnection(roomId, this.as, state.content, this.tokenStore, state.stateKey, this.github, this.config.github);
}
if (GitHubDiscussionConnection.EventTypes.includes(state.type)) {
if (!this.github || !this.config.github) {
throw Error('GitHub is not configured');
}
this.assertStateAllowed(state, "github");
return new GitHubDiscussionConnection(
roomId, this.as, state.content, state.stateKey, this.tokenStore, this.commentProcessor,
this.messageClient, this.config.github,
);
}
if (GitHubDiscussionSpace.EventTypes.includes(state.type)) {
if (!this.github) {
throw Error('GitHub is not configured');
}
this.assertStateAllowed(state, "github");
return new GitHubDiscussionSpace(
await this.as.botClient.getSpace(roomId), state.content, state.stateKey
);
}
if (GitHubIssueConnection.EventTypes.includes(state.type)) {
if (!this.github || !this.config.github) {
throw Error('GitHub is not configured');
}
this.assertStateAllowed(state, "github");
const issue = new GitHubIssueConnection(
roomId, this.as, state.content, state.stateKey || "", this.tokenStore,
this.commentProcessor, this.messageClient, this.github, this.config.github,
);
await issue.syncIssueState();
return issue;
}
if (GitHubUserSpace.EventTypes.includes(state.type)) {
if (!this.github) {
throw Error('GitHub is not configured');
}
this.assertStateAllowed(state, "github");
return new GitHubUserSpace(
await this.as.botClient.getSpace(roomId), state.content, state.stateKey
);
}
if (GitLabRepoConnection.EventTypes.includes(state.type)) {
if (!this.config.gitlab) {
throw Error('GitLab is not configured');
}
this.assertStateAllowed(state, "gitlab");
const instance = this.config.gitlab.instances[state.content.instance];
if (!instance) {
throw Error('Instance name not recognised');
}
return new GitLabRepoConnection(roomId, state.stateKey, this.as, state.content, this.tokenStore, instance);
}
if (GitLabIssueConnection.EventTypes.includes(state.type)) {
if (!this.github || !this.config.gitlab) {
throw Error('GitLab is not configured');
}
this.assertStateAllowed(state, "gitlab");
const instance = this.config.gitlab.instances[state.content.instance];
return new GitLabIssueConnection(
roomId,
this.as,
state.content,
state.stateKey as string,
this.tokenStore,
this.commentProcessor,
this.messageClient,
instance,
this.config.gitlab,
);
}
if (JiraProjectConnection.EventTypes.includes(state.type)) {
if (!this.config.jira) {
throw Error('JIRA is not configured');
}
this.assertStateAllowed(state, "jira");
return new JiraProjectConnection(roomId, this.as, state.content, state.stateKey, this.tokenStore);
}
if (FigmaFileConnection.EventTypes.includes(state.type)) {
if (!this.config.figma) {
throw Error('Figma is not configured');
}
this.assertStateAllowed(state, "figma");
return new FigmaFileConnection(roomId, state.stateKey, state.content, this.config.figma, this.as, this.storage);
}
if (FeedConnection.EventTypes.includes(state.type)) {
if (!this.config.feeds?.enabled) {
throw Error('RSS/Atom feeds are not configured');
}
this.assertStateAllowed(state, "feed");
return new FeedConnection(roomId, state.stateKey, state.content, this.config.feeds, this.as, this.storage);
}
if (GenericHookConnection.EventTypes.includes(state.type) && this.config.generic?.enabled) {
if (!this.config.generic) {
throw Error('Generic webhooks are not configured');
}
this.assertStateAllowed(state, "webhooks");
// Generic hooks store the hookId in the account data
const acctData = await this.as.botClient.getSafeRoomAccountData<GenericHookAccountData>(GenericHookConnection.CanonicalEventType, roomId, {});
// hookId => stateKey
let hookId = Object.entries(acctData).find(([, v]) => v === state.stateKey)?.[0];
if (!hookId) {
hookId = uuid();
log.warn(`hookId for ${roomId} not set in accountData, setting to ${hookId}`);
await GenericHookConnection.ensureRoomAccountData(roomId, this.as, hookId, state.stateKey);
}
return new GenericHookConnection(
roomId,
state.content,
hookId,
state.stateKey,
this.messageClient,
this.config.generic,
this.as,
);
}
const connectionType = ConnectionDeclarations.find(c => c.EventTypes.includes(state.type));
if (!connectionType) {
return;
}
this.assertStateAllowed(state, connectionType.ServiceCategory);
return connectionType.createConnectionForState(roomId, state, {
as: this.as,
config: this.config,
tokenStore: this.tokenStore,
commentProcessor: this.commentProcessor,
messageClient: this.messageClient,
storage: this.storage,
github: this.github,
});
}
public async createConnectionsForRoomId(roomId: string) {
const state = await this.as.botClient.getRoomState(roomId);
@ -395,7 +216,6 @@ export class ConnectionManager extends EventEmitter {
c.isInterestedInHookEvent(eventName))) as JiraProjectConnection[];
}
public getConnectionsForGenericWebhook(hookId: string): GenericHookConnection[] {
return this.connections.filter((c) => (c instanceof GenericHookConnection && c.hookId === hookId)) as GenericHookConnection[];
}
@ -470,7 +290,7 @@ export class ConnectionManager extends EventEmitter {
/**
* Get a list of possible targets for a given connection type when provisioning
* @param userId
* @param arg1
* @param type
*/
async getConnectionTargets(userId: string, type: string, filters: Record<string, unknown> = {}): Promise<unknown[]> {
if (type === GitLabRepoConnection.CanonicalEventType) {

View File

@ -1,11 +1,12 @@
import {Appservice} from "matrix-bot-sdk";
import { IConnection, IConnectionState } from ".";
import {Appservice, StateEvent} from "matrix-bot-sdk";
import { IConnection, IConnectionState, InstantiateConnectionOpts } from ".";
import { BridgeConfigFeeds } from "../Config/Config";
import { FeedEntry, FeedError} from "../feeds/FeedReader";
import LogWrapper from "../LogWrapper";
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
import { BaseConnection } from "./BaseConnection";
import markdown from "markdown-it";
import { Connection } from "./IConnection";
const log = new LogWrapper("FeedConnection");
const md = new markdown();
@ -14,9 +15,20 @@ export interface FeedConnectionState extends IConnectionState {
url: string;
}
@Connection
export class FeedConnection extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.feed";
static readonly EventTypes = [ FeedConnection.CanonicalEventType ];
static readonly ServiceCategory = "feed";
public static createConnectionForState(roomId: string, event: StateEvent<any>, {config, as, storage}: InstantiateConnectionOpts) {
if (!config.feeds?.enabled) {
throw Error('RSS/Atom feeds are not configured');
}
return new FeedConnection(roomId, event.stateKey, event.content, config.feeds, as, storage);
}
private hasError = false;
public get feedUrl(): string {

View File

@ -1,4 +1,4 @@
import { Appservice, MatrixClient } from "matrix-bot-sdk";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import markdownit from "markdown-it";
import { FigmaPayload } from "../figma/types";
import { BaseConnection } from "./BaseConnection";
@ -6,17 +6,19 @@ import { IConnection, IConnectionState } from ".";
import LogWrapper from "../LogWrapper";
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
import { BridgeConfigFigma } from "../Config/Config";
import { Connection, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
const log = new LogWrapper("FigmaFileConnection");
export interface FigmaFileConnectionState extends IConnectionState {
fileId?: string;
fileId: string;
instanceName?: string;
}
const THREAD_RELATION_TYPE = "m.thread";
const md = markdownit();
@Connection
export class FigmaFileConnection extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.figma.file";
static readonly LegacyEventType = "uk.half-shot.matrix-figma.file"; // Magically import from matrix-figma
@ -25,11 +27,40 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
FigmaFileConnection.CanonicalEventType,
FigmaFileConnection.LegacyEventType,
];
static readonly ServiceCategory = "figma";
public static async createState(roomId: string, fileId: string, client: MatrixClient) {
await client.sendStateEvent(roomId, FigmaFileConnection.CanonicalEventType, fileId, {
fileId: fileId,
} as FigmaFileConnectionState);
public static validateState(data: Record<string, unknown>): FigmaFileConnectionState {
if (!data.fileId || typeof data.fileId !== "string") {
throw Error('Missing or invalid fileId');
}
if (data.instanceName && typeof data.instanceName !== "string") {
throw Error('Invalid instanceName');
}
return {
instanceName: typeof data.instanceName === "string" ? data.instanceName : undefined,
fileId: data.fileId,
}
}
public static createConnectionForState(roomId: string, event: StateEvent<any>, {config, as, storage}: InstantiateConnectionOpts) {
if (!config.figma) {
throw Error('Figma is not configured');
}
return new FigmaFileConnection(roomId, event.stateKey, event.content, config.figma, as, storage);
}
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown> = {}, {as, config, storage}: ProvisionConnectionOpts) {
if (!config.figma) {
throw Error('Figma is not configured');
}
const validState = this.validateState(data);
const connection = new FigmaFileConnection(roomId, validState.fileId, validState, config.figma, as, storage);
await as.botClient.sendStateEvent(roomId, FigmaFileConnection.CanonicalEventType, validState.fileId, validState);
return {
connection,
stateEventContent: validState,
}
}

View File

@ -1,15 +1,16 @@
import { IConnection, IConnectionState } from "./IConnection";
import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
import LogWrapper from "../LogWrapper";
import { MessageSenderClient } from "../MatrixSender"
import markdownit from "markdown-it";
import { VMScript as Script, NodeVM } from "vm2";
import { MatrixEvent } from "../MatrixEvent";
import { Appservice } from "matrix-bot-sdk";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import { v4 as uuid} from "uuid";
import { ApiError, ErrCode } from "../api";
import { BaseConnection } from "./BaseConnection";
import { GetConnectionsResponseItem } from "../provisioning/api";
import { BridgeConfigGenericWebhooks } from "../Config/Config";
export interface GenericHookConnectionState extends IConnectionState {
/**
* This is ONLY used for display purposes, but the account data value is used to prevent misuse.
@ -59,13 +60,14 @@ const TRANSFORMATION_TIMEOUT_MS = 500;
/**
* Handles rooms connected to a github repo.
*/
@Connection
export class GenericHookConnection extends BaseConnection implements IConnection {
static validateState(state: Record<string, unknown>, allowJsTransformationFunctions: boolean): GenericHookConnectionState {
static validateState(state: Record<string, unknown>, allowJsTransformationFunctions?: boolean): GenericHookConnectionState {
const {name, transformationFunction} = state;
let transformationFunctionResult: string|undefined;
if (transformationFunction) {
if (!allowJsTransformationFunctions) {
if (allowJsTransformationFunctions !== undefined && !allowJsTransformationFunctions) {
throw new ApiError('Transformation functions are not allowed', ErrCode.DisabledFeature);
}
if (typeof transformationFunction !== "string") {
@ -85,13 +87,41 @@ export class GenericHookConnection extends BaseConnection implements IConnection
};
}
static async provisionConnection(roomId: string, as: Appservice, data: Record<string, unknown> = {}, config: BridgeConfigGenericWebhooks, messageClient: MessageSenderClient) {
const hookId = uuid();
const validState: GenericHookConnectionState = {
...GenericHookConnection.validateState(data, config.allowJsTransformationFunctions || false),
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {as, config, messageClient}: InstantiateConnectionOpts) {
if (!config.generic) {
throw Error('Generic webhooks are not configured');
}
// Generic hooks store the hookId in the account data
const acctData = await as.botClient.getSafeRoomAccountData<GenericHookAccountData>(GenericHookConnection.CanonicalEventType, roomId, {});
const state = this.validateState(event.content);
// hookId => stateKey
let hookId = Object.entries(acctData).find(([, v]) => v === event.stateKey)?.[0];
if (!hookId) {
hookId = uuid();
log.warn(`hookId for ${roomId} not set in accountData, setting to ${hookId}`);
await GenericHookConnection.ensureRoomAccountData(roomId, as, hookId, event.stateKey);
}
return new GenericHookConnection(
roomId,
state,
hookId,
};
const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config, as);
event.stateKey,
messageClient,
config.generic,
as,
);
}
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown> = {}, {as, config, messageClient}: ProvisionConnectionOpts) {
if (!config.generic) {
throw Error('Generic Webhooks are not configured');
}
const hookId = uuid();
const validState = GenericHookConnection.validateState(data, config.generic.allowJsTransformationFunctions || false);
await GenericHookConnection.ensureRoomAccountData(roomId, as, hookId, validState.name);
await as.botClient.sendStateEvent(roomId, this.CanonicalEventType, validState.name, validState);
const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config.generic, as);
return {
connection,
stateEventContent: validState,
@ -118,6 +148,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.generic.hook";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.generic.hook";
static readonly ServiceCategory = "webhooks";
static readonly EventTypes = [
GenericHookConnection.CanonicalEventType,

View File

@ -1,5 +1,5 @@
import { IConnection } from "./IConnection";
import { Appservice } from "matrix-bot-sdk";
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import { UserTokenStore } from "../UserTokenStore";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";
@ -28,6 +28,7 @@ const md = new markdown();
/**
* Handles rooms connected to a github repo.
*/
@Connection
export class GitHubDiscussionConnection extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.discussion";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.discussion";
@ -38,6 +39,18 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
];
static readonly QueryRoomRegex = /#github_disc_(.+)_(.+)_(\d+):.*/;
static readonly ServiceCategory = "github";
public static createConnectionForState(roomId: string, event: StateEvent<any>, {
github, config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
return new GitHubDiscussionConnection(
roomId, as, event.content, event.stateKey, tokenStore, commentProcessor,
messageClient, config.github,
);
}
readonly sentEvents = new Set<string>(); //TODO: Set some reasonable limits

View File

@ -1,5 +1,5 @@
import { IConnection } from "./IConnection";
import { Appservice, Space } from "matrix-bot-sdk";
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, Space, StateEvent } from "matrix-bot-sdk";
import LogWrapper from "../LogWrapper";
import { ReposGetResponseData } from "../Github/Types";
import axios from "axios";
@ -17,6 +17,7 @@ export interface GitHubDiscussionSpaceConnectionState {
/**
* Handles rooms connected to a github repo.
*/
@Connection
export class GitHubDiscussionSpace extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.discussion.space";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.discussion.space";
@ -27,6 +28,17 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
];
static readonly QueryRoomRegex = /#github_disc_(.+)_(.+):.*/;
static readonly ServiceCategory = "github";
public static async createConnectionForState(roomId: string, event: StateEvent<any>, {
github, config, as}: InstantiateConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
return new GitHubDiscussionSpace(
await as.botClient.getSpace(roomId), event.content, event.stateKey
);
}
static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise<Record<string, unknown>> {
if (!result || result.length < 2) {

View File

@ -1,5 +1,5 @@
import { IConnection } from "./IConnection";
import { Appservice } from "matrix-bot-sdk";
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
import markdown from "markdown-it";
import { UserTokenStore } from "../UserTokenStore";
@ -38,6 +38,7 @@ interface IQueryRoomOpts {
/**
* Handles rooms connected to a github repo.
*/
@Connection
export class GitHubIssueConnection extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.issue";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.bridge";
@ -48,11 +49,25 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
];
static readonly QueryRoomRegex = /#github_(.+)_(.+)_(\d+):.*/;
static readonly ServiceCategory = "github";
static generateAliasLocalpart(org: string, repo: string, issueNo: string|number) {
return `github_${org}_${repo}_${issueNo}`;
}
public static async createConnectionForState(roomId: string, event: StateEvent<any>, {
github, config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
const issue = new GitHubIssueConnection(
roomId, as, event.content, event.stateKey || "", tokenStore,
commentProcessor, messageClient, github, config.github,
);
await issue.syncIssueState();
return issue;
}
static async onQueryRoom(result: RegExpExecArray, opts: IQueryRoomOpts): Promise<unknown> {
const parts = result?.slice(1);
if (!parts) {

View File

@ -1,5 +1,5 @@
import { IConnection } from "./IConnection";
import { Appservice } from "matrix-bot-sdk";
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import LogWrapper from "../LogWrapper";
import { ProjectsGetResponseData } from "../Github/Types";
import { BaseConnection } from "./BaseConnection";
@ -14,15 +14,23 @@ const log = new LogWrapper("GitHubProjectConnection");
/**
* Handles rooms connected to a github repo.
*/
@Connection
export class GitHubProjectConnection extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.project";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.project";
static readonly ServiceCategory = "github";
static readonly EventTypes = [
GitHubProjectConnection.CanonicalEventType,
GitHubProjectConnection.LegacyCanonicalEventType,
];
public static createConnectionForState(roomId: string, event: StateEvent<any>, {config, as}: InstantiateConnectionOpts) {
if (!config.github) {
throw Error('GitHub is not configured');
}
return new GitHubProjectConnection(roomId, as, event.content, event.stateKey);
}
static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, inviteUser: string): Promise<GitHubProjectConnection> {
log.info(`Fetching ${project.name} ${project.id}`);

View File

@ -1,9 +1,9 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { Appservice, IRichReplyMetadata } from "matrix-bot-sdk";
import { Appservice, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands";
import { CommentProcessor } from "../CommentProcessor";
import { FormatUtil } from "../FormatUtil";
import { IConnection, IConnectionState } from "./IConnection";
import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
import { IssuesOpenedEvent, IssuesReopenedEvent, IssuesEditedEvent, PullRequestOpenedEvent, IssuesClosedEvent, PullRequestClosedEvent, PullRequestReadyForReviewEvent, PullRequestReviewSubmittedEvent, ReleaseCreatedEvent, IssuesLabeledEvent, IssuesUnlabeledEvent } from "@octokit/webhooks-types";
import { MatrixMessageContent, MatrixEvent, MatrixReactionContent } from "../MatrixEvent";
import { MessageSenderClient } from "../MatrixSender";
@ -148,9 +148,12 @@ function validateState(state: Record<string, unknown>): GitHubRepoConnectionStat
/**
* Handles rooms connected to a github repo.
*/
@Connection
export class GitHubRepoConnection extends CommandConnection implements IConnection {
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown>, as: Appservice,
tokenStore: UserTokenStore, githubInstance: GithubInstance, config: BridgeConfigGitHub) {
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown>, {as, tokenStore, github, config}: ProvisionConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
const validData = validateState(data);
const octokit = await tokenStore.getOctokitForUser(userId);
if (!octokit) {
@ -168,7 +171,7 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti
if (permissionLevel !== "admin" && permissionLevel !== "write") {
throw new ApiError("You must at least have write permissions to bridge this repository", ErrCode.ForbiddenUser);
}
const appOctokit = await githubInstance.getSafeOctokitForRepo(validData.org, validData.repo);
const appOctokit = await github.getSafeOctokitForRepo(validData.org, validData.repo);
if (!appOctokit) {
throw new ApiError(
"You need to add a GitHub App to this organisation / repository before you can bridge it. Open the link to add the app, and then retry this request",
@ -176,27 +179,34 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti
-1,
{
// E.g. https://github.com/apps/matrix-bridge/installations/new
installUrl: githubInstance.newInstallationUrl,
installUrl: github.newInstallationUrl,
}
);
}
const stateEventKey = `${validData.org}/${validData.repo}`;
await as.botClient.sendStateEvent(roomId, this.CanonicalEventType, stateEventKey, validData);
return {
stateEventContent: validData,
connection: new GitHubRepoConnection(roomId, as, validData, tokenStore, stateEventKey, githubInstance, config),
connection: new GitHubRepoConnection(roomId, as, validData, tokenStore, stateEventKey, github, config.github),
}
}
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.repository";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.repository";
static readonly EventTypes = [
GitHubRepoConnection.CanonicalEventType,
GitHubRepoConnection.LegacyCanonicalEventType,
];
static readonly ServiceCategory = "github";
static readonly QueryRoomRegex = /#github_(.+)_(.+):.*/;
static async createConnectionForState(roomId: string, state: StateEvent<Record<string, unknown>>, {as, tokenStore, github, config}: InstantiateConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
return new GitHubRepoConnection(roomId, as, validateState(state.content), tokenStore, state.stateKey, github, config.github);
}
static async onQueryRoom(result: RegExpExecArray, opts: IQueryRoomOpts): Promise<unknown> {
const parts = result?.slice(1);
if (!parts) {

View File

@ -1,5 +1,5 @@
import { IConnection } from "./IConnection";
import { Appservice, Space } from "matrix-bot-sdk";
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, Space, StateEvent } from "matrix-bot-sdk";
import LogWrapper from "../LogWrapper";
import axios from "axios";
import { GitHubDiscussionSpace } from ".";
@ -16,6 +16,7 @@ export interface GitHubUserSpaceConnectionState {
/**
* Handles rooms connected to a github repo.
*/
@Connection
export class GitHubUserSpace extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.user.space";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.user.space";
@ -25,6 +26,17 @@ export class GitHubUserSpace extends BaseConnection implements IConnection {
];
static readonly QueryRoomRegex = /#github_(.+):.*/;
static readonly ServiceCategory = "github";
public static async createConnectionForState(roomId: string, event: StateEvent<any>, {
github, config, as}: InstantiateConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
return new GitHubUserSpace(
await as.botClient.getSpace(roomId), event.content, event.stateKey
);
}
static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise<Record<string, unknown>> {
if (!result || result.length < 1) {

View File

@ -1,5 +1,5 @@
import { IConnection } from "./IConnection";
import { Appservice } from "matrix-bot-sdk";
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
import { UserTokenStore } from "../UserTokenStore";
import LogWrapper from "../LogWrapper";
@ -33,21 +33,36 @@ const log = new LogWrapper("GitLabIssueConnection");
/**
* Handles rooms connected to a github repo.
*/
@Connection
export class GitLabIssueConnection extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.gitlab.issue";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.gitlab.issue";
static readonly EventTypes = [
GitLabIssueConnection.CanonicalEventType,
GitLabIssueConnection.LegacyCanonicalEventType,
];
static readonly QueryRoomRegex = /#gitlab_(.+)_(.+)_(\d+):.*/;
static readonly ServiceCategory = "gitlab";
static getTopicString(authorName: string, state: string) {
`Author: ${authorName} | State: ${state === "closed" ? "closed" : "open"}`
}
public static async createConnectionForState(roomId: string, event: StateEvent<any>, {
config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) {
if (!config.gitlab) {
throw Error('GitHub is not configured');
}
const instance = config.gitlab.instances[event.content.instance];
if (!instance) {
throw Error('Instance name not recognised');
}
return new GitLabIssueConnection(
roomId, as, event.content, event.stateKey || "", tokenStore,
commentProcessor, messageClient, instance, config.gitlab,
);
}
public static async createRoomForIssue(instanceName: string, instance: GitLabInstance,
issue: GetIssueResponse, projects: string[], as: Appservice,
tokenStore: UserTokenStore, commentProcessor: CommentProcessor,

View File

@ -1,7 +1,7 @@
// We need to instantiate some functions which are not directly called, which confuses typescript.
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { UserTokenStore } from "../UserTokenStore";
import { Appservice } from "matrix-bot-sdk";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import { BotCommands, botCommand, compileBotCommands } from "../BotCommands";
import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
import markdown from "markdown-it";
@ -9,7 +9,7 @@ import LogWrapper from "../LogWrapper";
import { BridgeConfigGitLab, GitLabInstance } from "../Config/Config";
import { IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "../Gitlab/WebhookTypes";
import { CommandConnection } from "./CommandConnection";
import { IConnectionState } from "./IConnection";
import { Connection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
import { GetConnectionsResponseItem } from "../provisioning/api";
import { ErrCode, ApiError } from "../api"
import { AccessLevel } from "../Gitlab/Types";
@ -114,6 +114,7 @@ function validateState(state: Record<string, unknown>): GitLabRepoConnectionStat
/**
* Handles rooms connected to a github repo.
*/
@Connection
export class GitLabRepoConnection extends CommandConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.gitlab.repository";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.gitlab.repository";
@ -125,14 +126,29 @@ export class GitLabRepoConnection extends CommandConnection {
static botCommands: BotCommands;
static helpMessage: (cmdPrefix?: string | undefined) => MatrixMessageContent;
static ServiceCategory = "gitlab";
private readonly debounceMRComments = new Map<string, {comments: number, author: string, timeout: NodeJS.Timeout}>();
public static async provisionConnection(roomId: string, requester: string, data: Record<string, unknown>, as: Appservice, tokenStore: UserTokenStore, instanceName: string, gitlabConfig: BridgeConfigGitLab) {
const validData = validateState(data);
const instance = gitlabConfig.instances[instanceName];
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {as, tokenStore, github, config}: InstantiateConnectionOpts) {
if (!github || !config.gitlab) {
throw Error('GitLab is not configured');
}
const state = validateState(event.content);
const instance = config.gitlab.instances[state.instance];
if (!instance) {
throw Error(`provisionConnection provided an instanceName of ${instanceName} but the instance does not exist`);
throw Error('Instance name not recognised');
}
return new GitLabRepoConnection(roomId, event.stateKey, as, state, tokenStore, instance);
}
public static async provisionConnection(roomId: string, requester: string, data: Record<string, unknown>, { config, as, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts) {
if (!config.gitlab) {
throw Error('GitLab is not configured');
}
const gitlabConfig = config.gitlab;
const validData = validateState(data);
const instance = gitlabConfig.instances[validData.instance];
if (!instance) {
throw Error(`provisionConnection provided an instanceName of ${validData.instance} but the instance does not exist`);
}
const client = await tokenStore.getGitLabForUser(requester, instance.url);
if (!client) {
@ -151,6 +167,17 @@ export class GitLabRepoConnection extends CommandConnection {
throw new ApiError("You must at least have developer access to bridge this project", ErrCode.ForbiddenUser);
}
const stateEventKey = `${validData.instance}/${validData.path}`;
const connection = new GitLabRepoConnection(roomId, stateEventKey, as, validData, tokenStore, instance);
const existingConnections = getAllConnectionsOfType(GitLabRepoConnection);
const existing = existingConnections.find(c => c instanceof GitLabRepoConnection && c.stateKey === connection.stateKey) as undefined|GitLabRepoConnection;
if (existing) {
throw new ApiError("A GitLab repo connection for this project already exists", ErrCode.ConflictingConnection, -1, {
existingConnection: existing.getProvisionerDetails()
});
}
// Try to setup a webhook
if (gitlabConfig.webhook.publicUrl) {
const hooks = await client.projects.hooks.list(project.id);
@ -173,12 +200,8 @@ export class GitLabRepoConnection extends CommandConnection {
} else {
log.info(`Not creating webhook, webhookUrl is not defined in config`);
}
const stateEventKey = `${validData.instance}/${validData.path}`;
return {
stateEventContent: validData,
connection: new GitLabRepoConnection(roomId, stateEventKey, as, validData, tokenStore, instance),
}
await as.botIntent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, connection.stateKey, validData);
return {connection};
}
public static getProvisionerDetails(botUserId: string) {
@ -222,6 +245,8 @@ export class GitLabRepoConnection extends CommandConnection {
})) as GitLabRepoConnectionProjectTarget[];
}
private readonly debounceMRComments = new Map<string, {comments: number, author: string, timeout: NodeJS.Timeout}>();
constructor(roomId: string,
stateKey: string,
private readonly as: Appservice,

View File

@ -1,8 +1,14 @@
import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types";
import { GetConnectionsResponseItem } from "../provisioning/api";
import { IRichReplyMetadata } from "matrix-bot-sdk";
import { BridgePermissionLevel } from "../Config/Config";
import { Appservice, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
import { BridgeConfig, BridgePermissionLevel } from "../Config/Config";
import { UserTokenStore } from "../UserTokenStore";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
import { GithubInstance } from "../Github/GithubInstance";
import "reflect-metadata";
export type PermissionCheckFn = (service: string, level: BridgePermissionLevel) => boolean;
@ -70,3 +76,42 @@ export interface IConnection {
toString(): string;
}
export interface ConnectionDeclaration<C extends IConnection = IConnection> {
EventTypes: string[];
ServiceCategory: string;
provisionConnection?: (roomId: string, userId: string, data: Record<string, unknown>, opts: ProvisionConnectionOpts) => Promise<{connection: C}>;
createConnectionForState: (roomId: string, state: StateEvent<Record<string, unknown>>, opts: InstantiateConnectionOpts) => C|Promise<C>
}
export const ConnectionDeclarations: Array<ConnectionDeclaration> = [];
export interface InstantiateConnectionOpts {
as: Appservice,
config: BridgeConfig,
tokenStore: UserTokenStore,
commentProcessor: CommentProcessor,
messageClient: MessageSenderClient,
storage: IBridgeStorageProvider,
github?: GithubInstance,
}
export interface ProvisionConnectionOpts extends InstantiateConnectionOpts {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getAllConnectionsOfType<T extends IConnection>(typeT: new (...params : any[]) => T): T[],
}
export function Connection<T extends ConnectionDeclaration>(connectionType: T) {
// Event type clashes
if (ConnectionDeclarations.find(
(existingConn) => !!connectionType.EventTypes.find(
(evtType) => existingConn.EventTypes.includes(evtType))
)
) {
throw Error(`Provisioning connection for ${connectionType.EventTypes[0]} has a event type clash with another connection`);
}
ConnectionDeclarations.push(connectionType);
return connectionType;
}

View File

@ -1,5 +1,5 @@
import { IConnection, IConnectionState } from "./IConnection";
import { Appservice } from "matrix-bot-sdk";
import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import LogWrapper from "../LogWrapper";
import { JiraIssueEvent, JiraIssueUpdatedEvent } from "../Jira/WebhookTypes";
import { FormatUtil } from "../FormatUtil";
@ -49,6 +49,7 @@ const md = new markdownit();
/**
* Handles rooms connected to a github repo.
*/
@Connection
export class JiraProjectConnection extends CommandConnection implements IConnection {
@ -59,10 +60,19 @@ export class JiraProjectConnection extends CommandConnection implements IConnect
JiraProjectConnection.CanonicalEventType,
JiraProjectConnection.LegacyCanonicalEventType,
];
static readonly ServiceCategory = "jira";
static botCommands: BotCommands;
static helpMessage: (cmdPrefix?: string) => MatrixMessageContent;
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown>, as: Appservice, tokenStore: UserTokenStore) {
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown>, {getAllConnectionsOfType, as, tokenStore, config}: ProvisionConnectionOpts) {
if (!config.jira) {
throw new ApiError('JIRA integration is not configured', ErrCode.DisabledFeature);
}
const existingConnections = getAllConnectionsOfType(JiraProjectConnection);
if (existingConnections.find(c => c instanceof JiraProjectConnection)) {
// TODO: Support this.
throw Error("Cannot support multiple connections of the same type yet");
}
const validData = validateJiraConnectionState(data);
log.info(`Attempting to provisionConnection for ${roomId} ${validData.url} on behalf of ${userId}`);
const jiraClient = await tokenStore.getJiraForUser(userId, validData.url);
@ -84,8 +94,17 @@ export class JiraProjectConnection extends CommandConnection implements IConnect
} catch (ex) {
throw new ApiError("Requested project was not found", ErrCode.ForbiddenUser);
}
await as.botIntent.underlyingClient.sendStateEvent(roomId, JiraProjectConnection.CanonicalEventType, connection.stateKey, validData);
log.info(`Created connection via provisionConnection ${connection.toString()}`);
return {stateEventContent: validData, connection};
return {connection};
}
static createConnectionForState(roomId: string, state: StateEvent<Record<string, unknown>>, {config, as, tokenStore}: InstantiateConnectionOpts) {
if (!config.jira) {
throw Error('JIRA is not configured');
}
const connectionConfig = validateJiraConnectionState(state.content);
return new JiraProjectConnection(roomId, as, connectionConfig, state.stateKey, tokenStore);
}
public get projectId() {

View File

@ -1,13 +1,10 @@
// We need to instantiate some functions which are not directly called, which confuses typescript.
import { Appservice } from "matrix-bot-sdk";
import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands";
import { CommandConnection } from "./CommandConnection";
import { GenericHookConnection, GitHubRepoConnection, JiraProjectConnection } from ".";
import { CommandError } from "../errors";
import { UserTokenStore } from "../UserTokenStore";
import { GithubInstance } from "../Github/GithubInstance";
import { v4 as uuid } from "uuid";
import { BridgeConfig, BridgePermissionLevel } from "../Config/Config";
import { BridgePermissionLevel } from "../Config/Config";
import markdown from "markdown-it";
import { FigmaFileConnection } from "./FigmaFileConnection";
import { FeedConnection } from "./FeedConnection";
@ -15,6 +12,7 @@ import { URL } from "url";
import { SetupWidget } from "../Widgets/SetupWidget";
import { AdminRoom } from "../AdminRoom";
import { GitLabRepoConnection } from "./GitlabRepo";
import { ProvisionConnectionOpts } from "./IConnection";
const md = new markdown();
/**
@ -26,17 +24,22 @@ export class SetupConnection extends CommandConnection {
static botCommands: BotCommands;
static helpMessage: HelpFunction;
private get config() {
return this.provisionOpts.config;
}
private get as() {
return this.provisionOpts.as;
}
constructor(public readonly roomId: string,
private readonly as: Appservice,
private readonly tokenStore: UserTokenStore,
private readonly config: BridgeConfig,
private readonly getOrCreateAdminRoom: (userId: string) => Promise<AdminRoom>,
private readonly githubInstance?: GithubInstance,) {
private readonly provisionOpts: ProvisionConnectionOpts,
private readonly getOrCreateAdminRoom: (userId: string) => Promise<AdminRoom>,) {
super(
roomId,
"",
"",
as.botClient,
provisionOpts.as.botClient,
SetupConnection.botCommands,
SetupConnection.helpMessage,
"!hookshot",
@ -55,13 +58,12 @@ export class SetupConnection extends CommandConnection {
@botCommand("github repo", { help: "Create a connection for a GitHub repository. (You must be logged in with GitHub to do this.)", requiredArgs: ["url"], includeUserId: true, category: "github"})
public async onGitHubRepo(userId: string, url: string) {
if (!this.githubInstance || !this.config.github) {
if (!this.provisionOpts.github || !this.config.github) {
throw new CommandError("not-configured", "The bridge is not configured to support GitHub.");
}
await this.checkUserPermissions(userId, "github", GitHubRepoConnection.CanonicalEventType);
const octokit = await this.tokenStore.getOctokitForUser(userId);
const octokit = await this.provisionOpts.tokenStore.getOctokitForUser(userId);
if (!octokit) {
throw new CommandError("User not logged in", "You are not logged into GitHub. Start a DM with this bot and use the command `github login`.");
}
@ -70,9 +72,8 @@ export class SetupConnection extends CommandConnection {
throw new CommandError("Invalid GitHub url", "The GitHub url you entered was not valid.");
}
const [, org, repo] = urlParts;
const res = await GitHubRepoConnection.provisionConnection(this.roomId, userId, {org, repo}, this.as, this.tokenStore, this.githubInstance, this.config.github);
await this.as.botClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, url, res.stateEventContent);
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${org}/${repo}`);
const {connection} = await GitHubRepoConnection.provisionConnection(this.roomId, userId, {org, repo}, this.provisionOpts);
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.org}/${connection.repo}`);
}
@botCommand("gitlab project", { help: "Create a connection for a GitHub project. (You must be logged in with GitLab to do this.)", requiredArgs: ["url"], includeUserId: true, category: "gitlab"})
@ -89,7 +90,7 @@ export class SetupConnection extends CommandConnection {
throw new CommandError("not-configured", "No instance found that matches the provided URL.");
}
const client = await this.tokenStore.getGitLabForUser(userId, instance.url);
const client = await this.provisionOpts.tokenStore.getGitLabForUser(userId, instance.url);
if (!client) {
throw new CommandError("User not logged in", "You are not logged into this GitLab instance. Start a DM with this bot and use the command `gitlab personaltoken`.");
}
@ -97,9 +98,8 @@ export class SetupConnection extends CommandConnection {
if (!path) {
throw new CommandError("Invalid GitLab url", "The GitLab project url you entered was not valid.");
}
const res = await GitLabRepoConnection.provisionConnection(this.roomId, userId, {path, instance: name}, this.as, this.tokenStore, name, this.config.gitlab);
await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, url, res.stateEventContent);
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${path}`);
const {connection} = await GitLabRepoConnection.provisionConnection(this.roomId, userId, {path, instance: name}, this.provisionOpts);
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.path}`);
}
@botCommand("jira project", { help: "Create a connection for a JIRA project. (You must be logged in with JIRA to do this.)", requiredArgs: ["url"], includeUserId: true, category: "jira"})
@ -111,7 +111,7 @@ export class SetupConnection extends CommandConnection {
await this.checkUserPermissions(userId, "jira", JiraProjectConnection.CanonicalEventType);
const jiraClient = await this.tokenStore.getJiraForUser(userId, urlStr);
const jiraClient = await this.provisionOpts.tokenStore.getJiraForUser(userId, urlStr);
if (!jiraClient) {
throw new CommandError("User not logged in", "You are not logged into Jira. Start a DM with this bot and use the command `jira login`.");
}
@ -121,8 +121,7 @@ export class SetupConnection extends CommandConnection {
throw new CommandError("Invalid Jira url", "The JIRA project url you entered was not valid. It should be in the format of `https://jira-instance/.../projects/PROJECTKEY/...` or `.../RapidBoard.jspa?projectKey=TEST`.");
}
const safeUrl = `https://${url.host}/projects/${projectKey}`;
const res = await JiraProjectConnection.provisionConnection(this.roomId, userId, { url: safeUrl }, this.as, this.tokenStore);
await this.as.botClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, safeUrl, res.stateEventContent);
const res = await JiraProjectConnection.provisionConnection(this.roomId, userId, { url: safeUrl }, this.provisionOpts);
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}.`);
}
@ -139,8 +138,7 @@ export class SetupConnection extends CommandConnection {
}
const hookId = uuid();
const url = `${this.config.generic.urlPrefix}${this.config.generic.urlPrefix.endsWith('/') ? '' : '/'}${hookId}`;
await GenericHookConnection.ensureRoomAccountData(this.roomId, this.as, hookId, name);
await this.as.botClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, name, {hookId, name});
await GenericHookConnection.provisionConnection(this.roomId, userId, {name}, this.provisionOpts);
const adminRoom = await this.getOrCreateAdminRoom(userId);
await adminRoom.sendNotice(md.renderInline(`You have bridged a webhook. Please configure your webhook source to use \`${url}\`.`));
return this.as.botClient.sendNotice(this.roomId, `Room configured to bridge webhooks. See admin room for secret url.`);
@ -159,7 +157,7 @@ export class SetupConnection extends CommandConnection {
throw new CommandError("Invalid Figma url", "The Figma file url you entered was not valid. It should be in the format of `https://figma.com/file/FILEID/...`.");
}
const [, fileId] = res;
await this.as.botClient.sendStateEvent(this.roomId, FigmaFileConnection.CanonicalEventType, fileId, {fileId});
await FigmaFileConnection.provisionConnection(this.roomId, userId, { fileId }, this.provisionOpts);
return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge Figma file.`));
}

View File

@ -2,7 +2,6 @@ import { AdminRoomCommandHandler } from "../AdminRoomCommandHandler";
import { botCommand } from "../BotCommands";
import { JiraAPIAccessibleResource } from "./Types";
import LogWrapper from "../LogWrapper";
import { CLOUD_INSTANCE } from "./Client";
const log = new LogWrapper('JiraBotCommands');
@ -25,7 +24,7 @@ export class JiraBotCommands extends AdminRoomCommandHandler {
this.sendNotice(`Bot is not configured with JIRA OAuth support.`);
return;
}
if (await this.tokenStore.clearUserToken("jira", this.userId, this.config.jira.url || CLOUD_INSTANCE)) {
if (await this.tokenStore.clearUserToken("jira", this.userId, this.config.jira.instanceName)) {
return this.sendNotice(`Your JIRA account has been unlinked from your Matrix user.`);
}
return this.sendNotice(`No JIRA account was linked to your Matrix user.`);

View File

@ -13,8 +13,6 @@ export interface JiraClient {
getClientForResource(res: JiraAPIAccessibleResource): Promise<HookshotJiraApi|null>;
}
export const CLOUD_INSTANCE = "api.atlassian.com";
export class JiraApiError extends Error {
constructor(readonly errorMessages: string[], readonly errors: { description: string}) {
super();

View File

@ -4,7 +4,7 @@ import QuickLRU from "@alloc/quick-lru";
import { JiraAPIAccessibleResource, JiraIssue, JiraOAuthResult, JiraProject, JiraCloudProjectSearchResponse, JiraStoredToken } from '../Types';
import { BridgeConfigJira, BridgeConfigJiraCloudOAuth } from '../../Config/Config';
import LogWrapper from '../../LogWrapper';
import { CLOUD_INSTANCE, HookshotJiraApi, JiraClient } from '../Client';
import { HookshotJiraApi, JiraClient } from '../Client';
import JiraApi from 'jira-client';
const log = new LogWrapper("JiraCloudClient");
@ -129,7 +129,7 @@ export class JiraCloudClient implements JiraClient {
expires_in: data.expires_in,
refresh_token: data.refresh_token,
access_token: data.access_token,
instance: CLOUD_INSTANCE,
instance: this.config.instanceName,
};
this.onTokenRefreshed(this.storedToken);
}

View File

@ -29,7 +29,7 @@ export class MatrixSender {
private mq: MessageQueue;
private as: Appservice;
constructor(private config: BridgeConfig, registration: IAppserviceRegistration) {
this.mq = createMessageQueue(this.config);
this.mq = createMessageQueue(this.config.queue);
this.as = getAppservice(config, registration, new MemoryStorageProvider());
Metrics.registerMatrixSdkMetrics(this.as);
}

View File

@ -1,4 +1,4 @@
import { BridgeConfig } from "../Config/Config";
import { BridgeConfigQueue } from "../Config/Config";
import { LocalMQ } from "./LocalMQ";
import { RedisMQ } from "./RedisQueue";
import { MessageQueue } from "./Types";
@ -6,8 +6,8 @@ import { MessageQueue } from "./Types";
const staticLocalMq = new LocalMQ();
let staticRedisMq: RedisMQ|null = null;
export function createMessageQueue(config: BridgeConfig): MessageQueue {
if (config.queue.monolithic) {
export function createMessageQueue(config: BridgeConfigQueue): MessageQueue {
if (config.monolithic) {
return staticLocalMq;
}
if (staticRedisMq === null) {

View File

@ -1,7 +1,7 @@
import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT, MessageQueueMessageOut } from "./Types";
import { Redis, default as redis } from "ioredis";
import { BridgeConfig } from "../Config/Config";
import { BridgeConfig, BridgeConfigQueue } from "../Config/Config";
import { EventEmitter } from "events";
import LogWrapper from "../LogWrapper";
@ -21,11 +21,11 @@ export class RedisMQ extends EventEmitter implements MessageQueue {
private redisPub: Redis;
private redis: Redis;
private myUuid: string;
constructor(config: BridgeConfig) {
constructor(config: BridgeConfigQueue) {
super();
this.redisSub = new redis(config.queue.port, config.queue.host);
this.redisPub = new redis(config.queue.port, config.queue.host);
this.redis = new redis(config.queue.port, config.queue.host);
this.redisSub = new redis(config.port, config.host);
this.redisPub = new redis(config.port, config.host);
this.redis = new redis(config.port, config.host);
this.myUuid = uuid();
this.redisSub.on("pmessage", (_: string, channel: string, message: string) => {
const msg = JSON.parse(message) as MessageQueueMessageOut<unknown>;

View File

@ -49,7 +49,7 @@ export class NotifFilter {
};
}
public shouldInviteToRoom(user: string, repo: string, org: string): boolean {
public shouldInviteToRoom(): boolean {
return false;
}

View File

@ -26,7 +26,7 @@ export class UserNotificationWatcher {
private queue: MessageQueue;
constructor(private readonly config: BridgeConfig) {
this.queue = createMessageQueue(config);
this.queue = createMessageQueue(config.queue);
this.matrixMessageSender = new MessageSenderClient(this.queue);
}

View File

@ -4,9 +4,9 @@ import { Intent } from "matrix-bot-sdk";
import { promises as fs } from "fs";
import { publicEncrypt, privateDecrypt } from "crypto";
import LogWrapper from "./LogWrapper";
import { CLOUD_INSTANCE, isJiraCloudInstance, JiraClient } from "./Jira/Client";
import { isJiraCloudInstance, JiraClient } from "./Jira/Client";
import { JiraStoredToken } from "./Jira/Types";
import { BridgeConfig, BridgeConfigJiraOnPremOAuth, BridgePermissionLevel } from "./Config/Config";
import { BridgeConfig, BridgeConfigJira, BridgeConfigJiraOnPremOAuth, BridgePermissionLevel } from "./Config/Config";
import { v4 as uuid } from "uuid";
import { GitHubOAuthToken } from "./Github/Types";
import { ApiError, ErrCode } from "./api";
@ -202,15 +202,15 @@ export class UserTokenStore {
throw Error('Jira not configured');
}
let instance = instanceUrl ? new URL(instanceUrl).host : CLOUD_INSTANCE;
let instance = instanceUrl && new URL(instanceUrl).host;
if (isJiraCloudInstance(instance)) {
instance = CLOUD_INSTANCE;
if (!instance || isJiraCloudInstance(instance)) {
instance = BridgeConfigJira.CLOUD_INSTANCE_NAME;
}
let jsonData = await this.getUserToken("jira", userId, instance);
// XXX: Legacy fallback
if (!jsonData && instance === CLOUD_INSTANCE) {
if (!jsonData && instance === BridgeConfigJira.CLOUD_INSTANCE_NAME) {
jsonData = await this.getUserToken("jira", userId);
}
if (!jsonData) {
@ -219,9 +219,9 @@ export class UserTokenStore {
const storedToken = JSON.parse(jsonData) as JiraStoredToken;
if (!storedToken.instance) {
// Legacy stored tokens don't include the cloud instance string.
storedToken.instance = CLOUD_INSTANCE;
storedToken.instance = BridgeConfigJira.CLOUD_INSTANCE_NAME;
}
if (storedToken.instance === CLOUD_INSTANCE) {
if (storedToken.instance === BridgeConfigJira.CLOUD_INSTANCE_NAME) {
return new JiraCloudClient(storedToken, (data) => {
return this.storeJiraToken(userId, data);
}, this.config.jira, instance);

View File

@ -53,7 +53,7 @@ export class Webhooks extends EventEmitter {
// TODO: Move these
this.expressRouter.get("/oauth", this.onGitHubGetOauth.bind(this));
this.queue = createMessageQueue(config);
this.queue = createMessageQueue(config.queue);
if (this.config.jira) {
this.expressRouter.use("/jira", new JiraWebhooksRouter(this.queue).getRouter());
}

View File

@ -2,10 +2,8 @@ import { expect } from "chai";
import { createMessageQueue } from "../src/MessageQueue/MessageQueue";
const mq = createMessageQueue({
queue: {
monolithic: true,
},
} as any);
});
describe("MessageQueueTest", () => {
describe("LocalMq", () => {

View File

@ -15,10 +15,8 @@ function createGenericHook(state: GenericHookConnectionState = {
name: "some-name"
}, config: BridgeGenericWebhooksConfigYAML = { enabled: true, urlPrefix: "https://example.com/webhookurl"}): [GenericHookConnection, MessageQueue] {
const mq = createMessageQueue({
queue: {
monolithic: true,
},
} as any);
monolithic: true
});
mq.subscribe('*');
const messageClient = new MessageSenderClient(mq);
const connection = new GenericHookConnection(ROOM_ID, state, "foobar", "foobar", messageClient, new BridgeConfigGenericWebhooks(config), AppserviceMock.create())

View File

@ -8,7 +8,7 @@ export class IntentMock {
public readonly underlyingClient = new MatrixClientMock();
public sentEvents: {roomId: string, content: any}[] = [];
static create(userId?: string){
static create(){
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new this() as any;
}

View File

@ -1,5 +1,6 @@
// As per https://lwebapp.com/en/post/cannot-find-module-scss
declare module'*.scss' {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content: {[key: string]: any}
export = content
}