Skip to content

Commit

Permalink
AutoCreation of topic mappings (TediCross#372)
Browse files Browse the repository at this point in the history
New config option: topicBridgesAutoCreate
  • Loading branch information
Exzender committed Nov 22, 2023
1 parent 629065d commit 4ac3e90
Show file tree
Hide file tree
Showing 10 changed files with 208 additions and 42 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Setting up the bot requires basic knowledge of the command line, which is bash o
- It is important that the Discord channel ID is wrapped with single quotes when entered into the settings file. `'244791815503347712'`, not `244791815503347712`
12. Restart TediCross. You stop it by pressing CTRL + C in the terminal it is running in
13. To turn on threads support (EXPERIMENTAL) just add `topicBridges` section under particular `Bridge`. Write `/threadinfo` in telegram and discord threads to get corresponding IDs. !NOTE: previously this param was named `threadMap`. Old configs with that name still valid.
14. Auto mapping of threads (EXPERIMENTAL). Boolean option `topicBridgesAutoCreate` under particular `Bridge`. It's `false` by default. If it's `true` bot will create topic/thread on one side of the bridge when unmapped topic appears on another side. **NOTE** if you delete mapped topic on one of the sides of the bridge - bot will recreate it automatically on the other side - so you will need to delete topic on both sides.

Done! You now have a nice bridge between a Telegram chat and a Discord channel

Expand Down
1 change: 1 addition & 0 deletions example.settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ discord:
bridges:
- name: First bridge
direction: both
topicBridgesAutoCreate: false
topicBridges: # Remove this and (telegram/discord pairs below if not planing to use Threads )
- name: First thread # optional naming for thread mapping
telegram: TELEGRAM_THREAD_ID # Threads have positive IDs
Expand Down
5 changes: 5 additions & 0 deletions src/bridgestuff/Bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface BridgeProperties {
discord: BridgeSettingsDiscordProperties;
direction: "both" | "d2t" | "t2d";
topicBridges: any[] | undefined;
topicBridgesAutoCreate: boolean;
tgThread: number | undefined;
}

Expand All @@ -23,6 +24,7 @@ export class Bridge {
public telegram: BridgeSettingsTelegramProperties;
public discord: BridgeSettingsDiscordProperties;
public topicBridges: any[] | undefined;
public topicBridgesAutoCreate: boolean;
public threadMap: any[] | undefined;
public tgThread: number | undefined;
/**
Expand Down Expand Up @@ -53,6 +55,9 @@ export class Bridge {

/** Settings for the Threads mapping */
this.topicBridges = settings.topicBridges;

/** Settings for the auto create Threads mapping */
this.topicBridgesAutoCreate = settings.topicBridgesAutoCreate || false;
}

/**
Expand Down
65 changes: 61 additions & 4 deletions src/discord2telegram/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function makeJoinLeaveFunc(logger: Logger, verb: "joined" | "left", bridgeMap: B
* @param dcBot The Discord bot
* @param tgBot The Telegram bot
* @param messageMap Map between IDs of messages
* @param bridgeMap Map of the bridges to use
* @param extBridgeMap Map of the bridges to use
* @param settings Settings to use
* @param datadirPath Path to the directory to put data files in
*/
Expand All @@ -89,10 +89,11 @@ export function setup(
dcBot: Client,
tgBot: Telegraf,
messageMap: MessageMap,
bridgeMap: BridgeMap,
extBridgeMap: BridgeMap,
settings: Settings,
datadirPath: string
) {
let bridgeMap = extBridgeMap;
// Create the map of latest message IDs and bridges
const latestDiscordMessageIds = new LatestDiscordMessageIds(
logger,
Expand Down Expand Up @@ -181,14 +182,28 @@ export function setup(
)(message) as string;

// Check if the message is from the correct chat
const bridges = bridgeMap.fromDiscordChannelId(Number(message.channel.id));
let bridges = bridgeMap.fromDiscordChannelId(Number(message.channel.id));

if (R.isEmpty(bridges)) {
if (message.channel.type === 11) {
bridges = bridgeMap.fromDiscordChannelId(Number(message.channel.parentId));
}
}

if (!R.isEmpty(bridges)) {
for (const bridge of bridges) {
for (let bridge of bridges) {
// Ignore it if this is a telegram-to-discord bridge
if (bridge.direction === Bridge.DIRECTION_TELEGRAM_TO_DISCORD) {
continue;
}

// check for thread/topic mapping
if (bridge.topicBridgesAutoCreate) {
const res = await autoCreateTopic(bridge, message);
if (res.skip) continue;
if (res.bridge) bridge = res.bridge;
}

// This is now the latest message for this bridge
latestDiscordMessageIds.setLatest(message.id, bridge);

Expand Down Expand Up @@ -393,6 +408,7 @@ export function setup(
) {
// Check if it is the correct server
// The message is from the wrong chat. Inform the sender that this is a private bot, if they have not been informed the last minute

if (!antiInfoSpamSet.has(message.channel.id)) {
antiInfoSpamSet.add(message.channel.id);

Expand Down Expand Up @@ -456,6 +472,43 @@ export function setup(
});
});

async function autoCreateTopic(bridge: Bridge, message: Message) {
if (bridge.topicBridges) {
if (!bridge.tgThread) {
// create new topic on Telegram Side
let topicName: string;
let topicId: string;
if (message.type === 18) {
topicName = message.content;
topicId = message.reference?.channelId || "";
} else {
topicName = (message.channel as TextChannel).name;
topicId = (message.channel as TextChannel).id;
}
const tgTopic = await tgBot.telegram.createForumTopic(bridge.telegram.chatId, topicName);

// console.dir(message);

const newTopic = {
telegram: tgTopic.message_thread_id,
discord: topicId,
name: topicName
};
bridge.topicBridges.push(newTopic);
console.log("Created new Topic from DS");
settings.updateBridge(bridge);
bridge.tgThread = newTopic.telegram;

if (message.type === 18) {
return { bridge, skip: true };
} else {
return { bridge, skip: false };
}
}
}
return { bridge: null, skip: false };
}

// Listen for deleted messages
function onMessageDelete(message: Message): void {
// Check if it is a relayed message
Expand Down Expand Up @@ -602,4 +655,8 @@ export function setup(
//@ts-ignore
dcBot.ready = relayOldMessages(logger, dcBot, latestDiscordMessageIds, bridgeMap);
}

settings.on("bridgeUpdate", newBridgeMap => {
bridgeMap = newBridgeMap;
});
}
49 changes: 44 additions & 5 deletions src/fetchDiscordChannel.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,61 @@
import { Client, TextChannel } from "discord.js";
import { Client, ForumChannel, TextChannel } from "discord.js";

/**
* Gets a Discord channel, and logs an error if it doesn't exist
*
* @returns A Promise resolving to the channel, or rejecting if it could not be fetched for some reason
*/
// export const fetchDiscordChannel = R.curry((dcBot: Client, bridge) => {
export const fetchDiscordChannel = (dcBot: Client, bridge: any, threadID?: number) => {
export const fetchDiscordChannel = async (
dcBot: Client,
bridge: any,
threadID?: number,
topicName?: string,
text?: string
) => {
// Get the channel's ID
let channelId = bridge.discord.channelId;

if (bridge.topicBridges && threadID) {
for (const topicBridges of bridge.topicBridges) {
if (topicBridges.telegram === threadID) {
channelId = topicBridges.discord;
let topicFound = false;
for (const topicBridge of bridge.topicBridges) {
if (topicBridge.telegram === threadID) {
channelId = topicBridge.discord;
topicFound = true;
break;
}
}

// create new topic in discord
if (!topicFound && bridge.topicBridgesAutoCreate) {
// ignore empty messages
if (!topicName || !text) {
// console.error(`No topic name or Text: ${threadID} `);
throw "No topic / No text";
}

try {
const channel = await dcBot.channels.fetch(channelId);
// if ((channel as any)?.isThreadOnly()) {
const newTopicName = topicName || "bot-created-topic";
const newThread = await (channel as unknown as ForumChannel).threads.create({
name: newTopicName,
message: {
content: "Auto-Mapping for Telegram thread"
},
// autoArchiveDuration: 60,
reason: "Auto-Mapping for Telegram thread"
});

const newTopic = { telegram: threadID, discord: newThread.id, name: newTopicName };
bridge.topicBridges.push(newTopic);

return newThread as unknown as TextChannel;
} catch (err: any) {
console.error(`Could not create Discord channel ${channelId} in bridge ${bridge.name}: ${err.message}`);
throw err;
}
}
}

// Try to get the channel
Expand Down
21 changes: 9 additions & 12 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import yargs from "yargs";
import path from "path";
import { Logger } from "./Logger";
import { MessageMap } from "./MessageMap";
import { Bridge, BridgeProperties } from "./bridgestuff/Bridge";
import { BridgeMap } from "./bridgestuff/BridgeMap";
import { Settings } from "./settings/Settings";
import jsYaml from "js-yaml";
import fs from "fs";
Expand Down Expand Up @@ -50,21 +48,20 @@ const args = yargs
const settingsPath = args.config;
const rawSettingsObj = jsYaml.load(fs.readFileSync(settingsPath, "utf-8"));
const settings = Settings.fromObj(rawSettingsObj);
settings.setPath(settingsPath);

// Initialize logger
const logger = new Logger(settings.debug);

// Write the settings back to the settings file if they have been modified
const newRawSettingsObj = settings.toObj();

if (R.not(R.equals(rawSettingsObj, newRawSettingsObj))) {
// Turn it into notepad friendly YAML
//TODO: Replaced safeDump with dump. It needs to be verified
const yaml = jsYaml.dump(newRawSettingsObj).replace(/\n/g, "\r\n");

try {
fs.writeFileSync(settingsPath, yaml);
} catch (err: any) {
if (err.code === "EACCES") {
// Using toFile method
const res = settings.toFile(settingsPath, newRawSettingsObj);

if (res.error) {
if (res.error.code === "EACCES") {
// The settings file is not writable. Give a warning
logger.warn(
"Changes to TediCross' settings have been introduced. Your settings file it not writable, so it could not be automatically updated. TediCross will still work, with the modified settings, but you will see this warning until you update your settings file"
Expand All @@ -73,7 +70,7 @@ if (R.not(R.equals(rawSettingsObj, newRawSettingsObj))) {
// Write the settings to temp instead
const tmpPath = path.join(os.tmpdir(), "tedicross-settings.yaml");
try {
fs.writeFileSync(tmpPath, yaml);
fs.writeFileSync(tmpPath, res.yaml);
logger.info(
`The new settings file has instead been written to '${tmpPath}'. Copy it to its proper location to get rid of the warning`
);
Expand Down Expand Up @@ -109,7 +106,7 @@ const dcBot = new DiscordClient({
const messageMap = new MessageMap(settings, logger, args.dataDir);

// Create the bridge map
const bridgeMap = new BridgeMap(settings.bridges.map((bridgeSettings: BridgeProperties) => new Bridge(bridgeSettings)));
const bridgeMap = settings.getBridgeMap();

/*********************
* Set up the bridge *
Expand Down
59 changes: 51 additions & 8 deletions src/settings/Settings.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import fs from "fs";
import R from "ramda";
import { Bridge } from "../bridgestuff/Bridge";
import { Bridge, BridgeProperties } from "../bridgestuff/Bridge";
import { TelegramSettings } from "./TelegramSettings";
import { DiscordSettings } from "./DiscordSettings";
import jsYaml from "js-yaml";
import moment from "moment";
import EventEmitter from "events";
import { BridgeMap } from "../bridgestuff/BridgeMap";

interface SettingProperties {
telegram: TelegramSettings;
Expand All @@ -24,14 +26,15 @@ interface SettingProperties {
/**
* Settings class for TediCross
*/
export class Settings {
export class Settings extends EventEmitter {
debug: boolean;
messageTimeoutAmount: number;
messageTimeoutUnit: moment.unitOfTime.DurationConstructor;
persistentMessageMap: boolean;
discord: DiscordSettings;
telegram: TelegramSettings;
bridges: Bridge[];
settingsPath?: string;

/**
* Creates a new settings object
Expand All @@ -48,6 +51,7 @@ export class Settings {
* @throws If the raw settings object does not validate
*/
constructor(settings: SettingProperties) {
super();
// Make sure the settings are valid
Settings.validate(settings);

Expand All @@ -71,22 +75,59 @@ export class Settings {

/** The config for the bridges */
this.bridges = settings.bridges;

this.settingsPath = "./";
}

updateBridge(bridge: Bridge) {
// console.dir(bridge);
this.bridges.forEach(oldBridge => {
if (
oldBridge.telegram.chatId === bridge.telegram.chatId &&
oldBridge.discord.channelId === bridge.discord.channelId
) {
oldBridge.topicBridges = bridge.topicBridges;
}
});
this.toFile(this.settingsPath as string);
const bridgeMap = this.getBridgeMap();
this.emit("bridgeUpdate", bridgeMap);
}

getBridgeMap(): BridgeMap {
return new BridgeMap(this.bridges.map((bridgeSettings: BridgeProperties) => new Bridge(bridgeSettings)));
}

setPath(path: string) {
this.settingsPath = path;
}

/**
* Saves the settings to file
*
* @param filepath Filepath to save to. Absolute path is recommended
* @param saveObject External settings
*/
toFile(filepath: string) {
// The raw object is not suitable for YAML-ification. A few `toJSON()` methods will not be triggered that way. Go via JSON
const objectToSave = JSON.parse(JSON.stringify(this));
toFile(filepath: string, saveObject?: any) {
const objectToSave = saveObject || JSON.parse(JSON.stringify(this));

// don't export path
delete objectToSave.settingsPath;

// Convert the object to quite human-readable YAML and write it to the file
//TODO replaced safeDump with dump. The old method is deprecated. Check if it still works
const yaml = jsYaml.dump(objectToSave);
const notepadFriendlyYaml = yaml.replace(/\n/g, "\r\n");
fs.writeFileSync(filepath, notepadFriendlyYaml);

let error: any;
try {
// The raw object is not suitable for YAML-ification. A few `toJSON()` methods will not be triggered that way. Go via JSON
fs.writeFileSync(filepath, notepadFriendlyYaml);
} catch (err: any) {
error = err;
console.error(`Error occurred while writing settings to file: ${err}`);
}

return { yaml: notepadFriendlyYaml, error };
}

/**
Expand All @@ -96,7 +137,9 @@ export class Settings {
*/
toObj(): object {
// Hacky way to turn this into a plain object...
return JSON.parse(JSON.stringify(this));
const obj = JSON.parse(JSON.stringify(this));
delete obj.settingsPath;
return obj;
}

/**
Expand Down
Loading

0 comments on commit 4ac3e90

Please sign in to comment.