mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 13:17:08 +00:00
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:
parent
46467ac810
commit
9a7839ce42
1
changelog.d/573.feature
Normal file
1
changelog.d/573.feature
Normal 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.
|
@ -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
|
||||
#
|
||||
|
@ -27,3 +27,4 @@
|
||||
- [Workers](./advanced/workers.md)
|
||||
- [🔒 Encryption](./advanced/encryption.md)
|
||||
- [🪀 Widgets](./advanced/widgets.md)
|
||||
- [Service Bots](./advanced/service_bots.md)
|
||||
|
28
docs/advanced/service_bots.md
Normal file
28
docs/advanced/service_bots.md
Normal 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.
|
@ -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");
|
||||
|
360
src/Bridge.ts
360
src/Bridge.ts
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(",")}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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}}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
217
src/Managers/BotUsersManager.ts
Normal file
217
src/Managers/BotUsersManager.ts
Normal 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));
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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"]),
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
@ -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", () => {
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user