mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 21:19:13 +00:00
Add skeleton for admin room widget
This commit is contained in:
parent
4abf02da2a
commit
5c278a2b53
@ -39,6 +39,17 @@ logging:
|
|||||||
# Run openssl genpkey -out passkey.pem -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:4096
|
# Run openssl genpkey -out passkey.pem -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:4096
|
||||||
passFile: "passkey.pem"
|
passFile: "passkey.pem"
|
||||||
|
|
||||||
|
webhook:
|
||||||
|
port: 9000
|
||||||
|
|
||||||
|
widgets:
|
||||||
|
# The port to listen on for the widget API
|
||||||
|
port: 5000
|
||||||
|
# Public url that the widget API is reachable on
|
||||||
|
publicUrl: "https://example.com/bridgewidgets/"
|
||||||
|
# Add the widget to admin rooms
|
||||||
|
addToAdminRooms: true
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
displayname: "GitHub Bot"
|
displayname: "GitHub Bot"
|
||||||
avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
|
avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d
|
27
package.json
27
package.json
@ -9,39 +9,46 @@
|
|||||||
"private": false,
|
"private": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:web": "snowpack build",
|
"build:web": "snowpack build",
|
||||||
|
"build:app": "tsc --project tsconfig.json",
|
||||||
"dev:web": "snowpack dev",
|
"dev:web": "snowpack dev",
|
||||||
"build": "tsc --project tsconfig.json",
|
"build": "yarn run build:web && yarn run build:app",
|
||||||
"prepare": "yarn build",
|
"prepare": "yarn build",
|
||||||
"start": "node lib/App/BridgeApp.js",
|
"start": "node --require source-map-support/register lib/App/BridgeApp.js",
|
||||||
"start:app": "node lib/App/BridgeApp.js",
|
"start:app": "node --require source-map-support/register lib/App/BridgeApp.js",
|
||||||
"start:webhooks": "node lib/App/GithubWebhookApp.js",
|
"start:webhooks": "node --require source-map-support/register lib/App/GithubWebhookApp.js",
|
||||||
"start:matrixsender": "node lib/App/MatrixSenderApp.js",
|
"start:matrixsender": "node --require source-map-support/register lib/App/MatrixSenderApp.js",
|
||||||
"test": "mocha -r ts-node/register tests/*.ts",
|
"test": "mocha -r ts-node/register tests/*.ts",
|
||||||
"lint": "eslint -c .eslintrc.js src/**/*.ts"
|
"lint": "eslint -c .eslintrc.js src/**/*.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "^2.10.2",
|
"@octokit/auth-app": "2.10.2",
|
||||||
"@octokit/auth-token": "^2.4.3",
|
"@octokit/auth-token": "^2.4.4",
|
||||||
"@octokit/rest": "^18.0.9",
|
"@octokit/rest": "18.0.9",
|
||||||
"@prefresh/snowpack": "^2.2.0",
|
|
||||||
"axios": "^0.21.0",
|
"axios": "^0.21.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
|
"fontsource-open-sans": "^3.1.5",
|
||||||
"ioredis": "^4.19.2",
|
"ioredis": "^4.19.2",
|
||||||
"markdown-it": "^12.0.2",
|
"markdown-it": "^12.0.2",
|
||||||
"matrix-bot-sdk": "^0.5.8",
|
"matrix-bot-sdk": "^0.5.8",
|
||||||
|
"matrix-widget-api": "^0.1.0-beta.10",
|
||||||
"micromatch": "^4.0.2",
|
"micromatch": "^4.0.2",
|
||||||
"mime": "^2.4.6",
|
"mime": "^2.4.6",
|
||||||
|
"mini.css": "^3.0.1",
|
||||||
"mocha": "^8.2.1",
|
"mocha": "^8.2.1",
|
||||||
"node-emoji": "^1.10.0",
|
"node-emoji": "^1.10.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
|
"source-map-support": "^0.5.19",
|
||||||
"string-argv": "v0.3.1",
|
"string-argv": "v0.3.1",
|
||||||
"uuid": "^8.3.1",
|
"uuid": "^8.3.1",
|
||||||
"winston": "^3.3.3",
|
"winston": "^3.3.3",
|
||||||
"yaml": "^1.10.0"
|
"yaml": "^1.10.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@prefresh/snowpack": "^2.2.0",
|
||||||
"@snowpack/plugin-typescript": "^1.1.1",
|
"@snowpack/plugin-typescript": "^1.1.1",
|
||||||
"@types/chai": "^4.2.14",
|
"@types/chai": "^4.2.14",
|
||||||
|
"@types/cors": "^2.8.9",
|
||||||
"@types/express": "^4.17.9",
|
"@types/express": "^4.17.9",
|
||||||
"@types/ioredis": "^4.17.8",
|
"@types/ioredis": "^4.17.8",
|
||||||
"@types/markdown-it": "^10.0.3",
|
"@types/markdown-it": "^10.0.3",
|
||||||
@ -53,11 +60,13 @@
|
|||||||
"@types/uuid": "^8.3.0",
|
"@types/uuid": "^8.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.8.1",
|
"@typescript-eslint/eslint-plugin": "^4.8.1",
|
||||||
"@typescript-eslint/parser": "^4.8.1",
|
"@typescript-eslint/parser": "^4.8.1",
|
||||||
|
"autoprefixer": "^10.1.0",
|
||||||
"chai": "^4.2.0",
|
"chai": "^4.2.0",
|
||||||
"eslint": "^7.14.0",
|
"eslint": "^7.14.0",
|
||||||
"eslint-plugin-mocha": "^8.0.0",
|
"eslint-plugin-mocha": "^8.0.0",
|
||||||
"preact": "^10.5.7",
|
"preact": "^10.5.7",
|
||||||
"snowpack": "^2.18.3",
|
"snowpack": "^2.18.3",
|
||||||
|
"tailwind": "^4.0.0",
|
||||||
"ts-node": "^9.0.0",
|
"ts-node": "^9.0.0",
|
||||||
"typescript": "^4.1.2"
|
"typescript": "^4.1.2"
|
||||||
}
|
}
|
||||||
|
@ -1,17 +1,18 @@
|
|||||||
/** @type {import("snowpack").SnowpackUserConfig } */
|
/** @type {import("snowpack").SnowpackUserConfig } */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
mount: {
|
mount: {
|
||||||
"web/": '/',
|
"web/": '/'
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
['@snowpack/plugin-typescript', '--project tsconfig.web.json'],
|
'@prefresh/snowpack',
|
||||||
'@prefresh/snowpack',
|
['@snowpack/plugin-typescript', '--project tsconfig.web.json'],
|
||||||
],
|
],
|
||||||
install: [
|
install: [
|
||||||
/* ... */
|
/* ... */
|
||||||
],
|
],
|
||||||
installOptions: {
|
installOptions: {
|
||||||
installTypes: true,
|
installTypes: true,
|
||||||
|
polyfillNode: true,
|
||||||
},
|
},
|
||||||
devOptions: {
|
devOptions: {
|
||||||
/* ... */
|
/* ... */
|
||||||
|
@ -15,6 +15,7 @@ import { GetUserResponse } from "./Gitlab/Types";
|
|||||||
import { GithubInstance } from "./Github/GithubInstance";
|
import { GithubInstance } from "./Github/GithubInstance";
|
||||||
import { MatrixMessageContent } from "./MatrixEvent";
|
import { MatrixMessageContent } from "./MatrixEvent";
|
||||||
import { ProjectsListForUserResponseData, ProjectsListForRepoResponseData } from "@octokit/types";
|
import { ProjectsListForUserResponseData, ProjectsListForRepoResponseData } from "@octokit/types";
|
||||||
|
import { BridgeRoomState } from "./Widgets/BridgeWidgetInterface";
|
||||||
|
|
||||||
|
|
||||||
const md = new markdown();
|
const md = new markdown();
|
||||||
@ -44,6 +45,7 @@ export interface AdminAccountData {
|
|||||||
}
|
}
|
||||||
export class AdminRoom extends EventEmitter {
|
export class AdminRoom extends EventEmitter {
|
||||||
public static helpMessage: MatrixMessageContent;
|
public static helpMessage: MatrixMessageContent;
|
||||||
|
private widgetAccessToken = `abcdef`;
|
||||||
static botCommands: BotCommands;
|
static botCommands: BotCommands;
|
||||||
|
|
||||||
private pendingOAuthState: string|null = null;
|
private pendingOAuthState: string|null = null;
|
||||||
@ -54,6 +56,8 @@ export class AdminRoom extends EventEmitter {
|
|||||||
private tokenStore: UserTokenStore,
|
private tokenStore: UserTokenStore,
|
||||||
private config: BridgeConfig) {
|
private config: BridgeConfig) {
|
||||||
super();
|
super();
|
||||||
|
// TODO: Move this
|
||||||
|
this.backfillAccessToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
public get userId() {
|
public get userId() {
|
||||||
@ -64,6 +68,10 @@ export class AdminRoom extends EventEmitter {
|
|||||||
return this.pendingOAuthState;
|
return this.pendingOAuthState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public verifyWidgetAccessToken(token: string) {
|
||||||
|
return this.widgetAccessToken === token;
|
||||||
|
}
|
||||||
|
|
||||||
public notificationsEnabled(type: "github"|"gitlab", instanceName?: string) {
|
public notificationsEnabled(type: "github"|"gitlab", instanceName?: string) {
|
||||||
if (type === "github") {
|
if (type === "github") {
|
||||||
return this.data.github?.notifications?.enabled;
|
return this.data.github?.notifications?.enabled;
|
||||||
@ -140,6 +148,7 @@ export class AdminRoom extends EventEmitter {
|
|||||||
const octokit = GithubInstance.createUserOctokit(accessToken);
|
const octokit = GithubInstance.createUserOctokit(accessToken);
|
||||||
me = await octokit.users.getAuthenticated();
|
me = await octokit.users.getAuthenticated();
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
|
log.error("Failed to auth with GitHub", ex);
|
||||||
await this.sendNotice("Could not authenticate with GitHub. Is your token correct?");
|
await this.sendNotice("Could not authenticate with GitHub. Is your token correct?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -444,6 +453,77 @@ export class AdminRoom extends EventEmitter {
|
|||||||
// }
|
// }
|
||||||
// });
|
// });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getBridgeState(): Promise<BridgeRoomState> {
|
||||||
|
const gitHubEnabled = !!this.config.github;
|
||||||
|
const github: {enabled: boolean; tokenStored: boolean; identity: null|{name: string|null; avatarUrl: string|null}} = {
|
||||||
|
enabled: false,
|
||||||
|
tokenStored: false,
|
||||||
|
identity: null,
|
||||||
|
};
|
||||||
|
if (gitHubEnabled) {
|
||||||
|
const octokit = await this.tokenStore.getOctokitForUser(this.userId);
|
||||||
|
try {
|
||||||
|
const identity = await octokit?.users.getAuthenticated();
|
||||||
|
github.enabled = true;
|
||||||
|
github.tokenStored = !!octokit;
|
||||||
|
github.identity = {
|
||||||
|
name: identity?.data.login || null,
|
||||||
|
avatarUrl: identity?.data.avatar_url || null,
|
||||||
|
};
|
||||||
|
} catch (ex) {
|
||||||
|
log.warn(`Failed to get user identity: ${ex}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: "Admin Room",
|
||||||
|
github,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setupWidget() {
|
||||||
|
try {
|
||||||
|
const res = await this.botIntent.underlyingClient.getRoomStateEvent(this.roomId, "im.vector.modular.widgets", "bridge_control");
|
||||||
|
if (res) {
|
||||||
|
// No-op
|
||||||
|
// Validate?
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
// Didn't exist, create it.
|
||||||
|
}
|
||||||
|
const accessToken = uuid();
|
||||||
|
return this.botIntent.underlyingClient.sendStateEvent(
|
||||||
|
this.roomId,
|
||||||
|
"im.vector.modular.widgets",
|
||||||
|
"bridge_control",
|
||||||
|
{
|
||||||
|
"creatorUserId": this.botIntent.userId,
|
||||||
|
"data": {
|
||||||
|
"title": "Bridge Control"
|
||||||
|
},
|
||||||
|
"id": "bridge_control",
|
||||||
|
"name": "Bridge Control",
|
||||||
|
"type": "m.custom",
|
||||||
|
"url": `${this.config.widgets?.publicUrl}/#/?roomId=$matrix_room_id&widgetId=$matrix_widget_id&accessToken=${accessToken}`,
|
||||||
|
accessToken,
|
||||||
|
"waitForIframeLoad": true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async backfillAccessToken() {
|
||||||
|
try {
|
||||||
|
const res = await this.botIntent.underlyingClient.getRoomStateEvent(this.roomId, "im.vector.modular.widgets", "bridge_control");
|
||||||
|
if (res) {
|
||||||
|
log.debug(`Stored access token for widgets for ${this.roomId}`);
|
||||||
|
this.widgetAccessToken = res.accessToken;
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
log.info(`No widget access token for ${this.roomId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
@ -41,6 +41,12 @@ interface BridgeConfigGitLab {
|
|||||||
instances: {[name: string]: GitLabInstance};
|
instances: {[name: string]: GitLabInstance};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BridgeWidgetConfig {
|
||||||
|
port: number;
|
||||||
|
addToAdminRooms: boolean;
|
||||||
|
publicUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BridgeConfig {
|
export interface BridgeConfig {
|
||||||
github?: BridgeConfigGitHub;
|
github?: BridgeConfigGitHub;
|
||||||
gitlab?: BridgeConfigGitLab;
|
gitlab?: BridgeConfigGitLab;
|
||||||
@ -69,6 +75,7 @@ export interface BridgeConfig {
|
|||||||
displayname?: string;
|
displayname?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
}
|
}
|
||||||
|
widgets?: BridgeWidgetConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseRegistrationFile(filename: string) {
|
export async function parseRegistrationFile(filename: string) {
|
||||||
|
@ -26,7 +26,7 @@ import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNot
|
|||||||
import { GitLabIssueConnection } from "./Connections/GitlabIssue";
|
import { GitLabIssueConnection } from "./Connections/GitlabIssue";
|
||||||
import { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types"
|
import { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types"
|
||||||
import { GitLabClient } from "./Gitlab/Client";
|
import { GitLabClient } from "./Gitlab/Client";
|
||||||
// import { IGitLabWebhookMREvent } from "./Gitlab/WebhookTypes";
|
import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi";
|
||||||
|
|
||||||
const log = new LogWrapper("GithubBridge");
|
const log = new LogWrapper("GithubBridge");
|
||||||
|
|
||||||
@ -39,6 +39,7 @@ export class GithubBridge {
|
|||||||
private queue!: MessageQueue;
|
private queue!: MessageQueue;
|
||||||
private tokenStore!: UserTokenStore;
|
private tokenStore!: UserTokenStore;
|
||||||
private messageClient!: MessageSenderClient;
|
private messageClient!: MessageSenderClient;
|
||||||
|
private widgetApi!: BridgeWidgetApi;
|
||||||
|
|
||||||
private connections: IConnection[] = [];
|
private connections: IConnection[] = [];
|
||||||
|
|
||||||
@ -169,6 +170,8 @@ export class GithubBridge {
|
|||||||
storage,
|
storage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.widgetApi = new BridgeWidgetApi(this.adminRooms);
|
||||||
|
|
||||||
this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl);
|
this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl);
|
||||||
|
|
||||||
this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent);
|
this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent);
|
||||||
@ -392,7 +395,13 @@ export class GithubBridge {
|
|||||||
|
|
||||||
for (const roomId of joinedRooms) {
|
for (const roomId of joinedRooms) {
|
||||||
log.debug("Fetching state for " + roomId);
|
log.debug("Fetching state for " + roomId);
|
||||||
const connections = await this.createConnectionsForRoomId(roomId);
|
let connections: IConnection[];
|
||||||
|
try {
|
||||||
|
connections = await this.createConnectionsForRoomId(roomId);
|
||||||
|
} catch (ex) {
|
||||||
|
log.error(`Unable to create connection for ${roomId}`, ex);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
this.connections.push(...connections);
|
this.connections.push(...connections);
|
||||||
if (connections.length === 0) {
|
if (connections.length === 0) {
|
||||||
// TODO: Refactor this to be a connection
|
// TODO: Refactor this to be a connection
|
||||||
@ -400,7 +409,7 @@ export class GithubBridge {
|
|||||||
const accountData = await this.as.botIntent.underlyingClient.getRoomAccountData(
|
const accountData = await this.as.botIntent.underlyingClient.getRoomAccountData(
|
||||||
BRIDGE_ROOM_TYPE, roomId,
|
BRIDGE_ROOM_TYPE, roomId,
|
||||||
);
|
);
|
||||||
const adminRoom = this.setupAdminRoom(roomId, accountData);
|
const adminRoom = await this.setupAdminRoom(roomId, accountData);
|
||||||
// Call this on startup to set the state
|
// Call this on startup to set the state
|
||||||
await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user });
|
await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user });
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
@ -410,7 +419,9 @@ export class GithubBridge {
|
|||||||
log.info(`Room ${roomId} is connected to: ${connections.join(',')}`);
|
log.info(`Room ${roomId} is connected to: ${connections.join(',')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (this.config.widgets) {
|
||||||
|
await this.widgetApi.start(this.config.widgets.port);
|
||||||
|
}
|
||||||
await this.as.begin();
|
await this.as.begin();
|
||||||
log.info("Started bridge");
|
log.info("Started bridge");
|
||||||
}
|
}
|
||||||
@ -427,7 +438,7 @@ export class GithubBridge {
|
|||||||
}
|
}
|
||||||
await retry(() => this.as.botIntent.joinRoom(roomId), 5);
|
await retry(() => this.as.botIntent.joinRoom(roomId), 5);
|
||||||
if (event.content.is_direct) {
|
if (event.content.is_direct) {
|
||||||
const room = this.setupAdminRoom(roomId, {admin_user: event.sender});
|
const room = await this.setupAdminRoom(roomId, {admin_user: event.sender});
|
||||||
await this.as.botIntent.underlyingClient.setRoomAccountData(
|
await this.as.botIntent.underlyingClient.setRoomAccountData(
|
||||||
BRIDGE_ROOM_TYPE, roomId, room.data,
|
BRIDGE_ROOM_TYPE, roomId, room.data,
|
||||||
);
|
);
|
||||||
@ -652,7 +663,7 @@ export class GithubBridge {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupAdminRoom(roomId: string, accountData: AdminAccountData) {
|
private async setupAdminRoom(roomId: string, accountData: AdminAccountData) {
|
||||||
const adminRoom = new AdminRoom(
|
const adminRoom = new AdminRoom(
|
||||||
roomId, accountData, this.as.botIntent, this.tokenStore, this.config,
|
roomId, accountData, this.as.botIntent, this.tokenStore, this.config,
|
||||||
);
|
);
|
||||||
@ -680,6 +691,9 @@ export class GithubBridge {
|
|||||||
return this.as.botClient.inviteUser(adminRoom.userId, newConnection.roomId);
|
return this.as.botClient.inviteUser(adminRoom.userId, newConnection.roomId);
|
||||||
});
|
});
|
||||||
this.adminRooms.set(roomId, adminRoom);
|
this.adminRooms.set(roomId, adminRoom);
|
||||||
|
if (this.config.widgets?.addToAdminRooms && this.config.widgets.publicUrl) {
|
||||||
|
await adminRoom.setupWidget();
|
||||||
|
}
|
||||||
log.info(`Setup ${roomId} as an admin room for ${adminRoom.userId}`);
|
log.info(`Setup ${roomId} as an admin room for ${adminRoom.userId}`);
|
||||||
return adminRoom;
|
return adminRoom;
|
||||||
}
|
}
|
||||||
|
72
src/Widgets/BridgeWidgetApi.ts
Normal file
72
src/Widgets/BridgeWidgetApi.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { Application, default as express, Request, Response } from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import { AdminRoom } from "../AdminRoom";
|
||||||
|
import LogWrapper from "../LogWrapper";
|
||||||
|
|
||||||
|
const log = new LogWrapper("GithubBridge");
|
||||||
|
|
||||||
|
export class BridgeWidgetApi {
|
||||||
|
private app: Application;
|
||||||
|
constructor(private adminRooms: Map<string, AdminRoom>) {
|
||||||
|
this.app = express();
|
||||||
|
this.app.use((req, _res, next) => {
|
||||||
|
log.info(`${req.method} ${req.path} ${req.ip || ''} ${req.headers["user-agent"] || ''}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
this.app.use('/', express.static('public'));
|
||||||
|
this.app.use(cors());
|
||||||
|
this.app.get('/widgetapi/:roomId/verify', this.getVerifyToken.bind(this));
|
||||||
|
this.app.get('/widgetapi/:roomId', this.getRoomState.bind(this));
|
||||||
|
this.app.get('/health', this.getHealth.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
public start(port = 5000) {
|
||||||
|
log.info(`Widget API listening on port ${port}`)
|
||||||
|
this.app.listen(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRoomFromRequest(req: Request): Promise<AdminRoom|{error: string, statusCode: number}> {
|
||||||
|
const { roomId } = req.params;
|
||||||
|
const token = req.headers.authorization?.substr('Bearer '.length);
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
error: 'Access token not given',
|
||||||
|
statusCode: 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Replace with actual auth
|
||||||
|
const room = this.adminRooms.get(roomId);
|
||||||
|
if (!room || !room.verifyWidgetAccessToken(token)) {
|
||||||
|
return {error: 'Unauthorized access to room', statusCode: 401}
|
||||||
|
}
|
||||||
|
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private async getVerifyToken(req: Request, res: Response) {
|
||||||
|
const roomOrError = await this.getRoomFromRequest(req);
|
||||||
|
if (roomOrError instanceof AdminRoom) {
|
||||||
|
return res.sendStatus(204);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(roomOrError.statusCode).send({error: roomOrError.error});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRoomState(req: Request, res: Response) {
|
||||||
|
const roomOrError = await this.getRoomFromRequest(req);
|
||||||
|
if (!(roomOrError instanceof AdminRoom)) {
|
||||||
|
return res.status(roomOrError.statusCode).send({error: roomOrError.error});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return res.send(await roomOrError.getBridgeState());
|
||||||
|
} catch (ex) {
|
||||||
|
log.error(`Failed to get room state:`, ex);
|
||||||
|
return res.status(500).send({error: "An error occured when getting room state"});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHealth(req: Request, res: Response) {
|
||||||
|
res.status(200).send({ok: true});
|
||||||
|
}
|
||||||
|
}
|
11
src/Widgets/BridgeWidgetInterface.ts
Normal file
11
src/Widgets/BridgeWidgetInterface.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export interface BridgeRoomState {
|
||||||
|
title: string;
|
||||||
|
github: {
|
||||||
|
enabled: boolean;
|
||||||
|
tokenStored: boolean;
|
||||||
|
identity: {
|
||||||
|
name: string|null;
|
||||||
|
avatarUrl: string|null;
|
||||||
|
}|null;
|
||||||
|
}
|
||||||
|
}
|
@ -25,6 +25,9 @@
|
|||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
},
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"tests/**/*",
|
"tests/**/*",
|
||||||
"web/**/*"
|
"web/**/*"
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"include": ["src", "types"],
|
"include": ["web"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "esnext",
|
"module": "commonjs",
|
||||||
"target": "esnext",
|
"target": "es2019",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"jsxFactory": "h",
|
"jsxFactory": "h",
|
||||||
@ -17,7 +17,10 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"importsNotUsedAsValues": "error"
|
"importsNotUsedAsValues": "error",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"esModuleInterop": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
126
web/App.tsx
126
web/App.tsx
@ -1,37 +1,93 @@
|
|||||||
import { h } from 'preact';
|
import { h, Component } from 'preact';
|
||||||
import { useState, useEffect } from 'preact/hooks';
|
import WA from 'matrix-widget-api';
|
||||||
|
import BridgeAPI from './BridgeAPI';
|
||||||
function App() {
|
import { BridgeRoomState } from '../src/Widgets/BridgeWidgetInterface';
|
||||||
// Create the count state.
|
import ErrorPane from './components/ErrorPane';
|
||||||
const [count, setCount] = useState(0);
|
import AdminSettings from './components/AdminSettings';
|
||||||
// Create the counter (+1 every second).
|
interface IState {
|
||||||
useEffect(() => {
|
error: string|null,
|
||||||
const timer = setTimeout(() => setCount(count + 1), 1000);
|
busy: boolean,
|
||||||
return () => clearTimeout(timer);
|
roomId?: string,
|
||||||
}, [count, setCount]);
|
roomState?: BridgeRoomState;
|
||||||
// Return the App component.
|
|
||||||
return (
|
|
||||||
<div className="App">
|
|
||||||
<header className="App-header">
|
|
||||||
<p>
|
|
||||||
Edit <code>src/App.jsx</code> and save to reload.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Page has been open for <code>{count}</code> seconds.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a
|
|
||||||
className="App-link"
|
|
||||||
href="https://preactjs.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Learn Preact
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
function parseFragment() {
|
||||||
|
const fragmentString = (window.location.hash || "?");
|
||||||
|
return new URLSearchParams(fragmentString.substring(Math.max(fragmentString.indexOf('?'), 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertParam(fragment, name) {
|
||||||
|
const val = fragment.get(name);
|
||||||
|
if (!val) throw new Error(`${name} is not present in URL - cannot load widget`);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class App extends Component<void, IState> {
|
||||||
|
private widgetApi: WA.WidgetApi;
|
||||||
|
private bridgeApi: BridgeAPI;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.state = {
|
||||||
|
error: null,
|
||||||
|
busy: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async componentDidMount() {
|
||||||
|
try {
|
||||||
|
// Start widgeting
|
||||||
|
const qs = parseFragment();
|
||||||
|
const widgetId = assertParam(qs, 'widgetId');
|
||||||
|
const roomId = assertParam(qs, 'roomId');
|
||||||
|
const accessToken = assertParam(qs, 'accessToken');
|
||||||
|
this.bridgeApi = new BridgeAPI("http://localhost:5000", roomId, accessToken);
|
||||||
|
await this.bridgeApi.verify();
|
||||||
|
this.widgetApi = new WA.WidgetApi(widgetId);
|
||||||
|
this.widgetApi.on("ready", () => {
|
||||||
|
console.log("Widget ready:", this);
|
||||||
|
});
|
||||||
|
this.widgetApi.on(`action:${WA.WidgetApiToWidgetAction.NotifyCapabilities}`, (ev) => {
|
||||||
|
console.log(ev.detail.data.approved);
|
||||||
|
console.log(`${WA.WidgetApiToWidgetAction.NotifyCapabilities}`, ev);
|
||||||
|
})
|
||||||
|
this.widgetApi.on(`action:${WA.WidgetApiToWidgetAction.SendEvent}`, (ev) => {
|
||||||
|
console.log(`${WA.WidgetApiToWidgetAction.SendEvent}`, ev);
|
||||||
|
})
|
||||||
|
// Start the widget as soon as possible too, otherwise the client might time us out.
|
||||||
|
this.widgetApi.start();
|
||||||
|
const roomState = await this.bridgeApi.state();
|
||||||
|
console.log('Got state', roomState);
|
||||||
|
this.setState({
|
||||||
|
roomState,
|
||||||
|
roomId,
|
||||||
|
busy: false,
|
||||||
|
});
|
||||||
|
} catch (ex) {
|
||||||
|
console.error(`Bridge verifiation failed:`, ex);
|
||||||
|
this.setState({
|
||||||
|
error: ex.message,
|
||||||
|
busy: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
// Return the App component.
|
||||||
|
let content;
|
||||||
|
if (this.state.error) {
|
||||||
|
content = <ErrorPane>{this.state.error}</ErrorPane>;
|
||||||
|
} else if (this.state.roomState) {
|
||||||
|
content = <AdminSettings roomState={this.state.roomState}></AdminSettings>;
|
||||||
|
} else if (this.state.busy) {
|
||||||
|
content = <div class="spinner"></div>;
|
||||||
|
} else {
|
||||||
|
content = <b>Invalid state</b>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="App">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
41
web/BridgeAPI.ts
Normal file
41
web/BridgeAPI.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { BridgeRoomState } from '../src/Widgets/BridgeWidgetInterface';
|
||||||
|
|
||||||
|
export class BridgeAPIError extends Error {
|
||||||
|
constructor(msg: string, private body: Record<string, unknown>) {
|
||||||
|
super(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class BridgeAPI {
|
||||||
|
|
||||||
|
constructor(private baseUrl: string, private roomId: string, private accessToken: string) {
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(method: string, endpoint: string, body?: unknown) {
|
||||||
|
const req = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||||
|
cache: 'no-cache',
|
||||||
|
method,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (req.status === 204) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.status === 200) {
|
||||||
|
return req.json();
|
||||||
|
}
|
||||||
|
const resultBody = await req.json();
|
||||||
|
throw new BridgeAPIError(resultBody?.error || 'Request failed', resultBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
async verify() {
|
||||||
|
return this.request('GET', `/widgetapi/${encodeURIComponent(this.roomId)}/verify`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async state(): Promise<BridgeRoomState> {
|
||||||
|
return this.request('GET', `/widgetapi/${encodeURIComponent(this.roomId)}`);
|
||||||
|
}
|
||||||
|
}
|
0
web/IBridgeEvents.ts
Normal file
0
web/IBridgeEvents.ts
Normal file
7
web/components/AdminSettings.css
Normal file
7
web/components/AdminSettings.css
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.adminsettings > .card {
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminsettings > h1 {
|
||||||
|
text-align: center;
|
||||||
|
}
|
43
web/components/AdminSettings.tsx
Normal file
43
web/components/AdminSettings.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { h, Component } from 'preact';
|
||||||
|
import { BridgeRoomState } from "../../src/Widgets/BridgeWidgetInterface";
|
||||||
|
import "./AdminSettings.css";
|
||||||
|
import LoginCard from './LoginCard';
|
||||||
|
|
||||||
|
interface IProps{
|
||||||
|
roomState: BridgeRoomState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class AdminSettings extends Component<IProps> {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGitHub() {
|
||||||
|
const githubConfig = this.props.roomState.github;
|
||||||
|
if (!githubConfig.enabled) {
|
||||||
|
return <strong>
|
||||||
|
GitHub support is not enabled in the bridge
|
||||||
|
</strong>
|
||||||
|
}
|
||||||
|
if (!githubConfig.tokenStored) {
|
||||||
|
return <strong>
|
||||||
|
You have not logged into GitHub
|
||||||
|
</strong>
|
||||||
|
}
|
||||||
|
if (!githubConfig.identity) {
|
||||||
|
return <strong>
|
||||||
|
Your token does not appear to work
|
||||||
|
</strong>;
|
||||||
|
}
|
||||||
|
return <LoginCard name={githubConfig.identity.name} avatarUrl={githubConfig.identity.avatarUrl}/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <div class="adminsettings">
|
||||||
|
<h1>{this.props.roomState.title}</h1>
|
||||||
|
<h2>GitHub</h2>
|
||||||
|
{this.renderGitHub()}
|
||||||
|
<h2>GitLab</h2>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
}
|
3
web/components/ErrorPane.css
Normal file
3
web/components/ErrorPane.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.error-pane {
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
11
web/components/ErrorPane.tsx
Normal file
11
web/components/ErrorPane.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { h, FunctionComponent } from "preact";
|
||||||
|
import "./ErrorPane.css";
|
||||||
|
|
||||||
|
const ErrorPane: FunctionComponent<unknown> = ({ children }) => {
|
||||||
|
return <div class="card error error-pane">
|
||||||
|
<h3>Error occured during widget load</h3>
|
||||||
|
<p>{children}</p>
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorPane;
|
7
web/components/LoginCard.css
Normal file
7
web/components/LoginCard.css
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.login-card img {
|
||||||
|
height: 15vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card span {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
17
web/components/LoginCard.tsx
Normal file
17
web/components/LoginCard.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { h, FunctionComponent } from 'preact';
|
||||||
|
import "./LoginCard.css";
|
||||||
|
|
||||||
|
const LoginCard: FunctionComponent<{name: string; avatarUrl: string;}> = ({ name, avatarUrl }) => {
|
||||||
|
return <div class="container login-card">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-2">
|
||||||
|
<img src={avatarUrl} title="GitHub avatar"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
Logged in as <span>{name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginCard;
|
@ -2,24 +2,12 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="description" content="Web site created using create-snowpack-app" />
|
<title>GitHub Bridge Widget</title>
|
||||||
<title>Snowpack App</title>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<main id="root"></main>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<script type="module" src="/index.js"></script>
|
<script type="module" src="/index.js"></script>
|
||||||
<!--
|
|
||||||
This HTML file is a template.
|
|
||||||
If you open it directly in the browser, you will see an empty page.
|
|
||||||
|
|
||||||
You can add webfonts, meta tags, or analytics to this file.
|
|
||||||
The build step will place the bundled scripts into the <body> tag.
|
|
||||||
|
|
||||||
To begin the development, run `npm start` or `yarn start`.
|
|
||||||
To create a production bundle, use `npm run build` or `yarn build`.
|
|
||||||
-->
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import { h, render } from 'preact';
|
import { h, render } from 'preact';
|
||||||
import 'preact/devtools';
|
import 'preact/devtools';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import "./styling.css";
|
||||||
|
import "fontsource-open-sans/files/open-sans-latin-400-normal.woff2";
|
||||||
|
|
||||||
const root = document.getElementById('root')
|
const root = document.getElementsByTagName('main')[0];
|
||||||
|
|
||||||
if (root) {
|
if (root) {
|
||||||
render(<App />, root);
|
render(<App />, root);
|
||||||
|
7
web/styling.css
Normal file
7
web/styling.css
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
@import "fontsource-open-sans/400-normal.css";
|
||||||
|
@import "mini.css";
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Open Sans';
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user