diff --git a/Dockerfile b/Dockerfile index a2ab0d6..75a8611 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,10 @@ FROM node:9.11.2-alpine LABEL maintainer="Andreas Peters " #Upstream URL: https://git.aventer.biz/AVENTER/docker-matrix-dimension +RUN apk add dos2unix --no-cache --repository http://dl-3.alpinelinux.org/alpine/edge/community/ --allow-untrusted + RUN apk update && \ - apk add bash gcc python make g++ sqlite && \ + apk add --no-cache bash gcc python make g++ sqlite && \ mkdir /home/node/.npm-global && \ mkdir -p /home/node/app @@ -29,7 +31,8 @@ USER root RUN apk del gcc make g++ && \ rm /home/node/matrix-dimension/Dockerfile && \ - rm /home/node/matrix-dimension/docker-entrypoint.sh + rm /home/node/matrix-dimension/docker-entrypoint.sh && \ + dos2unix /docker-entrypoint.sh USER node diff --git a/src/MemoryCache.ts b/src/MemoryCache.ts index a4f2d41..7437299 100644 --- a/src/MemoryCache.ts +++ b/src/MemoryCache.ts @@ -48,4 +48,5 @@ export const CACHE_SCALAR_ACCOUNTS = "scalar-accounts"; export const CACHE_WIDGET_TITLES = "widget-titles"; export const CACHE_FEDERATION = "federation"; export const CACHE_IRC_BRIDGE = "irc-bridge"; -export const CACHE_STICKERS = "stickers"; \ No newline at end of file +export const CACHE_STICKERS = "stickers"; +export const CACHE_TELEGRAM_BRIDGE = "telegram-bridge"; \ No newline at end of file diff --git a/src/api/ApiError.ts b/src/api/ApiError.ts index 46db457..140bcdc 100644 --- a/src/api/ApiError.ts +++ b/src/api/ApiError.ts @@ -35,5 +35,7 @@ export class ApiError { this.jsonResponse = json; this.statusCode = statusCode; this.errorCode = errCode; + + this.jsonResponse["dim_errcode"] = this.errorCode; } } \ No newline at end of file diff --git a/src/api/admin/AdminTelegramService.ts b/src/api/admin/AdminTelegramService.ts new file mode 100644 index 0000000..d0c8b1a --- /dev/null +++ b/src/api/admin/AdminTelegramService.ts @@ -0,0 +1,118 @@ +import { GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; +import { AdminService } from "./AdminService"; +import { Cache, CACHE_INTEGRATIONS, CACHE_TELEGRAM_BRIDGE } from "../../MemoryCache"; +import { LogService } from "matrix-js-snippets"; +import { ApiError } from "../ApiError"; +import TelegramBridgeRecord from "../../db/models/TelegramBridgeRecord"; + +interface CreateWithUpstream { + upstreamId: number; +} + +interface CreateSelfhosted { + provisionUrl: string; + sharedSecret: string; + allowTgPuppets: boolean; + allowMxPuppets: boolean; +} + +interface BridgeResponse { + id: number; + upstreamId?: number; + provisionUrl?: string; + allowTgPuppets?: boolean; + allowMxPuppets?: boolean; + sharedSecret?: string; + isEnabled: boolean; +} + +/** + * Administrative API for configuring Telegram bridge instances. + */ +@Path("/api/v1/dimension/admin/telegram") +export class AdminTelegramService { + + @GET + @Path("all") + public async getBridges(@QueryParam("scalar_token") scalarToken: string): Promise { + await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const bridges = await TelegramBridgeRecord.findAll(); + return Promise.all(bridges.map(async b => { + return { + id: b.id, + upstreamId: b.upstreamId, + provisionUrl: b.provisionUrl, + allowTgPuppets: b.allowTgPuppets, + allowMxPuppets: b.allowMxPuppets, + sharedSecret: b.sharedSecret, + isEnabled: b.isEnabled, + }; + })); + } + + @GET + @Path(":bridgeId") + public async getBridge(@QueryParam("scalar_token") scalarToken: string, @PathParam("bridgeId") bridgeId: number): Promise { + await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const telegramBridge = await TelegramBridgeRecord.findByPrimary(bridgeId); + if (!telegramBridge) throw new ApiError(404, "Telegram Bridge not found"); + + return { + id: telegramBridge.id, + upstreamId: telegramBridge.upstreamId, + provisionUrl: telegramBridge.provisionUrl, + allowTgPuppets: telegramBridge.allowTgPuppets, + allowMxPuppets: telegramBridge.allowMxPuppets, + sharedSecret: telegramBridge.sharedSecret, + isEnabled: telegramBridge.isEnabled, + }; + } + + @POST + @Path(":bridgeId") + public async updateBridge(@QueryParam("scalar_token") scalarToken: string, @PathParam("bridgeId") bridgeId: number, request: CreateSelfhosted): Promise { + const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const bridge = await TelegramBridgeRecord.findByPrimary(bridgeId); + if (!bridge) throw new ApiError(404, "Bridge not found"); + + bridge.provisionUrl = request.provisionUrl; + bridge.sharedSecret = request.sharedSecret; + bridge.allowTgPuppets = request.allowTgPuppets; + bridge.allowMxPuppets = request.allowMxPuppets; + await bridge.save(); + + LogService.info("AdminTelegramService", userId + " updated Telegram Bridge " + bridge.id); + + Cache.for(CACHE_TELEGRAM_BRIDGE).clear(); + Cache.for(CACHE_INTEGRATIONS).clear(); + return this.getBridge(scalarToken, bridge.id); + } + + @POST + @Path("new/upstream") + public async newConfigForUpstream(@QueryParam("scalar_token") _scalarToken: string, _request: CreateWithUpstream): Promise { + throw new ApiError(400, "Cannot create a telegram bridge from an upstream"); + } + + @POST + @Path("new/selfhosted") + public async newSelfhosted(@QueryParam("scalar_token") scalarToken: string, request: CreateSelfhosted): Promise { + const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const bridge = await TelegramBridgeRecord.create({ + provisionUrl: request.provisionUrl, + sharedSecret: request.sharedSecret, + allowTgPuppets: request.allowTgPuppets, + allowMxPuppets: request.allowMxPuppets, + isEnabled: true, + }); + LogService.info("AdminTelegramService", userId + " created a new Telegram Bridge with provisioning URL " + request.provisionUrl); + + Cache.for(CACHE_TELEGRAM_BRIDGE).clear(); + Cache.for(CACHE_INTEGRATIONS).clear(); + return this.getBridge(scalarToken, bridge.id); + } +} \ No newline at end of file diff --git a/src/api/dimension/DimensionTelegramService.ts b/src/api/dimension/DimensionTelegramService.ts new file mode 100644 index 0000000..7024e98 --- /dev/null +++ b/src/api/dimension/DimensionTelegramService.ts @@ -0,0 +1,90 @@ +import { DELETE, GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; +import { ScalarService } from "../scalar/ScalarService"; +import { TelegramBridge } from "../../bridges/TelegramBridge"; +import { ApiError } from "../ApiError"; + +interface PortalInfoResponse { + bridged: boolean; + chatId: number; + roomId: string; + canUnbridge: boolean; + chatName: string; +} + +interface BridgeRoomRequest { + unbridgeOtherPortals: boolean; +} + +/** + * API for interacting with the Telegram bridge + */ +@Path("/api/v1/dimension/telegram") +export class DimensionTelegramService { + + @GET + @Path("chat/:chatId") + public async getPortalInfo(@QueryParam("scalar_token") scalarToken: string, @PathParam("chatId") chatId: number, @QueryParam("roomId") roomId: string): Promise { + const userId = await ScalarService.getTokenOwner(scalarToken); + + try { + const telegram = new TelegramBridge(userId); + const conf = await telegram.getChatConfiguration(chatId, roomId); + + return { + bridged: conf ? conf.bridged : false, + canUnbridge: conf ? conf.canUnbridge : false, + chatId: conf ? conf.chatId : null, + roomId: conf ? conf.roomId : null, + chatName: conf ? conf.chatName : null, + }; + } catch (e) { + if (e.errcode) { + throw new ApiError(400, "Error bridging room", e.errcode.toUpperCase()); + } else throw e; + } + } + + @POST + @Path("chat/:chatId/room/:roomId") + public async bridgeRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("chatId") chatId: number, @PathParam("roomId") roomId: string, request: BridgeRoomRequest): Promise { + const userId = await ScalarService.getTokenOwner(scalarToken); + + try { + const telegram = new TelegramBridge(userId); + const portal = await telegram.bridgeRoom(chatId, roomId, request.unbridgeOtherPortals); + return { + bridged: true, + canUnbridge: portal.canUnbridge, + chatId: portal.chatId, + roomId: portal.roomId, + chatName: portal.chatName, + }; + } catch (e) { + if (e.errcode) { + throw new ApiError(400, "Error bridging room", e.errcode.toUpperCase()); + } else throw e; + } + } + + @DELETE + @Path("room/:roomId") + public async unbridgeRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string): Promise { + const userId = await ScalarService.getTokenOwner(scalarToken); + + try { + const telegram = new TelegramBridge(userId); + const portal = await telegram.unbridgeRoom(roomId); + return { + bridged: false, + canUnbridge: portal.canUnbridge, + chatId: portal.chatId, + roomId: portal.roomId, + chatName: portal.chatName, + }; + } catch (e) { + if (e.errcode) { + throw new ApiError(400, "Error bridging room", e.errcode.toUpperCase()); + } else throw e; + } + } +} \ No newline at end of file diff --git a/src/api/matrix/MatrixAppServiceApiService.ts b/src/api/matrix/MatrixAppServiceApiService.ts index b232fae..aacde01 100644 --- a/src/api/matrix/MatrixAppServiceApiService.ts +++ b/src/api/matrix/MatrixAppServiceApiService.ts @@ -11,11 +11,8 @@ interface AppServiceTransaction { /** * API for handling appservice traffic from a homeserver */ -// Note: There's no actual defined prefix for this API. The following was chosen to be -// somewhat consistent with the other matrix APIs. In reality, the homeserver will just -// hit the URL given in the registration - be sure to define it to match this prefix. -// Eg: `url: "http://localhost:8184/_matrix/appservice/r0"` @Path("/_matrix/appservice/r0") +@Path("/_matrix/app/v1") // the matrix spec version export class MatrixAppServiceApiService { @PUT diff --git a/src/bridges/TelegramBridge.ts b/src/bridges/TelegramBridge.ts new file mode 100644 index 0000000..1841865 --- /dev/null +++ b/src/bridges/TelegramBridge.ts @@ -0,0 +1,231 @@ +import TelegramBridgeRecord from "../db/models/TelegramBridgeRecord"; +import { LogService } from "matrix-js-snippets"; +import * as request from "request"; +import { + BridgeInfoResponse, + PortalInformationResponse, + UserChatResponse, + UserInformationResponse +} from "./models/telegram"; + +export interface PortalInfo { + bridged: boolean; + chatId: number; + roomId: string; + canUnbridge: boolean; + chatName: string; +} + +export interface PuppetInfo { + advertiseTgPuppets: boolean; + advertiseMxPuppets: boolean; + linked: boolean; + telegram: { + username: string; + firstName: string; + lastName: string; + phone: number; + isBot: boolean; + permissions: string[]; + availableChats: { + id: number; + title: string; + }[]; + }; +} + +export interface BridgeInfo { + botUsername: string; +} + +export class TelegramBridge { + constructor(private requestingUserId: string) { + } + + private async getDefaultBridge(): Promise { + const bridges = await TelegramBridgeRecord.findAll({where: {isEnabled: true}}); + if (!bridges || bridges.length !== 1) { + throw new Error("No bridges or too many bridges found"); + } + + return bridges[0]; + } + + public async isBridgingEnabled(): Promise { + const bridges = await TelegramBridgeRecord.findAll({where: {isEnabled: true}}); + return !!bridges; + } + + public async getBridgeInfo(): Promise { + const bridge = await this.getDefaultBridge(); + + const info = await this.doProvisionRequest(bridge, "GET", `/bridge`); + return { + botUsername: info.relaybot_username, + }; + } + + public async getPuppetInfo(): Promise { + const bridge = await this.getDefaultBridge(); + + const info = await this.doProvisionRequest(bridge, "GET", `/user/${this.requestingUserId}`); + const puppet: PuppetInfo = { + advertiseTgPuppets: bridge.allowTgPuppets, + advertiseMxPuppets: bridge.allowMxPuppets, + linked: !!info.telegram, + telegram: { + permissions: [info.permissions], + username: info.telegram ? info.telegram.username : null, + firstName: info.telegram ? info.telegram.first_name : null, + lastName: info.telegram ? info.telegram.last_name : null, + phone: info.telegram ? info.telegram.phone : null, + isBot: info.telegram ? info.telegram.is_bot : null, + availableChats: [], // populated next + }, + }; + + if (puppet.linked) { + puppet.telegram.availableChats = await this.doProvisionRequest(bridge, "GET", `/user/${this.requestingUserId}/chats`); + } + + return puppet; + } + + public async getRoomConfiguration(inRoomId: string): Promise { + const bridge = await this.getDefaultBridge(); + + try { + const info = await this.doProvisionRequest(bridge, "GET", `/portal/${inRoomId}`); + return { + bridged: !!info && info.mxid === inRoomId, + chatId: info ? info.chat_id : 0, + roomId: info.mxid, + chatName: info ? info.title || info.username : null, + canUnbridge: info ? info.can_unbridge : false, + }; + } catch (e) { + if (!e.errBody || e.errBody["errcode"] !== "portal_not_found") { + throw e.error || e; + } + + return { + bridged: false, + chatId: 0, + roomId: "", + chatName: null, + canUnbridge: false, + }; + } + } + + public async getChatConfiguration(chatId: number, roomId: string): Promise { + const bridge = await this.getDefaultBridge(); + + try { + const info = await this.doProvisionRequest(bridge, "GET", `/portal/${chatId}`, {room_id: roomId}); + return { + bridged: info && !!info.mxid, + chatId: chatId, + roomId: info ? info.mxid : null, + chatName: info ? info.title || info.username : null, + canUnbridge: info ? info.can_unbridge : false, + }; + } catch (e) { + if (!e.errBody || e.errBody["errcode"] !== "portal_not_found") { + throw e.error || e; + } + + const rethrowCodes = ["bot_not_in_chat"]; + if (rethrowCodes.indexOf(e.errBody["errcode"]) !== -1) { + throw {errcode: e.errBody["errcode"]}; + } + + return { + bridged: false, + chatId: 0, + roomId: null, + chatName: null, + canUnbridge: false, + }; + } + } + + public async bridgeRoom(chatId: number, roomId: string, unbridgeOtherPortals = false): Promise { + const bridge = await this.getDefaultBridge(); + + try { + const qs = {}; + if (unbridgeOtherPortals) qs["force"] = "unbridge"; + await this.doProvisionRequest(bridge, "POST", `/portal/${roomId}/connect/${chatId}`, qs); + return this.getChatConfiguration(chatId, roomId); + } catch (e) { + if (!e.errBody) throw e.error || e; + + const rethrowCodes = ["not_enough_permissions", "room_already_bridged", "chat_already_bridged", "bot_not_in_chat"]; + if (rethrowCodes.indexOf(e.errBody["errcode"]) !== -1) { + throw {errcode: e.errBody["errcode"]}; + } + + throw e.error || e; + } + } + + public async unbridgeRoom(roomId: string): Promise { + const bridge = await this.getDefaultBridge(); + + try { + await this.doProvisionRequest(bridge, "POST", `/portal/${roomId}/disconnect`); + return this.getRoomConfiguration(roomId); + } catch (e) { + if (!e.errBody) throw e.error || e; + + const rethrowCodes = ["not_enough_permissions", "bot_not_in_chat"]; + if (rethrowCodes.indexOf(e.errBody["errcode"]) !== -1) { + throw {errcode: e.errBody["errcode"]}; + } + + throw e.error || e; + } + } + + private async doProvisionRequest(bridge: TelegramBridgeRecord, method: string, endpoint: string, qs?: any, body?: any): Promise { + const provisionUrl = bridge.provisionUrl; + const apiUrl = provisionUrl.endsWith("/") ? provisionUrl.substring(0, provisionUrl.length - 1) : provisionUrl; + const url = apiUrl + (endpoint.startsWith("/") ? endpoint : "/" + endpoint); + LogService.info("TelegramBridge", "Doing provision Telegram Bridge request: " + url); + + if (!qs) qs = {}; + + if (qs["user_id"] === false) delete qs["user_id"]; + else if (!qs["user_id"]) qs["user_id"] = this.requestingUserId; + + return new Promise((resolve, reject) => { + request({ + method: method, + url: url, + qs: qs, + json: body, + headers: { + "Authorization": `Bearer ${bridge.sharedSecret}`, + }, + }, (err, res, _body) => { + if (err) { + LogService.error("TelegramBridge", "Error calling" + url); + LogService.error("TelegramBridge", err); + reject(err); + } else if (!res) { + LogService.error("TelegramBridge", "There is no response for " + url); + reject(new Error("No response provided - is the service online?")); + } else if (res.statusCode !== 200 && res.statusCode !== 202) { + LogService.error("TelegramBridge", "Got status code " + res.statusCode + " when calling " + url); + LogService.error("TelegramBridge", res.body); + if (typeof(res.body) === "string") res.body = JSON.parse(res.body); + reject({errBody: res.body, error: new Error("Request failed")}); + } else { + if (typeof(res.body) === "string") res.body = JSON.parse(res.body); + resolve(res.body); + } + }); + }); + } +} \ No newline at end of file diff --git a/src/bridges/models/telegram.ts b/src/bridges/models/telegram.ts new file mode 100644 index 0000000..61bfd80 --- /dev/null +++ b/src/bridges/models/telegram.ts @@ -0,0 +1,32 @@ +export interface PortalInformationResponse { + mxid: string; + chat_id: number; + peer_type: string; + megagroup: boolean; + username: string; + title: string; + about: string; + can_unbridge: boolean; +} + +export interface UserInformationResponse { + mxid: string; + permissions: string; + telegram?: { + id: number; + username: string; + first_name: string; + last_name: string; + phone: number; + is_bot: boolean; + }; +} + +export interface UserChatResponse { + id: number; + title: string; +} + +export interface BridgeInfoResponse { + relaybot_username: string; +} \ No newline at end of file diff --git a/src/db/BridgeStore.ts b/src/db/BridgeStore.ts index 68a1950..d8ff425 100644 --- a/src/db/BridgeStore.ts +++ b/src/db/BridgeStore.ts @@ -1,7 +1,9 @@ +import { Bridge, TelegramBridgeConfiguration } from "../integrations/Bridge"; import { Bridge, WebhookBridgeConfiguration } from "../integrations/Bridge"; import BridgeRecord from "./models/BridgeRecord"; import { IrcBridge } from "../bridges/IrcBridge"; import { LogService } from "matrix-js-snippets"; +import { TelegramBridge } from "../bridges/TelegramBridge"; import { WebhooksBridge } from "../bridges/WebhooksBridge"; export class BridgeStore { @@ -45,6 +47,8 @@ export class BridgeStore { if (integrationType === "irc") { throw new Error("IRC Bridges should be modified with the dedicated API"); + } else if (integrationType === "telegram") { + throw new Error("Telegram bridges should be modified with the dedicated API"); } else if (integrationType === "webhooks") { throw new Error("Webhooks should be modified with the dedicated API"); } else throw new Error("Unsupported bridge"); @@ -54,6 +58,9 @@ export class BridgeStore { if (record.type === "irc") { const irc = new IrcBridge(requestingUserId); return irc.hasNetworks(); + } else if (record.type === "telegram") { + const telegram = new TelegramBridge(requestingUserId); + return telegram.isBridgingEnabled(); } else if (record.type === "webhooks") { const webhooks = new WebhooksBridge(requestingUserId); return webhooks.isBridgingEnabled(); @@ -65,6 +72,17 @@ export class BridgeStore { if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs const irc = new IrcBridge(requestingUserId); return irc.getRoomConfiguration(inRoomId); + } else if (record.type === "telegram") { + if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs + const telegram = new TelegramBridge(requestingUserId); + const roomConf = await telegram.getRoomConfiguration(inRoomId); + const bridgeInfo = await telegram.getBridgeInfo(); + return { + botUsername: bridgeInfo.botUsername, + linked: roomConf.bridged ? [roomConf.chatId] : [], + portalInfo: roomConf, + puppet: await telegram.getPuppetInfo(), + }; } else if (record.type === "webhooks") { if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs const webhooks = new WebhooksBridge(requestingUserId); diff --git a/src/db/DimensionStore.ts b/src/db/DimensionStore.ts index e5ea13c..19ced9e 100644 --- a/src/db/DimensionStore.ts +++ b/src/db/DimensionStore.ts @@ -21,6 +21,7 @@ import IrcBridgeNetwork from "./models/IrcBridgeNetwork"; import StickerPack from "./models/StickerPack"; import Sticker from "./models/Sticker"; import UserStickerPack from "./models/UserStickerPack"; +import TelegramBridgeRecord from "./models/TelegramBridgeRecord"; import WebhookBridgeRecord from "./models/WebhookBridgeRecord"; class _DimensionStore { @@ -54,6 +55,7 @@ class _DimensionStore { StickerPack, Sticker, UserStickerPack, + TelegramBridgeRecord, WebhookBridgeRecord, ]); } diff --git a/src/db/migrations/20180908155745-AddTelegramBridge.ts b/src/db/migrations/20180908155745-AddTelegramBridge.ts new file mode 100644 index 0000000..7ced66f --- /dev/null +++ b/src/db/migrations/20180908155745-AddTelegramBridge.ts @@ -0,0 +1,24 @@ +import { QueryInterface } from "sequelize"; +import { DataType } from "sequelize-typescript"; + +export default { + up: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.createTable("dimension_telegram_bridges", { + "id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false}, + "upstreamId": { + type: DataType.INTEGER, allowNull: true, + references: {model: "dimension_upstreams", key: "id"}, + onUpdate: "cascade", onDelete: "cascade", + }, + "provisionUrl": {type: DataType.STRING, allowNull: true}, + "sharedSecret": {type: DataType.STRING, allowNull: true}, + "allowPuppets": {type: DataType.BOOLEAN, allowNull: true}, + "isEnabled": {type: DataType.BOOLEAN, allowNull: false}, + })); + }, + down: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.dropTable("dimension_telegram_bridges")); + } +} \ No newline at end of file diff --git a/src/db/migrations/20180916014545-AddTelegramBridgeRecord.ts b/src/db/migrations/20180916014545-AddTelegramBridgeRecord.ts new file mode 100644 index 0000000..16d35ba --- /dev/null +++ b/src/db/migrations/20180916014545-AddTelegramBridgeRecord.ts @@ -0,0 +1,23 @@ +import { QueryInterface } from "sequelize"; + +export default { + up: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.bulkInsert("dimension_bridges", [ + { + type: "telegram", + name: "Telegram Bridge", + avatarUrl: "/img/avatars/telegram.png", + isEnabled: true, + isPublic: true, + description: "Bridges Telegram chats and channels to rooms on Matrix", + }, + ])); + }, + down: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.bulkDelete("dimension_bridges", { + type: "telegram", + })); + } +} \ No newline at end of file diff --git a/src/db/migrations/20181017191645-SplitTelegramPuppetProperties.ts b/src/db/migrations/20181017191645-SplitTelegramPuppetProperties.ts new file mode 100644 index 0000000..5727f2a --- /dev/null +++ b/src/db/migrations/20181017191645-SplitTelegramPuppetProperties.ts @@ -0,0 +1,17 @@ +import { QueryInterface } from "sequelize"; +import { DataType } from "sequelize-typescript"; + +export default { + up: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.removeColumn("dimension_telegram_bridges", "allowPuppets")) + .then(() => queryInterface.addColumn("dimension_telegram_bridges", "allowTgPuppets", DataType.BOOLEAN)) + .then(() => queryInterface.addColumn("dimension_telegram_bridges", "allowMxPuppets", DataType.BOOLEAN)); + }, + down: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.removeColumn("dimension_telegram_bridges", "allowTgPuppets")) + .then(() => queryInterface.removeColumn("dimension_telegram_bridges", "allowMxPuppets")) + .then(() => queryInterface.addColumn("dimension_telegram_bridges", "allowPuppets", DataType.BOOLEAN)); + } +} \ No newline at end of file diff --git a/src/db/models/TelegramBridgeRecord.ts b/src/db/models/TelegramBridgeRecord.ts new file mode 100644 index 0000000..43b9f65 --- /dev/null +++ b/src/db/models/TelegramBridgeRecord.ts @@ -0,0 +1,38 @@ +import { AllowNull, AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript"; +import Upstream from "./Upstream"; + +@Table({ + tableName: "dimension_telegram_bridges", + underscoredAll: false, + timestamps: false, +}) +export default class TelegramBridgeRecord extends Model { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @AllowNull + @Column + @ForeignKey(() => Upstream) + upstreamId?: number; + + @AllowNull + @Column + provisionUrl?: string; + + @AllowNull + @Column + sharedSecret?: string; + + @AllowNull + @Column + allowTgPuppets?: boolean; + + @AllowNull + @Column + allowMxPuppets?: boolean; + + @Column + isEnabled: boolean; +} \ No newline at end of file diff --git a/src/integrations/Bridge.ts b/src/integrations/Bridge.ts index 182488b..5f488ef 100644 --- a/src/integrations/Bridge.ts +++ b/src/integrations/Bridge.ts @@ -1,6 +1,7 @@ import { Integration } from "./Integration"; import BridgeRecord from "../db/models/BridgeRecord"; import { AvailableNetworks, LinkedChannels } from "../bridges/IrcBridge"; +import { PortalInfo, PuppetInfo } from "../bridges/TelegramBridge"; import { WebhookConfiguration } from "../bridges/models/webhooks"; export class Bridge extends Integration { @@ -23,6 +24,13 @@ export interface IrcBridgeConfiguration { links: LinkedChannels; } +export interface TelegramBridgeConfiguration { + botUsername: string; + linked: number[]; + portalInfo: PortalInfo; + puppet: PuppetInfo; +} + export interface WebhookBridgeConfiguration { webhooks: WebhookConfiguration[]; } \ No newline at end of file diff --git a/web/app/admin/bridges/irc/irc.component.html b/web/app/admin/bridges/irc/irc.component.html index 5a324f7..798f093 100644 --- a/web/app/admin/bridges/irc/irc.component.html +++ b/web/app/admin/bridges/irc/irc.component.html @@ -20,7 +20,7 @@ - No bridge configurations. + No bridge configurations. diff --git a/web/app/admin/bridges/telegram/manage-selfhosted/manage-selfhosted.component.html b/web/app/admin/bridges/telegram/manage-selfhosted/manage-selfhosted.component.html new file mode 100644 index 0000000..37bf426 --- /dev/null +++ b/web/app/admin/bridges/telegram/manage-selfhosted/manage-selfhosted.component.html @@ -0,0 +1,46 @@ +
+
+

