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

View File

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

View File

@ -7,8 +7,7 @@
import { Appservice, StateEvent } from "matrix-bot-sdk"; import { Appservice, StateEvent } from "matrix-bot-sdk";
import { CommentProcessor } from "./CommentProcessor"; import { CommentProcessor } from "./CommentProcessor";
import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config"; import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config";
import { GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, JiraProjectConnection } from "./Connections"; import { ConnectionDeclarations, GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, JiraProjectConnection } from "./Connections";
import { GenericHookAccountData } from "./Connections/GenericHook";
import { GithubInstance } from "./Github/GithubInstance"; import { GithubInstance } from "./Github/GithubInstance";
import { GitLabClient } from "./Gitlab/Client"; import { GitLabClient } from "./Gitlab/Client";
import { JiraProject } from "./Jira/Types"; import { JiraProject } from "./Jira/Types";
@ -17,7 +16,6 @@ import { MessageSenderClient } from "./MatrixSender";
import { GetConnectionTypeResponseItem } from "./provisioning/api"; import { GetConnectionTypeResponseItem } from "./provisioning/api";
import { ApiError, ErrCode } from "./api"; import { ApiError, ErrCode } from "./api";
import { UserTokenStore } from "./UserTokenStore"; import { UserTokenStore } from "./UserTokenStore";
import {v4 as uuid} from "uuid";
import { FigmaFileConnection, FeedConnection } from "./Connections"; import { FigmaFileConnection, FeedConnection } from "./Connections";
import { IBridgeStorageProvider } from "./Stores/StorageProvider"; import { IBridgeStorageProvider } from "./Stores/StorageProvider";
import Metrics from "./Metrics"; 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> { 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}`); log.info(`Looking to provision connection for ${roomId} ${type} for ${userId} with ${data}`);
const existingConnections = await this.getAllConnectionsForRoom(roomId); const connectionType = ConnectionDeclarations.find(c => c.EventTypes.includes(type));
if (JiraProjectConnection.EventTypes.includes(type)) { if (connectionType?.provisionConnection) {
if (!this.config.jira) { if (!this.config.checkPermission(userId, connectionType.ServiceCategory, BridgePermissionLevel.manageConnections)) {
throw new ApiError('JIRA integration is not configured', ErrCode.DisabledFeature); throw new ApiError(`User is not permitted to provision connections for this type of service.`, ErrCode.ForbiddenUser);
} }
if (existingConnections.find(c => c instanceof JiraProjectConnection)) { const { connection } = await connectionType.provisionConnection(roomId, userId, data, {
// TODO: Support this. as: this.as,
throw Error("Cannot support multiple connections of the same type yet"); config: this.config,
} tokenStore: this.tokenStore,
if (!this.config.checkPermission(userId, "jira", BridgePermissionLevel.manageConnections)) { commentProcessor: this.commentProcessor,
throw new ApiError('User is not permitted to provision connections for Jira', ErrCode.ForbiddenUser); messageClient: this.messageClient,
} storage: this.storage,
const res = await JiraProjectConnection.provisionConnection(roomId, userId, data, this.as, this.tokenStore); github: this.github,
await this.as.botIntent.underlyingClient.sendStateEvent(roomId, JiraProjectConnection.CanonicalEventType, res.connection.stateKey, res.stateEventContent); getAllConnectionsOfType: this.getAllConnectionsOfType.bind(this),
this.push(res.connection); });
return res.connection; this.push(connection);
} return 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()
});
}
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;
} }
throw new ApiError(`Connection type not known`); 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) { if (state.sender === this.as.botUserId) {
return; return;
} }
@ -161,145 +107,20 @@ export class ConnectionManager extends EventEmitter {
log.debug(`${roomId} has disabled state for ${state.type}`); log.debug(`${roomId} has disabled state for ${state.type}`);
return; return;
} }
const connectionType = ConnectionDeclarations.find(c => c.EventTypes.includes(state.type));
if (!connectionType) {
if (GitHubRepoConnection.EventTypes.includes(state.type)) { return;
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);
} }
this.assertStateAllowed(state, connectionType.ServiceCategory);
if (GitHubDiscussionConnection.EventTypes.includes(state.type)) { return connectionType.createConnectionForState(roomId, state, {
if (!this.github || !this.config.github) { as: this.as,
throw Error('GitHub is not configured'); config: this.config,
} tokenStore: this.tokenStore,
this.assertStateAllowed(state, "github"); commentProcessor: this.commentProcessor,
return new GitHubDiscussionConnection( messageClient: this.messageClient,
roomId, this.as, state.content, state.stateKey, this.tokenStore, this.commentProcessor, storage: this.storage,
this.messageClient, this.config.github, github: this.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,
);
}
return;
} }
public async createConnectionsForRoomId(roomId: string) { public async createConnectionsForRoomId(roomId: string) {
@ -395,7 +216,6 @@ export class ConnectionManager extends EventEmitter {
c.isInterestedInHookEvent(eventName))) as JiraProjectConnection[]; c.isInterestedInHookEvent(eventName))) as JiraProjectConnection[];
} }
public getConnectionsForGenericWebhook(hookId: string): GenericHookConnection[] { public getConnectionsForGenericWebhook(hookId: string): GenericHookConnection[] {
return this.connections.filter((c) => (c instanceof GenericHookConnection && c.hookId === hookId)) as 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 * Get a list of possible targets for a given connection type when provisioning
* @param userId * @param userId
* @param arg1 * @param type
*/ */
async getConnectionTargets(userId: string, type: string, filters: Record<string, unknown> = {}): Promise<unknown[]> { async getConnectionTargets(userId: string, type: string, filters: Record<string, unknown> = {}): Promise<unknown[]> {
if (type === GitLabRepoConnection.CanonicalEventType) { if (type === GitLabRepoConnection.CanonicalEventType) {

View File

@ -1,11 +1,12 @@
import {Appservice} from "matrix-bot-sdk"; import {Appservice, StateEvent} from "matrix-bot-sdk";
import { IConnection, IConnectionState } from "."; import { IConnection, IConnectionState, InstantiateConnectionOpts } from ".";
import { BridgeConfigFeeds } from "../Config/Config"; import { BridgeConfigFeeds } from "../Config/Config";
import { FeedEntry, FeedError} from "../feeds/FeedReader"; import { FeedEntry, FeedError} from "../feeds/FeedReader";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import { IBridgeStorageProvider } from "../Stores/StorageProvider"; import { IBridgeStorageProvider } from "../Stores/StorageProvider";
import { BaseConnection } from "./BaseConnection"; import { BaseConnection } from "./BaseConnection";
import markdown from "markdown-it"; import markdown from "markdown-it";
import { Connection } from "./IConnection";
const log = new LogWrapper("FeedConnection"); const log = new LogWrapper("FeedConnection");
const md = new markdown(); const md = new markdown();
@ -14,9 +15,20 @@ export interface FeedConnectionState extends IConnectionState {
url: string; url: string;
} }
@Connection
export class FeedConnection extends BaseConnection implements IConnection { export class FeedConnection extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.feed"; static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.feed";
static readonly EventTypes = [ FeedConnection.CanonicalEventType ]; 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; private hasError = false;
public get feedUrl(): string { 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 markdownit from "markdown-it";
import { FigmaPayload } from "../figma/types"; import { FigmaPayload } from "../figma/types";
import { BaseConnection } from "./BaseConnection"; import { BaseConnection } from "./BaseConnection";
@ -6,17 +6,19 @@ import { IConnection, IConnectionState } from ".";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import { IBridgeStorageProvider } from "../Stores/StorageProvider"; import { IBridgeStorageProvider } from "../Stores/StorageProvider";
import { BridgeConfigFigma } from "../Config/Config"; import { BridgeConfigFigma } from "../Config/Config";
import { Connection, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
const log = new LogWrapper("FigmaFileConnection"); const log = new LogWrapper("FigmaFileConnection");
export interface FigmaFileConnectionState extends IConnectionState { export interface FigmaFileConnectionState extends IConnectionState {
fileId?: string; fileId: string;
instanceName?: string; instanceName?: string;
} }
const THREAD_RELATION_TYPE = "m.thread"; const THREAD_RELATION_TYPE = "m.thread";
const md = markdownit(); const md = markdownit();
@Connection
export class FigmaFileConnection extends BaseConnection implements IConnection { export class FigmaFileConnection extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.figma.file"; static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.figma.file";
static readonly LegacyEventType = "uk.half-shot.matrix-figma.file"; // Magically import from matrix-figma 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.CanonicalEventType,
FigmaFileConnection.LegacyEventType, FigmaFileConnection.LegacyEventType,
]; ];
static readonly ServiceCategory = "figma";
public static async createState(roomId: string, fileId: string, client: MatrixClient) {
await client.sendStateEvent(roomId, FigmaFileConnection.CanonicalEventType, fileId, { public static validateState(data: Record<string, unknown>): FigmaFileConnectionState {
fileId: fileId, if (!data.fileId || typeof data.fileId !== "string") {
} as FigmaFileConnectionState); 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 LogWrapper from "../LogWrapper";
import { MessageSenderClient } from "../MatrixSender" import { MessageSenderClient } from "../MatrixSender"
import markdownit from "markdown-it"; import markdownit from "markdown-it";
import { VMScript as Script, NodeVM } from "vm2"; import { VMScript as Script, NodeVM } from "vm2";
import { MatrixEvent } from "../MatrixEvent"; import { MatrixEvent } from "../MatrixEvent";
import { Appservice } from "matrix-bot-sdk"; import { Appservice, StateEvent } from "matrix-bot-sdk";
import { v4 as uuid} from "uuid"; import { v4 as uuid} from "uuid";
import { ApiError, ErrCode } from "../api"; import { ApiError, ErrCode } from "../api";
import { BaseConnection } from "./BaseConnection"; import { BaseConnection } from "./BaseConnection";
import { GetConnectionsResponseItem } from "../provisioning/api"; import { GetConnectionsResponseItem } from "../provisioning/api";
import { BridgeConfigGenericWebhooks } from "../Config/Config"; import { BridgeConfigGenericWebhooks } from "../Config/Config";
export interface GenericHookConnectionState extends IConnectionState { export interface GenericHookConnectionState extends IConnectionState {
/** /**
* This is ONLY used for display purposes, but the account data value is used to prevent misuse. * 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. * Handles rooms connected to a github repo.
*/ */
@Connection
export class GenericHookConnection extends BaseConnection implements IConnection { 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; const {name, transformationFunction} = state;
let transformationFunctionResult: string|undefined; let transformationFunctionResult: string|undefined;
if (transformationFunction) { if (transformationFunction) {
if (!allowJsTransformationFunctions) { if (allowJsTransformationFunctions !== undefined && !allowJsTransformationFunctions) {
throw new ApiError('Transformation functions are not allowed', ErrCode.DisabledFeature); throw new ApiError('Transformation functions are not allowed', ErrCode.DisabledFeature);
} }
if (typeof transformationFunction !== "string") { 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) { static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {as, config, messageClient}: InstantiateConnectionOpts) {
const hookId = uuid(); if (!config.generic) {
const validState: GenericHookConnectionState = { throw Error('Generic webhooks are not configured');
...GenericHookConnection.validateState(data, config.allowJsTransformationFunctions || false), }
// 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, hookId,
}; event.stateKey,
const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config, as); 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 { return {
connection, connection,
stateEventContent: validState, 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 CanonicalEventType = "uk.half-shot.matrix-hookshot.generic.hook";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.generic.hook"; static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.generic.hook";
static readonly ServiceCategory = "webhooks";
static readonly EventTypes = [ static readonly EventTypes = [
GenericHookConnection.CanonicalEventType, GenericHookConnection.CanonicalEventType,

View File

@ -1,5 +1,5 @@
import { IConnection } from "./IConnection"; import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice } from "matrix-bot-sdk"; import { Appservice, StateEvent } from "matrix-bot-sdk";
import { UserTokenStore } from "../UserTokenStore"; import { UserTokenStore } from "../UserTokenStore";
import { CommentProcessor } from "../CommentProcessor"; import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender"; import { MessageSenderClient } from "../MatrixSender";
@ -28,6 +28,7 @@ const md = new markdown();
/** /**
* Handles rooms connected to a github repo. * Handles rooms connected to a github repo.
*/ */
@Connection
export class GitHubDiscussionConnection extends BaseConnection implements IConnection { export class GitHubDiscussionConnection extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.discussion"; static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.discussion";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-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 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 readonly sentEvents = new Set<string>(); //TODO: Set some reasonable limits

View File

@ -1,5 +1,5 @@
import { IConnection } from "./IConnection"; import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, Space } from "matrix-bot-sdk"; import { Appservice, Space, StateEvent } from "matrix-bot-sdk";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import { ReposGetResponseData } from "../Github/Types"; import { ReposGetResponseData } from "../Github/Types";
import axios from "axios"; import axios from "axios";
@ -17,6 +17,7 @@ export interface GitHubDiscussionSpaceConnectionState {
/** /**
* Handles rooms connected to a github repo. * Handles rooms connected to a github repo.
*/ */
@Connection
export class GitHubDiscussionSpace extends BaseConnection implements IConnection { export class GitHubDiscussionSpace extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.discussion.space"; static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.discussion.space";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-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 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>> { static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise<Record<string, unknown>> {
if (!result || result.length < 2) { if (!result || result.length < 2) {

View File

@ -1,5 +1,5 @@
import { IConnection } from "./IConnection"; import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice } from "matrix-bot-sdk"; import { Appservice, StateEvent } from "matrix-bot-sdk";
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
import markdown from "markdown-it"; import markdown from "markdown-it";
import { UserTokenStore } from "../UserTokenStore"; import { UserTokenStore } from "../UserTokenStore";
@ -38,6 +38,7 @@ interface IQueryRoomOpts {
/** /**
* Handles rooms connected to a github repo. * Handles rooms connected to a github repo.
*/ */
@Connection
export class GitHubIssueConnection extends BaseConnection implements IConnection { export class GitHubIssueConnection extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.issue"; static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.issue";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.bridge"; 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 QueryRoomRegex = /#github_(.+)_(.+)_(\d+):.*/;
static readonly ServiceCategory = "github";
static generateAliasLocalpart(org: string, repo: string, issueNo: string|number) { static generateAliasLocalpart(org: string, repo: string, issueNo: string|number) {
return `github_${org}_${repo}_${issueNo}`; 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> { static async onQueryRoom(result: RegExpExecArray, opts: IQueryRoomOpts): Promise<unknown> {
const parts = result?.slice(1); const parts = result?.slice(1);
if (!parts) { if (!parts) {

View File

@ -1,5 +1,5 @@
import { IConnection } from "./IConnection"; import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice } from "matrix-bot-sdk"; import { Appservice, StateEvent } from "matrix-bot-sdk";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import { ProjectsGetResponseData } from "../Github/Types"; import { ProjectsGetResponseData } from "../Github/Types";
import { BaseConnection } from "./BaseConnection"; import { BaseConnection } from "./BaseConnection";
@ -14,15 +14,23 @@ const log = new LogWrapper("GitHubProjectConnection");
/** /**
* Handles rooms connected to a github repo. * Handles rooms connected to a github repo.
*/ */
@Connection
export class GitHubProjectConnection extends BaseConnection implements IConnection { export class GitHubProjectConnection extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.project"; static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.project";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.project"; static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.project";
static readonly ServiceCategory = "github";
static readonly EventTypes = [ static readonly EventTypes = [
GitHubProjectConnection.CanonicalEventType, GitHubProjectConnection.CanonicalEventType,
GitHubProjectConnection.LegacyCanonicalEventType, 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> { static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, inviteUser: string): Promise<GitHubProjectConnection> {
log.info(`Fetching ${project.name} ${project.id}`); log.info(`Fetching ${project.name} ${project.id}`);

View File

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

View File

@ -1,5 +1,5 @@
import { IConnection } from "./IConnection"; import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, Space } from "matrix-bot-sdk"; import { Appservice, Space, StateEvent } from "matrix-bot-sdk";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import axios from "axios"; import axios from "axios";
import { GitHubDiscussionSpace } from "."; import { GitHubDiscussionSpace } from ".";
@ -16,6 +16,7 @@ export interface GitHubUserSpaceConnectionState {
/** /**
* Handles rooms connected to a github repo. * Handles rooms connected to a github repo.
*/ */
@Connection
export class GitHubUserSpace extends BaseConnection implements IConnection { export class GitHubUserSpace extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.user.space"; static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.user.space";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-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 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>> { static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise<Record<string, unknown>> {
if (!result || result.length < 1) { if (!result || result.length < 1) {

View File

@ -1,5 +1,5 @@
import { IConnection } from "./IConnection"; import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice } from "matrix-bot-sdk"; import { Appservice, StateEvent } from "matrix-bot-sdk";
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
import { UserTokenStore } from "../UserTokenStore"; import { UserTokenStore } from "../UserTokenStore";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
@ -33,21 +33,36 @@ const log = new LogWrapper("GitLabIssueConnection");
/** /**
* Handles rooms connected to a github repo. * Handles rooms connected to a github repo.
*/ */
@Connection
export class GitLabIssueConnection extends BaseConnection implements IConnection { export class GitLabIssueConnection extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.gitlab.issue"; static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.gitlab.issue";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.gitlab.issue"; static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.gitlab.issue";
static readonly EventTypes = [ static readonly EventTypes = [
GitLabIssueConnection.CanonicalEventType, GitLabIssueConnection.CanonicalEventType,
GitLabIssueConnection.LegacyCanonicalEventType, GitLabIssueConnection.LegacyCanonicalEventType,
]; ];
static readonly QueryRoomRegex = /#gitlab_(.+)_(.+)_(\d+):.*/; static readonly QueryRoomRegex = /#gitlab_(.+)_(.+)_(\d+):.*/;
static readonly ServiceCategory = "gitlab";
static getTopicString(authorName: string, state: string) { static getTopicString(authorName: string, state: string) {
`Author: ${authorName} | State: ${state === "closed" ? "closed" : "open"}` `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, public static async createRoomForIssue(instanceName: string, instance: GitLabInstance,
issue: GetIssueResponse, projects: string[], as: Appservice, issue: GetIssueResponse, projects: string[], as: Appservice,
tokenStore: UserTokenStore, commentProcessor: CommentProcessor, tokenStore: UserTokenStore, commentProcessor: CommentProcessor,

View File

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

View File

@ -1,8 +1,14 @@
import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types"; import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types";
import { GetConnectionsResponseItem } from "../provisioning/api"; import { GetConnectionsResponseItem } from "../provisioning/api";
import { IRichReplyMetadata } from "matrix-bot-sdk"; import { Appservice, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
import { BridgePermissionLevel } from "../Config/Config"; 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; export type PermissionCheckFn = (service: string, level: BridgePermissionLevel) => boolean;
@ -70,3 +76,42 @@ export interface IConnection {
toString(): string; 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 { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
import { Appservice } from "matrix-bot-sdk"; import { Appservice, StateEvent } from "matrix-bot-sdk";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import { JiraIssueEvent, JiraIssueUpdatedEvent } from "../Jira/WebhookTypes"; import { JiraIssueEvent, JiraIssueUpdatedEvent } from "../Jira/WebhookTypes";
import { FormatUtil } from "../FormatUtil"; import { FormatUtil } from "../FormatUtil";
@ -49,6 +49,7 @@ const md = new markdownit();
/** /**
* Handles rooms connected to a github repo. * Handles rooms connected to a github repo.
*/ */
@Connection
export class JiraProjectConnection extends CommandConnection implements IConnection { export class JiraProjectConnection extends CommandConnection implements IConnection {
@ -59,10 +60,19 @@ export class JiraProjectConnection extends CommandConnection implements IConnect
JiraProjectConnection.CanonicalEventType, JiraProjectConnection.CanonicalEventType,
JiraProjectConnection.LegacyCanonicalEventType, JiraProjectConnection.LegacyCanonicalEventType,
]; ];
static readonly ServiceCategory = "jira";
static botCommands: BotCommands; static botCommands: BotCommands;
static helpMessage: (cmdPrefix?: string) => MatrixMessageContent; 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); const validData = validateJiraConnectionState(data);
log.info(`Attempting to provisionConnection for ${roomId} ${validData.url} on behalf of ${userId}`); log.info(`Attempting to provisionConnection for ${roomId} ${validData.url} on behalf of ${userId}`);
const jiraClient = await tokenStore.getJiraForUser(userId, validData.url); const jiraClient = await tokenStore.getJiraForUser(userId, validData.url);
@ -84,8 +94,17 @@ export class JiraProjectConnection extends CommandConnection implements IConnect
} catch (ex) { } catch (ex) {
throw new ApiError("Requested project was not found", ErrCode.ForbiddenUser); 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()}`); 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() { public get projectId() {

View File

@ -1,13 +1,10 @@
// We need to instantiate some functions which are not directly called, which confuses typescript. // 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 { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands";
import { CommandConnection } from "./CommandConnection"; import { CommandConnection } from "./CommandConnection";
import { GenericHookConnection, GitHubRepoConnection, JiraProjectConnection } from "."; import { GenericHookConnection, GitHubRepoConnection, JiraProjectConnection } from ".";
import { CommandError } from "../errors"; import { CommandError } from "../errors";
import { UserTokenStore } from "../UserTokenStore";
import { GithubInstance } from "../Github/GithubInstance";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { BridgeConfig, BridgePermissionLevel } from "../Config/Config"; import { BridgePermissionLevel } from "../Config/Config";
import markdown from "markdown-it"; import markdown from "markdown-it";
import { FigmaFileConnection } from "./FigmaFileConnection"; import { FigmaFileConnection } from "./FigmaFileConnection";
import { FeedConnection } from "./FeedConnection"; import { FeedConnection } from "./FeedConnection";
@ -15,6 +12,7 @@ import { URL } from "url";
import { SetupWidget } from "../Widgets/SetupWidget"; import { SetupWidget } from "../Widgets/SetupWidget";
import { AdminRoom } from "../AdminRoom"; import { AdminRoom } from "../AdminRoom";
import { GitLabRepoConnection } from "./GitlabRepo"; import { GitLabRepoConnection } from "./GitlabRepo";
import { ProvisionConnectionOpts } from "./IConnection";
const md = new markdown(); const md = new markdown();
/** /**
@ -26,17 +24,22 @@ export class SetupConnection extends CommandConnection {
static botCommands: BotCommands; static botCommands: BotCommands;
static helpMessage: HelpFunction; static helpMessage: HelpFunction;
private get config() {
return this.provisionOpts.config;
}
private get as() {
return this.provisionOpts.as;
}
constructor(public readonly roomId: string, constructor(public readonly roomId: string,
private readonly as: Appservice, private readonly provisionOpts: ProvisionConnectionOpts,
private readonly tokenStore: UserTokenStore, private readonly getOrCreateAdminRoom: (userId: string) => Promise<AdminRoom>,) {
private readonly config: BridgeConfig,
private readonly getOrCreateAdminRoom: (userId: string) => Promise<AdminRoom>,
private readonly githubInstance?: GithubInstance,) {
super( super(
roomId, roomId,
"", "",
"", "",
as.botClient, provisionOpts.as.botClient,
SetupConnection.botCommands, SetupConnection.botCommands,
SetupConnection.helpMessage, SetupConnection.helpMessage,
"!hookshot", "!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"}) @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) { 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."); throw new CommandError("not-configured", "The bridge is not configured to support GitHub.");
} }
await this.checkUserPermissions(userId, "github", GitHubRepoConnection.CanonicalEventType); await this.checkUserPermissions(userId, "github", GitHubRepoConnection.CanonicalEventType);
const octokit = await this.provisionOpts.tokenStore.getOctokitForUser(userId);
const octokit = await this.tokenStore.getOctokitForUser(userId);
if (!octokit) { 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`."); 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."); throw new CommandError("Invalid GitHub url", "The GitHub url you entered was not valid.");
} }
const [, org, repo] = urlParts; const [, org, repo] = urlParts;
const res = await GitHubRepoConnection.provisionConnection(this.roomId, userId, {org, repo}, this.as, this.tokenStore, this.githubInstance, this.config.github); const {connection} = await GitHubRepoConnection.provisionConnection(this.roomId, userId, {org, repo}, this.provisionOpts);
await this.as.botClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, url, res.stateEventContent); await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.org}/${connection.repo}`);
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${org}/${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"}) @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."); 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) { 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`."); 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) { if (!path) {
throw new CommandError("Invalid GitLab url", "The GitLab project url you entered was not valid."); 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); const {connection} = await GitLabRepoConnection.provisionConnection(this.roomId, userId, {path, instance: name}, this.provisionOpts);
await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, url, res.stateEventContent); await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.path}`);
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${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"}) @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); 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) { 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`."); 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`."); 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 safeUrl = `https://${url.host}/projects/${projectKey}`;
const res = await JiraProjectConnection.provisionConnection(this.roomId, userId, { url: safeUrl }, this.as, this.tokenStore); const res = await JiraProjectConnection.provisionConnection(this.roomId, userId, { url: safeUrl }, this.provisionOpts);
await this.as.botClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, safeUrl, res.stateEventContent);
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}.`); 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 hookId = uuid();
const url = `${this.config.generic.urlPrefix}${this.config.generic.urlPrefix.endsWith('/') ? '' : '/'}${hookId}`; const url = `${this.config.generic.urlPrefix}${this.config.generic.urlPrefix.endsWith('/') ? '' : '/'}${hookId}`;
await GenericHookConnection.ensureRoomAccountData(this.roomId, this.as, hookId, name); await GenericHookConnection.provisionConnection(this.roomId, userId, {name}, this.provisionOpts);
await this.as.botClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, name, {hookId, name});
const adminRoom = await this.getOrCreateAdminRoom(userId); const adminRoom = await this.getOrCreateAdminRoom(userId);
await adminRoom.sendNotice(md.renderInline(`You have bridged a webhook. Please configure your webhook source to use \`${url}\`.`)); 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.`); 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/...`."); 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; 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.`)); return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge Figma file.`));
} }

