diff --git a/README.md b/README.md
index 27414fa..31a5d83 100644
--- a/README.md
+++ b/README.md
@@ -10,10 +10,10 @@ Very unfinished!! Mostly a programming exercise for me for now to practice fulls
## TODO
-- [ ] Manage matterbridge processes
+- [x] Manage matterbridge processes
- [ ] Sanitize user inputs
- [ ] Check status of matterbridge processes
-- [ ] Complete slack login workflow
+- [x] Complete slack login workflow
- [ ] Kill group processes & delete config when group is deleted
## Supported Clients
diff --git a/client/public/index.html b/client/public/index.html
index ffb4d68..7744282 100644
--- a/client/public/index.html
+++ b/client/public/index.html
@@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
-
ChatBridge2
+ ChatBridge
You need to enable JavaScript to run this app.
diff --git a/client/src/api/discord.ts b/client/src/api/discord.ts
index d2b521a..0f20608 100644
--- a/client/src/api/discord.ts
+++ b/client/src/api/discord.ts
@@ -4,4 +4,12 @@ export const getDiscordInstallURL = (callback: CallableFunction) => {
.then(res => {
callback(res.data.url)
})
+}
+
+export const getDiscordChannels = (callback: CallableFunction) => {
+ fetch('api/discord/channels')
+ .then(res => res.json())
+ .then(res => {
+ callback(res.data.channels)
+ })
}
\ No newline at end of file
diff --git a/client/src/components/join/joinChannel.tsx b/client/src/components/join/joinChannel.tsx
index 8f1416c..a858880 100644
--- a/client/src/components/join/joinChannel.tsx
+++ b/client/src/components/join/joinChannel.tsx
@@ -9,6 +9,7 @@ import {getSlackChannels, joinSlackChannel} from "../../api/slack";
import {useEffect, useState} from "react";
import {channelsType} from "../../types/channel";
import {bridgeType} from "../../types/bridge";
+import {getDiscordChannels} from "../../api/discord";
export interface JoinChannelProps {
@@ -37,6 +38,10 @@ const JoinChannel = ({
switch(platform){
case "Slack":
getSlackChannels(setChannels)
+ break
+ case "Discord":
+ getDiscordChannels(setChannels)
+ break
}
}
}, [platform, bridge])
@@ -51,7 +56,13 @@ const JoinChannel = ({
}
const onChannelChanged = (evt:any) => {
- setStepComplete({...stepComplete, channel:false})
+ if (platform === "Discord"){
+ // Discord bots are in all channels by default - selecting one here completes the step
+ setStepComplete({...stepComplete, channel:true})
+ } else {
+ // Otherwise, we need to do something to join the channel, so selecting means we have yet to join it.
+ setStepComplete({...stepComplete, channel: false})
+ }
setSelectedChannel(evt.target.value)
}
@@ -67,7 +78,7 @@ const JoinChannel = ({
return (
-
+
Select Channel
+ {platform === "Slack" ?
{stepComplete.channel ? 'Channel Joined!' : 'Join Channel'}
+ : null }
)
}
diff --git a/client/src/components/platforms/discordLogin.tsx b/client/src/components/platforms/discordLogin.tsx
index 0c9450f..1f59496 100644
--- a/client/src/components/platforms/discordLogin.tsx
+++ b/client/src/components/platforms/discordLogin.tsx
@@ -50,7 +50,7 @@ export const DiscordLogin = ({
color={bridge !== undefined ? 'success': undefined}
disabled={installLink === undefined}
>
- {installLink === undefined ? 'Waiting for Install Link...' : 'Add to Slack'}
+ {installLink === undefined ? 'Waiting for Install Link...' : 'Add to Discord'}
)
}
\ No newline at end of file
diff --git a/example.env b/example.env
index 8824b0e..d00f79f 100644
--- a/example.env
+++ b/example.env
@@ -2,6 +2,9 @@ PORT=8999
NODE_ENV=development
NODE_CONFIG_DIR = ./server/config
LOG_DIR=/var/log/chatbridge
+# Full URL that chatbridge is hosted at, including any sub-paths
+# Used in the discord handler to generate an OAUTH2 callback URL
+BASE_URL=
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=6500
@@ -33,5 +36,6 @@ SLACK_STATE_SECRET=
# Discord ----------------
DISCORD_TOKEN=
DISCORD_CLIENT_ID=
+DISCORD_CLIENT_SECRET=
diff --git a/server/config/custom-environment-variables.ts b/server/config/custom-environment-variables.ts
index 0d325ee..8e46343 100755
--- a/server/config/custom-environment-variables.ts
+++ b/server/config/custom-environment-variables.ts
@@ -15,7 +15,9 @@ export default {
},
discordConfig: {
token: 'DISCORD_TOKEN',
- client_id: "DISCORD_CLIENT_ID"
+ client_id: "DISCORD_CLIENT_ID",
+ client_secret: 'DISCORD_CLIENT_SECRET'
+
},
admin_token: 'ADMIN_TOKEN',
cookies:{
@@ -26,5 +28,6 @@ export default {
bin: 'MATTERBRIDGE_BINARY',
config: 'MATTERBRIDGE_CONFIG_DIR'
},
- logDir: 'LOG_DIR'
+ logDir: 'LOG_DIR',
+ baseURL: 'BASE_URL'
}
diff --git a/server/src/controllers/discord.controller.ts b/server/src/controllers/discord.controller.ts
index 011184c..a1ec513 100644
--- a/server/src/controllers/discord.controller.ts
+++ b/server/src/controllers/discord.controller.ts
@@ -5,12 +5,22 @@ import config from "config";
import {discordConfigType} from "../types/config";
import {DiscordBridge} from "../entities/bridge.entity";
import {AppDataSource} from "../db/data-source";
-
+import {URL} from "url";
+import {join as pathJoin} from 'path';
+import {DiscordOauthRedirectInput} from "../schemas/discord.schema";
+import logger from "../logging";
const discordBridgeRepository = AppDataSource.getRepository(DiscordBridge)
const discordConfig = config.get('discordConfig');
+const DISCORD_AUTHORIZE_URL = 'https://discord.com/oauth2/authorize'
+const DISCORD_TOKEN_URL = 'https://discord.com/api/oauth2/token'
+
+let baseURL = new URL(config.get('baseURL'))
+baseURL.pathname = pathJoin(baseURL.pathname, '/api/discord/oauth_redirect')
+const REDIRECT_URL = baseURL.toString()
+
export const DiscordInstallLinkHandler = async(
req: Request,
res: Response
@@ -18,7 +28,7 @@ export const DiscordInstallLinkHandler = async(
let state_token = randomUUID()
req.session.state_token = state_token;
- const url = `https://discordapp.com/oauth2/authorize?&client_id=${discordConfig.client_id}&scope=bot&permissions=536870912&state=${state_token}`
+ let url = `${DISCORD_AUTHORIZE_URL}?response_type=code&client_id=${discordConfig.client_id}&scope=bot&permissions=536870912&state=${state_token}&redirect_url=${REDIRECT_URL}`
res.status(200).json({
status: 'success',
@@ -32,8 +42,118 @@ export const DiscordInstallLinkHandler = async(
export const DiscordOAuthHandler = async(
- req: Request,
+ req: Request<{},{},{},DiscordOauthRedirectInput>,
res: Response
) => {
- console.log('discord oauth', req.body, req.query)
+ if (req.session.state_token !== req.query.state){
+ logger.warning('discord state token did not match on oauth redirect')
+ return res.status(401).json({
+ status: 'failure',
+ message: 'State token does not match!'
+ })
+ }
+
+ let data = new URLSearchParams({
+ client_id: discordConfig.client_id,
+ client_secret: discordConfig.client_secret,
+ grant_type: 'authorization_code',
+ code: req.query.code,
+ redirect_uri: REDIRECT_URL
+ })
+
+ let oauth_res = await fetch(DISCORD_TOKEN_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ body: data.toString()
+ })
+ // console.log(oauth_res.body)
+ // console.log(await oauth_res.text())
+ // console.log(oauth_res)
+ // return res.send(await oauth_res.text())
+ let oauth = await oauth_res.json()
+ console.log(oauth)
+ let bridge_data = {
+ Protocol: 'discord',
+ Label: oauth.guild.name,
+ state_token: req.session.state_token,
+ Token: discordConfig.token, // token is same for all bridges, guild ID/Server differentiates
+ Server: oauth.guild.id,
+ guild_id: oauth.guild.id,
+ guild_name: oauth.guild.name,
+ access_token: oauth.access_token,
+ refresh_token: oauth.refresh_token
+ }
+ try {
+ // check if we already have one
+ let bridge = await discordBridgeRepository.findOneBy({
+ Token: discordConfig.token,
+ Server: oauth.guild.id
+ })
+ if (!bridge) {
+ bridge = await discordBridgeRepository.create(bridge_data)
+ await discordBridgeRepository.save(bridge);
+ logger.debug(`Created new discord bridge for ${oauth.guild.name}`)
+ } else {
+ // update everything except user-set label
+ bridge_data.Label = bridge.Label
+ bridge_data = {...bridge, ...bridge_data}
+ await discordBridgeRepository.save(bridge_data)
+ logger.debug(`Updates discord bridge for ${oauth.guild.name}`)
+ }
+ return res.send('Success! Return to the chatbridge login window. This tab will close in 3 seconds... ')
+
+ } catch {
+ return res.status(500).json({
+ status: 'failed',
+ message: 'oauth succeeded, but error creating bridge in database'
+ })
+ }
}
+
+export const DiscordListChannelsHandler = async(
+ req: Request,
+ res: Response
+) => {
+ let bridge = await discordBridgeRepository.findOneBy({state_token: req.session.state_token})
+
+ try{
+ fetch(`https://discord.com/api/guilds/${bridge.guild_id}/channels`,{
+ headers: {
+ Authorization: `Bot ${bridge.Token}`
+ }
+ }).then(result => result.json())
+ .then(result => {
+ logger.debug('got discord channels %s', result)
+ let channels = result.filter(
+ (res:any) => res.type === 0
+ ).map(
+ (res:any) => {
+ return {
+ name: res.name,
+ id: res.id
+ }
+ }
+ )
+ res.status(200).json({
+ status: 'success',
+ data: {
+ channels
+ }
+ })
+ })
+ } catch {
+ return res.status(500).json({
+ status: 'failed',
+ message: 'Could not list channels!'
+ })
+ }
+}
+
+export const DiscordJoinChannelsHandler = async(
+ req: Request,
+ res: Response
+) => {
+
+}
\ No newline at end of file
diff --git a/server/src/controllers/slack.controller.ts b/server/src/controllers/slack.controller.ts
index 0534258..20abd8d 100644
--- a/server/src/controllers/slack.controller.ts
+++ b/server/src/controllers/slack.controller.ts
@@ -213,3 +213,5 @@ export const getBotInfo = async(
})
}
}
+
+
diff --git a/server/src/entities/bridge.entity.ts b/server/src/entities/bridge.entity.ts
index aa53f79..d8866bd 100644
--- a/server/src/entities/bridge.entity.ts
+++ b/server/src/entities/bridge.entity.ts
@@ -24,12 +24,6 @@ export class Bridge extends Model {
@Column()
state_token: string;
- // Bot token for slack
- @Column({
- unique: true
- })
- Token: string;
-
@Column({
default: true
})
@@ -52,6 +46,12 @@ export class Bridge extends Model {
@ChildEntity()
export class SlackBridge extends Bridge {
+ // Bot token for slack
+ @Column({
+ unique: true
+ })
+ Token: string;
+
// The ID of the team
@Column({nullable:true})
team_id: string;
@@ -73,6 +73,12 @@ export class SlackBridge extends Bridge {
@ChildEntity()
export class DiscordBridge extends Bridge {
+ // Tokens are not unique per bridge in discord
+ @Column({
+ unique: false
+ })
+ Token: string;
+
// Server name - needed to use multiple 'servers' with a single discord app
@Column()
Server: string;
@@ -80,6 +86,25 @@ export class DiscordBridge extends Bridge {
// Analogous to team_id in slack bridge
@Column()
guild_id: string;
+
+ @Column()
+ guild_name: string;
+
+ @Column({
+ default: true
+ })
+ AutoWebhooks: boolean;
+
+ @Column({
+ default: true
+ })
+ PreserveThreading: boolean;
+
+ @Column()
+ access_token: string;
+
+ @Column()
+ refresh_token: string;
}
@ChildEntity()
diff --git a/server/src/entities/channel.entity.ts b/server/src/entities/channel.entity.ts
index 74b96b9..87c4ccb 100644
--- a/server/src/entities/channel.entity.ts
+++ b/server/src/entities/channel.entity.ts
@@ -1,6 +1,6 @@
import { Entity, Column, Index, ManyToOne, OneToOne } from 'typeorm';
import Model from './model.entity';
-import { Bridge } from "./bridge.entity";
+import {Bridge, SlackBridge} from "./bridge.entity";
import { Group } from "./group.entity";
@Entity('channels')
@@ -8,7 +8,7 @@ export class Channel extends Model {
@Column()
name: string;
- @ManyToOne(() => Bridge, (bridge) => bridge.channels,
+ @ManyToOne((type) => {console.log('!!!!! type', type); return Bridge}, (bridge) => bridge.channels,
{
eager: true
}
diff --git a/server/src/matterbridge/config.ts b/server/src/matterbridge/config.ts
index e52ece5..6aa207b 100644
--- a/server/src/matterbridge/config.ts
+++ b/server/src/matterbridge/config.ts
@@ -9,10 +9,11 @@ import {Group} from "../entities/group.entity";
const slugify = require('slugify');
import * as TOML from '@ltd/j-toml';
import logger from "../logging";
+import {Bridge} from "../entities/bridge.entity";
const fs = require('fs');
const groupRepository = AppDataSource.getRepository(Group)
-
+const bridgeRepository = AppDataSource.getRepository(Bridge)
/*
@@ -27,7 +28,10 @@ type BridgeEntry = {
Label: string;
Token: string;
PrefixMessagesWithNick: boolean;
- RemoteNickFormat: string
+ RemoteNickFormat: string;
+ Server?: string;
+ AutoWebooks?: boolean;
+ PreserveThreading?: boolean;
}
/*
@@ -53,26 +57,56 @@ type Gateway = {
}
-export const getGroupConfig = async (group_name: string, group:object): Promise => {
+export const getGroupConfig = async (group_name: string): Promise => {
let group = await groupRepository.findOne({
where: {name: group_name},
relations: {channels: true}
})
logger.debug('config group', group)
+
// Construct config in the gateway style for programmatic use
let gateway = {
name: group.name,
enable: group.enable,
- bridges: group.channels.map((channel) => {
- return {
- protocol: channel.bridge.Protocol,
- name: slugify(channel.bridge.Label),
- Label: channel.bridge.Label,
- Token: channel.bridge.Token,
- PrefixMessagesWithNick: channel.bridge.PrefixMessagesWithNick,
- RemoteNickFormat: channel.bridge.RemoteNickFormat
+ bridges: await Promise.all(group.channels.map(async(channel) => {
+ // Need to load raw bridge to get all fields
+ let bridge = await bridgeRepository
+ .createQueryBuilder('bridge')
+ .where('bridge.id = :id', {id:channel.bridge.id})
+ .getRawOne()
+
+ // remove bridge_ prefix
+ Object.keys(bridge).forEach(key => {
+ bridge[key.replace('bridge_', '')] = bridge[key]
+ delete bridge[key]
+ })
+
+ switch (bridge.Protocol){
+ case 'slack':
+ return {
+ protocol: bridge.Protocol,
+ name: slugify(bridge.Label),
+ Label: bridge.Label,
+ Token: bridge.Token,
+ PrefixMessagesWithNick: bridge.PrefixMessagesWithNick,
+ RemoteNickFormat: bridge.RemoteNickFormat
+ }
+ case 'discord':
+ return {
+ protocol: bridge.Protocol,
+ name: slugify(bridge.Label),
+ Label: bridge.Label,
+ Token: bridge.Token,
+ Server: bridge.Server,
+ AutoWebhooks: bridge.AutoWebhooks,
+ PreserveThreading: bridge.PreserveThreading,
+ PrefixMessagesWithNick: bridge.PrefixMessagesWithNick,
+ RemoteNickFormat: bridge.RemoteNickFormat
+ }
+ default:
+ logger.error(`No matching protocol format found for protocol ${channel.bridge.Protocol}`)
}
- }),
+ })),
inOuts: group.channels.map((channel) => {
return {
account: `${channel.bridge.Protocol}.${slugify(channel.bridge.Label)}`,
@@ -140,12 +174,21 @@ export const GatewayToTOML = (gateway: Gateway) => {
// (ie. separate the different TOML table entries rather than representing them
// inline. See https://www.npmjs.com/package/@ltd/j-toml
gateway.bridges.forEach((bridge) => {
+ let bridgeEntry
+ if (bridge.protocol === "slack") {
+ bridgeEntry = {
+ Token: bridge.Token,
+ PrefixMessagesWithNick: bridge.PrefixMessagesWithNick,
+ RemoteNickFormat: bridge.RemoteNickFormat,
+ Label: bridge.Label
+ }
+ } else if (bridge.protocol === "discord"){
- let bridgeEntry = {
- Token: bridge.Token,
- PrefixMessagesWithNick: bridge.PrefixMessagesWithNick,
- RemoteNickFormat: bridge.RemoteNickFormat,
- Label: bridge.Label
+ const {protocol, name, ...bridgeEntryInner} = bridge;
+ bridgeEntry = bridgeEntryInner
+ } else {
+ logger.error(`unknown protocol ${bridge.protocol} when generating toml config`)
+ return
}
if (!protocols.hasOwnProperty(bridge.protocol)){
@@ -155,9 +198,8 @@ export const GatewayToTOML = (gateway: Gateway) => {
protocols[bridge.protocol][bridge.name] = TOML.Section(bridgeEntry)
})
- logger.debug('gateway toml protocols', protocols)
- return {
+ let gateway_toml = {
...protocols,
'gateway': [TOML.Section({
name: gateway.name,
@@ -165,21 +207,36 @@ export const GatewayToTOML = (gateway: Gateway) => {
inout: gateway.inOuts.map((inout) => TOML.Section(inout))
})]
}
+ logger.debug('gateway toml', gateway_toml)
+
+ return gateway_toml
}
export const writeTOML = (gateway_toml: object, out_file: string) => {
- let toml_string = TOML.stringify(
- gateway_toml,
- {
- newline: '\n'
- }
- )
+ let toml_string
- // logger.debug('toml string', toml_string)
+ try {
+ toml_string = TOML.stringify(
+ gateway_toml,
+ {
+ newline: '\n'
+ }
+ )
+ } catch (err: any) {
+ logger.error(`Error creating toml from config:
+${gateway_toml}`, err)
+ return false
+ }
- fs.writeFileSync(out_file, toml_string)
+ try {
+ fs.writeFileSync(out_file, toml_string)
+ } catch (err: any){
+ logger.error(`Error writing config to file ${out_file}`)
+ return false
+ }
logger.info('Wrote group config to %s', out_file)
+ return true
}
diff --git a/server/src/matterbridge/process.ts b/server/src/matterbridge/process.ts
index a582162..dcb8de8 100644
--- a/server/src/matterbridge/process.ts
+++ b/server/src/matterbridge/process.ts
@@ -9,7 +9,7 @@ import {AppDataSource} from "../db/data-source";
const pm2 = require('pm2');
import config from "config";
-import { writeGroupConfig } from "./config";
+import {GatewayToTOML, getGroupConfig, writeGroupConfig, writeTOML} from "./config";
import {Group} from "../entities/group.entity";
import slugify from "slugify";
import logger from "../logging";
@@ -57,6 +57,17 @@ class MatterbridgeManager {
async spawnProcess(group_name: string) {
let group_name_slug = slugify(group_name)
let group_filename = `${this.matterbridge_config_dir}/matterbridge-${group_name_slug}.toml`
+ let group_config = await getGroupConfig(group_name)
+ if (group_config.inOuts.length === 0){
+ logger.info(`Not spawning group ${group_name} with no bridged channels`)
+ return
+ }
+ let res = writeTOML(GatewayToTOML(group_config), group_filename)
+ if (res === false){
+ logger.error('Not spawning, config could not be updated')
+ return
+ }
+
await writeGroupConfig(group_name, group_filename);
pm2.connect(async(err:any) => {
diff --git a/server/src/routes/discord.routes.ts b/server/src/routes/discord.routes.ts
index f9aa45e..3552e48 100644
--- a/server/src/routes/discord.routes.ts
+++ b/server/src/routes/discord.routes.ts
@@ -1,7 +1,7 @@
import express from 'express';
import {
- DiscordInstallLinkHandler,
+ DiscordInstallLinkHandler, DiscordJoinChannelsHandler, DiscordListChannelsHandler,
DiscordOAuthHandler
} from "../controllers/discord.controller";
@@ -16,5 +16,9 @@ router.route('/install')
router.route('/oauth_redirect')
.get(DiscordOAuthHandler)
+router.route('/channels')
+ .get(requireStateToken, DiscordListChannelsHandler)
+ .post(requireStateToken, DiscordJoinChannelsHandler)
+
export default router
diff --git a/server/src/schemas/discord.schema.ts b/server/src/schemas/discord.schema.ts
new file mode 100644
index 0000000..5a1cd11
--- /dev/null
+++ b/server/src/schemas/discord.schema.ts
@@ -0,0 +1,14 @@
+import { object, string, TypeOf } from 'zod';
+
+export const discordOauthRedirectSchema = object({
+ query: object({
+ code: string(),
+ state: string(),
+ guild_id: string(),
+ permissions: string()
+ })
+})
+
+
+
+export type DiscordOauthRedirectInput = TypeOf['query'];
\ No newline at end of file
diff --git a/server/src/types/config.ts b/server/src/types/config.ts
index 3779f08..efd2bc1 100644
--- a/server/src/types/config.ts
+++ b/server/src/types/config.ts
@@ -8,4 +8,5 @@ export interface slackConfigType {
export interface discordConfigType {
token: string;
client_id: string;
+ client_secret: string;
}