discord is working

This commit is contained in:
ansible user/allowed to read system logs 2023-08-03 19:42:05 -07:00
parent 8504775ff3
commit c138fc68d0
16 changed files with 311 additions and 49 deletions

View file

@ -10,10 +10,10 @@ Very unfinished!! Mostly a programming exercise for me for now to practice fulls
## TODO ## TODO
- [ ] Manage matterbridge processes - [x] Manage matterbridge processes
- [ ] Sanitize user inputs - [ ] Sanitize user inputs
- [ ] Check status of matterbridge processes - [ ] Check status of matterbridge processes
- [ ] Complete slack login workflow - [x] Complete slack login workflow
- [ ] Kill group processes & delete config when group is deleted - [ ] Kill group processes & delete config when group is deleted
## Supported Clients ## Supported Clients

View file

@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL. 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`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<title>ChatBridge2</title> <title>ChatBridge</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View file

@ -5,3 +5,11 @@ export const getDiscordInstallURL = (callback: CallableFunction) => {
callback(res.data.url) callback(res.data.url)
}) })
} }
export const getDiscordChannels = (callback: CallableFunction) => {
fetch('api/discord/channels')
.then(res => res.json())
.then(res => {
callback(res.data.channels)
})
}

View file

@ -9,6 +9,7 @@ import {getSlackChannels, joinSlackChannel} from "../../api/slack";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {channelsType} from "../../types/channel"; import {channelsType} from "../../types/channel";
import {bridgeType} from "../../types/bridge"; import {bridgeType} from "../../types/bridge";
import {getDiscordChannels} from "../../api/discord";
export interface JoinChannelProps { export interface JoinChannelProps {
@ -37,6 +38,10 @@ const JoinChannel = ({
switch(platform){ switch(platform){
case "Slack": case "Slack":
getSlackChannels(setChannels) getSlackChannels(setChannels)
break
case "Discord":
getDiscordChannels(setChannels)
break
} }
} }
}, [platform, bridge]) }, [platform, bridge])
@ -51,7 +56,13 @@ const JoinChannel = ({
} }
const onChannelChanged = (evt:any) => { const onChannelChanged = (evt:any) => {
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}) setStepComplete({...stepComplete, channel: false})
}
setSelectedChannel(evt.target.value) setSelectedChannel(evt.target.value)
} }
@ -67,7 +78,7 @@ const JoinChannel = ({
return ( return (
<div className={"list-row"}> <div className={"list-row"}>
<FormControl sx={{width: "50%"}}> <FormControl sx={{width: platform === "Slack" ? "50%" : "100%"}}>
<InputLabel>Select Channel</InputLabel> <InputLabel>Select Channel</InputLabel>
<Select <Select
// value={selectedChannel} // value={selectedChannel}
@ -96,6 +107,7 @@ const JoinChannel = ({
</Select> </Select>
</FormControl> </FormControl>
{platform === "Slack" ?
<Button <Button
variant={"outlined"} variant={"outlined"}
onClick={onJoinButtonClicked} onClick={onJoinButtonClicked}
@ -104,6 +116,7 @@ const JoinChannel = ({
> >
{stepComplete.channel ? 'Channel Joined!' : 'Join Channel'} {stepComplete.channel ? 'Channel Joined!' : 'Join Channel'}
</Button> </Button>
: null }
</div> </div>
) )
} }

View file

@ -50,7 +50,7 @@ export const DiscordLogin = ({
color={bridge !== undefined ? 'success': undefined} color={bridge !== undefined ? 'success': undefined}
disabled={installLink === undefined} disabled={installLink === undefined}
> >
{installLink === undefined ? 'Waiting for Install Link...' : 'Add to Slack'} {installLink === undefined ? 'Waiting for Install Link...' : 'Add to Discord'}
</Button> </Button>
) )
} }

View file

@ -2,6 +2,9 @@ PORT=8999
NODE_ENV=development NODE_ENV=development
NODE_CONFIG_DIR = ./server/config NODE_CONFIG_DIR = ./server/config
LOG_DIR=/var/log/chatbridge 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_HOST=127.0.0.1
POSTGRES_PORT=6500 POSTGRES_PORT=6500
@ -33,5 +36,6 @@ SLACK_STATE_SECRET=
# Discord ---------------- # Discord ----------------
DISCORD_TOKEN= DISCORD_TOKEN=
DISCORD_CLIENT_ID= DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=

View file

@ -15,7 +15,9 @@ export default {
}, },
discordConfig: { discordConfig: {
token: 'DISCORD_TOKEN', token: 'DISCORD_TOKEN',
client_id: "DISCORD_CLIENT_ID" client_id: "DISCORD_CLIENT_ID",
client_secret: 'DISCORD_CLIENT_SECRET'
}, },
admin_token: 'ADMIN_TOKEN', admin_token: 'ADMIN_TOKEN',
cookies:{ cookies:{
@ -26,5 +28,6 @@ export default {
bin: 'MATTERBRIDGE_BINARY', bin: 'MATTERBRIDGE_BINARY',
config: 'MATTERBRIDGE_CONFIG_DIR' config: 'MATTERBRIDGE_CONFIG_DIR'
}, },
logDir: 'LOG_DIR' logDir: 'LOG_DIR',
baseURL: 'BASE_URL'
} }

View file

@ -5,12 +5,22 @@ import config from "config";
import {discordConfigType} from "../types/config"; import {discordConfigType} from "../types/config";
import {DiscordBridge} from "../entities/bridge.entity"; import {DiscordBridge} from "../entities/bridge.entity";
import {AppDataSource} from "../db/data-source"; 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 discordBridgeRepository = AppDataSource.getRepository(DiscordBridge)
const discordConfig = config.get<discordConfigType>('discordConfig'); const discordConfig = config.get<discordConfigType>('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<string>('baseURL'))
baseURL.pathname = pathJoin(baseURL.pathname, '/api/discord/oauth_redirect')
const REDIRECT_URL = baseURL.toString()
export const DiscordInstallLinkHandler = async( export const DiscordInstallLinkHandler = async(
req: Request, req: Request,
res: Response res: Response
@ -18,7 +28,7 @@ export const DiscordInstallLinkHandler = async(
let state_token = randomUUID() let state_token = randomUUID()
req.session.state_token = state_token; 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({ res.status(200).json({
status: 'success', status: 'success',
@ -32,8 +42,118 @@ export const DiscordInstallLinkHandler = async(
export const DiscordOAuthHandler = async( export const DiscordOAuthHandler = async(
req: Request<{},{},{},DiscordOauthRedirectInput>,
res: Response
) => {
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('<html><body><h1>Success! Return to the chatbridge login window.</h1><h3>This tab will close in 3 seconds...</h3><script>window.setTimeout(window.close, 3000)</script></body>')
} catch {
return res.status(500).json({
status: 'failed',
message: 'oauth succeeded, but error creating bridge in database'
})
}
}
export const DiscordListChannelsHandler = async(
req: Request, req: Request,
res: Response res: Response
) => { ) => {
console.log('discord oauth', req.body, req.query) 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
) => {
} }

View file

@ -213,3 +213,5 @@ export const getBotInfo = async(
}) })
} }
} }

View file

@ -24,12 +24,6 @@ export class Bridge extends Model {
@Column() @Column()
state_token: string; state_token: string;
// Bot token for slack
@Column({
unique: true
})
Token: string;
@Column({ @Column({
default: true default: true
}) })
@ -52,6 +46,12 @@ export class Bridge extends Model {
@ChildEntity() @ChildEntity()
export class SlackBridge extends Bridge { export class SlackBridge extends Bridge {
// Bot token for slack
@Column({
unique: true
})
Token: string;
// The ID of the team // The ID of the team
@Column({nullable:true}) @Column({nullable:true})
team_id: string; team_id: string;
@ -73,6 +73,12 @@ export class SlackBridge extends Bridge {
@ChildEntity() @ChildEntity()
export class DiscordBridge extends Bridge { 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 // Server name - needed to use multiple 'servers' with a single discord app
@Column() @Column()
Server: string; Server: string;
@ -80,6 +86,25 @@ export class DiscordBridge extends Bridge {
// Analogous to team_id in slack bridge // Analogous to team_id in slack bridge
@Column() @Column()
guild_id: string; 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() @ChildEntity()

View file

@ -1,6 +1,6 @@
import { Entity, Column, Index, ManyToOne, OneToOne } from 'typeorm'; import { Entity, Column, Index, ManyToOne, OneToOne } from 'typeorm';
import Model from './model.entity'; import Model from './model.entity';
import { Bridge } from "./bridge.entity"; import {Bridge, SlackBridge} from "./bridge.entity";
import { Group } from "./group.entity"; import { Group } from "./group.entity";
@Entity('channels') @Entity('channels')
@ -8,7 +8,7 @@ export class Channel extends Model {
@Column() @Column()
name: string; name: string;
@ManyToOne(() => Bridge, (bridge) => bridge.channels, @ManyToOne((type) => {console.log('!!!!! type', type); return Bridge}, (bridge) => bridge.channels,
{ {
eager: true eager: true
} }

View file

@ -9,10 +9,11 @@ import {Group} from "../entities/group.entity";
const slugify = require('slugify'); const slugify = require('slugify');
import * as TOML from '@ltd/j-toml'; import * as TOML from '@ltd/j-toml';
import logger from "../logging"; import logger from "../logging";
import {Bridge} from "../entities/bridge.entity";
const fs = require('fs'); const fs = require('fs');
const groupRepository = AppDataSource.getRepository(Group) const groupRepository = AppDataSource.getRepository(Group)
const bridgeRepository = AppDataSource.getRepository(Bridge)
/* /*
@ -27,7 +28,10 @@ type BridgeEntry = {
Label: string; Label: string;
Token: string; Token: string;
PrefixMessagesWithNick: boolean; 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<Gateway> => { export const getGroupConfig = async (group_name: string): Promise<Gateway> => {
let group = await groupRepository.findOne({ let group = await groupRepository.findOne({
where: {name: group_name}, where: {name: group_name},
relations: {channels: true} relations: {channels: true}
}) })
logger.debug('config group', group) logger.debug('config group', group)
// Construct config in the gateway style for programmatic use // Construct config in the gateway style for programmatic use
let gateway = <Gateway>{ let gateway = <Gateway>{
name: group.name, name: group.name,
enable: group.enable, enable: group.enable,
bridges: group.channels.map((channel) => { 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 { return {
protocol: channel.bridge.Protocol, protocol: bridge.Protocol,
name: slugify(channel.bridge.Label), name: slugify(bridge.Label),
Label: channel.bridge.Label, Label: bridge.Label,
Token: channel.bridge.Token, Token: bridge.Token,
PrefixMessagesWithNick: channel.bridge.PrefixMessagesWithNick, PrefixMessagesWithNick: bridge.PrefixMessagesWithNick,
RemoteNickFormat: channel.bridge.RemoteNickFormat 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) => { inOuts: group.channels.map((channel) => {
return { return {
account: `${channel.bridge.Protocol}.${slugify(channel.bridge.Label)}`, account: `${channel.bridge.Protocol}.${slugify(channel.bridge.Label)}`,
@ -140,13 +174,22 @@ export const GatewayToTOML = (gateway: Gateway) => {
// (ie. separate the different TOML table entries rather than representing them // (ie. separate the different TOML table entries rather than representing them
// inline. See https://www.npmjs.com/package/@ltd/j-toml // inline. See https://www.npmjs.com/package/@ltd/j-toml
gateway.bridges.forEach((bridge) => { gateway.bridges.forEach((bridge) => {
let bridgeEntry
let bridgeEntry = { if (bridge.protocol === "slack") {
bridgeEntry = {
Token: bridge.Token, Token: bridge.Token,
PrefixMessagesWithNick: bridge.PrefixMessagesWithNick, PrefixMessagesWithNick: bridge.PrefixMessagesWithNick,
RemoteNickFormat: bridge.RemoteNickFormat, RemoteNickFormat: bridge.RemoteNickFormat,
Label: bridge.Label Label: bridge.Label
} }
} else if (bridge.protocol === "discord"){
const {protocol, name, ...bridgeEntryInner} = bridge;
bridgeEntry = bridgeEntryInner
} else {
logger.error(`unknown protocol ${bridge.protocol} when generating toml config`)
return
}
if (!protocols.hasOwnProperty(bridge.protocol)){ if (!protocols.hasOwnProperty(bridge.protocol)){
protocols[bridge.protocol] = {}; protocols[bridge.protocol] = {};
@ -155,9 +198,8 @@ export const GatewayToTOML = (gateway: Gateway) => {
protocols[bridge.protocol][bridge.name] = TOML.Section(bridgeEntry) protocols[bridge.protocol][bridge.name] = TOML.Section(bridgeEntry)
}) })
logger.debug('gateway toml protocols', protocols)
return { let gateway_toml = {
...protocols, ...protocols,
'gateway': [TOML.Section({ 'gateway': [TOML.Section({
name: gateway.name, name: gateway.name,
@ -165,21 +207,36 @@ export const GatewayToTOML = (gateway: Gateway) => {
inout: gateway.inOuts.map((inout) => TOML.Section(inout)) 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) => { export const writeTOML = (gateway_toml: object, out_file: string) => {
let toml_string = TOML.stringify( let toml_string
try {
toml_string = TOML.stringify(
gateway_toml, gateway_toml,
{ {
newline: '\n' newline: '\n'
} }
) )
} catch (err: any) {
logger.error(`Error creating toml from config:
${gateway_toml}`, err)
return false
}
// logger.debug('toml string', toml_string) try {
fs.writeFileSync(out_file, toml_string) 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) logger.info('Wrote group config to %s', out_file)
return true
} }

View file

@ -9,7 +9,7 @@ import {AppDataSource} from "../db/data-source";
const pm2 = require('pm2'); const pm2 = require('pm2');
import config from "config"; import config from "config";
import { writeGroupConfig } from "./config"; import {GatewayToTOML, getGroupConfig, writeGroupConfig, writeTOML} from "./config";
import {Group} from "../entities/group.entity"; import {Group} from "../entities/group.entity";
import slugify from "slugify"; import slugify from "slugify";
import logger from "../logging"; import logger from "../logging";
@ -57,6 +57,17 @@ class MatterbridgeManager {
async spawnProcess(group_name: string) { async spawnProcess(group_name: string) {
let group_name_slug = slugify(group_name) let group_name_slug = slugify(group_name)
let group_filename = `${this.matterbridge_config_dir}/matterbridge-${group_name_slug}.toml` 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); await writeGroupConfig(group_name, group_filename);
pm2.connect(async(err:any) => { pm2.connect(async(err:any) => {

View file

@ -1,7 +1,7 @@
import express from 'express'; import express from 'express';
import { import {
DiscordInstallLinkHandler, DiscordInstallLinkHandler, DiscordJoinChannelsHandler, DiscordListChannelsHandler,
DiscordOAuthHandler DiscordOAuthHandler
} from "../controllers/discord.controller"; } from "../controllers/discord.controller";
@ -16,5 +16,9 @@ router.route('/install')
router.route('/oauth_redirect') router.route('/oauth_redirect')
.get(DiscordOAuthHandler) .get(DiscordOAuthHandler)
router.route('/channels')
.get(requireStateToken, DiscordListChannelsHandler)
.post(requireStateToken, DiscordJoinChannelsHandler)
export default router export default router

View file

@ -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<typeof discordOauthRedirectSchema>['query'];

View file

@ -8,4 +8,5 @@ export interface slackConfigType {
export interface discordConfigType { export interface discordConfigType {
token: string; token: string;
client_id: string; client_id: string;
client_secret: string;
} }