Support simple custom bots

Fixes https://github.com/turt2live/matrix-dimension/issues/165
This commit is contained in:
Travis Ralston 2018-10-22 22:10:28 -06:00
parent c1a55ade7c
commit 82343da942
24 changed files with 723 additions and 30 deletions

View File

@ -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";

View 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};
}
}

View File

@ -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
View 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() {
}
}

View File

@ -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,
]);
}

View File

@ -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));
}));
}

View 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"));
}
}

View 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;
}

View File

@ -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);
}
}

View File

@ -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",

View File

@ -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>

View 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>

View 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");
});
}
}

View 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>

View File

@ -0,0 +1,8 @@
tr td:last-child {
vertical-align: middle;
}
.editButton {
cursor: pointer;
vertical-align: text-bottom;
}

View 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");
})
}
}

View File

@ -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 {

View File

@ -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"},

View File

@ -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 {

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -3,7 +3,7 @@ $theme_dark: (
defaultFgColor: #eaeaea,
headerColor: #f9f9f9,
mutedColor: #d6d6d6,
altMutedColor: #c4cdd4,
altMutedColor: #a6b6c1,
anchorColor: #82c5ff,
tableBorderColor: #1e1e1e,
codeBgColor: #323233,