mirror of
https://github.com/turt2live/matrix-dimension.git
synced 2024-10-01 01:05:53 -04:00
Support simple custom bots
Fixes https://github.com/turt2live/matrix-dimension/issues/165
This commit is contained in:
parent
c1a55ade7c
commit
82343da942
@ -51,4 +51,5 @@ export const CACHE_IRC_BRIDGE = "irc-bridge";
|
||||
export const CACHE_STICKERS = "stickers";
|
||||
export const CACHE_TELEGRAM_BRIDGE = "telegram-bridge";
|
||||
export const CACHE_WEBHOOKS_BRIDGE = "webhooks-bridge";
|
||||
export const CACHE_GITTER_BRIDGE = "gitter-bridge";
|
||||
export const CACHE_GITTER_BRIDGE = "gitter-bridge";
|
||||
export const CACHE_SIMPLE_BOTS = "simple-bots";
|
92
src/api/admin/AdminCustomSimpleBotService.ts
Normal file
92
src/api/admin/AdminCustomSimpleBotService.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { DELETE, GET, Path, PathParam, POST, QueryParam } from "typescript-rest";
|
||||
import { AdminService } from "./AdminService";
|
||||
import { ApiError } from "../ApiError";
|
||||
import { LogService } from "matrix-js-snippets";
|
||||
import { BotStore } from "../../db/BotStore";
|
||||
import { Cache, CACHE_INTEGRATIONS } from "../../MemoryCache";
|
||||
|
||||
interface BotResponse extends BotRequest {
|
||||
id: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface BotRequest {
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
description: string;
|
||||
isEnabled: boolean;
|
||||
isPublic: boolean;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
interface BotProfile {
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Administrative API for managing custom simple bots hosted by Dimension
|
||||
*/
|
||||
@Path("/api/v1/dimension/admin/bots/simple/custom")
|
||||
export class AdminCustomSimpleBotService {
|
||||
|
||||
@GET
|
||||
@Path("all")
|
||||
public async getBots(@QueryParam("scalar_token") scalarToken: string): Promise<BotResponse[]> {
|
||||
await AdminService.validateAndGetAdminTokenOwner(scalarToken);
|
||||
return BotStore.getCustomBots();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path(":botId")
|
||||
public async getBot(@QueryParam("scalar_token") scalarToken: string, @PathParam("botId") botId: number): Promise<BotResponse> {
|
||||
await AdminService.validateAndGetAdminTokenOwner(scalarToken);
|
||||
|
||||
const bot = await BotStore.getCustomBot(botId);
|
||||
if (!bot) throw new ApiError(404, "Bot not found");
|
||||
return bot;
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("new")
|
||||
public async createBot(@QueryParam("scalar_token") scalarToken: string, request: BotRequest): Promise<BotResponse> {
|
||||
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
|
||||
|
||||
const bot = await BotStore.createCustom(request);
|
||||
LogService.info("AdminCustomSimpleBotService", userId + " created a simple bot");
|
||||
Cache.for(CACHE_INTEGRATIONS).clear();
|
||||
return bot;
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path(":botId")
|
||||
public async updateBot(@QueryParam("scalar_token") scalarToken: string, @PathParam("botId") botId: number, request: BotRequest): Promise<BotResponse> {
|
||||
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
|
||||
|
||||
const bot = await BotStore.updateCustom(botId, request);
|
||||
LogService.info("AdminCustomSimpleBotService", userId + " updated a simple bot");
|
||||
Cache.for(CACHE_INTEGRATIONS).clear();
|
||||
return bot;
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path(":botId")
|
||||
public async deleteBot(@QueryParam("scalar_token") scalarToken: string, @PathParam("botId") botId: number): Promise<any> {
|
||||
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
|
||||
|
||||
await BotStore.deleteCustom(botId);
|
||||
LogService.info("AdminCustomSimpleBotService", userId + " deleted a simple bot");
|
||||
Cache.for(CACHE_INTEGRATIONS).clear();
|
||||
return {}; // 200 OK
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("profile/:userId")
|
||||
public async getProfile(@QueryParam("scalar_token") scalarToken: string, @PathParam("userId") userId: string): Promise<BotProfile> {
|
||||
await AdminService.validateAndGetAdminTokenOwner(scalarToken);
|
||||
|
||||
const profile = await BotStore.getProfile(userId);
|
||||
return {name: profile.displayName, avatarUrl: profile.avatarMxc};
|
||||
}
|
||||
}
|
@ -10,6 +10,7 @@ import { NebStore } from "../../db/NebStore";
|
||||
import { ComplexBot } from "../../integrations/ComplexBot";
|
||||
import { Bridge } from "../../integrations/Bridge";
|
||||
import { BridgeStore } from "../../db/BridgeStore";
|
||||
import { BotStore } from "../../db/BotStore";
|
||||
|
||||
export interface IntegrationsResponse {
|
||||
widgets: Widget[],
|
||||
@ -58,7 +59,11 @@ export class DimensionIntegrationsService {
|
||||
const cached = Cache.for(CACHE_INTEGRATIONS).get("simple_bots");
|
||||
if (cached) return cached;
|
||||
|
||||
const bots = await NebStore.listSimpleBots(userId);
|
||||
const nebs = await NebStore.listSimpleBots(userId);
|
||||
const custom = (await BotStore.getCustomBots())
|
||||
.filter(b => b.isEnabled)
|
||||
.map(b => SimpleBot.fromCached(b));
|
||||
const bots = [...nebs, ...custom];
|
||||
Cache.for(CACHE_INTEGRATIONS).put("simple_bots", bots);
|
||||
return bots;
|
||||
}
|
||||
@ -121,7 +126,11 @@ export class DimensionIntegrationsService {
|
||||
const userId = await ScalarService.getTokenOwner(scalarToken);
|
||||
|
||||
if (category === "widget") throw new ApiError(400, "Widgets should be removed client-side");
|
||||
else if (category === "bot") await NebStore.removeSimpleBot(integrationType, roomId, userId);
|
||||
else if (category === "bot") {
|
||||
if (integrationType.startsWith(BotStore.TYPE_PREFIX)) {
|
||||
await BotStore.removeCustomByTypeFromRoom(integrationType, roomId);
|
||||
} else await NebStore.removeSimpleBot(integrationType, roomId, userId);
|
||||
}
|
||||
else if (category === "complex-bot") throw new ApiError(400, "Complex bots should be removed automatically");
|
||||
else if (category === "bridge") throw new ApiError(400, "Bridges should be removed automatically");
|
||||
else throw new ApiError(400, "Unrecognized category");
|
||||
|
141
src/db/BotStore.ts
Normal file
141
src/db/BotStore.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import CustomSimpleBotRecord from "./models/CustomSimpleBotRecord";
|
||||
import { Cache, CACHE_SIMPLE_BOTS } from "../MemoryCache";
|
||||
import { MatrixLiteClient } from "../matrix/MatrixLiteClient";
|
||||
import config from "../config";
|
||||
import * as randomString from "random-string";
|
||||
import { LogService } from "matrix-js-snippets";
|
||||
|
||||
export interface CachedSimpleBot extends SimpleBotTemplate {
|
||||
id: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SimpleBotTemplate {
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
description: string;
|
||||
isEnabled: boolean;
|
||||
isPublic: boolean;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface CachedProfile {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarMxc: string;
|
||||
}
|
||||
|
||||
export class BotStore {
|
||||
|
||||
public static readonly TYPE_PREFIX = "custom_";
|
||||
|
||||
public static async createCustom(template: SimpleBotTemplate): Promise<CachedSimpleBot> {
|
||||
const newType = `${BotStore.TYPE_PREFIX}${randomString({length: 32})}`;
|
||||
const bot = await CustomSimpleBotRecord.create({...template, type: newType});
|
||||
return BotStore.mapAndCacheCustom(bot);
|
||||
}
|
||||
|
||||
public static async updateCustom(id: number, template: SimpleBotTemplate): Promise<CachedSimpleBot> {
|
||||
const record = await CustomSimpleBotRecord.findByPrimary(id);
|
||||
if (!record) return null;
|
||||
|
||||
record.name = template.name;
|
||||
record.avatarUrl = template.avatarUrl;
|
||||
record.description = template.description;
|
||||
record.isEnabled = template.isEnabled;
|
||||
record.isPublic = template.isPublic;
|
||||
record.userId = template.userId;
|
||||
record.accessToken = template.accessToken;
|
||||
await record.save();
|
||||
|
||||
return BotStore.mapAndCacheCustom(record);
|
||||
}
|
||||
|
||||
public static async deleteCustom(id: number): Promise<any> {
|
||||
const record = await CustomSimpleBotRecord.findByPrimary(id);
|
||||
if (!record) return null;
|
||||
|
||||
await record.destroy();
|
||||
Cache.for(CACHE_SIMPLE_BOTS).del("bots");
|
||||
return true;
|
||||
}
|
||||
|
||||
public static async removeCustomByTypeFromRoom(type: string, roomId: string): Promise<any> {
|
||||
const record = await CustomSimpleBotRecord.findOne({where: {type: type}});
|
||||
if (!record) return null;
|
||||
|
||||
try {
|
||||
const client = new MatrixLiteClient(record.accessToken);
|
||||
await client.leaveRoom(roomId);
|
||||
} catch (e) {
|
||||
LogService.error("BotStore", "Error leaving room " + roomId + " for integration type " + type);
|
||||
LogService.error("BotStore", e);
|
||||
throw new Error("Error leaving room");
|
||||
}
|
||||
}
|
||||
|
||||
public static async getCustomBot(id: number): Promise<CachedSimpleBot> {
|
||||
const bots = await BotStore.getCustomBots();
|
||||
return bots.find(b => b.id === id);
|
||||
}
|
||||
|
||||
public static async getCustomBots(): Promise<CachedSimpleBot[]> {
|
||||
if (!Cache.for(CACHE_SIMPLE_BOTS).get("bots")) {
|
||||
const bots = await CustomSimpleBotRecord.findAll();
|
||||
const cached = [];
|
||||
await Promise.all(bots.map(async (b) => {
|
||||
cached.push(await BotStore.mapAndCacheCustom(b));
|
||||
return Promise.resolve();
|
||||
}));
|
||||
Cache.for(CACHE_SIMPLE_BOTS).put("bots", cached);
|
||||
}
|
||||
|
||||
return Cache.for(CACHE_SIMPLE_BOTS).get("bots").map(b => BotStore.cloneBot(b));
|
||||
}
|
||||
|
||||
public static async getProfile(userId: string): Promise<CachedProfile> {
|
||||
const key = "profile_" + userId;
|
||||
if (!Cache.for(CACHE_SIMPLE_BOTS).get(key)) {
|
||||
const client = new MatrixLiteClient(config.homeserver.accessToken);
|
||||
const profile = await client.getProfile(userId);
|
||||
const cached = {userId, displayName: profile.displayname, avatarMxc: profile.avatar_url};
|
||||
Cache.for(CACHE_SIMPLE_BOTS).put(key, cached);
|
||||
}
|
||||
|
||||
return Cache.for(CACHE_SIMPLE_BOTS).get(key);
|
||||
}
|
||||
|
||||
private static async mapAndCacheCustom(bot: CustomSimpleBotRecord): Promise<CachedSimpleBot> {
|
||||
Cache.for(CACHE_SIMPLE_BOTS).del("bots");
|
||||
return {
|
||||
id: bot.id,
|
||||
type: bot.type,
|
||||
name: bot.name,
|
||||
avatarUrl: bot.avatarUrl,
|
||||
description: bot.description,
|
||||
isEnabled: bot.isEnabled,
|
||||
isPublic: bot.isPublic,
|
||||
userId: bot.userId,
|
||||
accessToken: bot.accessToken,
|
||||
};
|
||||
}
|
||||
|
||||
private static cloneBot(bot: CachedSimpleBot): CachedSimpleBot {
|
||||
return {
|
||||
id: bot.id,
|
||||
type: bot.type,
|
||||
name: bot.name,
|
||||
avatarUrl: bot.avatarUrl,
|
||||
description: bot.description,
|
||||
isEnabled: bot.isEnabled,
|
||||
isPublic: bot.isPublic,
|
||||
userId: bot.userId,
|
||||
accessToken: bot.accessToken,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
private constructor() {
|
||||
}
|
||||
}
|
@ -24,6 +24,7 @@ import UserStickerPack from "./models/UserStickerPack";
|
||||
import TelegramBridgeRecord from "./models/TelegramBridgeRecord";
|
||||
import WebhookBridgeRecord from "./models/WebhookBridgeRecord";
|
||||
import GitterBridgeRecord from "./models/GitterBridgeRecord";
|
||||
import CustomSimpleBotRecord from "./models/CustomSimpleBotRecord";
|
||||
|
||||
class _DimensionStore {
|
||||
private sequelize: Sequelize;
|
||||
@ -59,6 +60,7 @@ class _DimensionStore {
|
||||
TelegramBridgeRecord,
|
||||
WebhookBridgeRecord,
|
||||
GitterBridgeRecord,
|
||||
CustomSimpleBotRecord,
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -129,7 +129,7 @@ export class NebStore {
|
||||
const rawIntegrations = await NebStore.listEnabledNebSimpleBots();
|
||||
return Promise.all(rawIntegrations.map(async i => {
|
||||
const proxy = new NebProxy(i.neb, requestingUserId);
|
||||
return new SimpleBot(i.integration, await proxy.getBotUserId(i.integration));
|
||||
return SimpleBot.fromNeb(i.integration, await proxy.getBotUserId(i.integration));
|
||||
}));
|
||||
}
|
||||
|
||||
|
23
src/db/migrations/20181022185045-AddCustomSimpleBots.ts
Normal file
23
src/db/migrations/20181022185045-AddCustomSimpleBots.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { QueryInterface } from "sequelize";
|
||||
import { DataType } from "sequelize-typescript";
|
||||
|
||||
export default {
|
||||
up: (queryInterface: QueryInterface) => {
|
||||
return Promise.resolve()
|
||||
.then(() => queryInterface.createTable("dimension_custom_simple_bots", {
|
||||
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
|
||||
"type": {type: DataType.STRING, allowNull: false},
|
||||
"name": {type: DataType.STRING, allowNull: false},
|
||||
"avatarUrl": {type: DataType.STRING, allowNull: false},
|
||||
"description": {type: DataType.STRING, allowNull: false},
|
||||
"isEnabled": {type: DataType.BOOLEAN, allowNull: false},
|
||||
"isPublic": {type: DataType.BOOLEAN, allowNull: false},
|
||||
"userId": {type: DataType.STRING, allowNull: false},
|
||||
"accessToken": {type: DataType.STRING, allowNull: false},
|
||||
}));
|
||||
},
|
||||
down: (queryInterface: QueryInterface) => {
|
||||
return Promise.resolve()
|
||||
.then(() => queryInterface.dropTable("dimension_custom_simple_bots"));
|
||||
}
|
||||
}
|
38
src/db/models/CustomSimpleBotRecord.ts
Normal file
38
src/db/models/CustomSimpleBotRecord.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { AutoIncrement, Column, Model, PrimaryKey, Table } from "sequelize-typescript";
|
||||
import { IntegrationRecord } from "./IntegrationRecord";
|
||||
|
||||
@Table({
|
||||
tableName: "dimension_custom_simple_bots",
|
||||
underscoredAll: false,
|
||||
timestamps: false,
|
||||
})
|
||||
export default class CustomSimpleBotRecord extends Model<CustomSimpleBotRecord> implements IntegrationRecord {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column
|
||||
id: number;
|
||||
|
||||
@Column
|
||||
type: string;
|
||||
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
@Column
|
||||
avatarUrl: string;
|
||||
|
||||
@Column
|
||||
description: string;
|
||||
|
||||
@Column
|
||||
isEnabled: boolean;
|
||||
|
||||
@Column
|
||||
isPublic: boolean;
|
||||
|
||||
@Column
|
||||
userId: string;
|
||||
|
||||
@Column
|
||||
accessToken: string;
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
import { Integration } from "./Integration";
|
||||
import { CachedSimpleBot } from "../db/BotStore";
|
||||
import { IntegrationRecord } from "../db/models/IntegrationRecord";
|
||||
import NebIntegration from "../db/models/NebIntegration";
|
||||
|
||||
export class SimpleBot extends Integration {
|
||||
constructor(bot: NebIntegration, public userId: string) {
|
||||
private constructor(bot: IntegrationRecord, public userId: string) {
|
||||
super(bot);
|
||||
this.category = "bot";
|
||||
this.requirements = [];
|
||||
@ -10,4 +12,13 @@ export class SimpleBot extends Integration {
|
||||
// We're going to go ahead and claim that none of the bots are supported in e2e rooms
|
||||
this.isEncryptionSupported = false;
|
||||
}
|
||||
|
||||
public static fromCached(bot: CachedSimpleBot) {
|
||||
delete bot.accessToken;
|
||||
return new SimpleBot(bot, bot.userId);
|
||||
}
|
||||
|
||||
public static fromNeb(bot: NebIntegration, userId: string) {
|
||||
return new SimpleBot(bot, userId);
|
||||
}
|
||||
}
|
@ -6,6 +6,11 @@ export interface MatrixUrlPreview {
|
||||
"og:title"?: string;
|
||||
}
|
||||
|
||||
export interface MatrixUserProfile {
|
||||
displayname?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export class MatrixLiteClient {
|
||||
|
||||
constructor(private accessToken: string) {
|
||||
@ -37,6 +42,22 @@ export class MatrixLiteClient {
|
||||
return response['user_id'];
|
||||
}
|
||||
|
||||
public async leaveRoom(roomId: string): Promise<string> {
|
||||
return doClientApiCall(
|
||||
"POST",
|
||||
"/_matrix/client/r0/rooms/" + roomId + "/leave",
|
||||
{access_token: this.accessToken}
|
||||
);
|
||||
}
|
||||
|
||||
public async getProfile(userId: string): Promise<MatrixUserProfile> {
|
||||
return doClientApiCall(
|
||||
"GET",
|
||||
"/_matrix/client/r0/profile/" + userId,
|
||||
{access_token: this.accessToken},
|
||||
);
|
||||
}
|
||||
|
||||
public async getDisplayName(): Promise<string> {
|
||||
const response = await doClientApiCall(
|
||||
"GET",
|
||||
|
@ -2,6 +2,7 @@
|
||||
<li (click)="goto('')" [ngClass]="[isActive('', true) ? 'active' : '']">Dashboard</li>
|
||||
<li (click)="goto('widgets')" [ngClass]="[isActive('widgets') ? 'active' : '']">Widgets</li>
|
||||
<li (click)="goto('neb')" [ngClass]="[isActive('neb') ? 'active' : '']">go-neb</li>
|
||||
<li (click)="goto('custom-bots')" [ngClass]="[isActive('custom-bots') ? 'active' : '']">Custom Bots</li>
|
||||
<li (click)="goto('bridges')" [ngClass]="[isActive('bridges') ? 'active' : '']">Bridges</li>
|
||||
<li (click)="goto('stickerpacks')" [ngClass]="[isActive('stickerpacks') ? 'active' : '']">Sticker Packs</li>
|
||||
</ul>
|
||||
|
54
web/app/admin/custom-bots/add/add.component.html
Normal file
54
web/app/admin/custom-bots/add/add.component.html
Normal file
@ -0,0 +1,54 @@
|
||||
<div class="dialog">
|
||||
<div class="dialog-header">
|
||||
<h4>{{ isAdding ? "Add a new" : "Edit" }} custom bot</h4>
|
||||
</div>
|
||||
<div class="dialog-content">
|
||||
<label class="label-block">
|
||||
User ID
|
||||
<span class="text-muted">The user ID that Dimension will invite to rooms.</span>
|
||||
<input type="text" class="form-control"
|
||||
placeholder="@yourbot:example.org"
|
||||
[(ngModel)]="bot.userId" [disabled]="isSaving" (blur)="loadProfile()"/>
|
||||
</label>
|
||||
|
||||
<label class="label-block">
|
||||
Description
|
||||
<span class="text-muted ">A few words here will help people understand what the bot does.</span>
|
||||
<input type="text" class="form-control"
|
||||
placeholder="Does awesome things"
|
||||
[(ngModel)]="bot.description" [disabled]="isSaving"/>
|
||||
</label>
|
||||
|
||||
<label class="label-block">
|
||||
Display Name
|
||||
<span class="text-muted ">This is the name Dimension will use to tell users which bot this is.</span>
|
||||
<input type="text" class="form-control"
|
||||
placeholder="Cool Bot"
|
||||
[(ngModel)]="bot.name" [disabled]="isSaving"/>
|
||||
</label>
|
||||
|
||||
<label class="label-block">
|
||||
Avatar URL
|
||||
<span class="text-muted ">This can either be an MXC URI or a plain URL.</span>
|
||||
<input type="text" class="form-control"
|
||||
placeholder="mxc://example.org/C00lAvat4r"
|
||||
[(ngModel)]="bot.avatarUrl" [disabled]="isSaving"/>
|
||||
</label>
|
||||
|
||||
<label class="label-block">
|
||||
Access Token
|
||||
<span class="text-muted ">This is used by Dimension to force the bot to leave the room when the user removes the bot. <a href="https://t2bot.io/docs/access_tokens/" target="_blank">Learn more about access tokens</a>.</span>
|
||||
<input type="text" class="form-control"
|
||||
placeholder="MDaX..."
|
||||
[(ngModel)]="bot.accessToken" [disabled]="isSaving"/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" (click)="add()" title="close" class="btn btn-primary btn-sm">
|
||||
<i class="far fa-save"></i> Save
|
||||
</button>
|
||||
<button type="button" (click)="dialog.close()" title="close" class="btn btn-secondary btn-sm">
|
||||
<i class="far fa-times-circle"></i> Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
0
web/app/admin/custom-bots/add/add.component.scss
Normal file
0
web/app/admin/custom-bots/add/add.component.scss
Normal file
96
web/app/admin/custom-bots/add/add.component.ts
Normal file
96
web/app/admin/custom-bots/add/add.component.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ToasterService } from "angular2-toaster";
|
||||
import { DialogRef, ModalComponent } from "ngx-modialog";
|
||||
import { BSModalContext } from "ngx-modialog/plugins/bootstrap";
|
||||
import { FE_CustomSimpleBot, FE_UserProfile } from "../../../shared/models/admin-responses";
|
||||
import { AdminCustomSimpleBotsApiService } from "../../../shared/services/admin/admin-custom-simple-bots-api.service";
|
||||
|
||||
export class AddCustomBotDialogContext extends BSModalContext {
|
||||
bot: FE_CustomSimpleBot;
|
||||
}
|
||||
|
||||
@Component({
|
||||
templateUrl: "./add.component.html",
|
||||
styleUrls: ["./add.component.scss"],
|
||||
})
|
||||
export class AdminAddCustomBotComponent implements ModalComponent<AddCustomBotDialogContext> {
|
||||
|
||||
public bot: FE_CustomSimpleBot;
|
||||
public isAdding = false;
|
||||
public isSaving = false;
|
||||
|
||||
private lastProfile: FE_UserProfile;
|
||||
|
||||
constructor(public dialog: DialogRef<AddCustomBotDialogContext>,
|
||||
private botApi: AdminCustomSimpleBotsApiService,
|
||||
private toaster: ToasterService) {
|
||||
this.bot = this.dialog.context.bot || <FE_CustomSimpleBot>{};
|
||||
this.isAdding = !this.dialog.context.bot;
|
||||
}
|
||||
|
||||
public loadProfile() {
|
||||
this.botApi.getProfile(this.bot.userId).then(profile => {
|
||||
if (!this.lastProfile || this.lastProfile.name === this.bot.name) {
|
||||
this.bot.name = profile.name;
|
||||
}
|
||||
if (!this.lastProfile || this.lastProfile.avatarUrl === this.bot.avatarUrl) {
|
||||
this.bot.avatarUrl = profile.avatarUrl;
|
||||
}
|
||||
this.lastProfile = profile;
|
||||
}).catch(error => {
|
||||
console.error(error);
|
||||
// We don't need to alert the user - this is non-fatal
|
||||
});
|
||||
}
|
||||
|
||||
public add() {
|
||||
if (!this.bot.name) {
|
||||
this.toaster.pop("warning", "Please enter a name for the bot");
|
||||
return;
|
||||
}
|
||||
if (!this.bot.avatarUrl) {
|
||||
this.toaster.pop("warning", "Please enter an avatar URL for the bot");
|
||||
return;
|
||||
}
|
||||
if (!this.bot.userId) {
|
||||
this.toaster.pop("warning", "Please enter a user ID for the bot");
|
||||
return;
|
||||
}
|
||||
if (!this.bot.description) {
|
||||
this.toaster.pop("warning", "Please enter a description for the bot");
|
||||
return;
|
||||
}
|
||||
if (!this.bot.accessToken) {
|
||||
this.toaster.pop("warning", "Please enter an access token for the bot");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSaving = true;
|
||||
|
||||
const config = {
|
||||
name: this.bot.name,
|
||||
avatarUrl: this.bot.avatarUrl,
|
||||
userId: this.bot.userId,
|
||||
accessToken: this.bot.accessToken,
|
||||
description: this.bot.description,
|
||||
isEnabled: true,
|
||||
isPublic: true,
|
||||
};
|
||||
|
||||
let promise = null;
|
||||
if (this.isAdding) {
|
||||
promise = this.botApi.createBot(config);
|
||||
} else {
|
||||
promise = this.botApi.updateBot(this.bot.id, config);
|
||||
}
|
||||
|
||||
promise.then(() => {
|
||||
this.toaster.pop("success", "Bot updated");
|
||||
this.dialog.close();
|
||||
}).catch(error => {
|
||||
this.isSaving = false;
|
||||
console.error(error);
|
||||
this.toaster.pop("error", "Error updating bot");
|
||||
});
|
||||
}
|
||||
}
|
45
web/app/admin/custom-bots/custom-bots.component.html
Normal file
45
web/app/admin/custom-bots/custom-bots.component.html
Normal file
@ -0,0 +1,45 @@
|
||||
<div *ngIf="isLoading">
|
||||
<my-spinner></my-spinner>
|
||||
</div>
|
||||
<div *ngIf="!isLoading">
|
||||
<my-ibox title="Custom bots">
|
||||
<div class="my-ibox-content">
|
||||
<p>
|
||||
Custom bots give you the ability to add your own bots to Dimension for users to add
|
||||
to their rooms. These bots can't have any configuration to them and must be able to
|
||||
accept room invites on their own. All Dimension will do when a user wants to add the
|
||||
bot is invite it to the room.
|
||||
</p>
|
||||
|
||||
<table class="table table-striped table-condensed table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="text-center" style="width: 120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngIf="!bots || bots.length === 0">
|
||||
<td colspan="2"><i>No custom bots.</i></td>
|
||||
</tr>
|
||||
<tr *ngFor="let bot of bots trackById">
|
||||
<td>
|
||||
{{ bot.name }}
|
||||
<span class="text-muted" style="display: inline-block;">({{ bot.userId }})</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<button type="button" class="btn btn-link btn-sm editButton" (click)="editBot(bot)">
|
||||
<i class="fa fa-pencil-alt"></i>
|
||||
</button>
|
||||
<ui-switch [checked]="bot.isEnabled" size="small" [disabled]="isUpdating"
|
||||
(change)="toggleBot(bot)"></ui-switch>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" class="btn btn-success btn-sm" (click)="addBot()">
|
||||
<i class="fa fa-plus"></i> Add custom bot
|
||||
</button>
|
||||
</div>
|
||||
</my-ibox>
|
||||
</div>
|
8
web/app/admin/custom-bots/custom-bots.component.scss
Normal file
8
web/app/admin/custom-bots/custom-bots.component.scss
Normal file
@ -0,0 +1,8 @@
|
||||
tr td:last-child {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.editButton {
|
||||
cursor: pointer;
|
||||
vertical-align: text-bottom;
|
||||
}
|
73
web/app/admin/custom-bots/custom-bots.component.ts
Normal file
73
web/app/admin/custom-bots/custom-bots.component.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ToasterService } from "angular2-toaster";
|
||||
import { FE_CustomSimpleBot } from "../../shared/models/admin-responses";
|
||||
import { AdminCustomSimpleBotsApiService } from "../../shared/services/admin/admin-custom-simple-bots-api.service";
|
||||
import { Modal, overlayConfigFactory } from "ngx-modialog";
|
||||
import { AddCustomBotDialogContext, AdminAddCustomBotComponent } from "./add/add.component";
|
||||
|
||||
@Component({
|
||||
templateUrl: "./custom-bots.component.html",
|
||||
styleUrls: ["./custom-bots.component.scss"],
|
||||
})
|
||||
export class AdminCustomBotsComponent {
|
||||
|
||||
public isLoading = true;
|
||||
public bots: FE_CustomSimpleBot[];
|
||||
public isUpdating = false;
|
||||
|
||||
constructor(private botApi: AdminCustomSimpleBotsApiService,
|
||||
private toaster: ToasterService,
|
||||
private modal: Modal) {
|
||||
|
||||
this.reload().then(() => this.isLoading = false).catch(error => {
|
||||
console.error(error);
|
||||
this.toaster.pop("error", "Error loading go-neb configuration");
|
||||
});
|
||||
}
|
||||
|
||||
private reload(): Promise<any> {
|
||||
return this.botApi.getBots().then(bots => {
|
||||
this.bots = bots;
|
||||
this.isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
public addBot() {
|
||||
this.modal.open(AdminAddCustomBotComponent, overlayConfigFactory({
|
||||
isBlocking: true,
|
||||
size: 'lg',
|
||||
}, AddCustomBotDialogContext)).result.then(() => {
|
||||
this.reload().catch(err => {
|
||||
console.error(err);
|
||||
this.toaster.pop("error", "Failed to get an updated bot list");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public editBot(bot: FE_CustomSimpleBot) {
|
||||
this.modal.open(AdminAddCustomBotComponent, overlayConfigFactory({
|
||||
isBlocking: true,
|
||||
size: 'lg',
|
||||
|
||||
bot: bot,
|
||||
}, AddCustomBotDialogContext)).result.then(() => {
|
||||
this.reload().catch(err => {
|
||||
console.error(err);
|
||||
this.toaster.pop("error", "Failed to get an updated bot list");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public toggleBot(bot: FE_CustomSimpleBot) {
|
||||
this.isUpdating = true;
|
||||
bot.isEnabled = !bot.isEnabled;
|
||||
this.botApi.updateBot(bot.id, bot).then(() => {
|
||||
this.isUpdating = false;
|
||||
this.toaster.pop("success", "Bot " + (bot.isEnabled ? "enabled" : "disabled"));
|
||||
}).catch(error => {
|
||||
console.error(error);
|
||||
bot.isEnabled = !bot.isEnabled;
|
||||
this.toaster.pop("error", "Error updating bot");
|
||||
})
|
||||
}
|
||||
}
|
@ -101,6 +101,9 @@ import { TradingViewWidgetConfigComponent } from "./configs/widget/tradingview/t
|
||||
import { TradingViewWidgetWrapperComponent } from "./widget-wrappers/tradingview/tradingview.component";
|
||||
import { SpotifyWidgetConfigComponent } from "./configs/widget/spotify/spotify.widget.component";
|
||||
import { SpotifyWidgetWrapperComponent } from "./widget-wrappers/spotify/spotify.component";
|
||||
import { AdminCustomSimpleBotsApiService } from "./shared/services/admin/admin-custom-simple-bots-api.service";
|
||||
import { AdminCustomBotsComponent } from "./admin/custom-bots/custom-bots.component";
|
||||
import { AdminAddCustomBotComponent } from "./admin/custom-bots/add/add.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@ -185,6 +188,8 @@ import { SpotifyWidgetWrapperComponent } from "./widget-wrappers/spotify/spotify
|
||||
TradingViewWidgetWrapperComponent,
|
||||
SpotifyWidgetConfigComponent,
|
||||
SpotifyWidgetWrapperComponent,
|
||||
AdminCustomBotsComponent,
|
||||
AdminAddCustomBotComponent,
|
||||
|
||||
// Vendor
|
||||
],
|
||||
@ -210,6 +215,7 @@ import { SpotifyWidgetWrapperComponent } from "./widget-wrappers/spotify/spotify
|
||||
WebhooksApiService,
|
||||
AdminGitterApiService,
|
||||
GitterApiService,
|
||||
AdminCustomSimpleBotsApiService,
|
||||
{provide: Window, useValue: window},
|
||||
|
||||
// Vendor
|
||||
@ -232,6 +238,7 @@ import { SpotifyWidgetWrapperComponent } from "./widget-wrappers/spotify/spotify
|
||||
TelegramCannotUnbridgeComponent,
|
||||
AdminWebhooksBridgeManageSelfhostedComponent,
|
||||
AdminGitterBridgeManageSelfhostedComponent,
|
||||
AdminAddCustomBotComponent,
|
||||
]
|
||||
})
|
||||
export class AppModule {
|
||||
|
@ -39,6 +39,7 @@ import { TradingViewWidgetConfigComponent } from "./configs/widget/tradingview/t
|
||||
import { TradingViewWidgetWrapperComponent } from "./widget-wrappers/tradingview/tradingview.component";
|
||||
import { SpotifyWidgetConfigComponent } from "./configs/widget/spotify/spotify.widget.component";
|
||||
import { SpotifyWidgetWrapperComponent } from "./widget-wrappers/spotify/spotify.component";
|
||||
import { AdminCustomBotsComponent } from "./admin/custom-bots/custom-bots.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{path: "", component: HomeComponent},
|
||||
@ -86,6 +87,16 @@ const routes: Routes = [
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "custom-bots",
|
||||
data: {breadcrumb: "Custom bots", name: "Custom bots"},
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: AdminCustomBotsComponent,
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "bridges",
|
||||
data: {breadcrumb: "Bridges", name: "Bridges"},
|
||||
|
@ -1,18 +1,29 @@
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChange, SimpleChanges } from "@angular/core";
|
||||
import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser";
|
||||
import { FE_Integration } from "../shared/models/integration";
|
||||
import { MediaService } from "../shared/services/media.service";
|
||||
|
||||
@Component({
|
||||
selector: "my-integration-bag",
|
||||
templateUrl: "./integration-bag.component.html",
|
||||
styleUrls: ["./integration-bag.component.scss"],
|
||||
})
|
||||
export class IntegrationBagComponent {
|
||||
export class IntegrationBagComponent implements OnChanges {
|
||||
|
||||
@Input() integrations: FE_Integration[];
|
||||
@Output() integrationClicked: EventEmitter<FE_Integration> = new EventEmitter<FE_Integration>();
|
||||
|
||||
constructor(private sanitizer: DomSanitizer) {
|
||||
constructor(private sanitizer: DomSanitizer, private media: MediaService) {
|
||||
}
|
||||
|
||||
public ngOnChanges(changes: SimpleChanges) {
|
||||
const change: SimpleChange = changes.integrations;
|
||||
|
||||
(<FE_Integration[]>change.currentValue).map(async (i) => {
|
||||
if (i.avatarUrl.startsWith("mxc://")) {
|
||||
i.avatarUrl = await this.media.getThumbnailUrl(i.avatarUrl, 128, 128, "scale", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public getSafeUrl(url: string): SafeResourceUrl {
|
||||
|
@ -36,4 +36,24 @@ export interface FE_NebConfiguration {
|
||||
appserviceId?: string;
|
||||
upstreamId?: string;
|
||||
integrations: FE_Integration[];
|
||||
}
|
||||
|
||||
export interface FE_CustomSimpleBot extends FE_CustomSimpleBotTemplate {
|
||||
id: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface FE_CustomSimpleBotTemplate {
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
description: string;
|
||||
isEnabled: boolean;
|
||||
isPublic: boolean;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface FE_UserProfile {
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Http } from "@angular/http";
|
||||
import { AuthedApi } from "../authed-api";
|
||||
import { FE_CustomSimpleBot, FE_CustomSimpleBotTemplate, FE_UserProfile } from "../../models/admin-responses";
|
||||
|
||||
@Injectable()
|
||||
export class AdminCustomSimpleBotsApiService extends AuthedApi {
|
||||
constructor(http: Http) {
|
||||
super(http);
|
||||
}
|
||||
|
||||
public getBots(): Promise<FE_CustomSimpleBot[]> {
|
||||
return this.authedGet("/api/v1/dimension/admin/bots/simple/custom/all").map(r => r.json()).toPromise();
|
||||
}
|
||||
|
||||
public getBot(id: number): Promise<FE_CustomSimpleBot> {
|
||||
return this.authedGet("/api/v1/dimension/admin/bots/simple/custom/" + id).map(r => r.json()).toPromise();
|
||||
}
|
||||
|
||||
public updateBot(id: number, config: FE_CustomSimpleBotTemplate): Promise<FE_CustomSimpleBot> {
|
||||
return this.authedPost("/api/v1/dimension/admin/bots/simple/custom/" + id, config).map(r => r.json()).toPromise();
|
||||
}
|
||||
|
||||
public createBot(config: FE_CustomSimpleBotTemplate): Promise<FE_CustomSimpleBot> {
|
||||
return this.authedPost("/api/v1/dimension/admin/bots/simple/custom/new", config).map(r => r.json()).toPromise();
|
||||
}
|
||||
|
||||
public getProfile(userId: string): Promise<FE_UserProfile> {
|
||||
return this.authedGet("/api/v1/dimension/admin/bots/simple/custom/profile/" + userId).map(r => r.json()).toPromise();
|
||||
}
|
||||
}
|
40
web/style/components/bootstrap_override.scss
vendored
40
web/style/components/bootstrap_override.scss
vendored
@ -1,30 +1,28 @@
|
||||
@import "../themes/themes";
|
||||
|
||||
.main-app {
|
||||
@include themify(){
|
||||
a {
|
||||
color: themed(anchorColor);
|
||||
}
|
||||
@include themifyRoot() {
|
||||
a {
|
||||
color: themed(anchorColor);
|
||||
}
|
||||
|
||||
table, td, th {
|
||||
border-color: themed(tableBorderColor);
|
||||
}
|
||||
table, td, th {
|
||||
border-color: themed(tableBorderColor);
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: themed(codeBgColor);
|
||||
}
|
||||
code {
|
||||
background-color: themed(codeBgColor);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: themed(altMutedColor) !important;
|
||||
}
|
||||
*.text-muted {
|
||||
color: themed(altMutedColor) !important;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
color: themed(formControlFgColor);
|
||||
background-color: themed(formControlBgColor);
|
||||
}
|
||||
.form-control {
|
||||
color: themed(formControlFgColor);
|
||||
background-color: themed(formControlBgColor);
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: themed(formControlPlaceholderColor);
|
||||
}
|
||||
.form-control::placeholder {
|
||||
color: themed(formControlPlaceholderColor);
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@ $theme_dark: (
|
||||
defaultFgColor: #eaeaea,
|
||||
headerColor: #f9f9f9,
|
||||
mutedColor: #d6d6d6,
|
||||
altMutedColor: #c4cdd4,
|
||||
altMutedColor: #a6b6c1,
|
||||
anchorColor: #82c5ff,
|
||||
tableBorderColor: #1e1e1e,
|
||||
codeBgColor: #323233,
|
||||
|
Loading…
Reference in New Issue
Block a user