Implement separate bot users per service (#573)

* Add service bots config

* Add joined rooms manager and keep track of joined rooms

* Add bot users manager and ensure registration and profiles

* Improve joined rooms manager and set up already joined rooms

* Handle invites with service bots

* Handle messages with service bots

* Use service bots for connections

* Use service bots in widget and provisioning APIs

* Use service bots in setup connections

* Use service bots for feed connections

* Handle admin rooms for service bots

* Fix confused event type and service type in provisioning and widget APIs

* Fix generic webhooks service name

* Fix enabled services config

* Handle power level change

* Create widgets with service scope

* Use service bots for gitlab repo connections

* Use service bots for gitlab issue connections

* Use service bots for generic webhook connections

* Use service bots for figma file connections

* Use service bots when verifying state events

* Use service bots for github repo connections

* Use service bots for github discussion connections

* Use service bots for github discussion space connections

* Use service bots for github project connections

* Use service bots for github issue connections

* Use service bots for github user space connections

* Use service bots for jira connections

* Make sure ghost users are invited for gitlab issue comments

* Configure one service per service bot

* Add changelog

* Update tests

* Fix up following rebase

* Fix comment

* Use getter for enabled services

* Ensure homeserver can be reached before registering bots

* Add intent getter on bot user

* Update config comment

* Merge joined rooms manager with bot users manager

* Remove unused localpart from bot user class

* Refactor to pass in bot users manager

* Improve priority sort function

Co-authored-by: Christian Paul <christianp@matrix.org>

* Fix priority sort

Higher priority should come first

* Add debug log when invites are rejected

* Use different state key for scoped setup widgets

* Use different subtitles to differentiate service bots setup widgets

* Refactor bot user setup into bot users manager

* Refactor to reduce duplication in widget API

* Consistent room ID and intent args order

* Add docs and update changelog

* Add overrideUserId deprecation warning

* Add service bots link

Co-authored-by: Christian Paul <christianp@matrix.org>
Co-authored-by: Will Hunt <will@half-shot.uk>
This commit is contained in:
Justin Carlson 2023-01-13 10:32:09 -05:00 committed by GitHub
parent 46467ac810
commit 9a7839ce42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1134 additions and 566 deletions

1
changelog.d/573.feature Normal file
View File

@ -0,0 +1 @@
Add support for additional bot users called "service bots" which handle a particular connection type, so that different services can be used through different bot users.

View File

@ -100,8 +100,16 @@ passFile:
bot:
# (Optional) Define profile information for the bot user
#
displayname: GitHub Bot
displayname: Hookshot Bot
avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
serviceBots:
# (Optional) Define additional bot users for specific services
#
- localpart: feeds
displayname: Feeds
avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
prefix: "!feeds"
service: feeds
metrics:
# (Optional) Prometheus metrics support
#

View File

@ -27,3 +27,4 @@
- [Workers](./advanced/workers.md)
- [🔒 Encryption](./advanced/encryption.md)
- [🪀 Widgets](./advanced/widgets.md)
- [Service Bots](./advanced/service_bots.md)

View File

@ -0,0 +1,28 @@
# Service Bots
Hookshot supports additional bot users called "service bots" which handle a particular connection type
(in addition to the default bot user which can handle any connection type).
These bots can coexist in a room, each handling a different service.
## Configuration
Service bots can be given a different localpart, display name, avatar, and command prefix.
They will only handle connections for the specified service, which can be one of:
* `feeds` - [Feeds](../setup/feeds.md)
* `figma` - [Figma](../setup/figma.md)
* `generic` - [Webhooks](../setup/webhooks.md)
* `github` - [GitHub](../setup/github.md)
* `gitlab` - [GitLab](../setup/gitlab.md)
* `jira` - [Jira](../setup/jira.md)
For example with this configuration:
```yaml
serviceBots:
- localpart: feeds
displayname: Feeds
avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
prefix: "!feeds"
service: feeds
```
There will be a bot user `@feeds:example.com` which responds to commands prefixed with `!feeds`, and only handles feeds connections.

View File

@ -8,6 +8,7 @@ import { ListenerService } from "../ListenerService";
import { Logger } from "matrix-appservice-bridge";
import { LogService } from "matrix-bot-sdk";
import { getAppservice } from "../appservice";
import BotUsersManager from "../Managers/BotUsersManager";
Logger.configure({console: "info"});
const log = new Logger("App");
@ -35,7 +36,9 @@ async function start() {
userNotificationWatcher.start();
}
const bridgeApp = new Bridge(config, listener, appservice, storage);
const botUsersManager = new BotUsersManager(config, appservice);
const bridgeApp = new Bridge(config, listener, appservice, storage, botUsersManager);
process.once("SIGTERM", () => {
log.error("Got SIGTERM");

View File

@ -1,6 +1,7 @@
import { AdminAccountData } from "./AdminRoomCommandHandler";
import { AdminRoom, BRIDGE_ROOM_TYPE, LEGACY_BRIDGE_ROOM_TYPE } from "./AdminRoom";
import { Appservice, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent } from "matrix-bot-sdk";
import { Appservice, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent, IAppserviceRegistration, 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";
@ -50,6 +51,7 @@ export class Bridge {
private connectionManager?: ConnectionManager;
private github?: GithubInstance;
private adminRooms: Map<string, AdminRoom> = new Map();
private widgetApi?: BridgeWidgetApi;
private provisioningApi?: Provisioner;
private replyProcessor = new RichRepliesPreprocessor(true);
@ -60,6 +62,7 @@ export class Bridge {
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);
@ -67,6 +70,7 @@ export class Bridge {
this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient);
this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config);
this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this));
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}));
}
@ -82,20 +86,21 @@ export class Bridge {
await this.storage.connect?.();
await this.queue.connect?.();
// Fetch all room state
let joinedRooms: string[]|undefined;
while(joinedRooms === undefined) {
log.info("Ensuring homeserver can be reached...");
let reached = false;
while (!reached) {
try {
log.info("Connecting to homeserver and fetching joined rooms..");
joinedRooms = await this.as.botIntent.getJoinedRooms();
log.debug(`Bridge bot is joined to ${joinedRooms.length} rooms`);
} catch (ex) {
// This is our first interaction with the homeserver, so wait if it's not ready yet.
log.warn("Failed to connect to homeserver, retrying in 5s", ex);
// 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) {
@ -112,20 +117,28 @@ export class Bridge {
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.github);
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.feeds?.enabled) {
new FeedReader(
this.config.feeds,
this.connectionManager,
this.queue,
// Use default bot when storing account data
this.as.botClient,
);
}
if (this.config.provisioning) {
const routers = [];
if (this.config.jira) {
@ -145,7 +158,13 @@ export class Bridge {
if (this.config.generic) {
this.connectionManager.registerProvisioningConnection(GenericHookConnection);
}
this.provisioningApi = new Provisioner(this.config.provisioning, this.connectionManager, this.as.botIntent, routers);
this.provisioningApi = new Provisioner(
this.config.provisioning,
this.connectionManager,
this.botUsersManager,
this.as,
routers,
);
}
this.as.on("query.room", async (roomAlias, cb) => {
@ -266,114 +285,114 @@ export class Bridge {
this.bindHandlerToQueue<GitHubWebhookTypes.IssuesUnlabeledEvent, GitHubRepoConnection>(
"github.issues.unlabeled",
(data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
(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),
(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),
(data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
(c, data) => c.onPROpened(data),
);
this.bindHandlerToQueue<GitHubWebhookTypes.PullRequestClosedEvent, GitHubRepoConnection>(
"github.pull_request.closed",
(data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name),
(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),
(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),
(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),
(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),
(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),
(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),
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onMergeRequestOpened(data),
);
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
"gitlab.merge_request.close",
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(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),
(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),
(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),
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onMergeRequestReviewed(data),
);
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
"gitlab.merge_request.update",
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(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),
(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),
(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),
(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),
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onWikiPageEvent(data),
);
@ -419,10 +438,10 @@ export class Bridge {
this.bindHandlerToQueue<IGitLabWebhookNoteEvent, GitLabIssueConnection|GitLabRepoConnection>(
"gitlab.note.created",
(data) => {
(data) => {
const iid = data.issue?.iid || data.merge_request?.iid;
return [
...( iid ? connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, iid) : []),
...( iid ? connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, iid) : []),
...connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
]},
(c, data) => c.onCommentCreated(data),
@ -430,19 +449,19 @@ export class Bridge {
this.bindHandlerToQueue<IGitLabWebhookIssueStateEvent, GitLabIssueConnection>(
"gitlab.issue.reopen",
(data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid),
(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),
(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),
(data) => connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.number),
(c, data) => c.onDiscussionCommentCreated(data),
);
@ -458,10 +477,16 @@ export class Bridge {
}
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,
@ -486,7 +511,7 @@ export class Bridge {
}
})
});
this.bindHandlerToQueue<JiraIssueEvent, JiraProjectConnection>(
"jira.issue_created",
(data) => connManager.getConnectionsForJiraProject(data.issue.fields.project),
@ -553,7 +578,7 @@ export class Bridge {
});
});
this.queue.on<GenericWebhookEvent>("generic-webhook.event", async (msg) => {
const { data, messageId } = msg;
const connections = connManager.getConnectionsForGenericWebhook(data.hookId);
@ -635,30 +660,13 @@ export class Bridge {
(c, data) => c.handleFeedError(data),
);
// Set the name and avatar of the bot
if (this.config.bot) {
// Ensure we are registered before we set a profile
await this.as.botIntent.ensureRegistered();
let profile;
try {
profile = await this.as.botClient.getUserProfile(this.as.botUserId);
} catch {
profile = {}
}
if (this.config.bot.avatar && profile.avatar_url !== this.config.bot.avatar) {
log.info(`Setting avatar to ${this.config.bot.avatar}`);
await this.as.botClient.setAvatarUrl(this.config.bot.avatar);
}
if (this.config.bot.displayname && profile.displayname !== this.config.bot.displayname) {
log.info(`Setting displayname to ${this.config.bot.displayname}`);
await this.as.botClient.setDisplayName(this.config.bot.displayname);
}
}
const queue = new PQueue({
concurrency: 2,
});
queue.addAll(joinedRooms.map(((roomId) => async () => {
// 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) {
@ -666,13 +674,19 @@ export class Bridge {
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 this.as.botClient.getSafeRoomAccountData<AdminAccountData>(
let accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
BRIDGE_ROOM_TYPE, roomId,
);
if (!accountData) {
accountData = await this.as.botClient.getSafeRoomAccountData<AdminAccountData>(
accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
LEGACY_BRIDGE_ROOM_TYPE, roomId,
);
if (!accountData) {
@ -680,18 +694,18 @@ export class Bridge {
return;
} else {
// Upgrade the room
await this.as.botClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData);
await botUser.intent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData);
}
}
let notifContent;
try {
notifContent = await this.as.botClient.getRoomStateEvent(
notifContent = await botUser.intent.underlyingClient.getRoomStateEvent(
roomId, NotifFilter.StateType, "",
);
} catch (ex) {
try {
notifContent = await this.as.botClient.getRoomStateEvent(
notifContent = await botUser.intent.underlyingClient.getRoomStateEvent(
roomId, NotifFilter.LegacyStateType, "",
);
}
@ -699,14 +713,14 @@ export class Bridge {
// No state yet
}
}
const adminRoom = await this.setUpAdminRoom(roomId, accountData, notifContent || NotifFilter.getDefaultContent());
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)) {
@ -719,16 +733,17 @@ export class Bridge {
const apps = this.listener.getApplicationsForResource('widgets');
if (apps.length > 1) {
throw Error('You may only bind `widgets` to one listener.');
}
new BridgeWidgetApi(
}
this.widgetApi = new BridgeWidgetApi(
this.adminRooms,
this.config,
this.storage,
apps[0],
this.connectionManager,
this.as.botIntent,
this.botUsersManager,
this.as,
);
}
if (this.provisioningApi) {
this.listener.bindResource('provisioning', this.provisioningApi.expressRouter);
@ -763,36 +778,63 @@ export class Bridge {
/* Do not handle invites from our users */
return;
}
log.info(`Got invite roomId=${roomId} from=${event.sender} to=${event.state_key}`);
// Room joins can fail over federation
if (event.state_key !== this.as.botUserId) {
return this.as.botClient.kickUser(event.state_key, roomId, "Bridge does not support DMing ghosts");
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.kickUser(invitedUserId, roomId, "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 this.as.botClient.kickUser(this.as.botUserId, roomId, "You do not have permission to invite this bot.");
return botUser.intent.underlyingClient.kickUser(botUser.userId, roomId, "You do not have permission to invite this bot.");
}
await retry(() => this.as.botIntent.joinRoom(roomId), 5);
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.kickUser(botUser.userId, roomId, "This bot does not support admin rooms.");
}
// Accept the invite
await retry(() => botUser.intent.joinRoom(roomId), 5);
if (event.content.is_direct) {
await this.as.botClient.setRoomAccountData(
await botUser.intent.underlyingClient.setRoomAccountData(
BRIDGE_ROOM_TYPE, roomId, {admin_user: event.sender},
);
}
}
private async onRoomLeave(roomId: string, event: MatrixEvent<MatrixMemberContent>) {
if (event.state_key !== this.as.botUserId) {
// Only interested in bot leaves.
private async onRoomLeave(roomId: string, matrixEvent: MatrixEvent<MatrixMemberContent>) {
const userId = matrixEvent.state_key;
if (!userId) {
return;
}
// If the bot has left the room, we want to vape all connections for that room.
try {
await this.connectionManager?.removeConnectionsForRoom(roomId);
} catch (ex) {
log.warn(`Failed to remove connections on leave for ${roomId}`);
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);
}
}
@ -838,13 +880,23 @@ export class Bridge {
}
if (!handled && this.config.checkPermissionAny(event.sender, BridgePermissionLevel.manageConnections)) {
// Divert to the setup room code if we didn't match any of these
try {
await (
new SetupConnection(
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,
@ -852,12 +904,16 @@ export class Bridge {
github: this.github,
getAllConnectionsOfType: this.connectionManager.getAllConnectionsOfType.bind(this.connectionManager),
},
this.getOrCreateAdminRoomForUser.bind(this),
this.getOrCreateAdminRoom.bind(this),
this.connectionManager.push.bind(this.connectionManager),
)
).onMessageEvent(event, checkPermission);
} catch (ex) {
log.warn(`Setup connection failed to handle:`, ex);
);
const handled = await setupConnection.onMessageEvent(event, checkPermission);
if (handled) {
break;
}
} catch (ex) {
log.warn(`Setup connection failed to handle:`, ex);
}
}
}
return;
@ -900,23 +956,30 @@ export class Bridge {
}
private async onRoomJoin(roomId: string, matrixEvent: MatrixEvent<MatrixMemberContent>) {
if (this.as.botUserId !== matrixEvent.sender) {
// Only act on bot joins
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 this.as.botClient.crypto.onRoomJoin(roomId);
await botUser.intent.underlyingClient.crypto.onRoomJoin(roomId);
}
const adminAccountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
const adminAccountData = await botUser.intent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
BRIDGE_ROOM_TYPE, roomId,
);
if (adminAccountData) {
const room = await this.setUpAdminRoom(roomId, adminAccountData, NotifFilter.getDefaultContent());
await this.as.botClient.setRoomAccountData(
const room = await this.setUpAdminRoom(botUser.intent, roomId, adminAccountData, NotifFilter.getDefaultContent());
await botUser.intent.underlyingClient.setRoomAccountData(
BRIDGE_ROOM_TYPE, roomId, room.accountData,
);
}
@ -926,20 +989,28 @@ export class Bridge {
return;
}
// Only fetch rooms we have no connections in yet.
const roomHasConnection =
this.connectionManager.isRoomConnected(roomId) ||
await this.connectionManager.createConnectionsForRoomId(roomId, true);
// Recreate connections for the room
await this.connectionManager.removeConnectionsForRoom(roomId);
await this.connectionManager.createConnectionsForRoomId(roomId, true);
// If room has connections or is an admin room, don't setup a wizard.
// 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 {
if (await this.as.botClient.userHasPowerLevelFor(this.as.botUserId, roomId, "im.vector.modular.widgets", true) === false) {
await this.as.botIntent.sendText(roomId, "Hello! To setup new integrations in this room, please promote me to a Moderator/Admin");
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 {
// Setup the widget
await SetupWidget.SetupRoomConfigWidget(roomId, this.as.botIntent, this.config.widgets);
// 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);
@ -988,28 +1059,31 @@ export class Bridge {
}
}
// 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?.[this.as.botUserId] || plEvent.defaultUserLevel;
const previousPl = plEvent.previousContent?.users?.[this.as.botUserId] || 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, this.as.botIntent, this.config.widgets);
} catch (ex) {
log.error(`Failed to create setup widget for ${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 (event.sender === this.as.botUserId) {
if (this.botUsersManager.isBotUser(event.sender)) {
// It's us
return;
}
@ -1169,16 +1243,16 @@ export class Bridge {
});
}
}
}
private async getOrCreateAdminRoomForUser(userId: string): Promise<AdminRoom> {
private async getOrCreateAdminRoom(intent: Intent, userId: string): Promise<AdminRoom> {
const existingRoom = this.getAdminRoomForUser(userId);
if (existingRoom) {
return existingRoom;
}
const roomId = await this.as.botClient.dms.getOrCreateDm(userId);
const room = await this.setUpAdminRoom(roomId, {admin_user: userId}, NotifFilter.getDefaultContent());
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,
);
@ -1194,23 +1268,28 @@ export class Bridge {
return null;
}
private async setUpAdminRoom(roomId: string, accountData: AdminAccountData, notifContent: NotificationFilterStateContent) {
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, this.as.botIntent, this.tokenStore, this.config, this.connectionManager,
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, adminRoom.userId);
const connection = await GitHubProjectConnection.onOpenProject(project, this.as, intent, adminRoom.userId);
this.connectionManager?.push(connection);
} else {
await this.as.botClient.inviteUser(adminRoom.userId, connection.roomId);
await intent.underlyingClient.inviteUser(adminRoom.userId, connection.roomId);
}
});
adminRoom.on("open.gitlab-issue", async (issueInfo: GetIssueOpts, res: GetIssueResponse, instanceName: string, instance: GitLabInstance) => {
@ -1219,25 +1298,26 @@ export class Bridge {
}
const [ connection ] = this.connectionManager?.getConnectionsForGitLabIssue(instance, issueInfo.projects, issueInfo.issue) || [];
if (connection) {
return this.as.botClient.inviteUser(adminRoom.userId, connection.roomId);
}
return intent.underlyingClient.inviteUser(adminRoom.userId, connection.roomId);
}
const newConnection = await GitLabIssueConnection.createRoomForIssue(
instanceName,
instance,
res,
issueInfo.projects,
this.as,
this.tokenStore,
intent,
this.tokenStore,
this.commentProcessor,
this.messageClient,
this.config.gitlab,
);
this.connectionManager?.push(newConnection);
return this.as.botClient.inviteUser(adminRoom.userId, newConnection.roomId);
return intent.underlyingClient.inviteUser(adminRoom.userId, newConnection.roomId);
});
this.adminRooms.set(roomId, adminRoom);
if (this.config.widgets?.addToAdminRooms) {
await SetupWidget.SetupAdminRoomConfigWidget(roomId, this.as.botIntent, this.config.widgets);
await SetupWidget.SetupAdminRoomConfigWidget(roomId, intent, this.config.widgets);
}
log.debug(`Set up ${roomId} as an admin room for ${adminRoom.userId}`);
return adminRoom;

View File

@ -79,7 +79,7 @@ export class BridgeConfigGitHub {
@configKey("Prefix used when creating ghost users for GitHub accounts.", true)
readonly userIdPrefix: string;
@configKey("URL for enterprise deployments. Does not include /api/v3", true)
private enterpriseUrl?: string;
@ -129,12 +129,12 @@ export interface BridgeConfigJiraYAML {
}
export class BridgeConfigJira implements BridgeConfigJiraYAML {
static CLOUD_INSTANCE_NAME = "api.atlassian.com";
@configKey("Webhook settings for JIRA")
readonly webhook: {
secret: string;
};
// To hide the undefined for now
@hideKey()
@configKey("URL for the instance if using on prem. Ignore if targetting cloud (atlassian.net)", true)
@ -411,6 +411,14 @@ interface BridgeConfigEncryption {
storagePath: string;
}
export interface BridgeConfigServiceBot {
localpart: string;
displayname?: string;
avatar?: string;
prefix: string;
service: string;
}
export interface BridgeConfigProvisioning {
bindAddress?: string;
port?: number;
@ -425,6 +433,7 @@ export interface BridgeConfigMetrics {
export interface BridgeConfigRoot {
bot?: BridgeConfigBot;
serviceBots?: BridgeConfigServiceBot[];
bridge: BridgeConfigBridge;
experimentalEncryption?: BridgeConfigEncryption;
figma?: BridgeConfigFigma;
@ -478,6 +487,8 @@ export class BridgeConfig {
public readonly feeds?: BridgeConfigFeeds;
@configKey("Define profile information for the bot user", true)
public readonly bot?: BridgeConfigBot;
@configKey("Define additional bot users for specific services", true)
public readonly serviceBots?: BridgeConfigServiceBot[];
@configKey("EXPERIMENTAL support for complimentary widgets", true)
public readonly widgets?: BridgeWidgetConfig;
@configKey("Provisioning API for integration managers", true)
@ -513,6 +524,7 @@ export class BridgeConfig {
this.provisioning = configData.provisioning;
this.passFile = configData.passFile;
this.bot = configData.bot;
this.serviceBots = configData.serviceBots;
this.metrics = configData.metrics;
this.queue = configData.queue || {
monolithic: true,
@ -525,7 +537,7 @@ export class BridgeConfig {
}
this.widgets = configData.widgets && new BridgeWidgetConfig(configData.widgets);
// To allow DEBUG as well as debug
this.logging.level = this.logging.level.toLowerCase() as "debug"|"info"|"warn"|"error"|"trace";
if (!ValidLogLevelStrings.includes(this.logging.level)) {
@ -547,7 +559,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
}];
this.bridgePermissions = new BridgePermissions(this.permissions);
if (!configData.permissions) {
if (!configData.permissions) {
log.warn(`You have not configured any permissions for the bridge, which by default means all users on ${this.bridge.domain} have admin levels of control. Please adjust your config.`);
}
@ -574,7 +586,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
});
log.warn("The `webhook` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config.");
}
if (configData.widgets?.port) {
this.listeners.push({
resources: ['widgets'],
@ -590,7 +602,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
})
log.warn("The `provisioning` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config.");
}
if (this.metrics?.port) {
this.listeners.push({
resources: ['metrics'],
@ -599,7 +611,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
})
log.warn("The `metrics` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config.");
}
if (configData.widgets?.port) {
this.listeners.push({
resources: ['widgets'],
@ -628,6 +640,10 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
if (this.encryption && !this.queue.port) {
throw new ConfigError("queue.port", "You must enable redis support for encryption to work.");
}
if (this.figma?.overrideUserId) {
log.warn("The `figma.overrideUserId` config value is deprecated. A service bot should be configured instead.");
}
}
public async prefillMembershipCache(client: MatrixClient) {
@ -637,7 +653,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
const membership = await client.getJoinedRoomMembers(await client.resolveRoom(roomEntry));
membership.forEach(userId => this.bridgePermissions.addMemberToCache(roomEntry, userId));
log.debug(`Found ${membership.length} users for ${roomEntry}`);
}
}
}
public addMemberToCache(roomId: string, userId: string) {
@ -656,6 +672,29 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.
return this.bridgePermissions.checkAction(mxid, service, BridgePermissionLevel[permission]);
}
public get enabledServices(): string[] {
const services = [];
if (this.feeds && this.feeds.enabled) {
services.push("feeds");
}
if (this.figma) {
services.push("figma");
}
if (this.generic && this.generic.enabled) {
services.push("generic");
}
if (this.github) {
services.push("github");
}
if (this.gitlab) {
services.push("gitlab");
}
if (this.jira) {
services.push("jira");
}
return services;
}
public getPublicConfigForService(serviceName: string): Record<string, unknown> {
let config: undefined|Record<string, unknown>;
switch (serviceName) {

View File

@ -11,7 +11,7 @@ export const DefaultConfig = new BridgeConfig({
url: "http://localhost:8008",
mediaUrl: "http://example.com",
port: 9993,
bindAddress: "127.0.0.1",
bindAddress: "127.0.0.1",
},
queue: {
monolithic: true,
@ -44,9 +44,18 @@ export const DefaultConfig = new BridgeConfig({
},
},
bot: {
displayname: "GitHub Bot",
displayname: "Hookshot Bot",
avatar: "mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d"
},
serviceBots: [
{
localpart: "feeds",
displayname: "Feeds",
avatar: "mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d",
prefix: "!feeds",
service: "feeds",
},
],
github: {
auth: {
id: 123,

View File

@ -4,8 +4,8 @@
* Manages connections between Matrix rooms and the remote side.
*/
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { ApiError, ErrCode } from "./api";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config";
import { CommentProcessor } from "./CommentProcessor";
import { ConnectionDeclaration, ConnectionDeclarations, GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, IConnectionState, JiraProjectConnection } from "./Connections";
@ -18,6 +18,7 @@ import { JiraProject, JiraVersion } from "./Jira/Types";
import { Logger } from "matrix-appservice-bridge";
import { MessageSenderClient } from "./MatrixSender";
import { UserTokenStore } from "./UserTokenStore";
import BotUsersManager from "./Managers/BotUsersManager";
import { retry, retryMatrixErrorFilter } from "./PromiseUtil";
import Metrics from "./Metrics";
import EventEmitter from "events";
@ -42,6 +43,7 @@ export class ConnectionManager extends EventEmitter {
private readonly commentProcessor: CommentProcessor,
private readonly messageClient: MessageSenderClient,
private readonly storage: IBridgeStorageProvider,
private readonly botUsersManager: BotUsersManager,
private readonly github?: GithubInstance
) {
super();
@ -66,21 +68,29 @@ export class ConnectionManager extends EventEmitter {
/**
* Used by the provisioner API to create new connections on behalf of users.
*
* @param roomId The target Matrix room.
* @param intent Bot user intent to create the connection with.
* @param userId The requesting Matrix user.
* @param type The type of room (corresponds to the event type of the connection)
* @param connectionType The connection declaration to provision.
* @param data The data corresponding to the connection state. This will be validated.
* @returns The resulting connection.
*/
public async provisionConnection(roomId: string, userId: string, type: string, data: Record<string, unknown>) {
log.info(`Looking to provision connection for ${roomId} ${type} for ${userId} with data ${JSON.stringify(data)}`);
const connectionType = ConnectionDeclarations.find(c => c.EventTypes.includes(type));
public async provisionConnection(
roomId: string,
intent: Intent,
userId: string,
connectionType: ConnectionDeclaration,
data: Record<string, unknown>,
) {
log.info(`Looking to provision connection for ${roomId} ${connectionType.ServiceCategory} for ${userId} with data ${JSON.stringify(data)}`);
if (connectionType?.provisionConnection) {
if (!this.config.checkPermission(userId, connectionType.ServiceCategory, BridgePermissionLevel.manageConnections)) {
throw new ApiError(`User is not permitted to provision connections for this type of service.`, ErrCode.ForbiddenUser);
}
const result = await connectionType.provisionConnection(roomId, userId, data, {
as: this.as,
intent: intent,
config: this.config,
tokenStore: this.tokenStore,
commentProcessor: this.commentProcessor,
@ -99,14 +109,15 @@ export class ConnectionManager extends EventEmitter {
* Check if a state event is sent by a user who is allowed to configure the type of connection the state event covers.
* If it isn't, optionally revert the state to the last-known valid value, or redact it if that isn't possible.
* @param roomId The target Matrix room.
* @param intent The bot intent to use.
* @param state The state event for altering a connection in the room.
* @param serviceType The type of connection the state event is altering.
* @returns Whether the state event was allowed to be set. If not, the state will be reverted asynchronously.
*/
public verifyStateEvent(roomId: string, state: StateEvent, serviceType: string, rollbackBadState: boolean) {
public verifyStateEvent(roomId: string, intent: Intent, state: StateEvent, serviceType: string, rollbackBadState: boolean) {
if (!this.isStateAllowed(roomId, state, serviceType)) {
if (rollbackBadState) {
void this.tryRestoreState(roomId, state, serviceType);
void this.tryRestoreState(roomId, intent, state, serviceType);
}
log.error(`User ${state.sender} is disallowed to manage state for ${serviceType} in ${roomId}`);
return false;
@ -121,30 +132,35 @@ export class ConnectionManager extends EventEmitter {
* @param state The state event for altering a connection in the room targeted by {@link connection}.
* @returns Whether the state event was allowed to be set. If not, the state will be reverted asynchronously.
*/
public verifyStateEventForConnection(connection: IConnection, state: StateEvent, rollbackBadState: boolean) {
public verifyStateEventForConnection(connection: IConnection, state: StateEvent, rollbackBadState: boolean): boolean {
const cd: ConnectionDeclaration = Object.getPrototypeOf(connection).constructor;
return !this.verifyStateEvent(connection.roomId, state, cd.ServiceCategory, rollbackBadState);
const botUser = this.botUsersManager.getBotUserInRoom(connection.roomId, cd.ServiceCategory);
if (!botUser) {
log.error(`Failed to find a bot in room '${connection.roomId}' for service type '${cd.ServiceCategory}' when verifying state for connection`);
throw Error('Could not find a bot to handle this connection');
}
return !this.verifyStateEvent(connection.roomId, botUser.intent, state, cd.ServiceCategory, rollbackBadState);
}
private isStateAllowed(roomId: string, state: StateEvent, serviceType: string) {
return state.sender === this.as.botUserId
return this.botUsersManager.isBotUser(state.sender)
|| this.config.checkPermission(state.sender, serviceType, BridgePermissionLevel.manageConnections);
}
private async tryRestoreState(roomId: string, originalState: StateEvent, serviceType: string) {
private async tryRestoreState(roomId: string, intent: Intent, originalState: StateEvent, serviceType: string) {
let state = originalState;
let attemptsRemaining = 5;
try {
do {
if (state.unsigned.replaces_state) {
state = new StateEvent(await this.as.botClient.getEvent(roomId, state.unsigned.replaces_state));
state = new StateEvent(await intent.underlyingClient.getEvent(roomId, state.unsigned.replaces_state));
} else {
await this.as.botClient.redactEvent(roomId, originalState.eventId,
await intent.underlyingClient.redactEvent(roomId, originalState.eventId,
`User ${originalState.sender} is disallowed to manage state for ${serviceType} in ${roomId}`);
return;
}
} while (--attemptsRemaining > 0 && !this.isStateAllowed(roomId, state, serviceType));
await this.as.botClient.sendStateEvent(roomId, state.type, state.stateKey, state.content);
await intent.underlyingClient.sendStateEvent(roomId, state.type, state.stateKey, state.content);
} catch (ex) {
log.warn(`Unable to undo state event from ${state.sender} for disallowed ${serviceType} connection management in ${roomId}`);
}
@ -156,15 +172,25 @@ export class ConnectionManager extends EventEmitter {
log.debug(`${roomId} has disabled state for ${state.type}`);
return;
}
const connectionType = ConnectionDeclarations.find(c => c.EventTypes.includes(state.type));
const connectionType = this.getConnectionTypeForEventType(state.type);
if (!connectionType) {
return;
}
if (!this.verifyStateEvent(roomId, state, connectionType.ServiceCategory, rollbackBadState)) {
// Get a bot user for the connection type
const botUser = this.botUsersManager.getBotUserInRoom(roomId, connectionType.ServiceCategory);
if (!botUser) {
log.error(`Failed to find a bot in room '${roomId}' for service type '${connectionType.ServiceCategory}' when creating connection for state`);
throw Error('Could not find a bot to handle this connection');
}
if (!this.verifyStateEvent(roomId, botUser.intent, state, connectionType.ServiceCategory, rollbackBadState)) {
return;
}
return connectionType.createConnectionForState(roomId, state, {
as: this.as,
intent: botUser.intent,
config: this.config,
tokenStore: this.tokenStore,
commentProcessor: this.commentProcessor,
@ -175,10 +201,15 @@ export class ConnectionManager extends EventEmitter {
}
public async createConnectionsForRoomId(roomId: string, rollbackBadState: boolean) {
let connectionCreated = false;
const botUser = this.botUsersManager.getBotUserInRoom(roomId);
if (!botUser) {
log.error(`Failed to find a bot in room '${roomId}' when creating connections`);
return;
}
// This endpoint can be heavy, wrap it in pillows.
const state = await retry(
() => this.as.botClient.getRoomState(roomId),
() => botUser.intent.underlyingClient.getRoomState(roomId),
GET_STATE_ATTEMPTS,
GET_STATE_TIMEOUT_MS,
retryMatrixErrorFilter
@ -190,13 +221,11 @@ export class ConnectionManager extends EventEmitter {
if (conn) {
log.debug(`Room ${roomId} is connected to: ${conn}`);
this.push(conn);
connectionCreated = true;
}
} catch (ex) {
log.error(`Failed to create connection for ${roomId}:`, ex);
}
}
return connectionCreated;
}
public getConnectionsForGithubIssue(org: string, repo: string, issueNumber: number): (GitHubIssueConnection|GitHubRepoConnection)[] {
@ -289,12 +318,16 @@ export class ConnectionManager extends EventEmitter {
public getConnectionsForFeedUrl(url: string): FeedConnection[] {
return this.connections.filter(c => c instanceof FeedConnection && c.feedUrl === url) as FeedConnection[];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public getAllConnectionsOfType<T extends IConnection>(typeT: new (...params : any[]) => T): T[] {
return this.connections.filter((c) => (c instanceof typeT)) as T[];
}
public getConnectionTypeForEventType(eventType: string): ConnectionDeclaration | undefined {
return ConnectionDeclarations.find(c => c.EventTypes.includes(eventType));
}
public isRoomConnected(roomId: string): boolean {
return !!this.connections.find(c => c.roomId === roomId);
}
@ -344,7 +377,7 @@ export class ConnectionManager extends EventEmitter {
/**
* Removes connections for a room from memory. This does NOT remove the state
* event from the room.
* @param roomId
* @param roomId
*/
public async removeConnectionsForRoom(roomId: string) {
log.info(`Removing all connections from ${roomId}`);
@ -363,8 +396,8 @@ export class ConnectionManager extends EventEmitter {
/**
* Get a list of possible targets for a given connection type when provisioning
* @param userId
* @param type
* @param userId
* @param type
*/
async getConnectionTargets(userId: string, type: string, filters: Record<string, unknown> = {}): Promise<unknown[]> {
switch (type) {

View File

@ -11,7 +11,6 @@ const log = new Logger("CommandConnection");
* by connections expecting to handle user input.
*/
export abstract class CommandConnection<StateType extends IConnectionState = IConnectionState, ValidatedStateType extends StateType = StateType> extends BaseConnection {
protected enabledHelpCategories?: string[];
protected includeTitlesInHelp?: boolean;
constructor(
roomId: string,
@ -21,6 +20,7 @@ export abstract class CommandConnection<StateType extends IConnectionState = ICo
private readonly botClient: MatrixClient,
private readonly botCommands: BotCommands,
private readonly helpMessage: HelpFunction,
protected readonly helpCategories: string[],
protected readonly defaultCommandPrefix: string,
protected readonly serviceName?: string,
) {
@ -80,6 +80,6 @@ export abstract class CommandConnection<StateType extends IConnectionState = ICo
@botCommand("help", "This help text")
public async helpCommand() {
return this.botClient.sendEvent(this.roomId, 'm.room.message', this.helpMessage(this.commandPrefix, this.enabledHelpCategories, this.includeTitlesInHelp));
return this.botClient.sendEvent(this.roomId, 'm.room.message', this.helpMessage(this.commandPrefix, this.helpCategories, this.includeTitlesInHelp));
}
}
}

View File

@ -1,4 +1,4 @@
import {Appservice, StateEvent} from "matrix-bot-sdk";
import {Appservice, Intent, StateEvent} from "matrix-bot-sdk";
import { IConnection, IConnectionState, InstantiateConnectionOpts } from ".";
import { ApiError, ErrCode } from "../api";
import { BridgeConfigFeeds } from "../Config/Config";
@ -10,7 +10,7 @@ import axios from "axios";
import markdown from "markdown-it";
import { Connection, ProvisionConnectionOpts } from "./IConnection";
import { GetConnectionsResponseItem } from "../provisioning/api";
import { StatusCodes } from "http-status-codes";
import { StatusCodes } from "http-status-codes";
const log = new Logger("FeedConnection");
const md = new markdown();
@ -42,13 +42,13 @@ const MAX_LAST_RESULT_ITEMS = 5;
export class FeedConnection extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.feed";
static readonly EventTypes = [ FeedConnection.CanonicalEventType ];
static readonly ServiceCategory = "feed";
static readonly ServiceCategory = "feeds";
public static createConnectionForState(roomId: string, event: StateEvent<any>, {config, as, storage}: InstantiateConnectionOpts) {
public static createConnectionForState(roomId: string, event: StateEvent<any>, {config, as, intent, 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);
return new FeedConnection(roomId, event.stateKey, event.content, config.feeds, as, intent, storage);
}
static async validateUrl(url: string): Promise<void> {
@ -74,7 +74,7 @@ export class FeedConnection extends BaseConnection implements IConnection {
}
}
static async provisionConnection(roomId: string, _userId: string, data: Record<string, unknown> = {}, {as, config, storage}: ProvisionConnectionOpts) {
static async provisionConnection(roomId: string, _userId: string, data: Record<string, unknown> = {}, {as, intent, config, storage}: ProvisionConnectionOpts) {
if (!config.feeds?.enabled) {
throw new ApiError('RSS/Atom feeds are not configured', ErrCode.DisabledFeature);
}
@ -90,8 +90,8 @@ export class FeedConnection extends BaseConnection implements IConnection {
const state = { url, label: data.label };
const connection = new FeedConnection(roomId, url, state, config.feeds, as, storage);
await as.botClient.sendStateEvent(roomId, FeedConnection.CanonicalEventType, url, state);
const connection = new FeedConnection(roomId, url, state, config.feeds, as, intent, storage);
await intent.underlyingClient.sendStateEvent(roomId, FeedConnection.CanonicalEventType, url, state);
return {
connection,
@ -104,14 +104,13 @@ export class FeedConnection extends BaseConnection implements IConnection {
service: "feeds",
eventType: FeedConnection.CanonicalEventType,
type: "Feed",
// TODO: Add ability to configure the bot per connnection type.
botUserId: botUserId,
}
}
public getProvisionerDetails(): FeedResponseItem {
return {
...FeedConnection.getProvisionerDetails(this.as.botUserId),
...FeedConnection.getProvisionerDetails(this.intent.userId),
id: this.connectionId,
config: {
url: this.feedUrl,
@ -136,6 +135,7 @@ export class FeedConnection extends BaseConnection implements IConnection {
private state: FeedConnectionState,
private readonly config: BridgeConfigFeeds,
private readonly as: Appservice,
private readonly intent: Intent,
private readonly storage: IBridgeStorageProvider
) {
super(roomId, stateKey, FeedConnection.CanonicalEventType)
@ -160,7 +160,7 @@ export class FeedConnection extends BaseConnection implements IConnection {
message += `: ${entryDetails}`;
}
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: 'm.notice',
format: "org.matrix.custom.html",
formatted_body: md.renderInline(message),
@ -190,7 +190,7 @@ export class FeedConnection extends BaseConnection implements IConnection {
return;
}
if (!this.hasError) {
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: 'm.notice',
format: 'm.text',
body: `Error fetching ${this.feedUrl}: ${error.cause.message}`
@ -202,7 +202,7 @@ export class FeedConnection extends BaseConnection implements IConnection {
// needed to ensure that the connection is removable
public async onRemove(): Promise<void> {
log.info(`Removing connection ${this.connectionId}`);
await this.as.botClient.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, this.feedUrl, {});
await this.intent.underlyingClient.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, this.feedUrl, {});
}
toString(): string {

View File

@ -1,4 +1,4 @@
import { Appservice, StateEvent } from "matrix-bot-sdk";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import markdownit from "markdown-it";
import { FigmaPayload } from "../figma/types";
import { BaseConnection } from "./BaseConnection";
@ -29,7 +29,7 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
];
static readonly ServiceCategory = "figma";
public static validateState(data: Record<string, unknown>): FigmaFileConnectionState {
if (!data.fileId || typeof data.fileId !== "string") {
throw Error('Missing or invalid fileId');
@ -43,20 +43,20 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
}
}
public static createConnectionForState(roomId: string, event: StateEvent<any>, {config, as, storage}: InstantiateConnectionOpts) {
public static createConnectionForState(roomId: string, event: StateEvent<any>, {config, as, intent, storage}: InstantiateConnectionOpts) {
if (!config.figma) {
throw Error('Figma is not configured');
}
return new FigmaFileConnection(roomId, event.stateKey, event.content, config.figma, as, storage);
return new FigmaFileConnection(roomId, event.stateKey, event.content, config.figma, as, intent, storage);
}
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown> = {}, {as, config, storage}: ProvisionConnectionOpts) {
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown> = {}, {as, intent, 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);
const connection = new FigmaFileConnection(roomId, validState.fileId, validState, config.figma, as, intent, storage);
await intent.underlyingClient.sendStateEvent(roomId, FigmaFileConnection.CanonicalEventType, validState.fileId, validState);
return {
connection,
stateEventContent: validState,
@ -70,6 +70,7 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
private state: FigmaFileConnectionState,
private readonly config: BridgeConfigFigma,
private readonly as: Appservice,
private readonly intent: Intent,
private readonly storage: IBridgeStorageProvider) {
super(roomId, stateKey, FigmaFileConnection.CanonicalEventType)
}
@ -100,8 +101,13 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
return;
}
const intent = this.as.getIntentForUserId(this.config.overrideUserId || this.as.botUserId);
let intent;
if (this.config.overrideUserId) {
intent = this.as.getIntentForUserId(this.config.overrideUserId);
} else {
intent = this.intent;
}
const permalink = `https://www.figma.com/file/${payload.file_key}#${payload.comment_id}`;
const comment = payload.comment.map(({text}) => text).join("\n");
const empty = ""; // This contains an empty character to thwart the notification matcher.
@ -109,7 +115,7 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
let content: Record<string, unknown>|undefined = undefined;
const parentEventId = payload.parent_id && await this.storage.getFigmaCommentEventId(this.roomId, payload.parent_id);
if (parentEventId) {
content = {
content = {
"m.relates_to": {
rel_type: THREAD_RELATION_TYPE,
event_id: parentEventId,

View File

@ -4,7 +4,7 @@ import { MessageSenderClient } from "../MatrixSender"
import markdownit from "markdown-it";
import { VMScript as Script, NodeVM } from "vm2";
import { MatrixEvent } from "../MatrixEvent";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { v4 as uuid} from "uuid";
import { ApiError, ErrCode } from "../api";
import { BaseConnection } from "./BaseConnection";
@ -68,14 +68,14 @@ export class GenericHookConnection extends BaseConnection implements IConnection
/**
* Ensures a JSON payload is compatible with Matrix JSON requirements, such
* as disallowing floating point values.
*
*
* If the `depth` exceeds `SANITIZE_MAX_DEPTH`, the value of `data` will be immediately returned.
* If the object contains more than `SANITIZE_MAX_BREADTH` entries, the remaining entries will not be checked.
*
*
* @param data The data to santise
* @param depth The depth of the `data` relative to the root.
* @param breadth The breadth of the `data` in the parent object.
* @returns
* @returns
*/
static sanitiseObjectForMatrixJSON(data: unknown, depth = 0, breadth = 0): unknown {
// Floats
@ -91,7 +91,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
if (depth > SANITIZE_MAX_DEPTH || breadth > SANITIZE_MAX_BREADTH) {
return JSON.stringify(data);
}
const newDepth = depth + 1;
if (Array.isArray(data)) {
return data.map((d, innerBreadth) => this.sanitiseObjectForMatrixJSON(d, newDepth, innerBreadth));
@ -130,19 +130,19 @@ export class GenericHookConnection extends BaseConnection implements IConnection
};
}
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {as, config, messageClient}: InstantiateConnectionOpts) {
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {as, intent, config, messageClient}: InstantiateConnectionOpts) {
if (!config.generic) {
throw Error('Generic webhooks are not configured');
}
// Generic hooks store the hookId in the account data
const acctData = await as.botClient.getSafeRoomAccountData<GenericHookAccountData>(GenericHookConnection.CanonicalEventType, roomId, {});
const acctData = await intent.underlyingClient.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);
await GenericHookConnection.ensureRoomAccountData(roomId, intent, hookId, event.stateKey);
}
return new GenericHookConnection(
@ -153,18 +153,19 @@ export class GenericHookConnection extends BaseConnection implements IConnection
messageClient,
config.generic,
as,
intent,
);
}
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown> = {}, {as, config, messageClient}: ProvisionConnectionOpts) {
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown> = {}, {as, intent, 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);
await GenericHookConnection.ensureRoomAccountData(roomId, intent, hookId, validState.name);
await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, validState.name, validState);
const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config.generic, as, intent);
return {
connection,
stateEventContent: validState,
@ -173,25 +174,22 @@ export class GenericHookConnection extends BaseConnection implements IConnection
/**
* This function ensures the account data for a room contains all the hookIds for the various state events.
* @param roomId
* @param as
* @param connection
*/
static async ensureRoomAccountData(roomId: string, as: Appservice, hookId: string, stateKey: string, remove = false) {
const data = await as.botClient.getSafeRoomAccountData<GenericHookAccountData>(GenericHookConnection.CanonicalEventType, roomId, {});
static async ensureRoomAccountData(roomId: string, intent: Intent, hookId: string, stateKey: string, remove = false) {
const data = await intent.underlyingClient.getSafeRoomAccountData<GenericHookAccountData>(GenericHookConnection.CanonicalEventType, roomId, {});
if (remove && data[hookId] === stateKey) {
delete data[hookId];
await as.botClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data);
await intent.underlyingClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data);
}
if (!remove && data[hookId] !== stateKey) {
data[hookId] = stateKey;
await as.botClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data);
await intent.underlyingClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data);
}
}
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.generic.hook";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.generic.hook";
static readonly ServiceCategory = "webhooks";
static readonly ServiceCategory = "generic";
static readonly EventTypes = [
GenericHookConnection.CanonicalEventType,
@ -200,23 +198,25 @@ export class GenericHookConnection extends BaseConnection implements IConnection
private transformationFunction?: Script;
private cachedDisplayname?: string;
constructor(roomId: string,
constructor(
roomId: string,
private state: GenericHookConnectionState,
public readonly hookId: string,
stateKey: string,
private readonly messageClient: MessageSenderClient,
private readonly config: BridgeConfigGenericWebhooks,
private readonly as: Appservice) {
super(roomId, stateKey, GenericHookConnection.CanonicalEventType);
if (state.transformationFunction && config.allowJsTransformationFunctions) {
this.transformationFunction = new Script(state.transformationFunction);
}
}
public get priority(): number {
return this.state.priority || super.priority;
private readonly as: Appservice,
private readonly intent: Intent,
) {
super(roomId, stateKey, GenericHookConnection.CanonicalEventType);
if (state.transformationFunction && config.allowJsTransformationFunctions) {
this.transformationFunction = new Script(state.transformationFunction);
}
}
public get priority(): number {
return this.state.priority || super.priority;
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return GenericHookConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
@ -224,28 +224,24 @@ export class GenericHookConnection extends BaseConnection implements IConnection
public getUserId() {
if (!this.config.userIdPrefix) {
return this.as.botUserId;
return this.intent.userId;
}
const [, domain] = this.as.botUserId.split(':');
const [, domain] = this.intent.userId.split(':');
const name = this.state.name &&
this.state.name.replace(/[A-Z]/g, (s) => s.toLowerCase()).replace(/([^a-z0-9\-.=_]+)/g, '');
return `@${this.config.userIdPrefix}${name || 'bot'}:${domain}`;
}
public async ensureDisplayname(sender: string) {
public async ensureDisplayname(userId: string) {
if (!this.state.name) {
return;
}
if (sender === this.as.botUserId) {
// Don't set the global displayname for the bot.
return;
}
const intent = this.as.getIntentForUserId(sender);
const intent = this.as.getIntentForUserId(userId);
const expectedDisplayname = `${this.state.name} (Webhook)`;
try {
if (this.cachedDisplayname !== expectedDisplayname) {
this.cachedDisplayname = (await intent.underlyingClient.getUserProfile(sender)).displayname;
this.cachedDisplayname = (await intent.underlyingClient.getUserProfile(userId)).displayname;
}
} catch (ex) {
// Couldn't fetch, probably not set.
@ -264,7 +260,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
try {
this.transformationFunction = new Script(validatedConfig.transformationFunction);
} catch (ex) {
await this.messageClient.sendMatrixText(this.roomId, 'Could not compile transformation function:' + ex);
await this.messageClient.sendMatrixText(this.roomId, 'Could not compile transformation function:' + ex, "m.text", this.intent.userId);
}
} else {
this.transformationFunction = undefined;
@ -352,7 +348,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
/**
* Processes an incoming generic hook
* @param data Structured data. This may either be a string, or an object.
* @returns `true` if the webhook completed, or `false` if it failed to complete
* @returns `true` if the webhook completed, or `false` if it failed to complete
*/
public async onGenericHook(data: unknown): Promise<boolean> {
log.info(`onGenericHook ${this.roomId} ${this.hookId}`);
@ -376,11 +372,15 @@ export class GenericHookConnection extends BaseConnection implements IConnection
}
const sender = this.getUserId();
await this.ensureDisplayname(sender);
if (sender !== this.intent.userId) {
// Make sure ghost user is invited to the room
await this.intent.underlyingClient.inviteUser(sender, this.roomId);
await this.ensureDisplayname(sender);
}
// Matrix cannot handle float data, so make sure we parse out any floats.
const safeData = GenericHookConnection.sanitiseObjectForMatrixJSON(data);
await this.messageClient.sendMatrixMessage(this.roomId, {
msgtype: content.msgtype || "m.notice",
body: content.plain,
@ -405,7 +405,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
public getProvisionerDetails(showSecrets = false): GenericHookResponseItem {
return {
...GenericHookConnection.getProvisionerDetails(this.as.botUserId),
...GenericHookConnection.getProvisionerDetails(this.intent.userId),
id: this.connectionId,
config: {
transformationFunction: this.state.transformationFunction,
@ -422,20 +422,20 @@ export class GenericHookConnection extends BaseConnection implements IConnection
log.info(`Removing ${this.toString()} for ${this.roomId}`);
// Do a sanity check that the event exists.
try {
await this.as.botClient.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
await this.as.botClient.getRoomStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
await GenericHookConnection.ensureRoomAccountData(this.roomId, this.as, this.hookId, this.stateKey, true);
await GenericHookConnection.ensureRoomAccountData(this.roomId, this.intent, this.hookId, this.stateKey, true);
}
public async provisionerUpdateConfig(userId: string, config: Record<string, unknown>) {
// Apply previous state to the current config, as provisioners might not return "unknown" keys.
config = { ...this.state, ...config };
const validatedConfig = GenericHookConnection.validateState(config, this.config.allowJsTransformationFunctions || false);
await this.as.botClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey,
await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey,
{
...validatedConfig,
hookId: this.hookId

View File

@ -1,5 +1,5 @@
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { UserTokenStore } from "../UserTokenStore";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";
@ -42,12 +42,12 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
static readonly ServiceCategory = "github";
public static createConnectionForState(roomId: string, event: StateEvent<any>, {
github, config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) {
github, config, as, intent, 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,
roomId, as, intent, event.content, event.stateKey, tokenStore, commentProcessor,
messageClient, config.github,
);
}
@ -55,7 +55,7 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
readonly sentEvents = new Set<string>(); //TODO: Set some reasonable limits
public static async createDiscussionRoom(
as: Appservice, userId: string|null, owner: string, repo: string, discussion: Discussion,
as: Appservice, intent: Intent, userId: string|null, owner: string, repo: string, discussion: Discussion,
tokenStore: UserTokenStore, commentProcessor: CommentProcessor, messageClient: MessageSenderClient,
config: BridgeConfigGitHub,
) {
@ -71,7 +71,7 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
discussion: discussion.number,
category: discussion.category.id,
};
const invite = [as.botUserId];
const invite = [intent.userId];
if (userId) {
invite.push(userId);
}
@ -93,20 +93,23 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
formatted_body: md.render(discussion.body),
format: 'org.matrix.custom.html',
});
await as.botIntent.ensureJoined(roomId);
return new GitHubDiscussionConnection(roomId, as, state, '', tokenStore, commentProcessor, messageClient, config);
await intent.ensureJoined(roomId);
return new GitHubDiscussionConnection(roomId, as, intent, state, '', tokenStore, commentProcessor, messageClient, config);
}
constructor(roomId: string,
constructor(
roomId: string,
private readonly as: Appservice,
private readonly intent: Intent,
private readonly state: GitHubDiscussionConnectionState,
stateKey: string,
private readonly tokenStore: UserTokenStore,
private readonly commentProcessor: CommentProcessor,
private readonly messageClient: MessageSenderClient,
private readonly config: BridgeConfigGitHub) {
super(roomId, stateKey, GitHubDiscussionConnection.CanonicalEventType);
}
private readonly config: BridgeConfigGitHub,
) {
super(roomId, stateKey, GitHubDiscussionConnection.CanonicalEventType);
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return GitHubDiscussionConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
@ -116,7 +119,7 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
const octokit = await this.tokenStore.getOctokitForUser(ev.sender);
if (octokit === null) {
// TODO: Use Reply - Also mention user.
await this.as.botClient.sendNotice(this.roomId, `${ev.sender}: Cannot send comment, you are not logged into GitHub`);
await this.intent.underlyingClient.sendNotice(this.roomId, `${ev.sender}: Cannot send comment, you are not logged into GitHub`);
return true;
}
const qlClient = new GithubGraphQLClient(octokit);
@ -147,6 +150,10 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
return;
}
const intent = await getIntentForUser(data.comment.user, this.as, this.config.userIdPrefix);
if (intent.userId !== this.intent.userId) {
// Make sure ghost user is invited to the room
await this.intent.underlyingClient.inviteUser(intent.userId, this.roomId);
}
await this.messageClient.sendMatrixMessage(this.roomId, {
body: data.comment.body,
formatted_body: md.render(data.comment.body),
@ -160,11 +167,11 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne
log.info(`Removing ${this.toString()} for ${this.roomId}`);
// Do a sanity check that the event exists.
try {
await this.as.botClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
await this.as.botClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
}
}
}

View File

@ -31,12 +31,12 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
static readonly ServiceCategory = "github";
public static async createConnectionForState(roomId: string, event: StateEvent<any>, {
github, config, as}: InstantiateConnectionOpts) {
github, config, as, intent}: InstantiateConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
return new GitHubDiscussionSpace(
await as.botClient.getSpace(roomId), event.content, event.stateKey
await intent.underlyingClient.getSpace(roomId), event.content, event.stateKey
);
}
@ -108,7 +108,7 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
preset: 'public_chat',
room_alias_name: `github_disc_${owner.toLowerCase()}_${repo.toLowerCase()}`,
initial_state: [
{
type: this.CanonicalEventType,
content: state,
@ -172,7 +172,7 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
log.info(`Removing ${this.toString()} for ${this.roomId}`);
// Do a sanity check that the event exists.
try {
await this.space.client.getRoomStateEvent(this.roomId, GitHubDiscussionSpace.CanonicalEventType, this.stateKey);
await this.space.client.sendStateEvent(this.roomId, GitHubDiscussionSpace.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
@ -180,4 +180,4 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
await this.space.client.sendStateEvent(this.roomId, GitHubDiscussionSpace.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
}
}
}

View File

@ -1,5 +1,5 @@
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
import markdown from "markdown-it";
import { UserTokenStore } from "../UserTokenStore";
@ -56,12 +56,12 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
}
public static async createConnectionForState(roomId: string, event: StateEvent<any>, {
github, config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) {
github, config, as, intent, 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,
roomId, as, intent, event.content, event.stateKey || "", tokenStore,
commentProcessor, messageClient, github, config.github,
);
await issue.syncIssueState();
@ -154,17 +154,20 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
};
}
constructor(roomId: string,
constructor(
roomId: string,
private readonly as: Appservice,
private readonly intent: Intent,
private state: GitHubIssueConnectionState,
stateKey: string,
private tokenStore: UserTokenStore,
private commentProcessor: CommentProcessor,
private messageClient: MessageSenderClient,
private github: GithubInstance,
private config: BridgeConfigGitHub,) {
super(roomId, stateKey, GitHubIssueConnection.CanonicalEventType);
}
private config: BridgeConfigGitHub,
) {
super(roomId, stateKey, GitHubIssueConnection.CanonicalEventType);
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return GitHubIssueConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
@ -214,13 +217,17 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
const matrixEvent = await this.commentProcessor.getEventBodyForGitHubComment(comment, event.repository, event.issue);
// Comment body may be blank
if (matrixEvent) {
if (commentIntent.userId !== this.intent.userId) {
// Make sure ghost user is invited to the room
await this.intent.underlyingClient.inviteUser(commentIntent.userId, this.roomId);
}
await this.messageClient.sendMatrixMessage(this.roomId, matrixEvent, "m.room.message", commentIntent.userId);
}
if (!updateState) {
return;
}
this.state.comments_processed++;
await this.as.botIntent.underlyingClient.sendStateEvent(
await this.intent.underlyingClient.sendStateEvent(
this.roomId,
GitHubIssueConnection.CanonicalEventType,
this.stateKey,
@ -245,6 +252,10 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
}, this.as, this.config.userIdPrefix);
// We've not sent any messages into the room yet, let's do it!
if (issue.data.body) {
if (creator.userId !== this.intent.userId) {
// Make sure ghost user is invited to the room
await this.intent.underlyingClient.inviteUser(creator.userId, this.roomId);
}
await this.messageClient.sendMatrixMessage(this.roomId, {
msgtype: "m.text",
external_url: issue.data.html_url,
@ -282,6 +293,10 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
if (issue.data.state === "closed") {
// TODO: Fix
const closedUserId = this.as.getUserIdForSuffix(issue.data.closed_by?.login as string);
if (closedUserId !== this.intent.userId) {
// Make sure ghost user is invited to the room
await this.intent.underlyingClient.inviteUser(closedUserId, this.roomId);
}
await this.messageClient.sendMatrixMessage(this.roomId, {
msgtype: "m.notice",
body: `closed the ${issue.data.pull_request ? "pull request" : "issue"} at ${issue.data.closed_at}`,
@ -289,14 +304,14 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
}, "m.room.message", closedUserId);
}
await this.as.botIntent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", {
await this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", {
topic: FormatUtil.formatRoomTopic(issue.data),
});
this.state.state = issue.data.state;
}
await this.as.botIntent.underlyingClient.sendStateEvent(
await this.intent.underlyingClient.sendStateEvent(
this.roomId,
GitHubIssueConnection.CanonicalEventType,
this.stateKey,
@ -308,7 +323,7 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
public async onMatrixIssueComment(event: MatrixEvent<MatrixMessageContent>, allowEcho = false) {
const clientKit = await this.tokenStore.getOctokitForUser(event.sender);
if (clientKit === null) {
await this.as.botClient.sendEvent(this.roomId, "m.reaction", {
await this.intent.underlyingClient.sendEvent(this.roomId, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: event.event_id,
@ -339,7 +354,7 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
// TODO: Fix types
if (event.issue && event.changes.title) {
await this.as.botIntent.underlyingClient.sendStateEvent(this.roomId, "m.room.name", "", {
await this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.name", "", {
name: FormatUtil.formatIssueRoomName(event.issue, event.repository),
});
}
@ -349,11 +364,11 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
log.info(`Removing ${this.toString()} for ${this.roomId}`);
// Do a sanity check that the event exists.
try {
await this.as.botClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
await this.as.botClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
}
@ -374,4 +389,4 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection
public toString() {
return `GitHubIssue ${this.state.org}/${this.state.repo}#${this.state.issues.join(",")}`;
}
}
}

View File

@ -1,5 +1,5 @@
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { Logger } from "matrix-appservice-bridge";
import { ProjectsGetResponseData } from "../Github/Types";
import { BaseConnection } from "./BaseConnection";
@ -24,14 +24,14 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti
GitHubProjectConnection.LegacyCanonicalEventType,
];
public static createConnectionForState(roomId: string, event: StateEvent<any>, {config, as}: InstantiateConnectionOpts) {
public static createConnectionForState(roomId: string, event: StateEvent<any>, {config, as, intent}: InstantiateConnectionOpts) {
if (!config.github) {
throw Error('GitHub is not configured');
}
return new GitHubProjectConnection(roomId, as, event.content, event.stateKey);
return new GitHubProjectConnection(roomId, as, intent, event.content, event.stateKey);
}
static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, inviteUser: string): Promise<GitHubProjectConnection> {
static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, intent: Intent, inviteUser: string): Promise<GitHubProjectConnection> {
log.info(`Fetching ${project.name} ${project.id}`);
// URL hack so we don't need to fetch the repo itself.
@ -41,7 +41,7 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti
state: project.state as "open"|"closed",
};
const roomId = await as.botClient.createRoom({
const roomId = await intent.underlyingClient.createRoom({
visibility: "private",
name: `${project.name}`,
topic: project.body || undefined,
@ -55,20 +55,23 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti
},
],
});
return new GitHubProjectConnection(roomId, as, state, project.url)
return new GitHubProjectConnection(roomId, as, intent, state, project.url)
}
get projectId() {
return this.state.project_id;
}
constructor(public readonly roomId: string,
constructor(
public readonly roomId: string,
as: Appservice,
intent: Intent,
private state: GitHubProjectConnectionState,
stateKey: string) {
super(roomId, stateKey, GitHubProjectConnection.CanonicalEventType);
}
stateKey: string,
) {
super(roomId, stateKey, GitHubProjectConnection.CanonicalEventType);
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return GitHubProjectConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
@ -77,4 +80,4 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti
public toString() {
return `GitHubProjectConnection ${this.state.project_id}}`;
}
}
}

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { Appservice, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands";
import { CommentProcessor } from "../CommentProcessor";
import { FormatUtil } from "../FormatUtil";
@ -91,12 +91,12 @@ export type GitHubRepoConnectionTarget = GitHubRepoConnectionOrgTarget|GitHubRep
export type GitHubRepoResponseItem = GetConnectionsResponseItem<GitHubRepoConnectionState>;
type AllowedEventsNames =
type AllowedEventsNames =
"issue.changed" |
"issue.created" |
"issue.edited" |
"issue.labeled" |
"issue" |
"issue" |
"pull_request.closed" |
"pull_request.merged" |
"pull_request.opened" |
@ -107,7 +107,7 @@ type AllowedEventsNames =
"release.drafted" |
"release" |
"workflow" |
"workflow.run" |
"workflow.run" |
"workflow.run.success" |
"workflow.run.failure" |
"workflow.run.neutral" |
@ -194,7 +194,7 @@ const ConnectionStateSchema = {
nullable: true,
maxLength: 24,
},
showIssueRoomLink: {
showIssueRoomLink: {
type: "boolean",
nullable: true,
},
@ -272,7 +272,7 @@ const ConnectionStateSchema = {
additionalProperties: true
} as JSONSchemaType<GitHubRepoConnectionState>;
type ReactionOptions =
type ReactionOptions =
| "+1"
| "-1"
| "laugh"
@ -356,7 +356,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
throw new ValidatorApiError(validator.errors);
}
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown>, {as, tokenStore, github, config}: ProvisionConnectionOpts) {
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown>, {as, intent, tokenStore, github, config}: ProvisionConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
@ -390,10 +390,10 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
);
}
const stateEventKey = `${validData.org}/${validData.repo}`;
await as.botClient.sendStateEvent(roomId, this.CanonicalEventType, stateEventKey, validData);
await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, stateEventKey, validData);
return {
stateEventContent: validData,
connection: new GitHubRepoConnection(roomId, as, validData, tokenStore, stateEventKey, github, config.github),
connection: new GitHubRepoConnection(roomId, as, intent, validData, tokenStore, stateEventKey, github, config.github),
}
}
@ -406,11 +406,11 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
static readonly ServiceCategory = "github";
static readonly QueryRoomRegex = /#github_(.+)_(.+):.*/;
static async createConnectionForState(roomId: string, state: StateEvent<Record<string, unknown>>, {as, tokenStore, github, config}: InstantiateConnectionOpts) {
static async createConnectionForState(roomId: string, state: StateEvent<Record<string, unknown>>, {as, intent, tokenStore, github, config}: InstantiateConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
return new GitHubRepoConnection(roomId, as, this.validateState(state.content, true), tokenStore, state.stateKey, github, config.github);
return new GitHubRepoConnection(roomId, as, intent, this.validateState(state.content, true), tokenStore, state.stateKey, github, config.github);
}
static async onQueryRoom(result: RegExpExecArray, opts: IQueryRoomOpts): Promise<unknown> {
@ -498,22 +498,25 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
public debounceOnIssueLabeled = new Map<number, {labels: Set<string>, timeout: NodeJS.Timeout}>();
constructor(roomId: string,
constructor(
roomId: string,
private readonly as: Appservice,
private readonly intent: Intent,
state: ConnectionValidatedState,
private readonly tokenStore: UserTokenStore,
stateKey: string,
private readonly githubInstance: GithubInstance,
private readonly config: BridgeConfigGitHub,
) {
) {
super(
roomId,
stateKey,
GitHubRepoConnection.CanonicalEventType,
state,
as.botClient,
intent.underlyingClient,
GitHubRepoConnection.botCommands,
GitHubRepoConnection.helpMessage,
["github"],
"!gh",
"github",
);
@ -607,7 +610,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
message += ` [Issue Room](https://matrix.to/#/${this.as.getAlias(GitHubIssueConnection.generateAliasLocalpart(this.org, this.repo, issue.number))})`;
}
const content = emoji.emojify(message);
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content ,
formatted_body: md.renderInline(content),
@ -650,14 +653,14 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
event: reviewEvent,
});
} catch (ex) {
await this.as.botClient.sendEvent(this.roomId, "m.reaction", {
await this.intent.underlyingClient.sendEvent(this.roomId, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: ev.event_id,
key: "⛔",
}
});
await this.as.botClient.sendEvent(this.roomId, 'm.room.message', {
await this.intent.underlyingClient.sendEvent(this.roomId, 'm.room.message', {
msgtype: "m.notice",
body: `Failed to submit review: ${ex.message}`,
});
@ -776,7 +779,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
const workflow = workflows.data.workflows.find(w => w.name.toLowerCase().trim() === name.toLowerCase().trim());
if (!workflow) {
const workflowNames = workflows.data.workflows.map(w => w.name).join(', ');
await this.as.botIntent.sendText(this.roomId, `Could not find a workflow by the name of "${name}". The workflows on this repository are ${workflowNames}.`, "m.notice");
await this.intent.sendText(this.roomId, `Could not find a workflow by the name of "${name}". The workflows on this repository are ${workflowNames}.`, "m.notice");
return;
}
try {
@ -806,7 +809,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
throw ex;
}
await this.as.botIntent.sendText(this.roomId, `Workflow started.`, "m.notice");
await this.intent.sendText(this.roomId, `Workflow started.`, "m.notice");
}
public async onIssueCreated(event: IssuesOpenedEvent) {
@ -833,8 +836,8 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
}
const content = emoji.emojify(message);
const labels = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
await this.as.botIntent.sendEvent(this.roomId, {
const labels = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content + (labels.plain.length > 0 ? ` with labels ${labels.plain}`: ""),
formatted_body: md.renderInline(content) + (labels.html.length > 0 ? ` with labels ${labels.html}`: ""),
@ -880,7 +883,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
}
const content = `**${event.sender.login}** ${state} issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emoji.emojify(event.issue.title)}"${withComment}`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -899,7 +902,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
log.info(`onIssueEdited ${this.roomId} ${this.org}/${this.repo} #${event.issue.number}`);
const orgRepoName = event.repository.full_name;
const content = `**${event.sender.login}** edited issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emoji.emojify(event.issue.title)}"`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -917,7 +920,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
if (Date.now() - new Date(event.issue.created_at).getTime() < CREATED_GRACE_PERIOD_MS) {
return;
}
log.info(`onIssueLabeled ${this.roomId} ${this.org}/${this.repo} #${event.issue.id} ${event.label.name}`);
const renderFn = () => {
const {labels} = this.debounceOnIssueLabeled.get(event.issue.id) || { labels: [] };
@ -928,9 +931,9 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
return;
}
const orgRepoName = event.repository.full_name;
const {plain, html} = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
const {plain, html} = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
const content = `**${event.sender.login}** labeled issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emoji.emojify(event.issue.title)}"`;
this.as.botIntent.sendEvent(this.roomId, {
this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content + (plain.length > 0 ? ` with labels ${plain}`: ""),
formatted_body: md.renderInline(content) + (html.length > 0 ? ` with labels ${html}`: ""),
@ -987,8 +990,8 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
}
const content = emoji.emojify(`**${event.sender.login}** ${verb} a new PR [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}): "${event.pull_request.title}"`);
const labels = FormatUtil.formatLabels(event.pull_request.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
await this.as.botIntent.sendEvent(this.roomId, {
const labels = FormatUtil.formatLabels(event.pull_request.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content + (labels.plain.length > 0 ? ` with labels ${labels}`: "") + diffContent,
formatted_body: md.renderInline(content) + (labels.html.length > 0 ? ` with labels ${labels.html}`: "") + diffContentHtml,
@ -1011,7 +1014,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
const orgRepoName = event.repository.full_name;
const content = emoji.emojify(`**${event.sender.login}** has marked [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}) as ready to review "${event.pull_request.title}"`);
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -1044,7 +1047,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
return;
}
const content = emoji.emojify(`**${event.sender.login}** ${emojiForReview} ${event.review.state.toLowerCase()} [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}) "${event.pull_request.title}"`);
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -1090,7 +1093,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
const content = emoji.emojify(`**${event.sender.login}** ${verb} PR [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}): "${event.pull_request.title}"${withComment}`);
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -1120,7 +1123,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
if (event.release.body) {
content += `\n\n${event.release.body}`
}
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@ -1146,14 +1149,14 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
if (event.release.body) {
content += `\n\n${event.release.body}`
}
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
format: "org.matrix.custom.html",
});
}
public async onWorkflowCompleted(event: WorkflowRunCompletedEvent) {
const workflowRun = event.workflow_run;
const workflowName = event.workflow_run.name;
@ -1179,7 +1182,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
log.info(`onWorkflowCompleted ${this.roomId} ${this.org}/${this.repo} '${workflowRun.id}'`);
const orgRepoName = event.repository.full_name;
const content = `Workflow **${event.workflow.name}** [${WORKFLOW_CONCLUSION_TO_NOTICE[workflowRun.conclusion]}](${workflowRun.html_url}) for ${orgRepoName} on branch \`${workflowRun.head_branch}\``;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@ -1194,7 +1197,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
if (evt.type === 'm.reaction') {
const {event_id, key} = (evt.content as MatrixReactionContent)["m.relates_to"];
const ev = await this.as.botClient.getEvent(this.roomId, event_id);
const ev = await this.intent.underlyingClient.getEvent(this.roomId, event_id);
const issueContent = ev.content["uk.half-shot.matrix-hookshot.github.issue"];
if (!issueContent) {
log.debug('Reaction to event did not pertain to a issue');
@ -1251,7 +1254,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
public getProvisionerDetails(): GitHubRepoResponseItem {
return {
...GitHubRepoConnection.getProvisionerDetails(this.as.botUserId),
...GitHubRepoConnection.getProvisionerDetails(this.intent.userId),
id: this.connectionId,
config: {
...this.state,
@ -1330,7 +1333,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
// Apply previous state to the current config, as provisioners might not return "unknown" keys.
const newState = { ...this.state, ...config };
const validatedConfig = GitHubRepoConnection.validateState(newState);
await this.as.botClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, validatedConfig);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, validatedConfig);
this.state = validatedConfig;
this.hookFilter.enabledHooks = this.state.enableHooks ?? [];
}
@ -1339,11 +1342,11 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
log.info(`Removing ${this.toString()} for ${this.roomId}`);
// Do a sanity check that the event exists.
try {
await this.as.botClient.getRoomStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
await this.as.botClient.getRoomStateEvent(this.roomId, GitHubRepoConnection.LegacyCanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitHubRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubRepoConnection.LegacyCanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
}

View File

@ -29,12 +29,12 @@ export class GitHubUserSpace extends BaseConnection implements IConnection {
static readonly ServiceCategory = "github";
public static async createConnectionForState(roomId: string, event: StateEvent<any>, {
github, config, as}: InstantiateConnectionOpts) {
github, config, intent}: InstantiateConnectionOpts) {
if (!github || !config.github) {
throw Error('GitHub is not configured');
}
return new GitHubUserSpace(
await as.botClient.getSpace(roomId), event.content, event.stateKey
await intent.underlyingClient.getSpace(roomId), event.content, event.stateKey
);
}
@ -104,7 +104,7 @@ export class GitHubUserSpace extends BaseConnection implements IConnection {
preset: 'public_chat',
room_alias_name: `github_${state.username.toLowerCase()}`,
initial_state: [
{
type: this.CanonicalEventType,
content: state,
@ -167,4 +167,4 @@ export class GitHubUserSpace extends BaseConnection implements IConnection {
await this.space.addChildRoom(discussion.roomId);
}
}
}
}

View File

@ -1,5 +1,5 @@
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
import { UserTokenStore } from "../UserTokenStore";
import { Logger } from "matrix-appservice-bridge";
@ -48,8 +48,11 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
return `Author: ${authorName} | State: ${state === "closed" ? "closed" : "open"}`
}
public static async createConnectionForState(roomId: string, event: StateEvent<any>, {
config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) {
public static async createConnectionForState(
roomId: string,
event: StateEvent<any>,
{ config, as, intent, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts,
) {
if (!config.gitlab) {
throw Error('GitHub is not configured');
}
@ -58,15 +61,31 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
throw Error('Instance name not recognised');
}
return new GitLabIssueConnection(
roomId, as, event.content, event.stateKey || "", tokenStore,
commentProcessor, messageClient, instance, config.gitlab,
roomId,
as,
intent,
event.content,
event.stateKey || "",
tokenStore,
commentProcessor,
messageClient,
instance,
config.gitlab,
);
}
public static async createRoomForIssue(instanceName: string, instance: GitLabInstance,
issue: GetIssueResponse, projects: string[], as: Appservice,
tokenStore: UserTokenStore, commentProcessor: CommentProcessor,
messageSender: MessageSenderClient, config: BridgeConfigGitLab) {
public static async createRoomForIssue(
instanceName: string,
instance: GitLabInstance,
issue: GetIssueResponse,
projects: string[],
as: Appservice,
intent: Intent,
tokenStore: UserTokenStore,
commentProcessor: CommentProcessor,
messageSender: MessageSenderClient,
config: BridgeConfigGitLab,
) {
const state: GitLabIssueConnectionState = {
projects,
state: issue.state,
@ -76,7 +95,7 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
authorName: issue.author.name,
};
const roomId = await as.botClient.createRoom({
const roomId = await intent.underlyingClient.createRoom({
visibility: "private",
name: `${issue.references.full}`,
topic: GitLabIssueConnection.getTopicString(issue.author.name, issue.state),
@ -91,7 +110,7 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
],
});
return new GitLabIssueConnection(roomId, as, state, issue.web_url, tokenStore, commentProcessor, messageSender, instance, config);
return new GitLabIssueConnection(roomId, as, intent, state, issue.web_url, tokenStore, commentProcessor, messageSender, instance, config);
}
public get projectPath() {
@ -102,18 +121,21 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
return this.instance.url;
}
constructor(roomId: string,
constructor(
roomId: string,
private readonly as: Appservice,
private readonly intent: Intent,
private state: GitLabIssueConnectionState,
stateKey: string,
private tokenStore: UserTokenStore,
private commentProcessor: CommentProcessor,
private messageClient: MessageSenderClient,
private instance: GitLabInstance,
private config: BridgeConfigGitLab) {
super(roomId, stateKey, GitLabIssueConnection.CanonicalEventType);
}
private config: BridgeConfigGitLab,
) {
super(roomId, stateKey, GitLabIssueConnection.CanonicalEventType);
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return GitLabIssueConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
}
@ -141,13 +163,17 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
}, this.as, this.config.userIdPrefix);
const matrixEvent = await this.commentProcessor.getEventBodyForGitLabNote(event);
if (commentIntent.userId !== this.intent.userId) {
// Make sure ghost user is invited to the room
await this.intent.underlyingClient.inviteUser(commentIntent.userId, this.roomId);
}
await this.messageClient.sendMatrixMessage(this.roomId, matrixEvent, "m.room.message", commentIntent.userId);
}
public async onMatrixIssueComment(event: MatrixEvent<MatrixMessageContent>, allowEcho = false) {
const clientKit = await this.tokenStore.getGitLabForUser(event.sender, this.instanceUrl);
if (clientKit === null) {
await this.as.botClient.sendEvent(this.roomId, "m.reaction", {
await this.intent.underlyingClient.sendEvent(this.roomId, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: event.event_id,
@ -178,8 +204,8 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
public async onIssueReopened() {
// TODO: We don't store the author data.
this.state.state = "reopened";
await this.as.botClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey, this.state);
return this.as.botClient.sendStateEvent(this.roomId, "m.room.topic", "", {
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey, this.state);
return this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", {
topic: GitLabIssueConnection.getTopicString(this.state.authorName, this.state.state),
});
}
@ -187,8 +213,8 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
public async onIssueClosed() {
// TODO: We don't store the author data.
this.state.state = "closed";
await this.as.botClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey , this.state);
return this.as.botClient.sendStateEvent(this.roomId, "m.room.topic", "", {
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey , this.state);
return this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", {
topic: GitLabIssueConnection.getTopicString(this.state.authorName, this.state.state),
});
}
@ -206,4 +232,4 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection
public toString() {
return `GitLabIssue ${this.instanceUrl}/${this.projectPath}#${this.issueNumber}`;
}
}
}

View File

@ -1,7 +1,7 @@
// We need to instantiate some functions which are not directly called, which confuses typescript.
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { UserTokenStore } from "../UserTokenStore";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { BotCommands, botCommand, compileBotCommands } from "../BotCommands";
import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
import markdown from "markdown-it";
@ -59,7 +59,7 @@ const MRRCOMMENT_DEBOUNCE_MS = 5000;
export type GitLabRepoResponseItem = GetConnectionsResponseItem<GitLabRepoConnectionState>;
type AllowedEventsNames =
type AllowedEventsNames =
"merge_request.open" |
"merge_request.close" |
"merge_request.merge" |
@ -68,7 +68,7 @@ type AllowedEventsNames =
"merge_request.review.comments" |
`merge_request.${string}` |
"merge_request" |
"tag_push" |
"tag_push" |
"push" |
"wiki" |
`wiki.${string}` |
@ -170,7 +170,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
GitLabRepoConnection.CanonicalEventType,
GitLabRepoConnection.LegacyCanonicalEventType,
];
static botCommands: BotCommands;
static helpMessage: (cmdPrefix?: string | undefined) => MatrixMessageContent;
static ServiceCategory = "gitlab";
@ -198,7 +198,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
throw new ValidatorApiError(validator.errors);
}
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {as, tokenStore, config}: InstantiateConnectionOpts) {
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {intent, tokenStore, config}: InstantiateConnectionOpts) {
if (!config.gitlab) {
throw Error('GitLab is not configured');
}
@ -207,10 +207,10 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
if (!instance) {
throw Error('Instance name not recognised');
}
return new GitLabRepoConnection(roomId, event.stateKey, as, state, tokenStore, instance);
return new GitLabRepoConnection(roomId, event.stateKey, intent, state, tokenStore, instance);
}
public static async provisionConnection(roomId: string, requester: string, data: Record<string, unknown>, { config, as, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts) {
public static async provisionConnection(roomId: string, requester: string, data: Record<string, unknown>, { config, intent, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts) {
if (!config.gitlab) {
throw Error('GitLab is not configured');
}
@ -234,11 +234,11 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
if (permissionLevel < AccessLevel.Developer) {
throw new ApiError("You must at least have developer access to bridge this project", ErrCode.ForbiddenUser);
}
const project = await client.projects.get(validData.path);
const stateEventKey = `${validData.instance}/${validData.path}`;
const connection = new GitLabRepoConnection(roomId, stateEventKey, as, validData, tokenStore, instance);
const connection = new GitLabRepoConnection(roomId, stateEventKey, intent, validData, tokenStore, instance);
const existingConnections = getAllConnectionsOfType(GitLabRepoConnection);
const existing = existingConnections.find(c => c.roomId === roomId && c.instance.url === connection.instance.url && c.path === connection.path);
@ -278,7 +278,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
};
log.warn(`Not creating webhook, permission level is insufficient (${permissionLevel} < ${AccessLevel.Maintainer})`)
}
await as.botIntent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, connection.stateKey, validData);
await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, connection.stateKey, validData);
return {connection, warning};
}
@ -312,7 +312,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
if (!client) {
throw new ApiError('Instance is not known or you do not have access to it.', ErrCode.NotFound);
}
const after = filters.after === undefined ? undefined : parseInt(filters.after, 10);
const after = filters.after === undefined ? undefined : parseInt(filters.after, 10);
const allProjects = await client.projects.list(AccessLevel.Developer, filters.parent, after, filters.search);
return allProjects.map(p => ({
state: {
@ -340,21 +340,23 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
private readonly mergeRequestSeenDiscussionIds = new QuickLRU<string, undefined>({ maxSize: 100 });
private readonly hookFilter: HookFilter<AllowedEventsNames>;
constructor(roomId: string,
constructor(
roomId: string,
stateKey: string,
private readonly as: Appservice,
private readonly intent: Intent,
state: ConnectionStateValidated,
private readonly tokenStore: UserTokenStore,
private readonly instance: GitLabInstance
) {
private readonly instance: GitLabInstance,
) {
super(
roomId,
stateKey,
GitLabRepoConnection.CanonicalEventType,
state,
as.botClient,
intent.underlyingClient,
GitLabRepoConnection.botCommands,
GitLabRepoConnection.helpMessage,
["gitlab"],
"!gl",
"gitlab",
)
@ -398,7 +400,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
public getProvisionerDetails(): GitLabRepoResponseItem {
return {
...GitLabRepoConnection.getProvisionerDetails(this.as.botUserId),
...GitLabRepoConnection.getProvisionerDetails(this.intent.userId),
id: this.connectionId,
config: {
...this.state,
@ -425,7 +427,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
});
const content = `Created issue #${res.iid}: [${res.web_url}](${res.web_url})`;
return this.as.botIntent.sendEvent(this.roomId,{
return this.intent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@ -445,7 +447,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
});
const content = `Created confidential issue #${res.iid}: [${res.web_url}](${res.web_url})`;
return this.as.botIntent.sendEvent(this.roomId,{
return this.intent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@ -481,7 +483,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
this.validateMREvent(event);
const orgRepoName = event.project.path_with_namespace;
const content = `**${event.user.username}** opened a new MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -497,7 +499,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
this.validateMREvent(event);
const orgRepoName = event.project.path_with_namespace;
const content = `**${event.user.username}** closed MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -513,7 +515,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
this.validateMREvent(event);
const orgRepoName = event.project.path_with_namespace;
const content = `**${event.user.username}** merged MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -545,7 +547,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
// Nothing changed, drop it.
return;
}
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -564,7 +566,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
}
const url = `${event.project.homepage}/-/tree/${tagname}`;
const content = `**${event.user_name}** pushed tag [\`${tagname}\`](${url}) for ${event.project.path_with_namespace}`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -585,7 +587,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
const tooManyCommits = event.total_commits_count > PUSH_MAX_COMMITS;
const displayedCommits = tooManyCommits ? 1 : Math.min(event.total_commits_count, PUSH_MAX_COMMITS);
// Take the top 5 commits. The array is ordered in reverse.
const commits = event.commits.reverse().slice(0,displayedCommits).map(commit => {
return `[\`${commit.id.slice(0,8)}\`](${event.project.homepage}/-/commit/${commit.id}) ${commit.title}${shouldName ? ` by ${commit.author.name}` : ""}`;
@ -603,14 +605,14 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
}
}
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
format: "org.matrix.custom.html",
});
}
public async onWikiPageEvent(data: IGitLabWebhookWikiPageEvent) {
const attributes = data.object_attributes;
if (this.hookFilter.shouldSkip('wiki', `wiki.${attributes.action}`)) {
@ -630,7 +632,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
const message = attributes.message && ` "${attributes.message}"`;
const content = `**${data.user.username}** ${statement} "[${attributes.title}](${attributes.url})" for ${data.project.path_with_namespace} ${message}`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -647,7 +649,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
const content = `**${data.commit.author.name}** 🪄 released [${data.name}](${data.url}) for ${orgRepoName}
${data.description}`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@ -684,7 +686,7 @@ ${data.description}`;
content += "\n\n> " + result.commentNotes.join("\n\n> ");
}
this.as.botIntent.sendEvent(this.roomId, {
this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -746,7 +748,7 @@ ${data.description}`;
}
this.debounceMergeRequestReview(
event.user,
event.object_attributes,
event.object_attributes,
event.project,
{
commentCount: 0,
@ -810,7 +812,7 @@ ${data.description}`;
// Apply previous state to the current config, as provisioners might not return "unknown" keys.
config = { ...this.state, ...config };
const validatedConfig = GitLabRepoConnection.validateState(config);
await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, validatedConfig);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, validatedConfig);
this.state = validatedConfig;
this.hookFilter.enabledHooks = this.state.enableHooks;
}
@ -819,11 +821,11 @@ ${data.description}`;
log.info(`Removing ${this.toString()} for ${this.roomId}`);
// Do a sanity check that the event exists.
try {
await this.as.botClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
await this.as.botClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
// TODO: Clean up webhooks
}

View File

@ -1,7 +1,7 @@
import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types";
import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api";
import { Appservice, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
import { BridgeConfig, BridgePermissionLevel } from "../Config/Config";
import { UserTokenStore } from "../UserTokenStore";
import { CommentProcessor } from "../CommentProcessor";
@ -88,6 +88,7 @@ export const ConnectionDeclarations: Array<ConnectionDeclaration> = [];
export interface InstantiateConnectionOpts {
as: Appservice,
intent: Intent,
config: BridgeConfig,
tokenStore: UserTokenStore,
commentProcessor: CommentProcessor,

View File

@ -1,5 +1,5 @@
import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
import { Appservice, StateEvent } from "matrix-bot-sdk";
import { Appservice, Intent, StateEvent } from "matrix-bot-sdk";
import { Logger } from "matrix-appservice-bridge";
import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "../Jira/WebhookTypes";
import { FormatUtil } from "../FormatUtil";
@ -106,7 +106,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
static botCommands: BotCommands;
static helpMessage: (cmdPrefix?: string) => MatrixMessageContent;
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown>, {as, tokenStore, config}: ProvisionConnectionOpts) {
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown>, {as, intent, tokenStore, config}: ProvisionConnectionOpts) {
if (!config.jira) {
throw new ApiError('JIRA integration is not configured', ErrCode.DisabledFeature);
}
@ -120,7 +120,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
if (!jiraResourceClient) {
throw new ApiError("User is not authenticated with this JIRA instance", ErrCode.ForbiddenUser);
}
const connection = new JiraProjectConnection(roomId, as, validData, validData.url, tokenStore);
const connection = new JiraProjectConnection(roomId, as, intent, validData, validData.url, tokenStore);
log.debug(`projectKey for ${validData.url} is ${connection.projectKey}`);
if (!connection.projectKey) {
throw Error('Expected projectKey to be defined');
@ -136,19 +136,19 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
} catch (ex) {
throw new ApiError("Requested project was not found", ErrCode.ForbiddenUser);
}
await as.botIntent.underlyingClient.sendStateEvent(roomId, JiraProjectConnection.CanonicalEventType, connection.stateKey, validData);
await intent.underlyingClient.sendStateEvent(roomId, JiraProjectConnection.CanonicalEventType, connection.stateKey, validData);
log.info(`Created connection via provisionConnection ${connection.toString()}`);
return {connection};
}
static createConnectionForState(roomId: string, state: StateEvent<Record<string, unknown>>, {config, as, tokenStore}: InstantiateConnectionOpts) {
static createConnectionForState(roomId: string, state: StateEvent<Record<string, unknown>>, {config, as, intent, 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);
return new JiraProjectConnection(roomId, as, intent, connectionConfig, state.stateKey, tokenStore);
}
public get projectId() {
return this.state.id;
}
@ -194,36 +194,39 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
}
/**
* The URL of the project
* The URL of the project
* @example https://test.atlassian.net/jira/software/c/projects/PLAY
*/
private projectUrl?: URL;
constructor(roomId: string,
constructor(
roomId: string,
private readonly as: Appservice,
private readonly intent: Intent,
state: JiraProjectConnectionState,
stateKey: string,
private readonly tokenStore: UserTokenStore,) {
super(
roomId,
stateKey,
JiraProjectConnection.CanonicalEventType,
state,
as.botClient,
JiraProjectConnection.botCommands,
JiraProjectConnection.helpMessage,
"!jira",
"jira"
);
if (state.url) {
this.projectUrl = new URL(state.url);
} else if (state.id) {
log.warn(`Legacy ID option in use, needs to be switched to 'url'`);
} else {
throw Error('State is missing both id and url, cannot create connection');
}
private readonly tokenStore: UserTokenStore,
) {
super(
roomId,
stateKey,
JiraProjectConnection.CanonicalEventType,
state,
intent.underlyingClient,
JiraProjectConnection.botCommands,
JiraProjectConnection.helpMessage,
["jira"],
"!jira",
"jira"
);
if (state.url) {
this.projectUrl = new URL(state.url);
} else if (state.id) {
log.warn(`Legacy ID option in use, needs to be switched to 'url'`);
} else {
throw Error('State is missing both id and url, cannot create connection');
}
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return JiraProjectConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
@ -248,7 +251,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
}
const url = generateJiraWebLinkFromIssue(data.issue);
const content = `${creator.displayName} created a new JIRA issue [${data.issue.key}](${url}): "${data.issue.fields.summary}"`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -262,14 +265,13 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
service: "jira",
eventType: JiraProjectConnection.CanonicalEventType,
type: "JiraProject",
// TODO: Add ability to configure the bot per connnection type.
botUserId: botUserId,
}
}
public getProvisionerDetails(): JiraProjectResponseItem {
return {
...JiraProjectConnection.getProvisionerDetails(this.as.botUserId),
...JiraProjectConnection.getProvisionerDetails(this.intent.userId),
id: this.connectionId,
config: {
...this.state,
@ -350,8 +352,8 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
} else {
content += `\n - ` + changes.join(`\n - `);
}
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -375,7 +377,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
(this.projectKey && this.projectUrl ? ` for project [${this.projectKey}](${this.projectUrl})` : "") +
`: [${data.version.name}](${url}) (_${data.version.description}_)`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -441,7 +443,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
const link = generateJiraWebLinkFromIssue({self: this.projectUrl?.toString() || result.self, key: result.key as string});
const content = `Created JIRA issue ${result.key}: [${link}](${link})`;
return this.as.botIntent.sendEvent(this.roomId,{
return this.intent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@ -465,7 +467,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
}
const content = `Issue types: ${(result.issueTypes || []).map((t) => t.name).join(', ')}`;
return this.as.botIntent.sendEvent(this.roomId,{
return this.intent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@ -498,11 +500,11 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
log.info(`Removing ${this.toString()} for ${this.roomId}`);
// Do a sanity check that the event exists.
try {
await this.as.botClient.getRoomStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
await this.as.botClient.getRoomStateEvent(this.roomId, JiraProjectConnection.LegacyCanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, JiraProjectConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, JiraProjectConnection.LegacyCanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, JiraProjectConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
}
@ -513,7 +515,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
if (!validatedConfig.id) {
await this.updateProjectId(validatedConfig, userId);
}
await this.as.botClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, validatedConfig);
await this.intent.underlyingClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, validatedConfig);
this.state = validatedConfig;
}
@ -540,4 +542,4 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = compileBotCommands(JiraProjectConnection.prototype as any, CommandConnection.prototype as any);
JiraProjectConnection.helpMessage = res.helpMessage;
JiraProjectConnection.botCommands = res.botCommands;
JiraProjectConnection.botCommands = res.botCommands;

View File

@ -14,6 +14,7 @@ import { AdminRoom } from "../AdminRoom";
import { GitLabRepoConnection } from "./GitlabRepo";
import { IConnection, IConnectionState, ProvisionConnectionOpts } from "./IConnection";
import { ApiError, Logger } from "matrix-appservice-bridge";
import { Intent } from "matrix-bot-sdk";
const md = new markdown();
const log = new Logger("SetupConnection");
@ -22,7 +23,7 @@ const log = new Logger("SetupConnection");
* no state, and is only invoked when messages from other clients fall through.
*/
export class SetupConnection extends CommandConnection {
static botCommands: BotCommands;
static helpMessage: HelpFunction;
@ -34,36 +35,41 @@ export class SetupConnection extends CommandConnection {
return this.provisionOpts.as;
}
private get intent() {
return this.provisionOpts.intent;
}
private get client() {
return this.intent.underlyingClient;
}
protected validateConnectionState(content: unknown) {
log.warn("SetupConnection has no state to be validated");
return content as IConnectionState;
}
constructor(public readonly roomId: string,
constructor(
readonly roomId: string,
readonly prefix: string,
readonly serviceTypes: string[],
readonly helpCategories: string[],
private readonly provisionOpts: ProvisionConnectionOpts,
private readonly getOrCreateAdminRoom: (userId: string) => Promise<AdminRoom>,
private readonly pushConnections: (...connections: IConnection[]) => void) {
super(
roomId,
"",
"",
// TODO Consider storing room-specific config in state.
{},
provisionOpts.as.botClient,
SetupConnection.botCommands,
SetupConnection.helpMessage,
"!hookshot",
)
this.enabledHelpCategories = [
this.config.github ? "github" : "",
this.config.gitlab ? "gitlab": "",
this.config.figma ? "figma": "",
this.config.jira ? "jira": "",
this.config.generic?.enabled ? "webhook": "",
this.config.feeds?.enabled ? "feed" : "",
this.config.widgets?.roomSetupWidget ? "widget" : "",
];
this.includeTitlesInHelp = false;
private readonly getOrCreateAdminRoom: (intent: Intent, userId: string) => Promise<AdminRoom>,
private readonly pushConnections: (...connections: IConnection[]) => void,
) {
super(
roomId,
"",
"",
// TODO Consider storing room-specific config in state.
{},
provisionOpts.intent.underlyingClient,
SetupConnection.botCommands,
SetupConnection.helpMessage,
helpCategories,
prefix,
);
this.includeTitlesInHelp = false;
}
@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"})
@ -84,7 +90,7 @@ export class SetupConnection extends CommandConnection {
const [, org, repo] = urlParts;
const {connection} = await GitHubRepoConnection.provisionConnection(this.roomId, userId, {org, repo}, this.provisionOpts);
this.pushConnections(connection);
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.org}/${connection.repo}`);
await this.client.sendNotice(this.roomId, `Room configured to bridge ${connection.org}/${connection.repo}`);
}
@botCommand("gitlab project", { help: "Create a connection for a GitHub project. (You must be logged in with GitLab to do this.)", requiredArgs: ["url"], includeUserId: true, category: "gitlab"})
@ -110,7 +116,7 @@ export class SetupConnection extends CommandConnection {
}
const {connection, warning} = await GitLabRepoConnection.provisionConnection(this.roomId, userId, {path, instance: name}, this.provisionOpts);
this.pushConnections(connection);
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.prettyPath}` + (warning ? `\n${warning.header}: ${warning.message}` : ""));
await this.client.sendNotice(this.roomId, `Room configured to bridge ${connection.prettyPath}` + (warning ? `\n${warning.header}: ${warning.message}` : ""));
}
private async checkJiraLogin(userId: string, urlStr: string) {
@ -142,12 +148,12 @@ export class SetupConnection extends CommandConnection {
const res = await JiraProjectConnection.provisionConnection(this.roomId, userId, { url: safeUrl }, this.provisionOpts);
this.pushConnections(res.connection);
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}.`);
await this.client.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}.`);
}
@botCommand("jira list project", { help: "Show JIRA projects currently connected to.", category: "jira"})
public async onJiraListProject() {
const projects: JiraProjectConnectionState[] = await this.as.botClient.getRoomState(this.roomId).catch((err: any) => {
const projects: JiraProjectConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => {
if (err.body.errcode === 'M_NOT_FOUND') {
return []; // not an error to us
}
@ -162,9 +168,9 @@ export class SetupConnection extends CommandConnection {
);
if (projects.length === 0) {
return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline('Not connected to any JIRA projects'));
return this.client.sendHtmlNotice(this.roomId, md.renderInline('Not connected to any JIRA projects'));
} else {
return this.as.botClient.sendHtmlNotice(this.roomId, md.render(
return this.client.sendHtmlNotice(this.roomId, md.render(
'Currently connected to these JIRA projects:\n\n' +
projects.map(project => ` - ${project.url}`).join('\n')
));
@ -185,7 +191,7 @@ export class SetupConnection extends CommandConnection {
let eventType = "";
for (eventType of eventTypes) {
try {
event = await this.as.botClient.getRoomStateEvent(this.roomId, eventType, safeUrl);
event = await this.client.getRoomStateEvent(this.roomId, eventType, safeUrl);
break;
} catch (err: any) {
if (err.body.errcode !== 'M_NOT_FOUND') {
@ -197,11 +203,11 @@ export class SetupConnection extends CommandConnection {
throw new CommandError("Invalid Jira project URL", `Feed "${urlStr}" is not currently bridged to this room`);
}
await this.as.botClient.sendStateEvent(this.roomId, eventType, safeUrl, {});
return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room no longer bridged to Jira project \`${safeUrl}\`.`));
await this.client.sendStateEvent(this.roomId, eventType, safeUrl, {});
return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room no longer bridged to Jira project \`${safeUrl}\`.`));
}
@botCommand("webhook", { help: "Create an inbound webhook.", requiredArgs: ["name"], includeUserId: true, category: "webhook"})
@botCommand("webhook", { help: "Create an inbound webhook.", requiredArgs: ["name"], includeUserId: true, category: "generic"})
public async onWebhook(userId: string, name: string) {
if (!this.config.generic?.enabled) {
throw new CommandError("not-configured", "The bridge is not configured to support webhooks.");
@ -215,9 +221,9 @@ export class SetupConnection extends CommandConnection {
const c = await GenericHookConnection.provisionConnection(this.roomId, userId, {name}, this.provisionOpts);
this.pushConnections(c.connection);
const url = new URL(c.connection.hookId, this.config.generic.parsedUrlPrefix);
const adminRoom = await this.getOrCreateAdminRoom(userId);
const adminRoom = await this.getOrCreateAdminRoom(this.intent, userId);
await adminRoom.sendNotice(`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.client.sendNotice(this.roomId, `Room configured to bridge webhooks. See admin room for secret url.`);
}
@botCommand("figma file", { help: "Bridge a Figma file to the room.", requiredArgs: ["url"], includeUserId: true, category: "figma"})
@ -235,10 +241,10 @@ export class SetupConnection extends CommandConnection {
const [, fileId] = res;
const {connection} = await FigmaFileConnection.provisionConnection(this.roomId, userId, { fileId }, this.provisionOpts);
this.pushConnections(connection);
return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge Figma file.`));
return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge Figma file.`));
}
@botCommand("feed", { help: "Bridge an RSS/Atom feed to the room.", requiredArgs: ["url"], optionalArgs: ["label"], includeUserId: true, category: "feed"})
@botCommand("feed", { help: "Bridge an RSS/Atom feed to the room.", requiredArgs: ["url"], optionalArgs: ["label"], includeUserId: true, category: "feeds"})
public async onFeed(userId: string, url: string, label?: string) {
if (!this.config.feeds?.enabled) {
throw new CommandError("not-configured", "The bridge is not configured to support feeds.");
@ -260,12 +266,12 @@ export class SetupConnection extends CommandConnection {
const {connection} = await FeedConnection.provisionConnection(this.roomId, userId, { url, label }, this.provisionOpts);
this.pushConnections(connection);
return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge \`${url}\``));
return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge \`${url}\``));
}
@botCommand("feed list", { help: "Show feeds currently subscribed to.", category: "feed"})
@botCommand("feed list", { help: "Show feeds currently subscribed to.", category: "feeds"})
public async onFeedList() {
const feeds: FeedConnectionState[] = await this.as.botClient.getRoomState(this.roomId).catch((err: any) => {
const feeds: FeedConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => {
if (err.body.errcode === 'M_NOT_FOUND') {
return []; // not an error to us
}
@ -277,7 +283,7 @@ export class SetupConnection extends CommandConnection {
);
if (feeds.length === 0) {
return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline('Not subscribed to any feeds'));
return this.client.sendHtmlNotice(this.roomId, md.renderInline('Not subscribed to any feeds'));
} else {
const feedDescriptions = feeds.map(feed => {
if (feed.label) {
@ -286,18 +292,18 @@ export class SetupConnection extends CommandConnection {
return feed.url;
});
return this.as.botClient.sendHtmlNotice(this.roomId, md.render(
return this.client.sendHtmlNotice(this.roomId, md.render(
'Currently subscribed to these feeds:\n\n' +
feedDescriptions.map(desc => ` - ${desc}`).join('\n')
));
}
}
@botCommand("feed remove", { help: "Unsubscribe from an RSS/Atom feed.", requiredArgs: ["url"], includeUserId: true, category: "feed"})
@botCommand("feed remove", { help: "Unsubscribe from an RSS/Atom feed.", requiredArgs: ["url"], includeUserId: true, category: "feeds"})
public async onFeedRemove(userId: string, url: string) {
await this.checkUserPermissions(userId, "feed", FeedConnection.CanonicalEventType);
const event = await this.as.botClient.getRoomStateEvent(this.roomId, FeedConnection.CanonicalEventType, url).catch((err: any) => {
const event = await this.client.getRoomStateEvent(this.roomId, FeedConnection.CanonicalEventType, url).catch((err: any) => {
if (err.body.errcode === 'M_NOT_FOUND') {
return null; // not an error to us
}
@ -307,8 +313,8 @@ export class SetupConnection extends CommandConnection {
throw new CommandError("Invalid feed URL", `Feed "${url}" is not currently bridged to this room`);
}
await this.as.botClient.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, url, {});
return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from \`${url}\``));
await this.client.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, url, {});
return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from \`${url}\``));
}
@botCommand("setup-widget", {category: "widget", help: "Open the setup widget in the room"})
@ -316,8 +322,8 @@ export class SetupConnection extends CommandConnection {
if (!this.config.widgets?.roomSetupWidget) {
throw new CommandError("Not configured", "The bridge is not configured to support setup widgets");
}
if (!await SetupWidget.SetupRoomConfigWidget(this.roomId, this.as.botIntent, this.config.widgets)) {
await this.as.botClient.sendNotice(this.roomId, `This room already has a setup widget, please open the "Hookshot Configuration" widget.`);
if (!await SetupWidget.SetupRoomConfigWidget(this.roomId, this.intent, this.config.widgets, this.serviceTypes)) {
await this.client.sendNotice(this.roomId, `This room already has a setup widget, please open the "Hookshot Configuration" widget.`);
}
}
@ -325,10 +331,10 @@ export class SetupConnection extends CommandConnection {
if (!this.config.checkPermission(userId, service, BridgePermissionLevel.manageConnections)) {
throw new CommandError(`You are not permitted to provision connections for ${service}.`);
}
if (!await this.as.botClient.userHasPowerLevelFor(userId, this.roomId, "", true)) {
if (!await this.client.userHasPowerLevelFor(userId, this.roomId, "", true)) {
throw new CommandError("not-configured", "You must be able to set state in a room ('Change settings') in order to set up new integrations.");
}
if (!await this.as.botClient.userHasPowerLevelFor(this.as.botUserId, this.roomId, stateEventType, true)) {
if (!await this.client.userHasPowerLevelFor(this.intent.userId, this.roomId, stateEventType, true)) {
throw new CommandError("Bot lacks power level to set room state", "I do not have permission to set up a bridge in this room. Please promote me to an Admin/Moderator.");
}
}

View File

@ -0,0 +1,217 @@
import { Appservice, IAppserviceRegistration, Intent } from "matrix-bot-sdk";
import { Logger } from "matrix-appservice-bridge";
import { BridgeConfig } from "../Config/Config";
const log = new Logger("BotUsersManager");
export class BotUser {
constructor(
private readonly as: Appservice,
readonly userId: string,
readonly services: string[],
readonly prefix: string,
// Bots with higher priority should handle a command first
readonly priority: number,
readonly avatar?: string,
readonly displayname?: string,
) {}
get intent(): Intent {
return this.as.getIntentForUserId(this.userId);
}
}
// Sort bot users by highest priority first.
const higherPriority: (a: BotUser, b: BotUser) => number = (a, b) => (b.priority - a.priority);
export default class BotUsersManager {
// Map of user ID to config for all our configured bot users
private _botUsers = new Map<string, BotUser>();
// Map of room ID to set of bot users in the room
private _botsInRooms = new Map<string, Set<BotUser>>();
constructor(
readonly config: BridgeConfig,
readonly as: Appservice,
) {
// Default bot user
this._botUsers.set(
this.as.botUserId,
new BotUser(
this.as,
this.as.botUserId,
// Default bot can handle all services
this.config.enabledServices,
"!hookshot",
0,
this.config.bot?.avatar,
this.config.bot?.displayname,
)
);
// Service bot users
if (this.config.serviceBots) {
this.config.serviceBots.forEach(bot => {
const botUserId = this.as.getUserId(bot.localpart);
this._botUsers.set(
botUserId,
new BotUser(
this.as,
botUserId,
[bot.service],
bot.prefix,
// Service bots should handle commands first
1,
bot.avatar,
bot.displayname,
)
);
});
}
}
async start(): Promise<void> {
await this.ensureProfiles();
await this.getJoinedRooms();
}
private async ensureProfiles(): Promise<void> {
log.info("Ensuring bot users are set up...");
for (const botUser of this.botUsers) {
// Ensure the bot is registered
log.debug(`Ensuring bot user ${botUser.userId} is registered`);
await botUser.intent.ensureRegistered();
// Set up the bot profile
let profile;
try {
profile = await botUser.intent.underlyingClient.getUserProfile(botUser.userId);
} catch {
profile = {}
}
if (botUser.avatar && profile.avatar_url !== botUser.avatar) {
log.info(`Setting avatar for "${botUser.userId}" to ${botUser.avatar}`);
await botUser.intent.underlyingClient.setAvatarUrl(botUser.avatar);
}
if (botUser.displayname && profile.displayname !== botUser.displayname) {
log.info(`Setting displayname for "${botUser.userId}" to ${botUser.displayname}`);
await botUser.intent.underlyingClient.setDisplayName(botUser.displayname);
}
}
}
private async getJoinedRooms(): Promise<void> {
log.info("Getting joined rooms...");
for (const botUser of this.botUsers) {
const joinedRooms = await botUser.intent.underlyingClient.getJoinedRooms();
for (const roomId of joinedRooms) {
this.onRoomJoin(botUser, roomId);
}
}
}
/**
* Records a bot user having joined a room.
*
* @param botUser
* @param roomId
*/
onRoomJoin(botUser: BotUser, roomId: string): void {
log.info(`Bot user ${botUser.userId} joined room ${roomId}`);
const botUsers = this._botsInRooms.get(roomId) ?? new Set<BotUser>();
botUsers.add(botUser);
this._botsInRooms.set(roomId, botUsers);
}
/**
* Records a bot user having left a room.
*
* @param botUser
* @param roomId
*/
onRoomLeave(botUser: BotUser, roomId: string): void {
log.info(`Bot user ${botUser.userId} left room ${roomId}`);
const botUsers = this._botsInRooms.get(roomId) ?? new Set<BotUser>();
botUsers.delete(botUser);
if (botUsers.size > 0) {
this._botsInRooms.set(roomId, botUsers);
} else {
this._botsInRooms.delete(roomId);
}
}
/**
* Gets the list of room IDs where at least one bot is a member.
*
* @returns List of room IDs.
*/
get joinedRooms(): string[] {
return Array.from(this._botsInRooms.keys());
}
/**
* Gets the configured bot users, ordered by priority.
*
* @returns List of bot users.
*/
get botUsers(): BotUser[] {
return Array.from(this._botUsers.values())
.sort(higherPriority)
}
/**
* Gets a configured bot user by user ID.
*
* @param userId User ID to get.
*/
getBotUser(userId: string): BotUser | undefined {
return this._botUsers.get(userId);
}
/**
* Checks if the given user ID belongs to a configured bot user.
*
* @param userId User ID to check.
* @returns `true` if the user ID belongs to a bot user, otherwise `false`.
*/
isBotUser(userId: string): boolean {
return this._botUsers.has(userId);
}
/**
* Gets all the bot users in a room, ordered by priority.
*
* @param roomId Room ID to get bots for.
*/
getBotUsersInRoom(roomId: string): BotUser[] {
return Array.from(this._botsInRooms.get(roomId) || new Set<BotUser>())
.sort(higherPriority);
}
/**
* Gets a bot user in a room, optionally for a particular service.
* When a service is specified, the bot user with the highest priority which handles that service is returned.
*
* @param roomId Room ID to get a bot user for.
* @param serviceType Optional service type for the bot.
*/
getBotUserInRoom(roomId: string, serviceType?: string): BotUser | undefined {
const botUsersInRoom = this.getBotUsersInRoom(roomId);
if (serviceType) {
return botUsersInRoom.find(b => b.services.includes(serviceType));
} else {
return botUsersInRoom[0];
}
}
/**
* Gets the bot user with the highest priority for a particular service.
*
* @param serviceType Service type for the bot.
*/
getBotUserForService(serviceType: string): BotUser | undefined {
return this.botUsers.find(b => b.services.includes(serviceType));
}
}

View File

@ -7,8 +7,9 @@ import { GetConnectionsForServiceResponse } from "./BridgeWidgetInterface";
import { ProvisioningApi, ProvisioningRequest } from "matrix-appservice-bridge";
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
import { ConnectionManager } from "../ConnectionManager";
import BotUsersManager, {BotUser} from "../Managers/BotUsersManager";
import { assertUserPermissionsInRoom, GetConnectionsResponseItem } from "../provisioning/api";
import { Intent, PowerLevelsEvent } from "matrix-bot-sdk";
import { Appservice, PowerLevelsEvent } from "matrix-bot-sdk";
const log = new Logger("BridgeWidgetApi");
@ -20,7 +21,8 @@ export class BridgeWidgetApi {
storageProvider: IBridgeStorageProvider,
expressApp: Application,
private readonly connMan: ConnectionManager,
private readonly intent: Intent,
private readonly botUsersManager: BotUsersManager,
private readonly as: Appservice,
) {
this.api = new ProvisioningApi(
storageProvider,
@ -54,6 +56,14 @@ export class BridgeWidgetApi {
this.api.addRoute("get", '/v1/targets/:type', wrapHandler(this.getConnectionTargets));
}
private getBotUserInRoom(roomId: string, serviceType: string): BotUser {
const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType);
if (!botUser) {
throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom);
}
return botUser;
}
private async getRoomFromRequest(req: ProvisioningRequest): Promise<AdminRoom> {
const room = [...this.adminRooms.values()].find(r => r.userId === req.userId);
if (!room) {
@ -92,9 +102,13 @@ export class BridgeWidgetApi {
if (!req.userId) {
throw Error('Cannot get connections without a valid userId');
}
await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "read", this.intent);
const allConnections = this.connMan.getAllConnectionsForRoom(req.params.roomId as string);
const powerlevel = new PowerLevelsEvent({content: await this.intent.underlyingClient.getRoomStateEvent(req.params.roomId, "m.room.power_levels", "")});
const roomId = req.params.roomId;
const serviceType = req.params.service;
const botUser = this.getBotUserInRoom(roomId, serviceType);
await assertUserPermissionsInRoom(req.userId, roomId, "read", botUser.intent);
const allConnections = this.connMan.getAllConnectionsForRoom(roomId);
const powerlevel = new PowerLevelsEvent({content: await botUser.intent.underlyingClient.getRoomStateEvent(roomId, "m.room.power_levels", "")});
const serviceFilter = req.params.service;
const connections = allConnections.map(c => c.getProvisionerDetails?.(true))
.filter(c => !!c)
@ -128,13 +142,22 @@ export class BridgeWidgetApi {
if (!req.userId) {
throw Error('Cannot get connections without a valid userId');
}
await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "write", this.intent);
const roomId = req.params.roomId;
const eventType = req.params.type;
const connectionType = this.connMan.getConnectionTypeForEventType(eventType);
if (!connectionType) {
throw new ApiError("Unknown event type", ErrCode.NotFound);
}
const serviceType = connectionType.ServiceCategory;
const botUser = this.getBotUserInRoom(roomId, serviceType);
await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent);
try {
if (!req.body || typeof req.body !== "object") {
throw new ApiError("A JSON body must be provided", ErrCode.BadValue);
}
this.connMan.validateCommandPrefix(req.params.roomId, req.body);
const result = await this.connMan.provisionConnection(req.params.roomId, req.userId, req.params.type, req.body);
const result = await this.connMan.provisionConnection(roomId, botUser.intent, req.userId, connectionType, req.body);
if (!result.connection.getProvisionerDetails) {
throw new Error('Connection supported provisioning but not getProvisionerDetails');
}
@ -152,15 +175,20 @@ export class BridgeWidgetApi {
if (!req.userId) {
throw Error('Cannot get connections without a valid userId');
}
await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "write", this.intent);
const connection = this.connMan.getConnectionById(req.params.roomId as string, req.params.connectionId as string);
const roomId = req.params.roomId;
const serviceType = req.params.type;
const connectionId = req.params.connectionId;
const botUser = this.getBotUserInRoom(roomId, serviceType);
await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent);
const connection = this.connMan.getConnectionById(roomId, connectionId);
if (!connection) {
throw new ApiError("Connection does not exist", ErrCode.NotFound);
}
if (!connection.provisionerUpdateConfig || !connection.getProvisionerDetails) {
throw new ApiError("Connection type does not support updates", ErrCode.UnsupportedOperation);
}
this.connMan.validateCommandPrefix(req.params.roomId, req.body, connection);
this.connMan.validateCommandPrefix(roomId, req.body, connection);
await connection.provisionerUpdateConfig(req.userId, req.body);
res.send(connection.getProvisionerDetails(true));
}
@ -169,9 +197,12 @@ export class BridgeWidgetApi {
if (!req.userId) {
throw Error('Cannot get connections without a valid userId');
}
const roomId = req.params.roomId as string;
const connectionId = req.params.connectionId as string;
await assertUserPermissionsInRoom(req.userId, roomId, "write", this.intent);
const roomId = req.params.roomId;
const serviceType = req.params.type;
const connectionId = req.params.connectionId;
const botUser = this.getBotUserInRoom(roomId, serviceType);
await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent);
const connection = this.connMan.getConnectionById(roomId, connectionId);
if (!connection) {
throw new ApiError("Connection does not exist", ErrCode.NotFound);

View File

@ -16,15 +16,31 @@ export class SetupWidget {
return false;
}
static async SetupRoomConfigWidget(roomId: string, botIntent: Intent, config: BridgeWidgetConfig): Promise<boolean> {
if (await SetupWidget.createWidgetInRoom(roomId, botIntent, config, HookshotWidgetKind.RoomConfiguration, "hookshot_room_config")) {
static async SetupRoomConfigWidget(roomId: string, botIntent: Intent, config: BridgeWidgetConfig, serviceTypes: string[]): Promise<boolean> {
// If this is for a single service, scope the widget
const serviceScope = serviceTypes.length === 1 ? serviceTypes[0] : undefined;
if (await SetupWidget.createWidgetInRoom(
roomId,
botIntent,
config,
HookshotWidgetKind.RoomConfiguration,
`hookshot_room_config_${config.parsedPublicUrl.host}${serviceScope ? '_' + serviceScope : ''}`,
serviceScope,
)) {
await botIntent.sendText(roomId, `Please open the ${config.branding.widgetTitle} widget to set up integrations.`);
return true;
}
return false;
}
private static async createWidgetInRoom(roomId: string, botIntent: Intent, config: BridgeWidgetConfig, kind: HookshotWidgetKind, stateKey: string): Promise<boolean> {
private static async createWidgetInRoom(
roomId: string,
botIntent: Intent,
config: BridgeWidgetConfig,
kind: HookshotWidgetKind,
stateKey: string,
serviceScope?: string,
): Promise<boolean> {
log.info(`Running SetupRoomConfigWidget for ${roomId}`);
if (!await botIntent.underlyingClient.userHasPowerLevelFor(botIntent.userId, roomId, "im.vector.modular.widgets", true)) {
throw new CommandError("Bot lacks power level to set room state", "I do not have permission to create a widget in this room. Please promote me to an Admin/Moderator.");
@ -53,15 +69,15 @@ export class SetupWidget {
{
"creatorUserId": botIntent.userId,
"data": {
"title": config.branding.widgetTitle
"title": serviceScope ? serviceScope : config.branding.widgetTitle,
},
"id": stateKey,
"name": config.branding.widgetTitle,
"type": "m.custom",
"url": new URL(`#/?kind=${kind}&roomId=$matrix_room_id&widgetId=$matrix_widget_id`, config.parsedPublicUrl).href,
"url": new URL(`#/?kind=${kind}&roomId=$matrix_room_id&widgetId=$matrix_widget_id${serviceScope ? `&serviceScope=${serviceScope}` : ''}`, config.parsedPublicUrl).href,
"waitForIframeLoad": true,
}
);
return true;
}
}
}

View File

@ -4,8 +4,9 @@ import { ConnectionManager } from "../ConnectionManager";
import { Logger } from "matrix-appservice-bridge";
import { assertUserPermissionsInRoom, GetConnectionsResponseItem, GetConnectionTypeResponseItem } from "./api";
import { ApiError, ErrCode } from "../api";
import { Intent } from "matrix-bot-sdk";
import { Appservice } from "matrix-bot-sdk";
import Metrics from "../Metrics";
import BotUsersManager from "../Managers/BotUsersManager";
const log = new Logger("Provisioner");
@ -19,7 +20,8 @@ export class Provisioner {
constructor(
private readonly config: BridgeConfigProvisioning,
private readonly connMan: ConnectionManager,
private readonly intent: Intent,
private readonly botUsersManager: BotUsersManager,
private readonly as: Appservice,
additionalRoutes: {route: string, router: Router}[]) {
if (!this.config.secret) {
throw Error('Missing secret in provisioning config');
@ -96,8 +98,14 @@ export class Provisioner {
private async checkUserPermission(requiredPermission: "read"|"write", req: Request<{roomId: string}, unknown, unknown, {userId: string}>, res: Response, next: NextFunction) {
const userId = req.query.userId;
const roomId = req.params.roomId;
const botUser = this.botUsersManager.getBotUserInRoom(roomId);
if (!botUser) {
throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom);
}
try {
await assertUserPermissionsInRoom(userId, roomId, requiredPermission, this.intent);
await assertUserPermissionsInRoom(userId, roomId, requiredPermission, botUser.intent);
next();
} catch (ex) {
next(ex);
@ -130,22 +138,38 @@ export class Provisioner {
}
private async putConnection(req: Request<{roomId: string, type: string}, unknown, Record<string, unknown>, {userId: string}>, res: Response<GetConnectionsResponseItem>, next: NextFunction) {
const roomId = req.params.roomId;
const userId = req.query.userId;
const eventType = req.params.type;
const connectionType = this.connMan.getConnectionTypeForEventType(eventType);
if (!connectionType) {
throw new ApiError("Unknown event type", ErrCode.NotFound);
}
const serviceType = connectionType.ServiceCategory;
// Need to figure out which connections are available
try {
if (!req.body || typeof req.body !== "object") {
throw new ApiError("A JSON body must be provided", ErrCode.BadValue);
}
this.connMan.validateCommandPrefix(req.params.roomId, req.body);
const result = await this.connMan.provisionConnection(req.params.roomId, req.query.userId, req.params.type, req.body);
this.connMan.validateCommandPrefix(roomId, req.body);
const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType);
if (!botUser) {
throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom);
}
const result = await this.connMan.provisionConnection(roomId, botUser.intent, userId, connectionType, req.body);
if (!result.connection.getProvisionerDetails) {
throw new Error('Connection supported provisioning but not getProvisionerDetails');
}
res.send({
...result.connection.getProvisionerDetails(true),
warning: result.warning,
});
} catch (ex) {
log.error(`Failed to create connection for ${req.params.roomId}`, ex);
log.error(`Failed to create connection for ${roomId}`, ex);
return next(ex);
}
}

View File

@ -10,7 +10,7 @@ import { IntentMock } from "./utils/IntentMock";
const ROOM_ID = "!foo:bar";
function createAdminRoom(data: any = {admin_user: "@admin:bar"}): [AdminRoom, IntentMock] {
const intent = IntentMock.create();
const intent = IntentMock.create("@admin:bar");
if (!data.admin_user) {
data.admin_user = "@admin:bar";
}
@ -28,4 +28,4 @@ describe("AdminRoom", () => {
content: AdminRoom.helpMessage(undefined, ["Github", "Gitlab", "Jira"]),
});
});
})
})

View File

@ -17,7 +17,9 @@ function createGenericHook(state: GenericHookConnectionState = {
const mq = new LocalMQ();
mq.subscribe('*');
const messageClient = new MessageSenderClient(mq);
const connection = new GenericHookConnection(ROOM_ID, state, "foobar", "foobar", messageClient, new BridgeConfigGenericWebhooks(config), AppserviceMock.create())
const as = AppserviceMock.create();
const intent = as.getIntentForUserId('@webhooks:example.test');
const connection = new GenericHookConnection(ROOM_ID, state, "foobar", "foobar", messageClient, new BridgeConfigGenericWebhooks(config), as, intent);
return [connection, mq];
}
@ -30,7 +32,7 @@ function handleMessage(mq: LocalMQ): Promise<IMatrixSendMessage> {
data: { 'eventId': '$foo:bar' },
});
r(msg.data as IMatrixSendMessage);
}));
}));
}
describe("GenericHookConnection", () => {

View File

@ -40,10 +40,12 @@ function createConnection(state: Record<string, unknown> = {}, isExistingState=f
});
mq.subscribe('*');
const as = AppserviceMock.create();
const intent = as.getIntentForUserId('@github:example.test');
const githubInstance = new GithubInstance("foo", "bar", new URL("https://github.com"));
const connection = new GitHubRepoConnection(
ROOM_ID,
as,
intent,
GitHubRepoConnection.validateState({
org: "a-fake-org",
repo: "a-fake-repo",
@ -56,7 +58,7 @@ function createConnection(state: Record<string, unknown> = {}, isExistingState=f
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
DefaultConfig.github!
);
return {connection, as};
return {connection, intent};
}
describe("GitHubRepoConnection", () => {
@ -127,15 +129,15 @@ describe("GitHubRepoConnection", () => {
});
describe("onIssueCreated", () => {
it("will handle a simple issue", async () => {
const { connection, as } = createConnection();
const { connection, intent } = createConnection();
await connection.onIssueCreated(GITHUB_ISSUE_CREATED_PAYLOAD as never);
// Statement text.
as.botIntent.expectEventBodyContains('**alice** created new issue', 0);
as.botIntent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, 0);
as.botIntent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, 0);
intent.expectEventBodyContains('**alice** created new issue', 0);
intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, 0);
intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, 0);
});
it("will filter out issues not matching includingLabels.", async () => {
const { connection, as } = createConnection({
const { connection, intent } = createConnection({
includingLabels: ["include-me"]
});
await connection.onIssueCreated({
@ -149,10 +151,10 @@ describe("GitHubRepoConnection", () => {
} as never);
// ..or issues with no labels
await connection.onIssueCreated(GITHUB_ISSUE_CREATED_PAYLOAD as never);
as.botIntent.expectNoEvent();
intent.expectNoEvent();
});
it("will filter out issues matching excludingLabels.", async () => {
const { connection, as } = createConnection({
const { connection, intent } = createConnection({
excludingLabels: ["exclude-me"]
});
await connection.onIssueCreated({
@ -164,10 +166,10 @@ describe("GitHubRepoConnection", () => {
}],
}
} as never);
as.botIntent.expectNoEvent();
intent.expectNoEvent();
});
it("will include issues matching includingLabels.", async () => {
const { connection, as } = createConnection({
const { connection, intent } = createConnection({
includingIssues: ["include-me"]
});
await connection.onIssueCreated({
@ -179,7 +181,7 @@ describe("GitHubRepoConnection", () => {
}],
}
} as never);
as.botIntent.expectEventBodyContains('**alice** created new issue', 0);
intent.expectEventBodyContains('**alice** created new issue', 0);
});
});
});
});

View File

@ -38,10 +38,11 @@ function createConnection(state: Record<string, unknown> = {}, isExistingState=f
});
mq.subscribe('*');
const as = AppserviceMock.create();
const intent = as.getIntentForUserId('@gitlab:example.test');
const connection = new GitLabRepoConnection(
ROOM_ID,
"state_key",
as,
intent,
GitLabRepoConnection.validateState({
instance: "bar",
path: "foo",
@ -52,7 +53,7 @@ function createConnection(state: Record<string, unknown> = {}, isExistingState=f
url: "https://gitlab.example.com"
},
);
return {connection, as};
return {connection, intent};
}
describe("GitLabRepoConnection", () => {
@ -126,15 +127,15 @@ describe("GitLabRepoConnection", () => {
});
describe("onIssueCreated", () => {
it("will handle a simple issue", async () => {
const { connection, as } = createConnection();
const { connection, intent } = createConnection();
await connection.onMergeRequestOpened(GITLAB_ISSUE_CREATED_PAYLOAD as never);
// Statement text.
as.botIntent.expectEventBodyContains('**alice** opened a new MR', 0);
as.botIntent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.url, 0);
as.botIntent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.title, 0);
intent.expectEventBodyContains('**alice** opened a new MR', 0);
intent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.url, 0);
intent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.title, 0);
});
it("will filter out issues not matching includingLabels.", async () => {
const { connection, as } = createConnection({
const { connection, intent } = createConnection({
includingLabels: ["include-me"]
});
await connection.onMergeRequestOpened({
@ -145,10 +146,10 @@ describe("GitLabRepoConnection", () => {
} as never);
// ..or issues with no labels
await connection.onMergeRequestOpened(GITLAB_ISSUE_CREATED_PAYLOAD as never);
as.botIntent.expectNoEvent();
intent.expectNoEvent();
});
it("will filter out issues matching excludingLabels.", async () => {
const { connection, as } = createConnection({
const { connection, intent } = createConnection({
excludingLabels: ["exclude-me"]
});
await connection.onMergeRequestOpened({
@ -157,10 +158,10 @@ describe("GitLabRepoConnection", () => {
title: "exclude-me",
}],
} as never);
as.botIntent.expectNoEvent();
intent.expectNoEvent();
});
it("will include issues matching includingLabels.", async () => {
const { connection, as } = createConnection({
const { connection, intent } = createConnection({
includingIssues: ["include-me"]
});
await connection.onMergeRequestOpened({
@ -169,7 +170,7 @@ describe("GitLabRepoConnection", () => {
title: "include-me",
}],
} as never);
as.botIntent.expectEventBodyContains('**alice** opened a new MR', 0);
intent.expectEventBodyContains('**alice** opened a new MR', 0);
});
});
});
});

View File

@ -1,21 +1,21 @@
import { IntentMock } from "./IntentMock";
export class AppserviceMock {
public readonly botIntent = IntentMock.create();
public readonly botIntent = IntentMock.create(`@bot:example.com`);
static create(){
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new this() as any;
}
get botUserId() {
return `@bot:example.com`;
return this.botIntent.userId;
}
get botClient() {
return this.botIntent.underlyingClient;
}
public getIntentForUserId() {
return IntentMock.create();
public getIntentForUserId(userId: string) {
return IntentMock.create(userId);
}
}

View File

@ -10,9 +10,11 @@ export class IntentMock {
public readonly underlyingClient = new MatrixClientMock();
public sentEvents: {roomId: string, content: any}[] = [];
static create(){
constructor(readonly userId: string) {}
static create(userId: string){
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new this() as any;
return new this(userId) as any;
}
sendText(roomId: string, noticeText: string, msgtype: string) {
@ -31,7 +33,7 @@ export class IntentMock {
content,
});
}
expectNoEvent() {
expect(this.sentEvents, 'Expected no events to be sent.').to.be.empty;
}