mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 13:17:08 +00:00

* Add logic to enable generic hook expiry * Add storage for hook expiry warnings. * Migrate generic hooks / add expiry field * Allow reporting a specific error and status code for generic webhooks * Report the specific error when a message fails to send * Refactor input class to better support datetime * Remove single use of innerChild * Add UI support for expiry configuration * Add new packages * Add warnings when the timer is about to expire. * Add send expiry notice config option * lint * document new option s * Fixup test * Add tests for expiry * Add textual command for setting a duration on a webhook. * Add e2e test for inbound hooks. * changelog * Add a configuration option to force webhooks to expire. * update config.sample.yml * fix field not working
1484 lines
66 KiB
TypeScript
1484 lines
66 KiB
TypeScript
import { AdminAccountData } from "./AdminRoomCommandHandler";
|
|
import { AdminRoom, BRIDGE_ROOM_TYPE, LEGACY_BRIDGE_ROOM_TYPE } from "./AdminRoom";
|
|
import { Appservice, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent, Intent } from "matrix-bot-sdk";
|
|
import BotUsersManager from "./Managers/BotUsersManager";
|
|
import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./config/Config";
|
|
import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi";
|
|
import { CommentProcessor } from "./CommentProcessor";
|
|
import { ConnectionManager } from "./ConnectionManager";
|
|
import { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types"
|
|
import { GithubInstance } from "./github/GithubInstance";
|
|
import { IBridgeStorageProvider } from "./Stores/StorageProvider";
|
|
import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace, JiraProjectConnection, GitLabRepoConnection,
|
|
GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitLabIssueConnection, FigmaFileConnection, FeedConnection, GenericHookConnection } from "./Connections";
|
|
import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "./Gitlab/WebhookTypes";
|
|
import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "./jira/WebhookTypes";
|
|
import { JiraOAuthResult } from "./jira/Types";
|
|
import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent";
|
|
import { MessageQueue, MessageQueueMessageOut, createMessageQueue } from "./MessageQueue";
|
|
import { MessageSenderClient } from "./MatrixSender";
|
|
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
|
|
import { NotificationProcessor } from "./NotificationsProcessor";
|
|
import { NotificationsEnableEvent, NotificationsDisableEvent, Webhooks } from "./Webhooks";
|
|
import { GitHubOAuthToken, GitHubOAuthTokenResponse, ProjectsGetResponseData } from "./github/Types";
|
|
import { retry } from "./PromiseUtil";
|
|
import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher";
|
|
import { UserTokenStore } from "./tokens/UserTokenStore";
|
|
import * as GitHubWebhookTypes from "@octokit/webhooks-types";
|
|
import { Logger } from "matrix-appservice-bridge";
|
|
import { Provisioner } from "./provisioning/provisioner";
|
|
import { JiraProvisionerRouter } from "./jira/Router";
|
|
import { GitHubProvisionerRouter } from "./github/Router";
|
|
import { OAuthRequest } from "./WebhookTypes";
|
|
import { promises as fs } from "fs";
|
|
import Metrics from "./Metrics";
|
|
import { FigmaEvent, ensureFigmaWebhooks } from "./figma";
|
|
import { ListenerService } from "./ListenerService";
|
|
import { SetupConnection } from "./Connections/SetupConnection";
|
|
import { JiraOAuthRequestCloud, JiraOAuthRequestOnPrem, JiraOAuthRequestResult } from "./jira/OAuth";
|
|
import { GenericWebhookEvent, GenericWebhookEventResult } from "./generic/types";
|
|
import { SetupWidget } from "./Widgets/SetupWidget";
|
|
import { FeedEntry, FeedError, FeedReader, FeedSuccess } from "./feeds/FeedReader";
|
|
import PQueue from "p-queue";
|
|
import * as Sentry from '@sentry/node';
|
|
import { HoundConnection, HoundPayload } from "./Connections/HoundConnection";
|
|
import { HoundReader } from "./hound/reader";
|
|
|
|
const log = new Logger("Bridge");
|
|
|
|
export class Bridge {
|
|
private readonly messageClient: MessageSenderClient;
|
|
private readonly queue: MessageQueue;
|
|
private readonly commentProcessor: CommentProcessor;
|
|
private readonly notifProcessor: NotificationProcessor;
|
|
private connectionManager?: ConnectionManager;
|
|
private github?: GithubInstance;
|
|
private adminRooms: Map<string, AdminRoom> = new Map();
|
|
private feedReader?: FeedReader;
|
|
private houndReader?: HoundReader;
|
|
private provisioningApi?: Provisioner;
|
|
private replyProcessor = new RichRepliesPreprocessor(true);
|
|
|
|
private ready = false;
|
|
|
|
constructor(
|
|
private config: BridgeConfig,
|
|
private readonly tokenStore: UserTokenStore,
|
|
private readonly listener: ListenerService,
|
|
private readonly as: Appservice,
|
|
private readonly storage: IBridgeStorageProvider,
|
|
private readonly botUsersManager: BotUsersManager,
|
|
) {
|
|
this.queue = createMessageQueue(this.config.queue);
|
|
this.messageClient = new MessageSenderClient(this.queue);
|
|
this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl || this.config.bridge.url);
|
|
this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient);
|
|
|
|
// Legacy routes, to be removed.
|
|
this.as.expressAppInstance.get("/live", (_, res) => res.send({ok: true}));
|
|
this.as.expressAppInstance.get("/ready", (_, res) => res.status(this.ready ? 200 : 500).send({ready: this.ready}));
|
|
}
|
|
|
|
public stop() {
|
|
this.feedReader?.stop();
|
|
this.houndReader?.stop();
|
|
this.tokenStore.stop();
|
|
this.as.stop();
|
|
if (this.queue.stop) this.queue.stop();
|
|
}
|
|
|
|
public async start() {
|
|
this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this));
|
|
log.info('Starting up');
|
|
await this.storage.connect?.();
|
|
await this.queue.connect?.();
|
|
|
|
log.info("Ensuring homeserver can be reached...");
|
|
let reached = false;
|
|
while (!reached) {
|
|
try {
|
|
// Make a request to determine if we can reach the homeserver
|
|
await this.as.botIntent.getJoinedRooms();
|
|
reached = true;
|
|
} catch (e) {
|
|
log.warn("Failed to connect to homeserver, retrying in 5s", e);
|
|
await new Promise((r) => setTimeout(r, 5000));
|
|
}
|
|
}
|
|
|
|
await this.botUsersManager.start();
|
|
|
|
await this.config.prefillMembershipCache(this.as.botClient);
|
|
|
|
if (this.config.github) {
|
|
this.github = new GithubInstance(
|
|
this.config.github.auth.id,
|
|
await fs.readFile(this.config.github.auth.privateKeyFile, 'utf-8'),
|
|
this.config.github.baseUrl,
|
|
);
|
|
await this.github.start();
|
|
}
|
|
|
|
if (this.config.figma) {
|
|
// Ensure webhooks are set up
|
|
await ensureFigmaWebhooks(this.config.figma, this.as.botClient);
|
|
}
|
|
|
|
const connManager = this.connectionManager = new ConnectionManager(
|
|
this.as,
|
|
this.config,
|
|
this.tokenStore,
|
|
this.commentProcessor,
|
|
this.messageClient,
|
|
this.storage,
|
|
this.botUsersManager,
|
|
this.github,
|
|
);
|
|
|
|
if (this.config.provisioning) {
|
|
const routers = [];
|
|
if (this.config.jira) {
|
|
routers.push({
|
|
route: "/v1/jira",
|
|
router: new JiraProvisionerRouter(this.config.jira, this.tokenStore).getRouter(),
|
|
});
|
|
this.connectionManager.registerProvisioningConnection(JiraProjectConnection);
|
|
}
|
|
if (this.config.github && this.github) {
|
|
routers.push({
|
|
route: "/v1/github",
|
|
router: new GitHubProvisionerRouter(this.config.github, this.tokenStore, this.github).getRouter(),
|
|
});
|
|
this.connectionManager.registerProvisioningConnection(GitHubRepoConnection);
|
|
}
|
|
if (this.config.generic) {
|
|
this.connectionManager.registerProvisioningConnection(GenericHookConnection);
|
|
}
|
|
this.provisioningApi = new Provisioner(
|
|
this.config.provisioning,
|
|
this.connectionManager,
|
|
this.botUsersManager,
|
|
this.as,
|
|
routers,
|
|
);
|
|
}
|
|
|
|
this.as.on("query.room", async (roomAlias, cb) => {
|
|
try {
|
|
cb(await this.onQueryRoom(roomAlias));
|
|
} catch (ex) {
|
|
log.error("Failed to create room:", ex);
|
|
cb(false);
|
|
}
|
|
});
|
|
|
|
this.as.on("room.invite", async (roomId, event) => {
|
|
return this.onRoomInvite(roomId, event);
|
|
});
|
|
|
|
this.as.on("room.message", async (roomId, event) => {
|
|
return this.onRoomMessage(roomId, event);
|
|
});
|
|
|
|
this.as.on("room.event", async (roomId, event) => {
|
|
Metrics.matrixAppserviceEvents.inc();
|
|
return this.onRoomEvent(roomId, event);
|
|
});
|
|
|
|
this.as.on("room.leave", async (roomId, event) => {
|
|
return this.onRoomLeave(roomId, event);
|
|
});
|
|
|
|
this.as.on("room.join", async (roomId, event) => {
|
|
return this.onRoomJoin(roomId, event);
|
|
});
|
|
|
|
this.as.on("room.failed_decryption", (roomId, event, err) => {
|
|
log.warn(`Failed to decrypt event ${event.event_id} from ${roomId}: ${err.message}`);
|
|
Metrics.matrixAppserviceDecryptionFailed.inc();
|
|
});
|
|
|
|
this.queue.subscribe("response.matrix.message");
|
|
this.queue.subscribe("notifications.user.events");
|
|
this.queue.subscribe("github.*");
|
|
this.queue.subscribe("gitlab.*");
|
|
this.queue.subscribe("jira.*");
|
|
this.queue.subscribe("figma.*");
|
|
this.queue.subscribe("feed.*");
|
|
|
|
const validateRepoIssue = (data: GitHubWebhookTypes.IssuesEvent|GitHubWebhookTypes.IssueCommentEvent) => {
|
|
if (!data.repository || !data.issue) {
|
|
throw Error("Malformed webhook event, missing repository or issue");
|
|
}
|
|
if (!data.repository.owner?.login) {
|
|
throw Error('Cannot get connection for ownerless issue');
|
|
}
|
|
return {
|
|
owner: data.repository.owner?.login,
|
|
repository: data.repository,
|
|
issue: data.issue,
|
|
};
|
|
}
|
|
|
|
|
|
this.queue.on<GitHubWebhookTypes.InstallationCreatedEvent>("github.installation.created", async (data) => {
|
|
this.github?.onInstallationCreated(data.data);
|
|
});
|
|
this.queue.on<GitHubWebhookTypes.InstallationUnsuspendEvent>("github.installation.unsuspend", async (data) => {
|
|
this.github?.onInstallationCreated(data.data);
|
|
});
|
|
this.queue.on<GitHubWebhookTypes.InstallationDeletedEvent>("github.installation.deleted", async (data) => {
|
|
this.github?.onInstallationRemoved(data.data);
|
|
});
|
|
this.queue.on<GitHubWebhookTypes.InstallationSuspendEvent>("github.installation.suspend", async (data) => {
|
|
this.github?.onInstallationRemoved(data.data);
|
|
});
|
|
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.IssueCommentCreatedEvent, GitHubIssueConnection|GitHubRepoConnection>(
|
|
"github.issue_comment.created",
|
|
(data) => {
|
|
const { repository, issue, owner } = validateRepoIssue(data);
|
|
return connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number);
|
|
},
|
|
(c, data) => c.onIssueCommentCreated(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.IssuesOpenedEvent, GitHubRepoConnection>(
|
|
"github.issues.opened",
|
|
(data) => {
|
|
const { repository, owner } = validateRepoIssue(data);
|
|
return connManager.getConnectionsForGithubRepo(owner, repository.name);
|
|
},
|
|
(c, data) => c.onIssueCreated(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.IssuesEditedEvent, GitHubIssueConnection|GitHubRepoConnection>(
|
|
"github.issues.edited",
|
|
(data) => {
|
|
const { repository, issue, owner } = validateRepoIssue(data);
|
|
return connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number);
|
|
},
|
|
(c, data) => c.onIssueEdited(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.IssuesClosedEvent, GitHubIssueConnection|GitHubRepoConnection>(
|
|
"github.issues.closed",
|
|
(data) => {
|
|
const { repository, issue, owner } = validateRepoIssue(data);
|
|
return connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number);
|
|
},
|
|
(c, data) => c.onIssueStateChange(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.IssuesReopenedEvent, GitHubIssueConnection|GitHubRepoConnection>(
|
|
"github.issues.reopened",
|
|
(data) => {
|
|
const { repository, issue, owner } = validateRepoIssue(data);
|
|
return connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number);
|
|
},
|
|
(c, data) => c.onIssueStateChange(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.IssuesUnlabeledEvent, GitHubRepoConnection>(
|
|
"github.issues.unlabeled",
|
|
(data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
|
|
(c, data) => c.onIssueUnlabeled(data),
|
|
);
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.IssuesLabeledEvent, GitHubRepoConnection>(
|
|
"github.issues.labeled",
|
|
(data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
|
|
(c, data) => c.onIssueLabeled(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.PullRequestOpenedEvent, GitHubRepoConnection>(
|
|
"github.pull_request.opened",
|
|
(data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
|
|
(c, data) => c.onPROpened(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.PushEvent, GitHubRepoConnection>(
|
|
"github.push",
|
|
(data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
|
|
(c, data) => c.onPush(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.PullRequestClosedEvent, GitHubRepoConnection>(
|
|
"github.pull_request.closed",
|
|
(data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
|
|
(c, data) => c.onPRClosed(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.PullRequestReadyForReviewEvent, GitHubRepoConnection>(
|
|
"github.pull_request.ready_for_review",
|
|
(data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
|
|
(c, data) => c.onPRReadyForReview(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.PullRequestReviewSubmittedEvent, GitHubRepoConnection>(
|
|
"github.pull_request_review.submitted",
|
|
(data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
|
|
(c, data) => c.onPRReviewed(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.WorkflowRunCompletedEvent, GitHubRepoConnection>(
|
|
"github.workflow_run.completed",
|
|
(data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
|
|
(c, data) => c.onWorkflowCompleted(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.ReleasePublishedEvent, GitHubRepoConnection>(
|
|
"github.release.published",
|
|
(data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
|
|
(c, data) => c.onReleaseCreated(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.ReleaseCreatedEvent, GitHubRepoConnection>(
|
|
"github.release.created",
|
|
(data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
|
|
(c, data) => c.onReleaseDrafted(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
|
|
"gitlab.merge_request.open",
|
|
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
|
(c, data) => c.onMergeRequestOpened(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
|
|
"gitlab.merge_request.reopen",
|
|
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
|
(c, data) => c.onMergeRequestReopened(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
|
|
"gitlab.merge_request.close",
|
|
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
|
(c, data) => c.onMergeRequestClosed(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
|
|
"gitlab.merge_request.merge",
|
|
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
|
(c, data) => c.onMergeRequestMerged(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
|
|
"gitlab.merge_request.approved",
|
|
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
|
(c, data) => c.onMergeRequestReviewed(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
|
|
"gitlab.merge_request.unapproved",
|
|
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
|
(c, data) => c.onMergeRequestReviewed(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
|
|
"gitlab.merge_request.approval",
|
|
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
|
(c, data) => c.onMergeRequestIndividualReview(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
|
|
"gitlab.merge_request.unapproval",
|
|
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
|
(c, data) => c.onMergeRequestIndividualReview(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
|
|
"gitlab.merge_request.update",
|
|
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
|
(c, data) => c.onMergeRequestUpdate(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookReleaseEvent, GitLabRepoConnection>(
|
|
"gitlab.release.create",
|
|
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
|
(c, data) => c.onRelease(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookTagPushEvent, GitLabRepoConnection>(
|
|
"gitlab.tag_push",
|
|
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
|
(c, data) => c.onGitLabTagPush(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookPushEvent, GitLabRepoConnection>(
|
|
"gitlab.push",
|
|
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
|
(c, data) => c.onGitLabPush(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookWikiPageEvent, GitLabRepoConnection>(
|
|
"gitlab.wiki_page",
|
|
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
|
(c, data) => c.onWikiPageEvent(data),
|
|
);
|
|
|
|
this.queue.on<UserNotificationsEvent>("notifications.user.events", async (msg) => {
|
|
const adminRoom = this.adminRooms.get(msg.data.roomId);
|
|
if (!adminRoom) {
|
|
log.warn("No admin room for this notif stream!");
|
|
return;
|
|
}
|
|
await this.notifProcessor.onUserEvents(msg.data, adminRoom);
|
|
});
|
|
|
|
this.queue.on<OAuthRequest>("github.oauth.response", async (msg) => {
|
|
const userId = this.tokenStore.getUserIdForOAuthState(msg.data.state, false);
|
|
await this.queue.push<boolean>({
|
|
data: !!userId,
|
|
sender: "Bridge",
|
|
messageId: msg.messageId,
|
|
eventName: "response.github.oauth.response",
|
|
});
|
|
});
|
|
|
|
this.queue.on<GitHubOAuthTokenResponse>("github.oauth.tokens", async (msg) => {
|
|
const userId = this.tokenStore.getUserIdForOAuthState(msg.data.state);
|
|
if (!userId) {
|
|
log.warn("Could not find internal state for successful tokens request. This shouldn't happen!");
|
|
return;
|
|
}
|
|
await this.tokenStore.storeUserToken("github", userId, JSON.stringify({
|
|
access_token: msg.data.access_token,
|
|
expires_in: msg.data.expires_in && ((parseInt(msg.data.expires_in) * 1000) + Date.now()),
|
|
token_type: msg.data.token_type,
|
|
refresh_token: msg.data.refresh_token,
|
|
refresh_token_expires_in: msg.data.refresh_token_expires_in && ((parseInt(msg.data.refresh_token_expires_in) * 1000) + Date.now()),
|
|
} as GitHubOAuthToken));
|
|
|
|
// Some users won't have an admin room and would have gone through provisioning.
|
|
const adminRoom = this.getAdminRoomForUser(userId);
|
|
if (adminRoom) {
|
|
await adminRoom.sendNotice("Logged into GitHub");
|
|
}
|
|
});
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookNoteEvent, GitLabIssueConnection|GitLabRepoConnection>(
|
|
"gitlab.note.created",
|
|
(data) => {
|
|
const iid = data.issue?.iid || data.merge_request?.iid;
|
|
return [
|
|
...( iid ? connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, iid) : []),
|
|
...connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
|
]},
|
|
(c, data) => c.onCommentCreated(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookIssueStateEvent, GitLabIssueConnection>(
|
|
"gitlab.issue.reopen",
|
|
(data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid),
|
|
(c) => c.onIssueReopened(),
|
|
);
|
|
|
|
this.bindHandlerToQueue<IGitLabWebhookIssueStateEvent, GitLabIssueConnection>(
|
|
"gitlab.issue.close",
|
|
(data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid),
|
|
(c) => c.onIssueClosed(),
|
|
);
|
|
|
|
this.bindHandlerToQueue<GitHubWebhookTypes.DiscussionCommentCreatedEvent, GitHubDiscussionConnection>(
|
|
"github.discussion_comment.created",
|
|
(data) => connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.number),
|
|
(c, data) => c.onDiscussionCommentCreated(data),
|
|
);
|
|
|
|
this.queue.on<GitHubWebhookTypes.DiscussionCreatedEvent>("github.discussion.created", async ({data}) => {
|
|
if (!this.github || !this.config.github) {
|
|
return;
|
|
}
|
|
const spaces = connManager.getConnectionsForGithubRepoDiscussion(data.repository.owner.login, data.repository.name);
|
|
if (spaces.length === 0) {
|
|
log.info(`Not creating discussion ${data.discussion.id} ${data.repository.owner.login}/${data.repository.name}, no target spaces`);
|
|
// We don't want to create any discussions if we have no target spaces.
|
|
return;
|
|
}
|
|
let [discussionConnection] = connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.id);
|
|
if (!discussionConnection) {
|
|
const botUser = this.botUsersManager.getBotUserForService(GitHubDiscussionConnection.ServiceCategory);
|
|
if (!botUser) {
|
|
throw Error('Could not find a bot to handle this connection');
|
|
}
|
|
|
|
try {
|
|
// If we don't have an existing connection for this discussion (likely), then create one.
|
|
discussionConnection = await GitHubDiscussionConnection.createDiscussionRoom(
|
|
this.as,
|
|
botUser.intent,
|
|
null,
|
|
data.repository.owner.login,
|
|
data.repository.name,
|
|
data.discussion,
|
|
this.tokenStore,
|
|
this.commentProcessor,
|
|
this.messageClient,
|
|
this.config,
|
|
);
|
|
connManager.push(discussionConnection);
|
|
} catch (ex) {
|
|
log.error(ex);
|
|
throw Error('Failed to create discussion room');
|
|
}
|
|
}
|
|
|
|
spaces.map(async (c) => {
|
|
try {
|
|
await c.onDiscussionCreated(discussionConnection);
|
|
} catch (ex) {
|
|
log.warn(`Failed to add discussion ${c.toString()} failed to handle comment.created:`, ex);
|
|
}
|
|
})
|
|
});
|
|
|
|
this.bindHandlerToQueue<JiraIssueEvent, JiraProjectConnection>(
|
|
"jira.issue_created",
|
|
(data) => connManager.getConnectionsForJiraProject(data.issue.fields.project),
|
|
(c, data) => c.onJiraIssueCreated(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<JiraIssueUpdatedEvent, JiraProjectConnection>(
|
|
"jira.issue_updated",
|
|
(data) => connManager.getConnectionsForJiraProject(data.issue.fields.project),
|
|
(c, data) => c.onJiraIssueUpdated(data),
|
|
);
|
|
|
|
for (const event of ["created", "updated", "released"]) {
|
|
this.bindHandlerToQueue<JiraVersionEvent, JiraProjectConnection>(
|
|
`jira.version_${event}`,
|
|
(data) => connManager.getConnectionsForJiraVersion(data.version),
|
|
(c, data) => c.onJiraVersionEvent(data),
|
|
);
|
|
}
|
|
|
|
this.queue.on<JiraOAuthRequestCloud|JiraOAuthRequestOnPrem>("jira.oauth.response", async (msg) => {
|
|
if (!this.config.jira || !this.tokenStore.jiraOAuth) {
|
|
throw Error('Cannot handle, JIRA oauth support not enabled');
|
|
}
|
|
let result: JiraOAuthRequestResult;
|
|
const userId = this.tokenStore.getUserIdForOAuthState(msg.data.state, false);
|
|
if (!userId) {
|
|
return this.queue.push<JiraOAuthRequestResult>({
|
|
data: JiraOAuthRequestResult.UserNotFound,
|
|
sender: "Bridge",
|
|
messageId: msg.messageId,
|
|
eventName: "response.jira.oauth.response",
|
|
});
|
|
}
|
|
try {
|
|
let tokenInfo: JiraOAuthResult;
|
|
if ("code" in msg.data) {
|
|
tokenInfo = await this.tokenStore.jiraOAuth.exchangeRequestForToken(msg.data.code);
|
|
} else {
|
|
tokenInfo = await this.tokenStore.jiraOAuth.exchangeRequestForToken(msg.data.oauthToken, msg.data.oauthVerifier);
|
|
}
|
|
await this.tokenStore.storeJiraToken(userId, {
|
|
access_token: tokenInfo.access_token,
|
|
refresh_token: tokenInfo.refresh_token,
|
|
instance: this.config.jira.instanceName,
|
|
expires_in: tokenInfo.expires_in,
|
|
});
|
|
|
|
// Some users won't have an admin room and would have gone through provisioning.
|
|
const adminRoom = this.getAdminRoomForUser(userId);
|
|
if (adminRoom) {
|
|
await adminRoom.sendNotice("Logged into Jira");
|
|
}
|
|
result = JiraOAuthRequestResult.Success;
|
|
} catch (ex) {
|
|
log.warn(`Failed to handle JIRA oauth token exchange`, ex);
|
|
result = JiraOAuthRequestResult.UnknownFailure;
|
|
}
|
|
await this.queue.push<JiraOAuthRequestResult>({
|
|
data: result,
|
|
sender: "Bridge",
|
|
messageId: msg.messageId,
|
|
eventName: "response.jira.oauth.response",
|
|
});
|
|
|
|
});
|
|
|
|
this.queue.on<GenericWebhookEvent>("generic-webhook.event", async (msg) => {
|
|
const { data, messageId } = msg;
|
|
const connections = connManager.getConnectionsForGenericWebhook(data.hookId);
|
|
log.debug(`generic-webhook.event for ${connections.map(c => c.toString()).join(', ') || '[empty]'}`);
|
|
|
|
if (!connections.length) {
|
|
await this.queue.push<GenericWebhookEventResult>({
|
|
data: {successful: true, notFound: true},
|
|
sender: "Bridge",
|
|
messageId: messageId,
|
|
eventName: "response.generic-webhook.event",
|
|
});
|
|
}
|
|
|
|
let didPush = false;
|
|
await Promise.all(connections.map(async (c, index) => {
|
|
try {
|
|
// TODO: Support webhook responses to more than one room
|
|
if (index !== 0) {
|
|
await c.onGenericHook(data.hookData);
|
|
return;
|
|
}
|
|
if (this.config.generic?.waitForComplete || c.waitForComplete) {
|
|
const result = await c.onGenericHook(data.hookData);
|
|
await this.queue.push<GenericWebhookEventResult>({
|
|
data: result,
|
|
sender: "Bridge",
|
|
messageId,
|
|
eventName: "response.generic-webhook.event",
|
|
});
|
|
} else {
|
|
await this.queue.push<GenericWebhookEventResult>({
|
|
data: {
|
|
successful: null,
|
|
},
|
|
sender: "Bridge",
|
|
messageId,
|
|
eventName: "response.generic-webhook.event",
|
|
});
|
|
await c.onGenericHook(data.hookData);
|
|
}
|
|
didPush = true;
|
|
}
|
|
catch (ex) {
|
|
log.warn(`Failed to handle generic webhook`, ex);
|
|
Metrics.connectionsEventFailed.inc({
|
|
event: "generic-webhook.event",
|
|
connectionId: c.connectionId
|
|
});
|
|
}
|
|
}));
|
|
|
|
// We didn't manage to complete sending the event or even sending a failure.
|
|
if (!didPush) {
|
|
await this.queue.push<GenericWebhookEventResult>({
|
|
data: {
|
|
successful: false
|
|
},
|
|
sender: "Bridge",
|
|
messageId,
|
|
eventName: "response.generic-webhook.event",
|
|
});
|
|
}
|
|
});
|
|
|
|
this.bindHandlerToQueue<FigmaEvent, FigmaFileConnection>(
|
|
"figma.payload",
|
|
(data) => connManager.getForFigmaFile(data.payload.file_key, data.instanceName),
|
|
(c, data) => c.handleNewComment(data.payload),
|
|
)
|
|
|
|
this.bindHandlerToQueue<FeedEntry, FeedConnection>(
|
|
"feed.entry",
|
|
(data) => connManager.getConnectionsForFeedUrl(data.feed.url),
|
|
(c, data) => c.handleFeedEntry(data),
|
|
);
|
|
this.bindHandlerToQueue<FeedSuccess, FeedConnection>(
|
|
"feed.success",
|
|
(data) => connManager.getConnectionsForFeedUrl(data.url),
|
|
c => c.handleFeedSuccess(),
|
|
);
|
|
this.bindHandlerToQueue<FeedError, FeedConnection>(
|
|
"feed.error",
|
|
(data) => connManager.getConnectionsForFeedUrl(data.url),
|
|
(c, data) => c.handleFeedError(data),
|
|
);
|
|
|
|
this.bindHandlerToQueue<HoundPayload, HoundConnection>(
|
|
"hound.activity",
|
|
(data) => connManager.getConnectionsForHoundChallengeId(data.challengeId),
|
|
(c, data) => c.handleNewActivity(data.activity)
|
|
);
|
|
|
|
const queue = new PQueue({
|
|
concurrency: 2,
|
|
});
|
|
// Set up already joined rooms
|
|
await queue.addAll(this.botUsersManager.joinedRooms.map((roomId) => async () => {
|
|
log.debug("Fetching state for " + roomId);
|
|
|
|
try {
|
|
await connManager.createConnectionsForRoomId(roomId, false);
|
|
} catch (ex) {
|
|
log.error(`Unable to create connection for ${roomId}`, ex);
|
|
return;
|
|
}
|
|
|
|
const botUser = this.botUsersManager.getBotUserInRoom(roomId);
|
|
if (!botUser) {
|
|
log.error(`Failed to find a bot in room '${roomId}' when setting up admin room`);
|
|
return;
|
|
}
|
|
|
|
// TODO: Refactor this to be a connection
|
|
try {
|
|
let accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
|
|
BRIDGE_ROOM_TYPE, roomId,
|
|
);
|
|
if (!accountData) {
|
|
accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
|
|
LEGACY_BRIDGE_ROOM_TYPE, roomId,
|
|
);
|
|
if (!accountData) {
|
|
log.debug(`Room ${roomId} has no connections and is not an admin room`);
|
|
return;
|
|
} else {
|
|
// Upgrade the room
|
|
await botUser.intent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData);
|
|
}
|
|
}
|
|
|
|
let notifContent;
|
|
try {
|
|
notifContent = await botUser.intent.underlyingClient.getRoomStateEvent(
|
|
roomId, NotifFilter.StateType, "",
|
|
);
|
|
} catch (ex) {
|
|
try {
|
|
notifContent = await botUser.intent.underlyingClient.getRoomStateEvent(
|
|
roomId, NotifFilter.LegacyStateType, "",
|
|
);
|
|
}
|
|
catch (ex) {
|
|
// No state yet
|
|
}
|
|
}
|
|
const adminRoom = await this.setUpAdminRoom(botUser.intent, roomId, accountData, notifContent || NotifFilter.getDefaultContent());
|
|
// Call this on startup to set the state
|
|
await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user });
|
|
log.debug(`Room ${roomId} is connected to: ${adminRoom.toString()}`);
|
|
} catch (ex) {
|
|
log.error(`Failed to set up admin room ${roomId}:`, ex);
|
|
}
|
|
}));
|
|
|
|
// Handle spaces
|
|
for (const discussion of connManager.getAllConnectionsOfType(GitHubDiscussionSpace)) {
|
|
const user = connManager.getConnectionForGithubUser(discussion.owner);
|
|
if (user) {
|
|
await user.ensureDiscussionInSpace(discussion);
|
|
}
|
|
}
|
|
if (this.config.widgets) {
|
|
const apps = this.listener.getApplicationsForResource('widgets');
|
|
if (apps.length > 1) {
|
|
throw Error('You may only bind `widgets` to one listener.');
|
|
}
|
|
new BridgeWidgetApi(
|
|
this.adminRooms,
|
|
this.config,
|
|
this.storage,
|
|
apps[0],
|
|
this.connectionManager,
|
|
this.botUsersManager,
|
|
this.as,
|
|
this.tokenStore,
|
|
this.github,
|
|
);
|
|
|
|
}
|
|
if (this.provisioningApi) {
|
|
this.listener.bindResource('provisioning', this.provisioningApi.expressRouter);
|
|
}
|
|
if (this.config.metrics?.enabled) {
|
|
this.listener.bindResource('metrics', Metrics.expressRouter);
|
|
}
|
|
await queue.onIdle();
|
|
log.info(`All connections loaded`);
|
|
|
|
// Load feeds after connections, to limit the chances of us double
|
|
// posting to rooms if a previous hookshot instance is being replaced.
|
|
if (this.config.feeds?.enabled) {
|
|
this.feedReader = new FeedReader(
|
|
this.config.feeds,
|
|
this.connectionManager,
|
|
this.queue,
|
|
this.storage,
|
|
);
|
|
}
|
|
|
|
if (this.config.challengeHound?.token) {
|
|
this.houndReader = new HoundReader(
|
|
this.config.challengeHound,
|
|
this.connectionManager,
|
|
this.queue,
|
|
this.storage,
|
|
);
|
|
}
|
|
|
|
const webhookHandler = new Webhooks(this.config);
|
|
this.listener.bindResource('webhooks', webhookHandler.expressRouter);
|
|
|
|
await this.as.begin();
|
|
log.info(`Bridge is now ready. Found ${this.connectionManager.size} connections`);
|
|
this.ready = true;
|
|
}
|
|
|
|
private async handleHookshotEvent<EventType, ConnType extends IConnection>(msg: MessageQueueMessageOut<EventType>, connection: ConnType, handler: (c: ConnType, data: EventType) => Promise<unknown>|unknown) {
|
|
try {
|
|
await handler(connection, msg.data);
|
|
} catch (e) {
|
|
Sentry.withScope((scope) => {
|
|
scope.setTransactionName('handleHookshotEvent');
|
|
scope.setTags({
|
|
eventType: msg.eventName,
|
|
roomId: connection.roomId,
|
|
});
|
|
scope.setContext("connection", {
|
|
id: connection.connectionId,
|
|
});
|
|
log.warn(`Connection ${connection.toString()} failed to handle ${msg.eventName}:`, e);
|
|
Metrics.connectionsEventFailed.inc({ event: msg.eventName, connectionId: connection.connectionId });
|
|
Sentry.captureException(e, scope);
|
|
});
|
|
}
|
|
}
|
|
|
|
private async bindHandlerToQueue<EventType, ConnType extends IConnection>(event: string, connectionFetcher: (data: EventType) => ConnType[], handler: (c: ConnType, data: EventType) => Promise<unknown>|unknown) {
|
|
const connectionFetcherBound = connectionFetcher.bind(this);
|
|
this.queue.on<EventType>(event, (msg) => {
|
|
const connections = connectionFetcherBound(msg.data);
|
|
log.debug(`${event} for ${connections.map(c => c.toString()).join(', ') || '[empty]'}`);
|
|
connections.forEach((connection) => {
|
|
void this.handleHookshotEvent(msg, connection, handler);
|
|
});
|
|
});
|
|
}
|
|
|
|
private async onRoomInvite(roomId: string, event: MatrixEvent<MatrixMemberContent>) {
|
|
if (this.as.isNamespacedUser(event.sender)) {
|
|
/* Do not handle invites from our users */
|
|
return;
|
|
}
|
|
const invitedUserId = event.state_key;
|
|
if (!invitedUserId) {
|
|
return;
|
|
}
|
|
log.info(`Got invite roomId=${roomId} from=${event.sender} to=${invitedUserId}`);
|
|
|
|
const botUser = this.botUsersManager.getBotUser(invitedUserId);
|
|
if (!botUser) {
|
|
// We got an invite but it's not a configured bot user, must be for a ghost user
|
|
log.debug(`Rejecting invite to room ${roomId} for ghost user ${invitedUserId}`);
|
|
const client = this.as.getIntentForUserId(invitedUserId).underlyingClient;
|
|
return client.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/leave", null, {
|
|
reason: "Bridge does not support DMing ghosts"
|
|
});
|
|
}
|
|
|
|
// Don't accept invites from people who can't do anything
|
|
if (!this.config.checkPermissionAny(event.sender, BridgePermissionLevel.login)) {
|
|
return botUser.intent.underlyingClient.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/leave", null, {
|
|
reason: "You do not have permission to invite this bot."
|
|
});
|
|
}
|
|
|
|
if (event.content.is_direct && botUser.userId !== this.as.botUserId) {
|
|
// Service bots do not support direct messages (admin rooms)
|
|
log.debug(`Rejecting direct message (admin room) invite to room ${roomId} for service bot ${botUser.userId}`);
|
|
return botUser.intent.underlyingClient.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/leave", null, {
|
|
reason: "This bot does not support admin rooms."
|
|
});
|
|
}
|
|
|
|
// Accept the invite
|
|
await retry(async () => {
|
|
try {
|
|
await botUser.intent.joinRoom(roomId);
|
|
} catch (ex) {
|
|
log.warn(`Failed to join ${roomId}`, ex);
|
|
throw ex;
|
|
}
|
|
}, 5);
|
|
if (event.content.is_direct) {
|
|
await botUser.intent.underlyingClient.setRoomAccountData(
|
|
BRIDGE_ROOM_TYPE, roomId, {admin_user: event.sender},
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
private async onRoomLeave(roomId: string, matrixEvent: MatrixEvent<MatrixMemberContent>) {
|
|
const userId = matrixEvent.state_key;
|
|
if (!userId) {
|
|
return;
|
|
}
|
|
|
|
const botUser = this.botUsersManager.getBotUser(userId);
|
|
if (!botUser) {
|
|
// Not for one of our bots
|
|
return;
|
|
}
|
|
this.botUsersManager.onRoomLeave(botUser, roomId);
|
|
|
|
if (!this.connectionManager) {
|
|
return;
|
|
}
|
|
|
|
// Remove all the connections for this room
|
|
await this.connectionManager.removeConnectionsForRoom(roomId);
|
|
if (this.botUsersManager.getBotUsersInRoom(roomId).length > 0) {
|
|
// If there are still bots in the room, recreate connections
|
|
await this.connectionManager.createConnectionsForRoomId(roomId, true);
|
|
}
|
|
}
|
|
|
|
private async onRoomMessage(roomId: string, event: MatrixEvent<MatrixMessageContent>) {
|
|
if (!this.connectionManager) {
|
|
// Not ready yet.
|
|
return;
|
|
}
|
|
if (this.as.isNamespacedUser(event.sender)) {
|
|
/* We ignore messages from our users */
|
|
return;
|
|
}
|
|
if (Date.now() - event.origin_server_ts > 30000) {
|
|
/* We ignore old messages too */
|
|
return;
|
|
}
|
|
log.info(`Got message roomId=${roomId} type=${event.type} from=${event.sender}`);
|
|
log.debug("Content:", JSON.stringify(event));
|
|
let processedReply: any;
|
|
let processedReplyMetadata: IRichReplyMetadata|undefined = undefined;
|
|
try {
|
|
processedReply = await this.replyProcessor.processEvent(event, this.as.botClient, EventKind.RoomEvent);
|
|
processedReplyMetadata = processedReply?.mx_richreply;
|
|
} catch (ex) {
|
|
log.warn(`Event ${event.event_id} could not be processed by the reply processor, possibly a faulty event`);
|
|
}
|
|
const adminRoom = this.adminRooms.get(roomId);
|
|
const checkPermission = (service: string, level: BridgePermissionLevel) => this.config.checkPermission(event.sender, service, level);
|
|
|
|
if (!adminRoom) {
|
|
let handled = false;
|
|
for (const connection of this.connectionManager.getAllConnectionsForRoom(roomId)) {
|
|
const scope = new Sentry.Scope();
|
|
scope.setTransactionName('onRoomMessage');
|
|
scope.setTags({
|
|
eventId: event.event_id,
|
|
sender: event.sender,
|
|
eventType: event.type,
|
|
roomId: connection.roomId,
|
|
});
|
|
scope.setContext("connection", {
|
|
id: connection.connectionId,
|
|
});
|
|
try {
|
|
if (connection.onMessageEvent) {
|
|
handled = await connection.onMessageEvent(event, checkPermission, processedReplyMetadata);
|
|
}
|
|
} catch (ex) {
|
|
log.warn(`Connection ${connection.toString()} failed to handle message:`, ex);
|
|
Sentry.captureException(ex, scope);
|
|
}
|
|
if (handled) {
|
|
break;
|
|
}
|
|
}
|
|
if (!handled && this.config.checkPermissionAny(event.sender, BridgePermissionLevel.manageConnections)) {
|
|
// Divert to the setup room code if we didn't match any of these
|
|
|
|
const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId);
|
|
// Try each bot in the room until one handles the command
|
|
for (const botUser of botUsersInRoom) {
|
|
try {
|
|
const setupConnection = new SetupConnection(
|
|
roomId,
|
|
botUser.prefix,
|
|
botUser.services,
|
|
[
|
|
...botUser.services,
|
|
this.config.widgets?.roomSetupWidget ? "widget" : "",
|
|
],
|
|
{
|
|
config: this.config,
|
|
as: this.as,
|
|
intent: botUser.intent,
|
|
tokenStore: this.tokenStore,
|
|
commentProcessor: this.commentProcessor,
|
|
messageClient: this.messageClient,
|
|
storage: this.storage,
|
|
github: this.github,
|
|
getAllConnectionsOfType: this.connectionManager.getAllConnectionsOfType.bind(this.connectionManager),
|
|
},
|
|
this.getOrCreateAdminRoom.bind(this),
|
|
this.connectionManager.push.bind(this.connectionManager),
|
|
);
|
|
const handled = await setupConnection.onMessageEvent(event, checkPermission);
|
|
if (handled) {
|
|
break;
|
|
}
|
|
} catch (ex) {
|
|
log.warn(`Setup connection failed to handle:`, ex);
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (adminRoom.userId !== event.sender) {
|
|
return;
|
|
}
|
|
|
|
if (processedReply && processedReplyMetadata) {
|
|
log.info(`Handling reply to ${processedReplyMetadata.parentEventId} for ${adminRoom.userId}`);
|
|
// This might be a reply to a notification
|
|
try {
|
|
const ev = processedReplyMetadata.realEvent;
|
|
const splitParts: string[] = ev.content["uk.half-shot.matrix-hookshot.github.repo"]?.name.split("/");
|
|
const issueNumber = ev.content["uk.half-shot.matrix-hookshot.github.issue"]?.number;
|
|
if (splitParts && issueNumber) {
|
|
log.info(`Handling reply for ${splitParts}${issueNumber}`);
|
|
const connections = this.connectionManager.getConnectionsForGithubIssue(splitParts[0], splitParts[1], issueNumber);
|
|
await Promise.all(connections.map(async c => {
|
|
if (c instanceof GitHubIssueConnection) {
|
|
return c.onMatrixIssueComment(processedReply);
|
|
}
|
|
}));
|
|
} else {
|
|
log.info("Missing parts!:", splitParts, issueNumber);
|
|
}
|
|
} catch (ex) {
|
|
await adminRoom.sendNotice("Failed to handle reply. You may not be authenticated to do that.");
|
|
log.error("Reply event could not be handled:", ex);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const command = event.content.body;
|
|
if (command) {
|
|
await adminRoom.handleCommand(event.event_id, command);
|
|
}
|
|
|
|
}
|
|
|
|
private async onRoomJoin(roomId: string, matrixEvent: MatrixEvent<MatrixMemberContent>) {
|
|
const userId = matrixEvent.state_key;
|
|
if (!userId) {
|
|
return;
|
|
}
|
|
|
|
const botUser = this.botUsersManager.getBotUser(userId);
|
|
if (!botUser) {
|
|
// Not for one of our bots
|
|
return;
|
|
}
|
|
this.botUsersManager.onRoomJoin(botUser, roomId);
|
|
|
|
if (this.config.encryption) {
|
|
// Ensure crypto is aware of all members of this room before posting any messages,
|
|
// so that the bot can share room keys to all recipients first.
|
|
await botUser.intent.underlyingClient.crypto.onRoomJoin(roomId);
|
|
}
|
|
|
|
const adminAccountData = await botUser.intent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
|
|
BRIDGE_ROOM_TYPE, roomId,
|
|
);
|
|
if (adminAccountData) {
|
|
const room = await this.setUpAdminRoom(botUser.intent, roomId, adminAccountData, NotifFilter.getDefaultContent());
|
|
await botUser.intent.underlyingClient.setRoomAccountData(
|
|
BRIDGE_ROOM_TYPE, roomId, room.accountData,
|
|
);
|
|
}
|
|
|
|
if (!this.connectionManager) {
|
|
// Not ready yet.
|
|
return;
|
|
}
|
|
|
|
// Recreate connections for the room
|
|
await this.connectionManager.removeConnectionsForRoom(roomId);
|
|
await this.connectionManager.createConnectionsForRoomId(roomId, true);
|
|
|
|
// Only fetch rooms we have no connections in yet.
|
|
const roomHasConnection = this.connectionManager.isRoomConnected(roomId);
|
|
|
|
// If room has connections or is an admin room, don't set up a wizard.
|
|
// Otherwise it's a new room
|
|
if (!roomHasConnection && !adminAccountData && this.config.widgets?.roomSetupWidget?.addOnInvite) {
|
|
try {
|
|
const hasPowerlevel = await botUser.intent.underlyingClient.userHasPowerLevelFor(
|
|
botUser.intent.userId,
|
|
roomId,
|
|
"im.vector.modular.widgets",
|
|
true,
|
|
);
|
|
if (!hasPowerlevel) {
|
|
await botUser.intent.sendText(roomId, "Hello! To set up new integrations in this room, please promote me to a Moderator/Admin.");
|
|
} else {
|
|
// Set up the widget
|
|
await SetupWidget.SetupRoomConfigWidget(roomId, botUser.intent, this.config.widgets, botUser.services);
|
|
}
|
|
} catch (ex) {
|
|
log.error(`Failed to setup new widget for room`, ex);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async onRoomEvent(roomId: string, event: MatrixEvent<Record<string, unknown>>) {
|
|
if (!this.connectionManager) {
|
|
// Not ready yet.
|
|
return;
|
|
}
|
|
if (event.state_key !== undefined) {
|
|
if (event.type === "m.room.member") {
|
|
if (event.content.membership === "join") {
|
|
this.config.addMemberToCache(roomId, event.state_key);
|
|
} else {
|
|
this.config.removeMemberFromCache(roomId, event.state_key);
|
|
}
|
|
return;
|
|
}
|
|
// A state update, hurrah!
|
|
const existingConnections = this.connectionManager.getInterestedForRoomState(roomId, event.type, event.state_key);
|
|
const state = new StateEvent(event);
|
|
for (const connection of existingConnections) {
|
|
if (!this.connectionManager.verifyStateEventForConnection(connection, state, true)) {
|
|
continue;
|
|
}
|
|
const scope = new Sentry.Scope();
|
|
scope.setTransactionName('onStateUpdate');
|
|
scope.setTags({
|
|
eventId: event.event_id,
|
|
sender: event.sender,
|
|
eventType: event.type,
|
|
roomId: connection.roomId,
|
|
});
|
|
scope.setContext("connection", {
|
|
id: connection.connectionId,
|
|
});
|
|
try {
|
|
// Empty object == redacted
|
|
if (event.content.disabled === true || Object.keys(event.content).length === 0) {
|
|
await this.connectionManager.purgeConnection(connection.roomId, connection.connectionId, false);
|
|
} else {
|
|
await connection.onStateUpdate?.(event);
|
|
}
|
|
} catch (ex) {
|
|
log.warn(`Connection ${connection.toString()} for ${roomId} failed to handle state update:`, ex);
|
|
}
|
|
}
|
|
if (!existingConnections.length) {
|
|
// Is anyone interested in this state?
|
|
try {
|
|
const connection = await this.connectionManager.createConnectionForState(roomId, new StateEvent(event), true);
|
|
if (connection) {
|
|
log.info(`New connected added to ${roomId}: ${connection.toString()}`);
|
|
this.connectionManager.push(connection);
|
|
}
|
|
} catch (ex) {
|
|
log.error(`Failed to handle connection for state ${event.type} in ${roomId}`, ex);
|
|
}
|
|
}
|
|
|
|
const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId);
|
|
for (const botUser of botUsersInRoom) {
|
|
// If it's a power level event for a new room, we might want to create the setup widget.
|
|
if (this.config.widgets?.roomSetupWidget?.addOnInvite && event.type === "m.room.power_levels" && event.state_key === "" && !this.connectionManager.isRoomConnected(roomId)) {
|
|
log.debug(`${roomId} got a new powerlevel change and isn't connected to any connections, testing to see if we should create a setup widget`)
|
|
const plEvent = new PowerLevelsEvent(event);
|
|
const currentPl = plEvent.content.users?.[botUser.userId] ?? plEvent.defaultUserLevel;
|
|
const previousPl = plEvent.previousContent?.users?.[botUser.userId] ?? plEvent.previousContent?.users_default;
|
|
const requiredPl = plEvent.content.events?.["im.vector.modular.widgets"] ?? plEvent.defaultStateEventLevel;
|
|
if (currentPl !== previousPl && currentPl >= requiredPl) {
|
|
// PL changed for bot user, check to see if the widget can be created.
|
|
try {
|
|
log.info(`Bot has powerlevel required to create a setup widget, attempting`);
|
|
await SetupWidget.SetupRoomConfigWidget(roomId, botUser.intent, this.config.widgets, botUser.services);
|
|
} catch (ex) {
|
|
log.error(`Failed to create setup widget for ${roomId}`, ex);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// We still want to react to our own state events.
|
|
if (this.botUsersManager.isBotUser(event.sender)) {
|
|
// It's us
|
|
return;
|
|
}
|
|
|
|
for (const connection of this.connectionManager.getAllConnectionsForRoom(roomId)) {
|
|
if (!connection.onEvent) {
|
|
continue;
|
|
}
|
|
const scope = new Sentry.Scope();
|
|
scope.setTransactionName('onRoomEvent');
|
|
scope.setTags({
|
|
eventId: event.event_id,
|
|
sender: event.sender,
|
|
eventType: event.type,
|
|
roomId: connection.roomId,
|
|
});
|
|
scope.setContext("connection", {
|
|
id: connection.connectionId,
|
|
});
|
|
try {
|
|
await connection.onEvent(event);
|
|
} catch (ex) {
|
|
Sentry.captureException(ex, scope);
|
|
log.warn(`Connection ${connection.toString()} failed to handle onEvent:`, ex);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async onQueryRoom(roomAlias: string) {
|
|
log.info("Got room query request:", roomAlias);
|
|
// Determine which type of room it is.
|
|
let res: RegExpExecArray | null;
|
|
res = GitHubIssueConnection.QueryRoomRegex.exec(roomAlias);
|
|
if (res) {
|
|
if (!this.github) {
|
|
throw Error("GitHub is not configured on this bridge");
|
|
}
|
|
try {
|
|
return await GitHubIssueConnection.onQueryRoom(res, {
|
|
as: this.as,
|
|
tokenStore: this.tokenStore,
|
|
messageClient: this.messageClient,
|
|
commentProcessor: this.commentProcessor,
|
|
githubInstance: this.github,
|
|
});
|
|
} catch (ex) {
|
|
log.error(`Could not handle alias with GitHubIssueConnection`, ex);
|
|
throw ex;
|
|
}
|
|
}
|
|
|
|
res = GitHubDiscussionSpace.QueryRoomRegex.exec(roomAlias);
|
|
if (res) {
|
|
if (!this.github) {
|
|
throw Error("GitHub is not configured on this bridge");
|
|
}
|
|
try {
|
|
return await GitHubDiscussionSpace.onQueryRoom(res, {
|
|
githubInstance: this.github,
|
|
as: this.as,
|
|
});
|
|
} catch (ex) {
|
|
log.error(`Could not handle alias with GitHubRepoConnection`, ex);
|
|
throw ex;
|
|
}
|
|
}
|
|
|
|
res = GitHubRepoConnection.QueryRoomRegex.exec(roomAlias);
|
|
if (res) {
|
|
if (!this.github) {
|
|
throw Error("GitHub is not configured on this bridge");
|
|
}
|
|
try {
|
|
return await GitHubRepoConnection.onQueryRoom(res, {
|
|
as: this.as,
|
|
tokenStore: this.tokenStore,
|
|
messageClient: this.messageClient,
|
|
commentProcessor: this.commentProcessor,
|
|
githubInstance: this.github,
|
|
});
|
|
} catch (ex) {
|
|
log.error(`Could not handle alias with GitHubRepoConnection`, ex);
|
|
throw ex;
|
|
}
|
|
}
|
|
|
|
res = GitHubUserSpace.QueryRoomRegex.exec(roomAlias);
|
|
if (res) {
|
|
if (!this.github) {
|
|
throw Error("GitHub is not configured on this bridge");
|
|
}
|
|
try {
|
|
return await GitHubUserSpace.onQueryRoom(res, {
|
|
githubInstance: this.github,
|
|
as: this.as,
|
|
});
|
|
} catch (ex) {
|
|
log.error(`Could not handle alias with GitHubRepoConnection`, ex);
|
|
throw ex;
|
|
}
|
|
}
|
|
|
|
throw Error('No regex matching query pattern');
|
|
}
|
|
|
|
private async onAdminRoomSettingsChanged(adminRoom: AdminRoom, settings: AdminAccountData, oldSettings: AdminAccountData) {
|
|
log.debug(`Settings changed for ${adminRoom.userId}`, settings);
|
|
// Make this more efficent.
|
|
if (!oldSettings.github?.notifications?.enabled && settings.github?.notifications?.enabled) {
|
|
log.info(`Notifications enabled for ${adminRoom.userId}`);
|
|
const token = await this.tokenStore.getGitHubToken(adminRoom.userId);
|
|
if (token) {
|
|
log.info(`Notifications enabled for ${adminRoom.userId} and token was found`);
|
|
await this.queue.push<NotificationsEnableEvent>({
|
|
eventName: "notifications.user.enable",
|
|
sender: "Bridge",
|
|
data: {
|
|
userId: adminRoom.userId,
|
|
roomId: adminRoom.roomId,
|
|
token,
|
|
since: await adminRoom.getNotifSince("github"),
|
|
filterParticipating: adminRoom.notificationsParticipating("github"),
|
|
type: "github",
|
|
instanceUrl: undefined,
|
|
},
|
|
});
|
|
} else {
|
|
log.warn(`Notifications enabled for ${adminRoom.userId} but no token stored!`);
|
|
}
|
|
} else if (oldSettings.github?.notifications?.enabled && !settings.github?.notifications?.enabled) {
|
|
await this.queue.push<NotificationsDisableEvent>({
|
|
eventName: "notifications.user.disable",
|
|
sender: "Bridge",
|
|
data: {
|
|
userId: adminRoom.userId,
|
|
type: "github",
|
|
instanceUrl: undefined,
|
|
},
|
|
});
|
|
}
|
|
|
|
for (const [instanceName, instanceSettings] of Object.entries(settings.gitlab || {})) {
|
|
const instanceUrl = this.config.gitlab?.instances[instanceName].url;
|
|
const token = await this.tokenStore.getUserToken("gitlab", adminRoom.userId, instanceUrl);
|
|
if (token && instanceSettings.notifications.enabled) {
|
|
log.info(`GitLab ${instanceName} notifications enabled for ${adminRoom.userId}`);
|
|
await this.queue.push<NotificationsEnableEvent>({
|
|
eventName: "notifications.user.enable",
|
|
sender: "Bridge",
|
|
data: {
|
|
userId: adminRoom.userId,
|
|
roomId: adminRoom.roomId,
|
|
token,
|
|
since: await adminRoom.getNotifSince("gitlab", instanceName),
|
|
filterParticipating: adminRoom.notificationsParticipating("gitlab"),
|
|
type: "gitlab",
|
|
instanceUrl,
|
|
},
|
|
});
|
|
} else if (!instanceSettings.notifications.enabled) {
|
|
log.info(`GitLab ${instanceName} notifications disabled for ${adminRoom.userId}`);
|
|
await this.queue.push<NotificationsDisableEvent>({
|
|
eventName: "notifications.user.disable",
|
|
sender: "Bridge",
|
|
data: {
|
|
userId: adminRoom.userId,
|
|
type: "gitlab",
|
|
instanceUrl,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
private async getOrCreateAdminRoom(intent: Intent, userId: string): Promise<AdminRoom> {
|
|
const existingRoom = this.getAdminRoomForUser(userId);
|
|
if (existingRoom) {
|
|
return existingRoom;
|
|
}
|
|
const roomId = await intent.underlyingClient.dms.getOrCreateDm(userId);
|
|
const room = await this.setUpAdminRoom(intent, roomId, {admin_user: userId}, NotifFilter.getDefaultContent());
|
|
await this.as.botClient.setRoomAccountData(
|
|
BRIDGE_ROOM_TYPE, roomId, room.accountData,
|
|
);
|
|
return room;
|
|
}
|
|
|
|
private getAdminRoomForUser(userId: string): AdminRoom|null {
|
|
for (const adminRoom of this.adminRooms.values()) {
|
|
if (adminRoom.userId === userId) {
|
|
return adminRoom;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private async setUpAdminRoom(
|
|
intent: Intent,
|
|
roomId: string,
|
|
accountData: AdminAccountData,
|
|
notifContent: NotificationFilterStateContent,
|
|
) {
|
|
if (!this.connectionManager) {
|
|
throw Error('setUpAdminRoom() called before connectionManager was ready');
|
|
}
|
|
|
|
const adminRoom = new AdminRoom(
|
|
roomId, accountData, notifContent, intent, this.tokenStore, this.config, this.connectionManager,
|
|
);
|
|
|
|
adminRoom.on("settings.changed", this.onAdminRoomSettingsChanged.bind(this));
|
|
adminRoom.on("open.project", async (project: ProjectsGetResponseData) => {
|
|
const [connection] = this.connectionManager?.getForGitHubProject(project.id) || [];
|
|
if (!connection) {
|
|
const connection = await GitHubProjectConnection.onOpenProject(project, this.as, intent, this.config, adminRoom.userId);
|
|
this.connectionManager?.push(connection);
|
|
} else {
|
|
await intent.underlyingClient.inviteUser(adminRoom.userId, connection.roomId);
|
|
}
|
|
});
|
|
adminRoom.on("open.gitlab-issue", async (issueInfo: GetIssueOpts, res: GetIssueResponse, instanceName: string, instance: GitLabInstance) => {
|
|
if (!this.config.gitlab) {
|
|
return;
|
|
}
|
|
const [ connection ] = this.connectionManager?.getConnectionsForGitLabIssue(instance, issueInfo.projects, issueInfo.issue) || [];
|
|
if (connection) {
|
|
return intent.underlyingClient.inviteUser(adminRoom.userId, connection.roomId);
|
|
}
|
|
const newConnection = await GitLabIssueConnection.createRoomForIssue(
|
|
instanceName,
|
|
instance,
|
|
res,
|
|
issueInfo.projects,
|
|
this.as,
|
|
intent,
|
|
this.tokenStore,
|
|
this.commentProcessor,
|
|
this.messageClient,
|
|
this.config,
|
|
);
|
|
this.connectionManager?.push(newConnection);
|
|
return intent.underlyingClient.inviteUser(adminRoom.userId, newConnection.roomId);
|
|
});
|
|
this.adminRooms.set(roomId, adminRoom);
|
|
if (this.config.widgets?.addToAdminRooms) {
|
|
await SetupWidget.SetupAdminRoomConfigWidget(roomId, intent, this.config.widgets);
|
|
}
|
|
log.debug(`Set up ${roomId} as an admin room for ${adminRoom.userId}`);
|
|
return adminRoom;
|
|
}
|
|
|
|
private onTokenUpdated(type: string, userId: string, token: string, instanceUrl?: string) {
|
|
let instanceName: string|undefined;
|
|
if (type === "gitlab") {
|
|
// TODO: Refactor our API to depend on either instanceUrl or instanceName.
|
|
instanceName = Object.entries(this.config.gitlab?.instances || {}).find(i => i[1].url === instanceUrl)?.[0];
|
|
} else if (type === "github") {
|
|
// GitHub tokens are special
|
|
token = UserTokenStore.parseGitHubToken(token).access_token;
|
|
} else {
|
|
return;
|
|
}
|
|
for (const adminRoom of this.adminRooms.values()) {
|
|
if (adminRoom.userId !== userId) continue;
|
|
if (adminRoom?.notificationsEnabled(type, instanceName)) {
|
|
log.debug(`Token was updated for ${userId} (${type}), notifying notification watcher`);
|
|
this.queue.push<NotificationsEnableEvent>({
|
|
eventName: "notifications.user.enable",
|
|
sender: "Bridge",
|
|
data: {
|
|
userId: adminRoom.userId,
|
|
roomId: adminRoom.roomId,
|
|
token,
|
|
filterParticipating: adminRoom.notificationsParticipating("github"),
|
|
type,
|
|
instanceUrl,
|
|
},
|
|
}).catch(ex => log.error(`Failed to push notifications.user.enable:`, ex));
|
|
}
|
|
}
|
|
}
|
|
}
|