{{ isAdding ? "Add a new" : "Edit" }} self-hosted Telegram bridge

+
+
+

Self-hosted Telegram bridges must have provisioning enabled in the configuration.

+ + + + + + + + +
+ +
\ No newline at end of file diff --git a/web/app/admin/bridges/telegram/manage-selfhosted/manage-selfhosted.component.scss b/web/app/admin/bridges/telegram/manage-selfhosted/manage-selfhosted.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/app/admin/bridges/telegram/manage-selfhosted/manage-selfhosted.component.ts b/web/app/admin/bridges/telegram/manage-selfhosted/manage-selfhosted.component.ts new file mode 100644 index 0000000..74d54db --- /dev/null +++ b/web/app/admin/bridges/telegram/manage-selfhosted/manage-selfhosted.component.ts @@ -0,0 +1,66 @@ +import { Component } from "@angular/core"; +import { ToasterService } from "angular2-toaster"; +import { DialogRef, ModalComponent } from "ngx-modialog"; +import { BSModalContext } from "ngx-modialog/plugins/bootstrap"; +import { AdminTelegramApiService } from "../../../../shared/services/admin/admin-telegram-api.service"; + +export class ManageSelfhostedTelegramBridgeDialogContext extends BSModalContext { + public provisionUrl: string; + public sharedSecret: string; + public allowTgPuppets = false; + public allowMxPuppets = false; + public bridgeId: number; +} + +@Component({ + templateUrl: "./manage-selfhosted.component.html", + styleUrls: ["./manage-selfhosted.component.scss"], +}) +export class AdminTelegramBridgeManageSelfhostedComponent implements ModalComponent { + + public isSaving = false; + public provisionUrl: string; + public sharedSecret: string; + public allowTgPuppets = false; + public allowMxPuppets = false; + public bridgeId: number; + public isAdding = false; + + constructor(public dialog: DialogRef, + private telegramApi: AdminTelegramApiService, + private toaster: ToasterService) { + this.provisionUrl = dialog.context.provisionUrl; + this.sharedSecret = dialog.context.sharedSecret; + this.allowTgPuppets = dialog.context.allowTgPuppets; + this.allowMxPuppets = dialog.context.allowMxPuppets; + this.bridgeId = dialog.context.bridgeId; + this.isAdding = !this.bridgeId; + } + + public add() { + this.isSaving = true; + const options = { + allowTgPuppets: this.allowTgPuppets, + allowMxPuppets: this.allowMxPuppets, + }; + if (this.isAdding) { + this.telegramApi.newSelfhosted(this.provisionUrl, this.sharedSecret, options).then(() => { + this.toaster.pop("success", "Telegram bridge added"); + this.dialog.close(); + }).catch(err => { + console.error(err); + this.isSaving = false; + this.toaster.pop("error", "Failed to create Telegram bridge"); + }); + } else { + this.telegramApi.updateSelfhosted(this.bridgeId, this.provisionUrl, this.sharedSecret, options).then(() => { + this.toaster.pop("success", "Telegram bridge updated"); + this.dialog.close(); + }).catch(err => { + console.error(err); + this.isSaving = false; + this.toaster.pop("error", "Failed to update Telegram bridge"); + }); + } + } +} diff --git a/web/app/admin/bridges/telegram/telegram.component.html b/web/app/admin/bridges/telegram/telegram.component.html new file mode 100644 index 0000000..3b56f70 --- /dev/null +++ b/web/app/admin/bridges/telegram/telegram.component.html @@ -0,0 +1,48 @@ +
+ +
+
+ +
+