View File

@ -10,4 +10,4 @@ export * from "./GitlabRepo";
export * from "./IConnection"; export * from "./IConnection";
export * from "./JiraProject"; export * from "./JiraProject";
export * from "./FigmaFileConnection"; export * from "./FigmaFileConnection";
export * from "./FeedConnection"; export * from "./FeedConnection";

View File

@ -2,7 +2,6 @@ import { AdminRoomCommandHandler } from "../AdminRoomCommandHandler";
import { botCommand } from "../BotCommands"; import { botCommand } from "../BotCommands";
import { JiraAPIAccessibleResource } from "./Types"; import { JiraAPIAccessibleResource } from "./Types";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import { CLOUD_INSTANCE } from "./Client";
const log = new LogWrapper('JiraBotCommands'); const log = new LogWrapper('JiraBotCommands');
@ -25,7 +24,7 @@ export class JiraBotCommands extends AdminRoomCommandHandler {
this.sendNotice(`Bot is not configured with JIRA OAuth support.`); this.sendNotice(`Bot is not configured with JIRA OAuth support.`);
return; 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(`Your JIRA account has been unlinked from your Matrix user.`);
} }
return this.sendNotice(`No JIRA account was linked to 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>; getClientForResource(res: JiraAPIAccessibleResource): Promise<HookshotJiraApi|null>;
} }
export const CLOUD_INSTANCE = "api.atlassian.com";
export class JiraApiError extends Error { export class JiraApiError extends Error {
constructor(readonly errorMessages: string[], readonly errors: { description: string}) { constructor(readonly errorMessages: string[], readonly errors: { description: string}) {
super(); super();

View File

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

View File

@ -29,7 +29,7 @@ export class MatrixSender {
private mq: MessageQueue; private mq: MessageQueue;
private as: Appservice; private as: Appservice;
constructor(private config: BridgeConfig, registration: IAppserviceRegistration) { constructor(private config: BridgeConfig, registration: IAppserviceRegistration) {
this.mq = createMessageQueue(this.config); this.mq = createMessageQueue(this.config.queue);
this.as = getAppservice(config, registration, new MemoryStorageProvider()); this.as = getAppservice(config, registration, new MemoryStorageProvider());
Metrics.registerMatrixSdkMetrics(this.as); 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 { LocalMQ } from "./LocalMQ";
import { RedisMQ } from "./RedisQueue"; import { RedisMQ } from "./RedisQueue";
import { MessageQueue } from "./Types"; import { MessageQueue } from "./Types";
@ -6,8 +6,8 @@ import { MessageQueue } from "./Types";
const staticLocalMq = new LocalMQ(); const staticLocalMq = new LocalMQ();
let staticRedisMq: RedisMQ|null = null; let staticRedisMq: RedisMQ|null = null;
export function createMessageQueue(config: BridgeConfig): MessageQueue { export function createMessageQueue(config: BridgeConfigQueue): MessageQueue {
if (config.queue.monolithic) { if (config.monolithic) {
return staticLocalMq; return staticLocalMq;
} }
if (staticRedisMq === null) { if (staticRedisMq === null) {

View File

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

View File

@ -26,7 +26,7 @@ export class UserNotificationWatcher {
private queue: MessageQueue; private queue: MessageQueue;
constructor(private readonly config: BridgeConfig) { constructor(private readonly config: BridgeConfig) {
this.queue = createMessageQueue(config); this.queue = createMessageQueue(config.queue);
this.matrixMessageSender = new MessageSenderClient(this.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 { promises as fs } from "fs";
import { publicEncrypt, privateDecrypt } from "crypto"; import { publicEncrypt, privateDecrypt } from "crypto";
import LogWrapper from "./LogWrapper"; import LogWrapper from "./LogWrapper";
import { CLOUD_INSTANCE, isJiraCloudInstance, JiraClient } from "./Jira/Client"; import { isJiraCloudInstance, JiraClient } from "./Jira/Client";
import { JiraStoredToken } from "./Jira/Types"; 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 { v4 as uuid } from "uuid";
import { GitHubOAuthToken } from "./Github/Types"; import { GitHubOAuthToken } from "./Github/Types";
import { ApiError, ErrCode } from "./api"; import { ApiError, ErrCode } from "./api";
@ -202,15 +202,15 @@ export class UserTokenStore {
throw Error('Jira not configured'); throw Error('Jira not configured');
} }
let instance = instanceUrl ? new URL(instanceUrl).host : CLOUD_INSTANCE; let instance = instanceUrl && new URL(instanceUrl).host;
if (isJiraCloudInstance(instance)) { if (!instance || isJiraCloudInstance(instance)) {
instance = CLOUD_INSTANCE; instance = BridgeConfigJira.CLOUD_INSTANCE_NAME;
} }
let jsonData = await this.getUserToken("jira", userId, instance); let jsonData = await this.getUserToken("jira", userId, instance);
// XXX: Legacy fallback // XXX: Legacy fallback
if (!jsonData && instance === CLOUD_INSTANCE) { if (!jsonData && instance === BridgeConfigJira.CLOUD_INSTANCE_NAME) {
jsonData = await this.getUserToken("jira", userId); jsonData = await this.getUserToken("jira", userId);
} }
if (!jsonData) { if (!jsonData) {
@ -219,9 +219,9 @@ export class UserTokenStore {
const storedToken = JSON.parse(jsonData) as JiraStoredToken; const storedToken = JSON.parse(jsonData) as JiraStoredToken;
if (!storedToken.instance) { if (!storedToken.instance) {
// Legacy stored tokens don't include the cloud instance string. // 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 new JiraCloudClient(storedToken, (data) => {
return this.storeJiraToken(userId, data); return this.storeJiraToken(userId, data);
}, this.config.jira, instance); }, this.config.jira, instance);

View File

@ -53,7 +53,7 @@ export class Webhooks extends EventEmitter {
// TODO: Move these // TODO: Move these
this.expressRouter.get("/oauth", this.onGitHubGetOauth.bind(this)); this.expressRouter.get("/oauth", this.onGitHubGetOauth.bind(this));
this.queue = createMessageQueue(config); this.queue = createMessageQueue(config.queue);
if (this.config.jira) { if (this.config.jira) {
this.expressRouter.use("/jira", new JiraWebhooksRouter(this.queue).getRouter()); 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"; import { createMessageQueue } from "../src/MessageQueue/MessageQueue";
const mq = createMessageQueue({ const mq = createMessageQueue({
queue: { monolithic: true,
monolithic: true, });
},
} as any);
describe("MessageQueueTest", () => { describe("MessageQueueTest", () => {
describe("LocalMq", () => { describe("LocalMq", () => {

View File

@ -15,10 +15,8 @@ function createGenericHook(state: GenericHookConnectionState = {
name: "some-name" name: "some-name"
}, config: BridgeGenericWebhooksConfigYAML = { enabled: true, urlPrefix: "https://example.com/webhookurl"}): [GenericHookConnection, MessageQueue] { }, config: BridgeGenericWebhooksConfigYAML = { enabled: true, urlPrefix: "https://example.com/webhookurl"}): [GenericHookConnection, MessageQueue] {
const mq = createMessageQueue({ const mq = createMessageQueue({
queue: { monolithic: true
monolithic: true, });
},
} as any);
mq.subscribe('*'); mq.subscribe('*');
const messageClient = new MessageSenderClient(mq); const messageClient = new MessageSenderClient(mq);
const connection = new GenericHookConnection(ROOM_ID, state, "foobar", "foobar", messageClient, new BridgeConfigGenericWebhooks(config), AppserviceMock.create()) 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 readonly underlyingClient = new MatrixClientMock();
public sentEvents: {roomId: string, content: any}[] = []; public sentEvents: {roomId: string, content: any}[] = [];
static create(userId?: string){ static create(){
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
return new this() as any; return new this() as any;
} }

View File

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