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 index f6cce87..d0c8b1a 100644 --- a/src/api/admin/AdminTelegramService.ts +++ b/src/api/admin/AdminTelegramService.ts @@ -12,14 +12,16 @@ interface CreateWithUpstream { interface CreateSelfhosted { provisionUrl: string; sharedSecret: string; - allowPuppets: boolean; + allowTgPuppets: boolean; + allowMxPuppets: boolean; } interface BridgeResponse { id: number; upstreamId?: number; provisionUrl?: string; - allowPuppets?: boolean; + allowTgPuppets?: boolean; + allowMxPuppets?: boolean; sharedSecret?: string; isEnabled: boolean; } @@ -41,7 +43,8 @@ export class AdminTelegramService { id: b.id, upstreamId: b.upstreamId, provisionUrl: b.provisionUrl, - allowPuppets: b.allowPuppets, + allowTgPuppets: b.allowTgPuppets, + allowMxPuppets: b.allowMxPuppets, sharedSecret: b.sharedSecret, isEnabled: b.isEnabled, }; @@ -60,7 +63,8 @@ export class AdminTelegramService { id: telegramBridge.id, upstreamId: telegramBridge.upstreamId, provisionUrl: telegramBridge.provisionUrl, - allowPuppets: telegramBridge.allowPuppets, + allowTgPuppets: telegramBridge.allowTgPuppets, + allowMxPuppets: telegramBridge.allowMxPuppets, sharedSecret: telegramBridge.sharedSecret, isEnabled: telegramBridge.isEnabled, }; @@ -76,7 +80,8 @@ export class AdminTelegramService { bridge.provisionUrl = request.provisionUrl; bridge.sharedSecret = request.sharedSecret; - bridge.allowPuppets = request.allowPuppets; + bridge.allowTgPuppets = request.allowTgPuppets; + bridge.allowMxPuppets = request.allowMxPuppets; await bridge.save(); LogService.info("AdminTelegramService", userId + " updated Telegram Bridge " + bridge.id); @@ -100,7 +105,8 @@ export class AdminTelegramService { const bridge = await TelegramBridgeRecord.create({ provisionUrl: request.provisionUrl, sharedSecret: request.sharedSecret, - allowPuppets: request.allowPuppets, + allowTgPuppets: request.allowTgPuppets, + allowMxPuppets: request.allowMxPuppets, isEnabled: true, }); LogService.info("AdminTelegramService", userId + " created a new Telegram Bridge with provisioning URL " + request.provisionUrl); diff --git a/src/api/dimension/DimensionTelegramService.ts b/src/api/dimension/DimensionTelegramService.ts new file mode 100644 index 0000000..02d2e50 --- /dev/null +++ b/src/api/dimension/DimensionTelegramService.ts @@ -0,0 +1,93 @@ +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; +} + +/** + * 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); + if (!conf) return { + bridged: false, + canUnbridge: true, + chatId: chatId, + roomId: null, + chatName: null, + }; + + return { + bridged: true, + canUnbridge: conf.canUnbridge, + chatId: chatId, + roomId: conf.roomId, + chatName: conf.chatName, + }; + } 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): Promise { + const userId = await ScalarService.getTokenOwner(scalarToken); + + try { + const telegram = new TelegramBridge(userId); + const portal = await telegram.bridgeRoom(chatId, roomId); + 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/bridges/TelegramBridge.ts b/src/bridges/TelegramBridge.ts index da23608..e715034 100644 --- a/src/bridges/TelegramBridge.ts +++ b/src/bridges/TelegramBridge.ts @@ -1,34 +1,189 @@ -import { TelegramBridgeConfiguration } from "../integrations/Bridge"; import TelegramBridgeRecord from "../db/models/TelegramBridgeRecord"; import { LogService } from "matrix-js-snippets"; import * as request from "request"; -import { PortalInformationResponse } from "./models/telegram"; +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 getRoomConfiguration(inRoomId: string): Promise { - const bridges = await TelegramBridgeRecord.findAll({where: {isEnabled: true}}); + public async getBridgeInfo(): Promise { + const bridge = await this.getDefaultBridge(); - const linkedChats: number[] = []; - for (const bridge of bridges) { - try { - const chatInfo = await this.doProvisionRequest(bridge, "GET", `/portal/${inRoomId}`); - linkedChats.push(chatInfo.chat_id); - } catch (e) { - if (!e.errBody || e.errBody["errcode"] !== "portal_not_found") { - throw e.error || e; - } - } + 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 {linkedChatIds: linkedChats}; + 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, + chatId: info ? info.chat_id : 0, + roomId: inRoomId, + 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, + 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): Promise { + const bridge = await this.getDefaultBridge(); + + try { + await this.doProvisionRequest(bridge, "POST", `/portal/${roomId}/connect/${chatId}`); + 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 { @@ -38,7 +193,9 @@ export class TelegramBridge { LogService.info("TelegramBridge", "Doing provision Telegram Bridge request: " + url); if (!qs) qs = {}; - if (!qs["user_id"]) qs["user_id"] = this.requestingUserId; + + 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({ @@ -46,6 +203,9 @@ export class TelegramBridge { url: url, qs: qs, json: body, + headers: { + "Authorization": `Bearer ${bridge.sharedSecret}`, + }, }, (err, res, _body) => { if (err) { LogService.error("TelegramBridge", "Error calling" + url); @@ -54,7 +214,7 @@ export class TelegramBridge { } 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) { + } 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); diff --git a/src/bridges/models/telegram.ts b/src/bridges/models/telegram.ts index a7550f2..61bfd80 100644 --- a/src/bridges/models/telegram.ts +++ b/src/bridges/models/telegram.ts @@ -6,4 +6,27 @@ export interface PortalInformationResponse { 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 67d9859..4c637f5 100644 --- a/src/db/BridgeStore.ts +++ b/src/db/BridgeStore.ts @@ -1,4 +1,4 @@ -import { Bridge } from "../integrations/Bridge"; +import { Bridge, TelegramBridgeConfiguration } from "../integrations/Bridge"; import BridgeRecord from "./models/BridgeRecord"; import { IrcBridge } from "../bridges/IrcBridge"; import { LogService } from "matrix-js-snippets"; @@ -68,7 +68,14 @@ export class BridgeStore { } else if (record.type === "telegram") { if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs const telegram = new TelegramBridge(requestingUserId); - return telegram.getRoomConfiguration(inRoomId); + 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 return {}; } 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 index f70d316..43b9f65 100644 --- a/src/db/models/TelegramBridgeRecord.ts +++ b/src/db/models/TelegramBridgeRecord.ts @@ -27,7 +27,11 @@ export default class TelegramBridgeRecord extends Model { @AllowNull @Column - allowPuppets?: boolean; + allowTgPuppets?: boolean; + + @AllowNull + @Column + allowMxPuppets?: boolean; @Column isEnabled: boolean; diff --git a/src/integrations/Bridge.ts b/src/integrations/Bridge.ts index 70aae2e..a32e781 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"; export class Bridge extends Integration { constructor(bridge: BridgeRecord, public config: any) { @@ -23,5 +24,8 @@ export interface IrcBridgeConfiguration { } export interface TelegramBridgeConfiguration { - linkedChatIds: number[]; + botUsername: string; + linked: number[]; + portalInfo: PortalInfo; + puppet: PuppetInfo; } \ No newline at end of file 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 index aa7a830..37bf426 100644 --- a/web/app/admin/bridges/telegram/manage-selfhosted/manage-selfhosted.component.html +++ b/web/app/admin/bridges/telegram/manage-selfhosted/manage-selfhosted.component.html @@ -22,10 +22,17 @@ + +