+ mautrix-telegram + is a Telegram bridge that supports bridging channels/supergroups to Matrix. This can be + done through a "relay bot" (all Matrix users are represented through a single bot in Telegram) + or by making use of a user's real Telegram account (known as "puppeting"). Currently + only one Telegram bridge can be configured at a time. +

+ + + + + + + + + + + + + + + + + + + +
NameEnabled FeaturesActions
No bridge configurations.
+ {{ bridge.upstreamId ? "matrix.org's bridge" : "Self-hosted bridge" }} + ({{ bridge.provisionUrl }}) + + {{ getEnabledFeaturesString(bridge) }} + + + + +
+ +
+
+
\ No newline at end of file diff --git a/web/app/admin/bridges/telegram/telegram.component.scss b/web/app/admin/bridges/telegram/telegram.component.scss new file mode 100644 index 0000000..788d7ed --- /dev/null +++ b/web/app/admin/bridges/telegram/telegram.component.scss @@ -0,0 +1,3 @@ +.editButton { + cursor: pointer; +} \ No newline at end of file diff --git a/web/app/admin/bridges/telegram/telegram.component.ts b/web/app/admin/bridges/telegram/telegram.component.ts new file mode 100644 index 0000000..7ce6002 --- /dev/null +++ b/web/app/admin/bridges/telegram/telegram.component.ts @@ -0,0 +1,79 @@ +import { Component, OnInit } from "@angular/core"; +import { ToasterService } from "angular2-toaster"; +import { Modal, overlayConfigFactory } from "ngx-modialog"; +import { + AdminTelegramBridgeManageSelfhostedComponent, + ManageSelfhostedTelegramBridgeDialogContext +} from "./manage-selfhosted/manage-selfhosted.component"; +import { FE_TelegramBridge } from "../../../shared/models/telegram"; +import { AdminTelegramApiService } from "../../../shared/services/admin/admin-telegram-api.service"; + +@Component({ + templateUrl: "./telegram.component.html", + styleUrls: ["./telegram.component.scss"], +}) +export class AdminTelegramBridgeComponent implements OnInit { + + public isLoading = true; + public isUpdating = false; + public configurations: FE_TelegramBridge[] = []; + + constructor(private telegramApi: AdminTelegramApiService, + private toaster: ToasterService, + private modal: Modal) { + } + + public ngOnInit() { + this.reload().then(() => this.isLoading = false); + } + + private async reload(): Promise { + try { + this.configurations = await this.telegramApi.getBridges(); + } catch (err) { + console.error(err); + this.toaster.pop("error", "Error loading bridges"); + } + } + + public addSelfHostedBridge() { + this.modal.open(AdminTelegramBridgeManageSelfhostedComponent, overlayConfigFactory({ + isBlocking: true, + size: 'lg', + + provisionUrl: '', + sharedSecret: '', + allowPuppets: false, + }, ManageSelfhostedTelegramBridgeDialogContext)).result.then(() => { + this.reload().catch(err => { + console.error(err); + this.toaster.pop("error", "Failed to get an update Telegram bridge list"); + }); + }); + } + + public getEnabledFeaturesString(bridge: FE_TelegramBridge): string { + if (!bridge.options) return ""; + if (bridge.options.allowTgPuppets) return "Telegram Puppetting"; + if (bridge.options.allowMxPuppets) return "Matrix Puppetting"; + return ""; + } + + public editBridge(bridge: FE_TelegramBridge) { + this.modal.open(AdminTelegramBridgeManageSelfhostedComponent, overlayConfigFactory({ + isBlocking: true, + size: 'lg', + + provisionUrl: bridge.provisionUrl, + sharedSecret: bridge.sharedSecret, + allowTgPuppets: bridge.options ? bridge.options.allowTgPuppets : false, + allowMxPuppets: bridge.options ? bridge.options.allowMxPuppets : false, + bridgeId: bridge.id, + }, ManageSelfhostedTelegramBridgeDialogContext)).result.then(() => { + this.reload().catch(err => { + console.error(err); + this.toaster.pop("error", "Failed to get an update Telegram bridge list"); + }); + }); + } +} diff --git a/web/app/app.module.ts b/web/app/app.module.ts index 6f6451f..e959d60 100644 --- a/web/app/app.module.ts +++ b/web/app/app.module.ts @@ -78,6 +78,13 @@ import { MediaService } from "./shared/services/media.service"; import { StickerApiService } from "./shared/services/integrations/sticker-api.service"; import { StickerpickerComponent } from "./configs/stickerpicker/stickerpicker.component"; import { StickerPickerWidgetWrapperComponent } from "./widget-wrappers/sticker-picker/sticker-picker.component"; +import { AdminTelegramApiService } from "./shared/services/admin/admin-telegram-api.service"; +import { AdminTelegramBridgeComponent } from "./admin/bridges/telegram/telegram.component"; +import { AdminTelegramBridgeManageSelfhostedComponent } from "./admin/bridges/telegram/manage-selfhosted/manage-selfhosted.component"; +import { TelegramApiService } from "./shared/services/integrations/telegram-api.service"; +import { TelegramBridgeConfigComponent } from "./configs/bridge/telegram/telegram.bridge.component"; +import { TelegramAskUnbridgeComponent } from "./configs/bridge/telegram/ask-unbridge/ask-unbridge.component"; +import { TelegramCannotUnbridgeComponent } from "./configs/bridge/telegram/cannot-unbridge/cannot-unbridge.component"; @NgModule({ imports: [ @@ -145,6 +152,11 @@ import { StickerPickerWidgetWrapperComponent } from "./widget-wrappers/sticker-p AdminStickerPackPreviewComponent, StickerpickerComponent, StickerPickerWidgetWrapperComponent, + AdminTelegramBridgeComponent, + AdminTelegramBridgeManageSelfhostedComponent, + TelegramBridgeConfigComponent, + TelegramAskUnbridgeComponent, + TelegramCannotUnbridgeComponent, // Vendor ], @@ -164,6 +176,8 @@ import { StickerPickerWidgetWrapperComponent } from "./widget-wrappers/sticker-p AdminStickersApiService, MediaService, StickerApiService, + AdminTelegramApiService, + TelegramApiService, {provide: Window, useValue: window}, // Vendor @@ -181,6 +195,9 @@ import { StickerPickerWidgetWrapperComponent } from "./widget-wrappers/sticker-p AdminIrcBridgeNetworksComponent, AdminIrcBridgeAddSelfhostedComponent, AdminStickerPackPreviewComponent, + AdminTelegramBridgeManageSelfhostedComponent, + TelegramAskUnbridgeComponent, + TelegramCannotUnbridgeComponent, ] }) export class AppModule { diff --git a/web/app/app.routing.ts b/web/app/app.routing.ts index b46e185..6e60ec6 100644 --- a/web/app/app.routing.ts +++ b/web/app/app.routing.ts @@ -27,6 +27,8 @@ import { IrcBridgeConfigComponent } from "./configs/bridge/irc/irc.bridge.compon import { AdminStickerPacksComponent } from "./admin/sticker-packs/sticker-packs.component"; import { StickerpickerComponent } from "./configs/stickerpicker/stickerpicker.component"; import { StickerPickerWidgetWrapperComponent } from "./widget-wrappers/sticker-picker/sticker-picker.component"; +import { AdminTelegramBridgeComponent } from "./admin/bridges/telegram/telegram.component"; +import { TelegramBridgeConfigComponent } from "./configs/bridge/telegram/telegram.bridge.component"; const routes: Routes = [ {path: "", component: HomeComponent}, @@ -87,6 +89,11 @@ const routes: Routes = [ component: AdminIrcBridgeComponent, data: {breadcrumb: "IRC Bridge", name: "IRC Bridge"}, }, + { + path: "telegram", + component: AdminTelegramBridgeComponent, + data: {breadcrumb: "Telegram Bridge", name: "Telegram Bridge"}, + }, ], }, { @@ -164,6 +171,11 @@ const routes: Routes = [ component: IrcBridgeConfigComponent, data: {breadcrumb: "IRC Bridge Configuration", name: "IRC Bridge Configuration"}, }, + { + path: "telegram", + component: TelegramBridgeConfigComponent, + data: {breadcrumb: "Telegram Bridge Configuration", name: "Telegram Bridge Configuration"}, + }, ], }, { diff --git a/web/app/configs/bridge/telegram/ask-unbridge/ask-unbridge.component.html b/web/app/configs/bridge/telegram/ask-unbridge/ask-unbridge.component.html new file mode 100644 index 0000000..3336e5e --- /dev/null +++ b/web/app/configs/bridge/telegram/ask-unbridge/ask-unbridge.component.html @@ -0,0 +1,17 @@ +
+
+

Telegram chat is already bridged

+
+
+ You have the appropriate permissions to be able to unbridge the chat, however. Would you like to unbridge + the other room and instead bridge it here? +
+ +
\ No newline at end of file diff --git a/web/app/configs/bridge/telegram/ask-unbridge/ask-unbridge.component.scss b/web/app/configs/bridge/telegram/ask-unbridge/ask-unbridge.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/app/configs/bridge/telegram/ask-unbridge/ask-unbridge.component.ts b/web/app/configs/bridge/telegram/ask-unbridge/ask-unbridge.component.ts new file mode 100644 index 0000000..fee4d9b --- /dev/null +++ b/web/app/configs/bridge/telegram/ask-unbridge/ask-unbridge.component.ts @@ -0,0 +1,24 @@ +import { Component } from "@angular/core"; +import { DialogRef, ModalComponent } from "ngx-modialog"; +import { BSModalContext } from "ngx-modialog/plugins/bootstrap"; + +export class AskUnbridgeDialogContext extends BSModalContext { +} + +@Component({ + templateUrl: "./ask-unbridge.component.html", + styleUrls: ["./ask-unbridge.component.scss"], +}) +export class TelegramAskUnbridgeComponent implements ModalComponent { + + constructor(public dialog: DialogRef) { + } + + public unbridgeAndContinue(): void { + this.dialog.close({unbridge: true}); + } + + public cancel(): void { + this.dialog.close({unbridge: false}); + } +} diff --git a/web/app/configs/bridge/telegram/cannot-unbridge/cannot-unbridge.component.html b/web/app/configs/bridge/telegram/cannot-unbridge/cannot-unbridge.component.html new file mode 100644 index 0000000..bcb45f2 --- /dev/null +++ b/web/app/configs/bridge/telegram/cannot-unbridge/cannot-unbridge.component.html @@ -0,0 +1,14 @@ +
+
+

Telegram chat is already bridged

+
+
+ That Telegram chat is bridged to another Matrix room and cannot be bridged here. Unfortunately, you do not + have the required permissions to be able to unbridge the other room. +
+ +
\ No newline at end of file diff --git a/web/app/configs/bridge/telegram/cannot-unbridge/cannot-unbridge.component.scss b/web/app/configs/bridge/telegram/cannot-unbridge/cannot-unbridge.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/app/configs/bridge/telegram/cannot-unbridge/cannot-unbridge.component.ts b/web/app/configs/bridge/telegram/cannot-unbridge/cannot-unbridge.component.ts new file mode 100644 index 0000000..7496884 --- /dev/null +++ b/web/app/configs/bridge/telegram/cannot-unbridge/cannot-unbridge.component.ts @@ -0,0 +1,16 @@ +import { Component } from "@angular/core"; +import { DialogRef, ModalComponent } from "ngx-modialog"; +import { BSModalContext } from "ngx-modialog/plugins/bootstrap"; + +export class CannotUnbridgeDialogContext extends BSModalContext { +} + +@Component({ + templateUrl: "./cannot-unbridge.component.html", + styleUrls: ["./cannot-unbridge.component.scss"], +}) +export class TelegramCannotUnbridgeComponent implements ModalComponent { + + constructor(public dialog: DialogRef) { + } +} diff --git a/web/app/configs/bridge/telegram/telegram.bridge.component.html b/web/app/configs/bridge/telegram/telegram.bridge.component.html new file mode 100644 index 0000000..b7a6407 --- /dev/null +++ b/web/app/configs/bridge/telegram/telegram.bridge.component.html @@ -0,0 +1,32 @@ + + + +
+ Bridge to Telegram +
+
+
+ This room is bridged to "{{ chatName }}" ({{ chatId }}) on Telegram. +
+ +
+ + You do not have the necessary permissions in this room to unbridge the channel. + +
+
+ + +
+
+
+
+
\ No newline at end of file diff --git a/web/app/configs/bridge/telegram/telegram.bridge.component.scss b/web/app/configs/bridge/telegram/telegram.bridge.component.scss new file mode 100644 index 0000000..7c9eeab --- /dev/null +++ b/web/app/configs/bridge/telegram/telegram.bridge.component.scss @@ -0,0 +1,4 @@ +.actions-col { + width: 120px; + text-align: center; +} \ No newline at end of file diff --git a/web/app/configs/bridge/telegram/telegram.bridge.component.ts b/web/app/configs/bridge/telegram/telegram.bridge.component.ts new file mode 100644 index 0000000..07870d4 --- /dev/null +++ b/web/app/configs/bridge/telegram/telegram.bridge.component.ts @@ -0,0 +1,143 @@ +import { Component } from "@angular/core"; +import { BridgeComponent } from "../bridge.component"; +import { TelegramApiService } from "../../../shared/services/integrations/telegram-api.service"; +import { FE_PortalInfo } from "../../../shared/models/telegram"; +import { Modal, overlayConfigFactory } from "ngx-modialog"; +import { AskUnbridgeDialogContext, TelegramAskUnbridgeComponent } from "./ask-unbridge/ask-unbridge.component"; +import { + CannotUnbridgeDialogContext, + TelegramCannotUnbridgeComponent +} from "./cannot-unbridge/cannot-unbridge.component"; + +interface TelegramConfig { + puppet: { + advertise: boolean; + linked: boolean; + telegram: { + username: string; + firstName: string; + lastName: string; + phone: number; + isBot: boolean; + permissions: string[]; + availableChats: { + id: number; + title: string; + }[]; + }; + }; + portalInfo: FE_PortalInfo; + botUsername: string; + linked: number[]; +} + +@Component({ + templateUrl: "telegram.bridge.component.html", + styleUrls: ["telegram.bridge.component.scss"], +}) +export class TelegramBridgeConfigComponent extends BridgeComponent { + + public isUpdating: boolean; + + constructor(private telegram: TelegramApiService, private modal: Modal) { + super("telegram"); + } + + public get isBridged(): boolean { + return this.bridge.config.linked.length > 0; + } + + public get canUnbridge(): boolean { + return this.bridge.config.portalInfo ? this.bridge.config.portalInfo.canUnbridge : false; + } + + public get botUsername(): string { + return this.bridge.config.botUsername; + } + + public get chatName(): string { + return this.bridge.config.portalInfo ? this.bridge.config.portalInfo.chatName : null; + } + + public get chatId(): number { + return this.bridge.config.portalInfo ? this.bridge.config.portalInfo.chatId : 0; + } + + public set chatId(n: number) { + if (!this.bridge.config.portalInfo) this.bridge.config.portalInfo = { + chatId: n, + chatName: null, + canUnbridge: false, + bridged: false, + roomId: this.roomId, + }; + else this.bridge.config.portalInfo.chatId = n; + } + + public bridgeRoom(): void { + this.telegram.getPortalInfo(this.bridge.config.portalInfo.chatId, this.roomId).then(async (chatInfo) => { + let forceUnbridge = false; + if (chatInfo.bridged && chatInfo.canUnbridge) { + const response = await this.modal.open(TelegramAskUnbridgeComponent, overlayConfigFactory({ + isBlocking: true, + size: 'lg', + }, AskUnbridgeDialogContext)).result; + + if (response.unbridge) { + forceUnbridge = true; + } else { + return {aborted: true}; + } + } else if (chatInfo.bridged) { + this.modal.open(TelegramCannotUnbridgeComponent, overlayConfigFactory({ + isBlocking: true, + size: 'lg', + }, CannotUnbridgeDialogContext)); + return {aborted: true}; + } + + return this.telegram.bridgeRoom(this.roomId, this.bridge.config.portalInfo.chatId, forceUnbridge); + }).then((portalInfo: FE_PortalInfo) => { + if ((portalInfo).aborted) return; + + this.bridge.config.portalInfo = portalInfo; + this.bridge.config.linked = [portalInfo.chatId]; + this.isUpdating = false; + this.toaster.pop("success", "Bridge updated"); + }).catch(error => { + this.isUpdating = false; + console.error(error); + + const body = error.json ? error.json() : null; + let message = "Error bridging room"; + if (body) { + if (body["dim_errcode"] === "CHAT_ALREADY_BRIDGED") message = "That Telegram chat is already bridged to another room"; + if (body["dim_errcode"] === "ROOM_ALREADY_BRIDGED") message = "This room is already bridged to a Telegram chat"; + if (body["dim_errcode"] === "BOT_NOT_IN_CHAT") message = "The Telegram bot has not been invited to the chat"; + if (body["dim_errcode"] === "NOT_ENOUGH_PERMISSIONS") message = "You do not have permission to bridge that chat"; + } + this.toaster.pop("error", message); + }); + } + + public unbridgeRoom(): void { + this.isUpdating = true; + this.telegram.unbridgeRoom(this.roomId).then(portalInfo => { + this.bridge.config.portalInfo = portalInfo; + this.bridge.config.linked = []; + this.isUpdating = false; + this.toaster.pop("success", "Bridge removed"); + }).catch(error => { + this.isUpdating = false; + console.error(error); + + const body = error.json ? error.json() : null; + let message = "Error removing bridge"; + if (body) { + if (body["dim_errcode"] === "BOT_NOT_IN_CHAT") message = "The Telegram bot has not been invited to the chat"; + if (body["dim_errcode"] === "NOT_ENOUGH_PERMISSIONS") message = "You do not have permission to unbridge that chat"; + } + this.toaster.pop("error", message); + }); + } +} \ No newline at end of file diff --git a/web/app/shared/models/telegram.ts b/web/app/shared/models/telegram.ts new file mode 100644 index 0000000..074441c --- /dev/null +++ b/web/app/shared/models/telegram.ts @@ -0,0 +1,21 @@ +export interface FE_TelegramBridge { + id: number; + upstreamId?: number; + provisionUrl?: string; + sharedSecret?: string; + isEnabled: boolean; + options?: FE_TelegramBridgeOptions; +} + +export interface FE_PortalInfo { + bridged: boolean; + chatId: number; + roomId: string; + canUnbridge: boolean; + chatName: string; +} + +export interface FE_TelegramBridgeOptions { + allowTgPuppets: boolean; + allowMxPuppets: boolean; +} \ No newline at end of file diff --git a/web/app/shared/registry/integrations.registry.ts b/web/app/shared/registry/integrations.registry.ts index 6ecacf8..d2f988e 100644 --- a/web/app/shared/registry/integrations.registry.ts +++ b/web/app/shared/registry/integrations.registry.ts @@ -17,6 +17,7 @@ export class IntegrationsRegistry { }, "bridge": { "irc": {}, + "telegram": {}, }, "widget": { "custom": { diff --git a/web/app/shared/services/admin/admin-telegram-api.service.ts b/web/app/shared/services/admin/admin-telegram-api.service.ts new file mode 100644 index 0000000..2e0c3ef --- /dev/null +++ b/web/app/shared/services/admin/admin-telegram-api.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from "@angular/core"; +import { Http } from "@angular/http"; +import { AuthedApi } from "../authed-api"; +import { FE_Upstream } from "../../models/admin-responses"; +import { FE_TelegramBridge, FE_TelegramBridgeOptions } from "../../models/telegram"; + +@Injectable() +export class AdminTelegramApiService extends AuthedApi { + constructor(http: Http) { + super(http); + } + + public getBridges(): Promise { + return this.authedGet("/api/v1/dimension/admin/telegram/all").map(r => r.json()).toPromise(); + } + + public getBridge(bridgeId: number): Promise { + return this.authedGet("/api/v1/dimension/admin/telegram/" + bridgeId).map(r => r.json()).toPromise(); + } + + public newFromUpstream(upstream: FE_Upstream): Promise { + return this.authedPost("/api/v1/dimension/admin/telegram/new/upstream", {upstreamId: upstream.id}).map(r => r.json()).toPromise(); + } + + public newSelfhosted(provisionUrl: string, sharedSecret: string, options: FE_TelegramBridgeOptions): Promise { + return this.authedPost("/api/v1/dimension/admin/telegram/new/selfhosted", { + provisionUrl: provisionUrl, + sharedSecret: sharedSecret, + options: options, + }).map(r => r.json()).toPromise(); + } + + public updateSelfhosted(bridgeId: number, provisionUrl: string, sharedSecret: string, options: FE_TelegramBridgeOptions): Promise { + return this.authedPost("/api/v1/dimension/admin/telegram/" + bridgeId, { + provisionUrl: provisionUrl, + sharedSecret: sharedSecret, + options: options, + }).map(r => r.json()).toPromise(); + } +} diff --git a/web/app/shared/services/integrations/telegram-api.service.ts b/web/app/shared/services/integrations/telegram-api.service.ts new file mode 100644 index 0000000..ab7aa00 --- /dev/null +++ b/web/app/shared/services/integrations/telegram-api.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from "@angular/core"; +import { Http } from "@angular/http"; +import { AuthedApi } from "../authed-api"; +import { FE_PortalInfo } from "../../models/telegram"; + +@Injectable() +export class TelegramApiService extends AuthedApi { + constructor(http: Http) { + super(http); + } + + public getPortalInfo(chatId: number, roomId: string): Promise { + return this.authedGet("/api/v1/dimension/telegram/chat/" + chatId, {roomId: roomId}).map(r => r.json()).toPromise(); + } + + public bridgeRoom(roomId: string, chatId: number, unbridgeOtherPortals = false): Promise { + return this.authedPost("/api/v1/dimension/telegram/chat/" + chatId + "/room/" + roomId, {unbridgeOtherPortals}) + .map(r => r.json()).toPromise(); + } + + public unbridgeRoom(roomId: string): Promise { + return this.authedDelete("/api/v1/dimension/telegram/room/" + roomId).map(r => r.json()).toPromise(); + } +} \ No newline at end of file diff --git a/web/public/img/avatars/telegram.png b/web/public/img/avatars/telegram.png new file mode 100644 index 0000000..6553bdf Binary files /dev/null and b/web/public/img/avatars/telegram.png differ