Compare commits

...

6 Commits
master ... ts

Author SHA1 Message Date
62f45722c4 -.- 2025-03-19 12:54:12 +01:00
90f43a734e ... 2025-03-19 11:47:34 +01:00
2d13c865af refactor: remove flumm-fetch dependency 2025-03-19 11:47:26 +01:00
ca114f677e refactor: enhance event handling in IRC, Slack, and Telegram clients 2025-03-18 10:47:58 +01:00
3e1cfe18f9 xd 2025-03-18 09:57:09 +01:00
52d79e0763 typescript schmypescript 2025-03-18 09:55:57 +01:00
68 changed files with 2231 additions and 894 deletions

93
dist/clients/irc.d.ts vendored Normal file
View File

@ -0,0 +1,93 @@
import EventEmitter from "events";
type MessageModes = "normal" | "action" | "notice";
interface IRCOptions {
channels?: string[];
host?: string;
port?: number;
ssl?: boolean;
selfSigned?: boolean;
sasl?: boolean;
network?: string;
nickname?: string;
username?: string;
realname?: string;
set?: string;
}
interface ParsedCommand {
prefix: string | null;
command: string;
params: string[];
}
interface IRCEvents {
data: [string | [string, any]];
error: [string];
}
export default class irc extends EventEmitter {
private options;
private socket?;
private _recachetime;
private _cmd;
private server;
emit<K extends keyof IRCEvents>(event: K, ...args: IRCEvents[K]): boolean;
on<K extends keyof IRCEvents>(event: K, listener: (...args: IRCEvents[K]) => void): this;
constructor(options: IRCOptions);
initialize(): Promise<void>;
private createSocket;
connect(reconnect?: boolean): void;
private handleConnection;
private handleData;
private handleDisconnect;
private handleError;
private join;
private part;
private whois;
send(data: string): void;
sendmsg(mode: MessageModes, recipient: string, msg: string | string[]): void;
parse(data: string): ParsedCommand;
private format;
parsePrefix(prefix: string | null): {
nick: string;
username: string;
hostname: string;
} | false;
reply(tmp: ParsedCommand): {
type: string;
network: string;
channel: string;
channelid: string;
user: {
account: string | boolean;
prefix: string;
nick?: string | undefined;
username?: string | undefined;
hostname?: string | undefined;
};
message: string;
time: number;
raw: ParsedCommand;
reply: (msg: string) => void;
replyAction: (msg: string) => void;
replyNotice: (msg: string) => void;
self: {
set: string;
motd: string;
me: Record<string, unknown>;
channel: Map<string, unknown>;
user: Map<string, {
account?: string;
cached: number;
}>;
};
_chan: unknown;
_user: Map<string, {
account?: string;
cached: number;
}>;
_cmd: Map<string, (cmd: ParsedCommand) => void>;
join: (chan: string) => void;
part: (chan: string, msg?: string) => void;
whois: (user: string) => void;
write: (msg: string) => void;
};
}
export {};

214
dist/clients/irc.js vendored Normal file
View File

@ -0,0 +1,214 @@
import _fs from "fs";
import { fileURLToPath } from "url";
import { dirname } from "path";
import net from "net";
import tls from "tls";
import EventEmitter from "events";
const fs = _fs.promises;
const __dirname = dirname(fileURLToPath(import.meta.url));
const colors = {
white: "00", black: "01", navy: "02", green: "03", red: "04",
brown: "05", purple: "06", orange: "07", yellow: "08",
lightgreen: "09", teal: "10", cyan: "11", blue: "12",
magenta: "13", gray: "14", lightgray: "15"
};
const msgmodes = {
normal: "PRIVMSG {recipient} :{msg}",
action: "PRIVMSG {recipient} :\u0001ACTION {msg}\u0001",
notice: "NOTICE {recipient} :{msg}",
};
const replaceColor = (match, color, text) => {
return colors[color] ? `\x03${colors[color]}${text}\x0F` : text;
};
export default class irc extends EventEmitter {
options;
socket;
_recachetime = 60 * 30;
_cmd = new Map();
server;
emit(event, ...args) {
return super.emit(event, ...args);
}
on(event, listener) {
return super.on(event, listener);
}
constructor(options) {
super();
this.options = {
channels: [],
host: "127.0.0.1",
port: 6667,
ssl: false,
selfSigned: false,
sasl: false,
network: "test",
nickname: "test",
username: "test",
realname: "test",
set: "all",
...options
};
this.server = {
set: this.options.set,
motd: "",
me: {},
channel: new Map(),
user: new Map()
};
return (async () => {
await this.initialize();
return this;
})();
}
async initialize() {
const dir = (await fs.readdir(`${__dirname}/irc`)).filter(f => f.endsWith(".js"));
await Promise.all(dir.map(async (mod) => {
return (await import(`${__dirname}/irc/${mod}`)).default(this);
}));
this.connect();
}
createSocket() {
return this.options.ssl
? tls.connect({
host: this.options.host,
port: this.options.port,
rejectUnauthorized: !this.options.selfSigned,
})
: net.connect({
host: this.options.host,
port: this.options.port,
});
}
connect(reconnect = false) {
if (reconnect)
this.socket = undefined;
this.socket = this.createSocket();
this.socket.on("data", (msg) => this.handleData(msg));
this.socket.on("end", () => this.handleDisconnect());
this.socket.on("error", (err) => this.handleError(err));
this.socket.setEncoding("utf-8");
this.handleConnection();
}
handleConnection() {
this.send(`NICK ${this.options.nickname}`);
this.send(`USER ${this.options.username} 0 * :${this.options.realname}`);
if (this.options.sasl)
this.send("CAP LS");
this.emit("data", "[irc] connected!");
}
handleData(msg) {
msg.split(/\r?\n|\r/).forEach((line) => {
if (line.trim().length > 0) {
const cmd = this.parse(line);
if (this._cmd.has(cmd.command))
this._cmd.get(cmd.command)?.(cmd);
}
});
}
handleDisconnect() {
this.emit("data", ["error", "[irc] stream ended, reconnecting in progress"]);
this.connect(true);
}
handleError(err) {
this.emit("data", ["error", `[irc] socket error: ${err.message}`]);
this.connect(true);
}
join(channels) {
if (!Array.isArray(channels))
channels = [channels];
channels.forEach(e => {
this.send(`JOIN ${e}`);
});
}
part(channel, msg) {
this.send(`PART ${channel} :${msg || ""}`);
}
whois(nick) {
if (!Array.isArray(nick))
nick = [nick];
nick.forEach(e => {
this.send(`WHOIS ${e}`);
});
}
send(data) {
if (this.socket)
this.socket.write(`${data}\n`);
else
this.emit("data", ["info", `[irc] nope: ${data}`]);
}
sendmsg(mode, recipient, msg) {
const messages = Array.isArray(msg) ? msg : msg.split(/\r?\n/);
if (messages.length >= 5)
this.emit("data", ["error", "[irc] too many lines"]);
messages.forEach((e) => {
const formatted = msgmodes[mode]
.replace("{recipient}", recipient)
.replace("{msg}", this.format(e));
this.send(formatted);
});
}
parse(data) {
const [a, ...b] = data.split(/ +:/);
const tmp = a.split(" ").concat(b);
const prefix = data.charAt(0) === ":"
? tmp.shift() ?? null
: null;
const command = tmp.shift();
const params = command.toLowerCase() === "privmsg"
? [tmp.shift(), tmp.join(" :")]
: tmp;
return { prefix, command, params };
}
format(msg) {
return msg
.replace(/\[b\](.*?)\[\/b\]/g, "\x02$1\x02")
.replace(/\[i\](.*?)\[\/i\]/g, "\x1D$1\x1D")
.replace(/\[color=(.*?)](.*?)\[\/color\]/g, replaceColor);
}
parsePrefix(prefix) {
if (!prefix)
return false;
const parsed = /:?(.*)\!(.*)@(.*)/.exec(prefix);
if (!parsed)
return false;
return {
nick: parsed[1],
username: parsed[2],
hostname: parsed[3],
};
}
reply(tmp) {
return {
type: "irc",
network: this.options.network,
channel: tmp.params[0],
channelid: tmp.params[0],
user: {
...this.parsePrefix(tmp.prefix),
account: (() => {
const parsedPrefix = this.parsePrefix(tmp.prefix);
return parsedPrefix && this.server.user.has(parsedPrefix.nick)
? this.server.user.get(parsedPrefix.nick)?.account || false
: false;
})(),
prefix: (tmp.prefix?.charAt(0) === ":"
? tmp.prefix.substring(1)
: tmp.prefix) + `@${this.options.network}`,
},
message: tmp.params[1].replace(/\u0002/g, ""),
time: Math.floor(Date.now() / 1000),
raw: tmp,
reply: (msg) => this.sendmsg("normal", tmp.params[0], msg),
replyAction: (msg) => this.sendmsg("action", tmp.params[0], msg),
replyNotice: (msg) => this.sendmsg("notice", tmp.params[0], msg),
self: this.server,
_chan: this.server.channel.get(tmp.params[0]),
_user: this.server.user,
_cmd: this._cmd,
join: (chan) => this.join(chan),
part: (chan, msg) => this.part(chan, msg),
whois: (user) => this.whois(user),
write: (msg) => this.send(msg)
};
}
}

3
dist/clients/irc/cap.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import { Bot } from "../../types";
declare const _default: (bot: Bot) => void;
export default _default;

20
dist/clients/irc/cap.js vendored Normal file
View File

@ -0,0 +1,20 @@
export default (bot) => {
bot._cmd.set("CAP", (msg) => {
switch (msg.params[1]) {
case "LS":
bot.send(`CAP REQ :${msg.params[2]}`);
break;
case "ACK":
bot.send("AUTHENTICATE PLAIN");
break;
}
});
bot._cmd.set("AUTHENTICATE", (msg) => {
if (msg.params[0].match(/\+/)) {
bot.send(`AUTHENTICATE ${Buffer.from(bot.username + "\u0000" + bot.username + "\u0000" + bot.options.password).toString("base64")}`);
}
});
bot._cmd.set("900", (msg) => {
bot.send("CAP END");
});
};

3
dist/clients/irc/invite.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import { Bot } from "../../types";
declare const _default: (bot: Bot) => void;
export default _default;

12
dist/clients/irc/invite.js vendored Normal file
View File

@ -0,0 +1,12 @@
export default (bot) => {
bot._cmd.set("INVITE", (msg) => {
const user = bot.parsePrefix(msg.prefix);
const channel = msg.params[1];
if (!bot.server.channel.has(channel)) {
bot.join(channel);
setTimeout(() => {
bot.send(`PRIVMSG ${channel} :Hi. Wurde von ${user.nick} eingeladen.`);
}, 1000);
}
});
};

3
dist/clients/irc/join.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import { Bot } from "../../types";
declare const _default: (bot: Bot) => void;
export default _default;

5
dist/clients/irc/join.js vendored Normal file
View File

@ -0,0 +1,5 @@
export default (bot) => {
bot._cmd.set("JOIN", (msg) => {
bot.send(`WHO ${msg.params[0]}`);
});
};

3
dist/clients/irc/motd.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import { Bot } from "../../types";
declare const _default: (bot: Bot) => void;
export default _default;

12
dist/clients/irc/motd.js vendored Normal file
View File

@ -0,0 +1,12 @@
export default (bot) => {
bot._cmd.set("372", (msg) => {
bot.server.motd += `${msg.params[1]}\n`;
});
bot._cmd.set("375", (msg) => {
bot.server.motd = `${msg.params[1]}\n`;
});
bot._cmd.set("376", (msg) => {
bot.server.motd += `${msg.params[1]}\n`;
bot.emit("data", ["motd", bot.server.motd]);
});
};

3
dist/clients/irc/msg.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import { Bot } from "../../types";
declare const _default: (bot: Bot) => void;
export default _default;

13
dist/clients/irc/msg.js vendored Normal file
View File

@ -0,0 +1,13 @@
export default (bot) => {
bot._cmd.set("PRIVMSG", (msg) => {
if (msg.params[1] === "\u0001VERSION\u0001")
return bot.emit("data", ["ctcp:version", bot.reply(msg)]);
else if (msg.params[1].match(/^\u0001PING .*\u0001/i))
return bot.emit("data", ["ctcp:ping", bot.reply(msg)]);
else
bot.emit("data", ["message", bot.reply(msg)]);
});
bot._cmd.set("NOTICE", (msg) => {
bot.emit("data", ["notice", msg.params[1]]);
});
};

3
dist/clients/irc/nick.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import { Bot } from "../../types";
declare const _default: (bot: Bot) => void;
export default _default;

8
dist/clients/irc/nick.js vendored Normal file
View File

@ -0,0 +1,8 @@
export default (bot) => {
bot._cmd.set("NICK", (msg) => {
const prefix = bot.parsePrefix(msg.prefix);
if (bot.server.user.has(prefix.nick))
bot.server.user.delete(prefix.nick);
bot.whois(msg.params[0], true);
});
};

3
dist/clients/irc/part.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import { Bot } from "../../types";
declare const _default: (bot: Bot) => void;
export default _default;

5
dist/clients/irc/part.js vendored Normal file
View File

@ -0,0 +1,5 @@
export default (bot) => {
bot._cmd.set("PART", (msg) => {
delete bot.server.user[msg.params[0]];
});
};

3
dist/clients/irc/ping.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import { Bot } from "../../types";
declare const _default: (bot: Bot) => void;
export default _default;

5
dist/clients/irc/ping.js vendored Normal file
View File

@ -0,0 +1,5 @@
export default (bot) => {
bot._cmd.set("PING", (msg) => {
bot.send(`PONG ${msg.params.join('')}`);
});
};

3
dist/clients/irc/pwdreq.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import { Bot } from "../../types";
declare const _default: (bot: Bot) => void;
export default _default;

6
dist/clients/irc/pwdreq.js vendored Normal file
View File

@ -0,0 +1,6 @@
export default (bot) => {
bot._cmd.set("464", (msg) => {
if (bot.options.password.length > 0 && !bot.options.sasl)
bot.send(`PASS ${bot.options.password}`);
});
};

3
dist/clients/irc/welcome.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import { Bot } from "../../types";
declare const _default: (bot: Bot) => void;
export default _default;

6
dist/clients/irc/welcome.js vendored Normal file
View File

@ -0,0 +1,6 @@
export default (bot) => {
bot._cmd.set("001", (msg) => {
bot.join(bot.options.channels);
bot.emit("data", ["connected", msg.params[1]]);
});
};

3
dist/clients/irc/who.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import { Bot } from "../../types";
declare const _default: (bot: Bot) => void;
export default _default;

17
dist/clients/irc/who.js vendored Normal file
View File

@ -0,0 +1,17 @@
const max = 400;
let whois = [];
let chan;
export default (bot) => {
bot._cmd.set("352", (msg) => {
chan = msg.params[1];
whois.push(msg.params[5]);
});
bot._cmd.set("315", (msg) => {
bot.server.channel.set(chan, whois);
whois = [...new Set(whois)];
Array(Math.ceil(whois.length / 10)).fill(undefined).map(() => whois.splice(0, 10)).forEach((l) => {
bot.whois(l);
});
whois = [];
});
};

3
dist/clients/irc/whois.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import { Bot } from "../../types";
declare const _default: (bot: Bot) => void;
export default _default;

62
dist/clients/irc/whois.js vendored Normal file
View File

@ -0,0 +1,62 @@
export default (bot) => {
bot._cmd.set("307", (msg) => {
let tmpuser = bot.server.user.get(msg.params[1]) || {};
tmpuser.account = msg.params[1];
tmpuser.registered = true;
bot.server.user.set(msg.params[1], tmpuser);
});
bot._cmd.set("311", (msg) => {
let tmpuser = bot.server.user.get(msg.params[1]) || {};
tmpuser.nickname = msg.params[1];
tmpuser.username = msg.params[2];
tmpuser.hostname = msg.params[3];
tmpuser.realname = msg.params[5];
tmpuser.prefix = `${msg.params[1]}!${msg.params[2]}@${msg.params[3]}`;
bot.server.user.set(msg.params[1], tmpuser);
});
bot._cmd.set("313", (msg) => {
let tmpuser = bot.server.user.get(msg.params[1]) || {};
tmpuser.oper = true;
bot.server.user.set(msg.params[1], tmpuser);
});
bot._cmd.set("318", (msg) => {
let tmpuser = bot.server.user.get(msg.params[1]) || {};
tmpuser = {
nick: tmpuser.nick || false,
nickname: tmpuser.nickname || false,
username: tmpuser.username || false,
hostname: tmpuser.hostname || false,
realname: tmpuser.realname || false,
account: tmpuser.account || false,
prefix: tmpuser.prefix || false,
registered: tmpuser.registered || false,
oper: tmpuser.oper || false,
channels: tmpuser.channels || [],
cached: Math.floor(Date.now() / 1000),
};
bot.server.user.set(msg.params[1], tmpuser);
if (msg.params[0] === msg.params[1]) {
bot.server.me = tmpuser;
bot.server.user.delete(msg.params[1]);
}
});
bot._cmd.set("319", (msg) => {
let tmpchan = new Map();
let tmpuser = bot.server.user.get(msg.params[1]) || {};
if (tmpuser.channels)
tmpchan = new Map(tmpuser.channels);
const chans = msg.params[2].trim().split(" ");
chans.forEach((chan) => {
const [flags, name] = chan.split("#");
tmpchan.set(`#${name}`, flags);
});
tmpuser.channels = tmpchan;
bot.server.user.set(msg.params[1], tmpuser);
});
bot._cmd.set("330", (msg) => {
let tmpuser = bot.server.user.get(msg.params[1]) || {};
tmpuser.account = msg.params[2];
tmpuser.registered = true;
bot.server.user.set(msg.params[1], tmpuser);
});
};

44
dist/clients/slack.d.ts vendored Normal file
View File

@ -0,0 +1,44 @@
import EventEmitter from "node:events";
interface SlackOptions {
token: string;
set?: string;
}
interface User {
account: string;
nickname: string;
}
interface SlackMessage {
type: string;
channel: string;
user: string;
text: string;
}
interface SlackEvents {
data: [string | [string, any]];
error: [string];
message: [SlackMessage];
}
export default class slack extends EventEmitter {
private options;
private token;
private api;
private interval;
private server;
private reconnectAttempts;
emit<K extends keyof SlackEvents>(event: K, ...args: SlackEvents[K]): boolean;
on<K extends keyof SlackEvents>(event: K, listener: (...args: SlackEvents[K]) => void): this;
constructor(options: SlackOptions);
connect(): Promise<void>;
private initializeWebSocket;
private handleWebSocketEvents;
reconnect(): Promise<void>;
getChannel(channelId: string): Promise<string | undefined>;
getUser(userId: string): Promise<User | undefined>;
send(channel: string, text: string | string[]): Promise<void>;
ping(): Promise<void>;
write(json: object): Promise<void>;
reply(tmp: SlackMessage): any;
private parseData;
format(msg: string): string;
}
export {};

209
dist/clients/slack.js vendored Normal file
View File

@ -0,0 +1,209 @@
import https from "node:https";
import url from "node:url";
import EventEmitter from "node:events";
export default class slack extends EventEmitter {
options;
token;
api = "https://slack.com/api";
interval = null;
server;
reconnectAttempts = 0;
emit(event, ...args) {
return super.emit(event, ...args);
}
on(event, listener) {
return super.on(event, listener);
}
constructor(options) {
super();
this.options = {
set: "all",
...options,
};
this.token = this.options.token;
this.server = {
set: this.options.set,
channel: new Map(),
user: new Map(),
wss: {
url: null,
socket: null,
},
me: {},
};
return (async () => {
await this.connect();
return this;
})();
}
async connect() {
const response = await fetch(`${this.api}/rtm.start?token=${this.token}`);
const res = await response.json();
if (!res.ok) {
this.emit("data", ["error", res.description || "Connection failed"]);
return;
}
res.channels?.forEach(channel => {
this.server.channel.set(channel.id, channel.name);
});
res.users?.forEach(user => {
this.server.user.set(user.id, {
account: user.name,
nickname: user.real_name,
});
});
if (res.url) {
this.server.wss.url = url.parse(res.url);
this.reconnectAttempts = 0;
this.initializeWebSocket();
}
else
this.emit("data", ["error", "No WebSocket URL provided"]);
}
initializeWebSocket() {
https.get({
hostname: this.server.wss.url?.host,
path: this.server.wss.url?.path,
port: 443,
headers: {
Upgrade: "websocket",
Connection: "Upgrade",
"Sec-WebSocket-Version": 13,
"Sec-WebSocket-Key": Buffer.from(Array(16)
.fill(0)
.map(() => Math.round(Math.random() * 0xff))).toString("base64"),
}
}, () => { })
.on("upgrade", (_, sock) => {
this.server.wss.socket = sock;
this.server.wss.socket.setEncoding("utf-8");
this.handleWebSocketEvents();
})
.on("error", err => {
this.emit("data", ["error", `Failed to establish WebSocket: ${err.message}`]);
});
}
handleWebSocketEvents() {
if (!this.server.wss.socket)
return;
this.interval = setInterval(async () => await this.ping(), 3e4);
this.server.wss.socket.on("data", async (data) => {
try {
const parsedData = this.parseData(data);
if (parsedData?.type === "message") {
await Promise.all([
this.getChannel(parsedData.channel),
this.getUser(parsedData.user),
]);
this.emit("data", ["message", this.reply(parsedData)]);
}
}
catch (err) {
this.emit("data", ["error", err]);
}
});
this.server.wss.socket.on("end", async () => {
this.emit("data", ["debug", "WebSocket stream ended"]);
await this.reconnect();
});
this.server.wss.socket.on("error", async (err) => {
this.emit("data", ["error", err.message]);
await this.reconnect();
});
}
async reconnect() {
if (this.reconnectAttempts >= 5) {
this.emit("data", ["error", "Too many reconnect attempts"]);
return;
}
this.reconnectAttempts++;
setTimeout(async () => {
this.emit("data", ["info", "Reconnecting to Slack"]);
await this.connect();
}, this.reconnectAttempts * 1e3);
}
async getChannel(channelId) {
if (this.server.channel.has(channelId))
return this.server.channel.get(channelId);
const res = await (await fetch(`${this.api}/conversations.info?channel=${channelId}&token=${this.token}`)).json();
if (!res.channel)
throw new Error("Channel not found");
this.server.channel.set(channelId, res.channel.name);
return res.channel.name;
}
async getUser(userId) {
if (this.server.user.has(userId))
return this.server.user.get(userId);
const res = await (await fetch(`${this.api}/users.info?user=${userId}&token=${this.token}`)).json();
if (!res.user)
throw new Error("User not found");
const user = { account: res.user.name, nickname: res.user.real_name };
this.server.user.set(userId, user);
return user;
}
async send(channel, text) {
const message = Array.isArray(text) ? text.join("\n") : text;
const formatted = message.includes("\n") ? "```" + message + "```" : message;
await this.write({
type: "message",
channel: channel,
text: this.format(formatted),
});
}
async ping() {
await this.write({ type: "ping" });
}
async write(json) {
const msg = JSON.stringify(json);
const payload = Buffer.from(msg);
if (payload.length > 2 ** 14) {
this.emit("data", ["error", "message too long, slack limit reached"]);
return;
}
if (!this.server.wss.socket) {
await this.reconnect();
return;
}
try {
this.server.wss.socket.cork();
this.server.wss.socket.write(payload);
this.server.wss.socket.uncork();
}
catch (err) {
console.error(err);
await this.reconnect();
}
}
reply(tmp) {
return {
type: "slack",
network: "Slack",
channel: this.server.channel.get(tmp.channel),
channelid: tmp.channel,
user: this.server.user.get(tmp.user),
self: this.server,
message: tmp.text,
time: ~~(Date.now() / 1000),
raw: tmp,
reply: (msg) => this.send(tmp.channel, msg),
replyAction: (msg) => this.send(tmp.channel, `[i]${msg}[/i]`),
replyNotice: (msg) => this.send(tmp.channel, msg),
};
}
parseData(data) {
try {
return JSON.parse(data.toString());
}
catch (err) {
this.emit("data", ["error", "failed to parse data"]);
return undefined;
}
}
format(msg) {
return msg.toString()
.replace(/\[b\](.*?)\[\/b\]/g, "*$1*")
.replace(/\[s\](.*?)\[\/s\]/g, "~$1~")
.replace(/\[i\](.*?)\[\/i\]/g, "_$1_")
.replace(/\[color=(.*?)](.*?)\[\/color\]/g, "$2");
}
}

33
dist/clients/tg.d.ts vendored Normal file
View File

@ -0,0 +1,33 @@
import EventEmitter from "events";
interface TelegramOptions {
token: string;
pollrate?: number;
set?: string;
}
interface TelegramEvents {
data: [string | [string, any]];
error: [string];
}
export default class tg extends EventEmitter {
private options;
private token;
private api;
private lastUpdate;
private lastMessage;
private poller;
private server;
emit<K extends keyof TelegramEvents>(event: K, ...args: TelegramEvents[K]): boolean;
on<K extends keyof TelegramEvents>(event: K, listener: (...args: TelegramEvents[K]) => void): this;
constructor(options: TelegramOptions);
connect(): Promise<void>;
getFile(fileId: string): Promise<string | false>;
poll(): Promise<void>;
private fetchUpdates;
private processUpdate;
private handleMessage;
private handlePollError;
reply(tmp: any, opt?: {}): any;
send(chatId: number, msg: string, reply?: number | null, opt?: {}): Promise<any>;
format(msg: string): string;
}
export {};

170
dist/clients/tg.js vendored Normal file
View File

@ -0,0 +1,170 @@
import EventEmitter from "events";
const allowedFiles = ["audio", "video", "photo", "document"];
export default class tg extends EventEmitter {
options;
token;
api;
lastUpdate = 0;
lastMessage = 0;
poller = null;
server = {
set: "",
channel: new Map(),
user: new Map(),
me: {},
};
emit(event, ...args) {
return super.emit(event, ...args);
}
on(event, listener) {
return super.on(event, listener);
}
constructor(options) {
super();
this.options = {
pollrate: 1000,
set: "all",
...options,
};
this.token = this.options.token;
this.api = `https://api.telegram.org/bot${this.token}`;
this.server.set = this.options.set;
return (async () => {
await this.connect();
await this.poll();
return this;
})();
}
async connect() {
const res = await (await fetch(`${this.api}/getMe`)).json();
if (!res.ok)
throw this.emit("data", ["error", res.description ?? "Unknown error"]);
this.server.me = {
nickname: res.result.first_name,
username: res.result.username,
account: res.result.id.toString(),
prefix: `${res.result.username}!${res.result.id.toString()}`,
id: res.result.id.toString(),
};
}
async getFile(fileId) {
const res = await (await fetch(`${this.api}/getFile?file_id=${fileId}`)).json();
if (!res.ok)
return false;
return `https://api.telegram.org/file/bot${this.token}/${res.result?.file_path}`;
}
async poll() {
try {
const updates = await this.fetchUpdates();
if (!updates || updates.length === 0)
return;
this.lastUpdate = updates[updates.length - 1].update_id + 1;
for (const update of updates)
await this.processUpdate(update);
}
catch (err) {
await this.handlePollError(err);
}
finally {
setTimeout(() => this.poll(), this.options.pollrate);
}
}
async fetchUpdates() {
const res = await (await fetch(`${this.api}/getUpdates?offset=${this.lastUpdate}&allowed_updates=message`)).json();
if (!res.ok)
throw new Error(res.description || "Failed to fetch updates");
return res.result || [];
}
async processUpdate(update) {
if (update.message)
await this.handleMessage(update.message);
else if (update.callback_query)
this.emit("data", ["callback_query", this.reply(update.callback_query.message, update.callback_query)]);
else if (update.inline_query)
this.emit("data", ["inline_query", update.inline_query]);
}
async handleMessage(message) {
if (message.date >= Math.floor(Date.now() / 1000) - 10 &&
message.message_id !== this.lastMessage) {
this.lastMessage = message.message_id;
if (!this.server.user.has(message.from.username || message.from.first_name)) {
this.server.user.set(message.from.username || message.from.first_name, {
nick: message.from.first_name,
username: message.from.username,
account: message.from.id.toString(),
prefix: `${message.from.username}!${message.from.id.toString()}@Telegram`,
id: message.from.id,
});
}
try {
const fileKey = Object.keys(message).find(key => allowedFiles.includes(key));
if (fileKey) {
let media = message[fileKey];
if (fileKey === "photo")
media = message[fileKey][message[fileKey].length - 1];
message.media = await this.getFile(media.file_id);
message.text = message.caption;
delete message[fileKey];
}
}
catch { }
this.emit("data", ["message", this.reply(message)]);
}
}
async handlePollError(err) {
if (!err.type)
this.emit("data", ["error", "tg timed out lol"]);
else if (err.type === "tg")
this.emit("data", ["error", err.message]);
await this.connect();
}
reply(tmp, opt = {}) {
return {
type: "tg",
network: "Telegram",
channel: tmp.chat?.title,
channelid: tmp.chat?.id,
user: {
prefix: `${tmp.from.username}!${tmp.from.id}@Telegram`,
nick: tmp.from.first_name,
username: tmp.from.username,
account: tmp.from.id.toString(),
},
self: this.server,
message: tmp.text,
time: tmp.date,
raw: tmp,
media: tmp.media || null,
reply: async (msg, opt = {}) => await this.send(tmp.chat.id, msg, tmp.message_id, opt),
replyAction: async (msg, opt = {}) => await this.send(tmp.chat.id, `Action ${msg}`, tmp.message_id, opt),
};
}
async send(chatId, msg, reply = null, opt = {}) {
const body = {
chat_id: chatId,
text: this.format(msg),
parse_mode: "HTML",
...opt,
};
if (reply)
body["reply_to_message_id"] = reply;
const opts = {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json"
}
};
return await (await fetch(`${this.api}/sendMessage`, opts)).json();
}
format(msg) {
return msg.toString()
.split("&").join("&amp;")
.split("<").join("&lt;")
.split(">").join("&gt;")
.replace(/\[b\](.*?)\[\/b\]/gsm, "<b>$1</b>")
.replace(/\[i\](.*?)\[\/i\]/gsm, "<i>$1</i>")
.replace(/\[color=(.*?)](.*?)\[\/color\]/gsm, "$2")
.replace(/\[pre\](.*?)\[\/pre\]/gsm, "<pre>$1</pre>");
}
}

20
dist/index.d.ts vendored Normal file
View File

@ -0,0 +1,20 @@
import EventEmitter from "node:events";
interface Config {
enabled: boolean;
type: string;
network: string;
}
interface CuffeoEvents {
data: [string, any];
error: [Error];
}
export default class Cuffeo extends EventEmitter {
private clients;
private libs;
emit<K extends keyof CuffeoEvents>(event: K, ...args: CuffeoEvents[K]): boolean;
on<K extends keyof CuffeoEvents>(event: K, listener: (...args: CuffeoEvents[K]) => void): this;
constructor(cfg: Config[]);
private loadLibs;
private registerClients;
}
export {};

48
dist/index.js vendored Normal file
View File

@ -0,0 +1,48 @@
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import EventEmitter from "node:events";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default class Cuffeo extends EventEmitter {
clients = [];
libs = {};
emit(event, ...args) {
return super.emit(event, ...args);
}
on(event, listener) {
return super.on(event, listener);
}
constructor(cfg) {
super();
return (async () => {
this.libs = await this.loadLibs();
this.clients = await this.registerClients(cfg);
return this;
})();
}
async loadLibs() {
const clientFiles = await fs.promises.readdir(`${__dirname}/clients`);
const modules = await Promise.all(clientFiles
.filter(f => f.endsWith(".js"))
.map(async (client) => {
const lib = (await import(`./clients/${client}`)).default;
return { [lib.name]: lib };
}));
return modules.reduce((a, b) => ({ ...a, ...b }), {});
}
async registerClients(cfg) {
return Promise.all(cfg
.filter(e => e.enabled)
.map(async (srv) => {
if (!Object.keys(this.libs).includes(srv.type))
throw new Error(`unsupported client: ${srv.type}`);
const client = {
name: srv.network,
type: srv.type,
client: await new this.libs[srv.type](srv),
};
client.client.on("data", ([type, data]) => this.emit(type, data));
return client;
}));
}
}

View File

@ -1,9 +1,12 @@
{
"name": "cuffeo",
"version": "1.2.2",
"version": "2.0.0",
"description": "A multi-protocol chatbot library with nearly zero dependencies.",
"main": "src/index.mjs",
"scripts": {},
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"test": "node src/test.mjs"
},
"repository": {
"type": "git",
"url": "gitea@git.lat:keinBot/cuffeo.git"
@ -16,11 +19,13 @@
],
"author": "Flummi & jkhsjdhjs",
"license": "MIT",
"dependencies": {
"flumm-fetch": "^1.0.1"
},
"type": "module",
"bugs": {
"url": "https://git.lat/keinBot/cuffeo/issues"
},
"homepage": "https://git.lat/keinBot/cuffeo"
"homepage": "https://git.lat/keinBot/cuffeo",
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.8.2"
}
}

View File

@ -1,77 +0,0 @@
import Discord from "discord.js";
import EventEmitter from "events";
export class discord extends EventEmitter {
constructor(options) {
super();
this.options = options || {};
this.token = options.token || null;
this.set = this.options.set || "all";
this.network = "discord";
this.bot = new Discord.Client();
this.bot.login(this.token);
this.server = {
set: this.set,
channel: new Map(),
user: new Map(),
me: {}
};
this.bot.on("ready", () => {
this.server.me = {
nickname: this.bot.user.username,
username: this.bot.user.username,
account: this.bot.user.id.toString(),
prefix: `${this.bot.user.username}!${this.bot.user.id.toString()}`,
id: this.bot.user.id.toString()
};
});
this.bot.on("message", msg => {
if(msg.author.id !== this.server.me.id)
this.emit("data", ["message", this.reply(msg)]);
});
}
reply(tmp) {
return {
type: "discord",
network: "Discord",
channel: tmp.channel.name,
channelid: tmp.channel.id,
user: {
prefix: `${tmp.author.username}!${tmp.author.id}`,
nick: tmp.author.username,
username: tmp.author.username,
account: tmp.author.id.toString()
},
message: tmp.content,
time: ~~(Date.now() / 1000),
self: this.server,
reply: msg => this.send(tmp, this.format(msg)),
replyAction: msg => this.send(tmp, this.format(`*${msg}*`), "normal"),
replyNotice: msg => this.send(tmp, this.format(msg))
};
}
send(r, msg, mode = "blah") {
switch(mode) {
case "normal":
r.channel.send(msg);
break;
default:
r.reply(msg);
break;
}
}
sendmsg(mode, recipient, msg) {
this.bot.channels.get(recipient).send(msg);
}
format(msg) {
return msg.toString()
.replace(/\[b\](.*?)\[\/b\]/g, "**$1**") // bold
.replace(/\[i\](.*?)\[\/i\]/g, "*$1*") // italic
.replace(/\[color=(.*?)](.*?)\[\/color\]/g, "$2")
;
}
};

View File

@ -1,184 +0,0 @@
import _fs from "fs";
import { fileURLToPath } from "url";
import { dirname } from "path";
import net from "net";
import tls from "tls";
import EventEmitter from "events";
const fs = _fs.promises;
const __dirname = dirname(fileURLToPath(import.meta.url));
const colors = [
"white", "black", "navy",
"green", "red", "brown",
"purple", "orange", "yellow",
"lightgreen", "teal", "cyan",
"blue", "magenta", "gray",
"lightgray"
].reduce((a, b, i) => ({...a, ...{[b]: i.toString().padStart(2, 0)}}), {});
const msgmodes = {
normal: "PRIVMSG {recipient} :{msg}",
action: "PRIVMSG {recipient} :\u0001ACTION {msg}\u0001",
notice: "NOTICE {recipient} :{msg}"
};
const replaceColor = (match, color, text) => {
if (colors.hasOwnProperty(color))
return `\x03${colors[color]}${text}\x0F`;
return text;
};
export default class irc extends EventEmitter {
constructor(options) {
super();
this.options = options || {};
this.options.channels = this.options.channels || [];
this.options.host = this.options.host || "127.0.0.1";
this.options.port = this.options.port || 6667;
this.options.ssl = this.options.ssl || false;
this.options.selfSigned = this.options.selfSigned || false;
this.options.sasl = this.options.sasl || false;
this.network = this.options.network || "test";
this.nickname = this.options.nickname || "test";
this.username = this.options.username || "test";
this.realname = this.options.realname || "test";
this.channels = this.options.channels || [];
this.set = this.options.set || "all";
this._recachetime = 60 * 30; // 30 minutes
this._cmd = new Map();
this.server = {
set: this.set,
motd: "",
me: {},
channel: new Map(),
user: new Map()
};
return (async () => {
const dir = (await fs.readdir(`${__dirname}/irc`)).filter(f => f.endsWith(".mjs"));
await Promise.all(dir.map(async mod => {
return (await import(`${__dirname}/irc/${mod}`)).default(this);
}));
this.connect();
return this;
})();
}
connect(reconnect = false) {
if(reconnect)
this.socket = null;
this.socket = (this.options.ssl ? tls : net).connect({
host: this.options.host,
port: this.options.port,
rejectUnauthorized: !this.options.selfSigned
}, () => {
this.send(`NICK ${this.nickname}`);
this.send(`USER ${this.username} 0 * : ${this.realname}`);
if (this.options.sasl)
this.send("CAP LS");
this.emit("data", "[irc] connected!");
});
this.socket.setEncoding("utf-8");
this.socket.on("data", msg => {
msg.split(/\r?\n|\r/).filter(tmp => tmp.length > 0).forEach(tmp => {
const cmd = this.parse(tmp);
if (this._cmd.has(cmd.command))
this._cmd.get(cmd.command)(cmd);
})
});
this.socket.on("end", () => {
this.connect(true);
return this.emit("data", ["error", "[irc] stream ended, reconnecting in progress"]);
});
}
send(data) {
if(this.socket)
this.socket.write(`${data}\n`);
else
this.emit("data", ["info", `[irc] nope: ${data}`]);
}
sendmsg(mode, recipient, msg) {
msg = Array.isArray(msg) ? msg : msg.split(/\r?\n/);
if (msg.length >= 5)
return this.emit("data", ["error", "[irc] too many lines"]);
msg.forEach(e => this.send( msgmodes[mode].replace("{recipient}", recipient).replace("{msg}", this.format(e.toString())) ));
}
parse(data, [a, ...b] = data.split(/ +:/), tmp = a.split(" ").concat(b)) {
const prefix = data.charAt(0) === ":" ? tmp.shift() : null
, command = tmp.shift()
, params = command.toLowerCase() === "privmsg" ? [ tmp.shift(), tmp.join(" :") ] : tmp;
return {
prefix: prefix,
command: command,
params: params
};
}
reply(tmp) {
return {
type: "irc",
network: this.network,
channel: tmp.params[0],
channelid: tmp.params[0],
user: { ...this.parsePrefix(tmp.prefix), ...{
account: this.server.user.has(this.parsePrefix(tmp.prefix).nick) ? this.server.user.get(this.parsePrefix(tmp.prefix).nick).account : false,
prefix: (tmp.prefix.charAt(0) === ":" ? tmp.prefix.substring(1) : tmp.prefix) + `@${this.network}`
}},
message: tmp.params[1].replace(/\u0002/, ""),
time: ~~(Date.now() / 1000),
raw: tmp,
reply: msg => this.sendmsg("normal", tmp.params[0], msg),
replyAction: msg => this.sendmsg("action", tmp.params[0], msg),
replyNotice: msg => this.sendmsg("notice", tmp.params[0], msg),
self: this.server,
_chan: this.server.channel.get(tmp.params[0]),
_user: this.server.user,
_cmd: this._cmd,
join: chan => this.join(chan),
part: (chan, msg) => this.part(chan, msg),
whois: user => this.whois(user),
write: msg => this.send(msg),
socket: this.socket
};
}
join(channel) {
this.send(`JOIN ${(typeof channel === "object") ? channel.join(",") : channel}`);
}
who(channel) {
this.send(`WHO ${channel}`);
}
part(channel, msg = false) {
this.send(`PART ${(typeof channel === "object") ? channel.join(",") : channel}${msg ? " " + msg : " part"}`);
}
whois(userlist, force = false, whois = []) {
for(const u of (typeof userlist === "object") ? userlist : userlist.split(",")) {
let tmpuser = { cached: 0 };
if (this.server.user.has(u) && !force)
tmpuser = this.server.user.get(u);
if (tmpuser.cached < ~~(Date.now() / 1000) - this._recachetime)
whois.push(u);
}
whois = whois.filter(e => e.trim());
if(whois.length === 0)
return false;
this.emit("data", ["info", `[irc] whois > ${whois}`]);
this.send(`WHOIS ${whois.join(',')}`);
}
parsePrefix(prefix) {
prefix = /:?(.*)\!(.*)@(.*)/.exec(prefix);
if (!prefix)
return false;
return {
nick: prefix[1],
username: prefix[2],
hostname: prefix[3]
};
}
format(msg) {
return msg
.replace(/\[b\](.*?)\[\/b\]/g, "\x02$1\x02") // bold
.replace(/\[i\](.*?)\[\/i\]/g, "\x1D$1\x1D") // italic
.replace(/\[color=(.*?)](.*?)\[\/color\]/g, replaceColor) // colors
;
}
}

283
src/clients/irc.ts Normal file
View File

@ -0,0 +1,283 @@
import _fs from "fs";
import { fileURLToPath } from "url";
import { dirname } from "path";
import net from "net";
import tls from "tls";
import EventEmitter from "events";
const fs = _fs.promises;
const __dirname = dirname(fileURLToPath(import.meta.url));
type MessageModes = "normal" | "action" | "notice";
interface IRCOptions {
channels?: string[];
host?: string;
port?: number;
ssl?: boolean;
selfSigned?: boolean;
sasl?: boolean;
network?: string;
nickname?: string;
username?: string;
realname?: string;
set?: string;
}
interface ParsedCommand {
prefix: string | null;
command: string;
params: string[];
}
interface IRCEvents {
data: [string | [string, any]];
error: [string];
}
const colors = {
white: "00", black: "01", navy: "02", green: "03", red: "04",
brown: "05", purple: "06", orange: "07", yellow: "08",
lightgreen: "09", teal: "10", cyan: "11", blue: "12",
magenta: "13", gray: "14", lightgray: "15"
};
const msgmodes: Record<MessageModes, string> = {
normal: "PRIVMSG {recipient} :{msg}",
action: "PRIVMSG {recipient} :\u0001ACTION {msg}\u0001",
notice: "NOTICE {recipient} :{msg}",
};
const replaceColor = (match: string, color: keyof typeof colors, text: string) => {
return colors[color] ? `\x03${colors[color]}${text}\x0F` : text;
};
export default class irc extends EventEmitter {
private options: Required<IRCOptions>;
private socket?: net.Socket | tls.TLSSocket;
private _recachetime: number = 60 * 30; // 30 minutes
private _cmd: Map<string, (cmd: ParsedCommand) => void> = new Map();
private server: {
set: string;
motd: string;
me: Record<string, unknown>;
channel: Map<string, unknown>;
user: Map<string, { account?: string; cached: number }>;
};
emit<K extends keyof IRCEvents>(event: K, ...args: IRCEvents[K]): boolean {
return super.emit(event, ...args);
}
on<K extends keyof IRCEvents>(event: K, listener: (...args: IRCEvents[K]) => void): this {
return super.on(event, listener);
}
constructor(options: IRCOptions) {
super();
this.options = {
channels: [],
host: "127.0.0.1",
port: 6667,
ssl: false,
selfSigned: false,
sasl: false,
network: "test",
nickname: "test",
username: "test",
realname: "test",
set: "all",
...options
};
this.server = {
set: this.options.set,
motd: "",
me: {},
channel: new Map(),
user: new Map()
};
return (async () => {
await this.initialize();
return this;
})() as unknown as irc;
}
async initialize() {
const dir = (await fs.readdir(`${__dirname}/irc`)).filter(f =>
f.endsWith(".js")
);
await Promise.all(
dir.map(async mod => {
return (await import(`${__dirname}/irc/${mod}`)).default(this);
})
);
this.connect();
}
private createSocket(): net.Socket | tls.TLSSocket {
return this.options.ssl
? tls.connect({
host: this.options.host,
port: this.options.port,
rejectUnauthorized: !this.options.selfSigned,
})
: net.connect({
host: this.options.host,
port: this.options.port,
});
}
connect(reconnect: boolean = false): void {
if(reconnect)
this.socket = undefined;
this.socket = this.createSocket();
this.socket.on("data", (msg: string) => this.handleData(msg));
this.socket.on("end", () => this.handleDisconnect());
this.socket.on("error", (err: Error) => this.handleError(err));
this.socket.setEncoding("utf-8");
this.handleConnection();
}
private handleConnection(): void {
this.send(`NICK ${this.options.nickname}`);
this.send(`USER ${this.options.username} 0 * :${this.options.realname}`);
if(this.options.sasl)
this.send("CAP LS");
this.emit("data", "[irc] connected!");
}
private handleData(msg: string): void {
msg.split(/\r?\n|\r/).forEach((line) => {
if(line.trim().length > 0) {
const cmd = this.parse(line);
if(this._cmd.has(cmd.command))
this._cmd.get(cmd.command)?.(cmd);
}
});
}
private handleDisconnect(): void {
this.emit("data", ["error", "[irc] stream ended, reconnecting in progress"]);
this.connect(true);
}
private handleError(err: Error): void {
this.emit("data", ["error", `[irc] socket error: ${err.message}`]);
this.connect(true);
}
private join(channels: string | string[]): void {
if(!Array.isArray(channels))
channels = [channels];
channels.forEach(e => {
this.send(`JOIN ${e}`);
});
}
private part(channel: string, msg?: string): void {
this.send(`PART ${channel} :${msg || ""}`);
}
private whois(nick: string | string[]): void {
if(!Array.isArray(nick))
nick = [nick];
nick.forEach(e => {
this.send(`WHOIS ${e}`);
});
}
send(data: string) {
if(this.socket)
this.socket.write(`${data}\n`);
else
this.emit("data", ["info", `[irc] nope: ${data}`]);
}
sendmsg(mode: MessageModes, recipient: string, msg: string | string[]) {
const messages = Array.isArray(msg) ? msg : msg.split(/\r?\n/);
if(messages.length >= 5)
this.emit("data", ["error", "[irc] too many lines"]);
messages.forEach((e) => {
const formatted = msgmodes[mode]
.replace("{recipient}", recipient)
.replace("{msg}", this.format(e));
this.send(formatted);
});
}
parse(data: string): ParsedCommand {
const [a, ...b] = data.split(/ +:/);
const tmp = a.split(" ").concat(b);
const prefix: string | null = data.charAt(0) === ":"
? tmp.shift() ?? null
: null;
const command = tmp.shift()!;
const params = command.toLowerCase() === "privmsg"
? [tmp.shift()!, tmp.join(" :")]
: tmp;
return { prefix, command, params };
}
private format(msg: string): string {
return msg
.replace(/\[b\](.*?)\[\/b\]/g, "\x02$1\x02") // bold
.replace(/\[i\](.*?)\[\/i\]/g, "\x1D$1\x1D") // italic
.replace(/\[color=(.*?)](.*?)\[\/color\]/g, replaceColor) // colors
;
}
parsePrefix(prefix: string | null): {
nick: string; username: string; hostname: string
} | false {
if (!prefix) return false; // Null oder undefined behandeln
const parsed = /:?(.*)\!(.*)@(.*)/.exec(prefix);
if (!parsed) return false;
return {
nick: parsed[1],
username: parsed[2],
hostname: parsed[3],
};
}
reply(tmp: ParsedCommand) {
return {
type: "irc",
network: this.options.network,
channel: tmp.params[0],
channelid: tmp.params[0],
user: {
...this.parsePrefix(tmp.prefix),
account: (() => {
const parsedPrefix = this.parsePrefix(tmp.prefix);
return parsedPrefix && this.server.user.has(parsedPrefix.nick)
? this.server.user.get(parsedPrefix.nick)?.account || false
: false;
})(),
prefix: (tmp.prefix?.charAt(0) === ":"
? tmp.prefix.substring(1)
: tmp.prefix) + `@${this.options.network}`,
},
message: tmp.params[1].replace(/\u0002/g, ""), // Entfernt Steuerzeichen
time: Math.floor(Date.now() / 1000),
raw: tmp,
reply: (msg: string) => this.sendmsg("normal", tmp.params[0], msg),
replyAction: (msg: string) => this.sendmsg("action", tmp.params[0], msg),
replyNotice: (msg: string) => this.sendmsg("notice", tmp.params[0], msg),
self: this.server,
_chan: this.server.channel.get(tmp.params[0]),
_user: this.server.user,
_cmd: this._cmd,
join: (chan: string) => this.join(chan),
part: (chan: string, msg?: string) => this.part(chan, msg),
whois: (user: string) => this.whois(user),
write: (msg: string) => this.send(msg)
};
}
}

View File

@ -1,21 +0,0 @@
export default bot => {
bot._cmd.set("CAP", msg => { // capkram
switch (msg.params[1]) {
case "LS": // list
bot.send(`CAP REQ :${msg.params[2]}`);
break;
case "ACK": // success
bot.send("AUTHENTICATE PLAIN");
break;
}
});
bot._cmd.set("AUTHENTICATE", msg => { // auth
if (msg.params[0].match(/\+/))
bot.send(`AUTHENTICATE ${new Buffer(bot.username + "\u0000" + bot.username + "\u0000" + bot.options.password).toString("base64")}`);
});
bot._cmd.set("900", msg => { // cap end
bot.send("CAP END");
});
};

26
src/clients/irc/cap.ts Normal file
View File

@ -0,0 +1,26 @@
import { Bot, Message } from "../../types";
export default (bot: Bot) => {
bot._cmd.set("CAP", (msg: Message) => { // CAP Handling
switch(msg.params[1]) {
case "LS": // List
bot.send(`CAP REQ :${msg.params[2]}`);
break;
case "ACK": // Success
bot.send("AUTHENTICATE PLAIN");
break;
}
});
bot._cmd.set("AUTHENTICATE", (msg: Message) => { // Authentication
if(msg.params[0].match(/\+/)) {
bot.send(`AUTHENTICATE ${Buffer.from(
bot.username + "\u0000" + bot.username + "\u0000" + bot.options.password
).toString("base64")}`);
}
});
bot._cmd.set("900", (msg: Message) => { // End CAP
bot.send("CAP END");
});
};

View File

@ -1,13 +0,0 @@
export default bot => {
bot._cmd.set("INVITE", msg => { // invite
const user = bot.parsePrefix(msg.prefix);
const channel = msg.params[1];
if(!bot.server.channel.has(channel)) {
bot.join(channel);
setTimeout(() => {
bot.send(`PRIVMSG ${channel} :Hi. Wurde von ${user.nick} eingeladen.`);
}, 1000);
}
});
};

15
src/clients/irc/invite.ts Normal file
View File

@ -0,0 +1,15 @@
import { Bot, Message, User } from "../../types";
export default (bot: Bot) => {
bot._cmd.set("INVITE", (msg: Message) => {
const user: User = bot.parsePrefix(msg.prefix);
const channel: string = msg.params[1];
if(!bot.server.channel.has(channel)) {
bot.join(channel);
setTimeout(() => {
bot.send(`PRIVMSG ${channel} :Hi. Wurde von ${user.nick} eingeladen.`);
}, 1000);
}
});
};

View File

@ -1,5 +0,0 @@
export default bot => {
bot._cmd.set("JOIN", msg => { // join
bot.send(`WHO ${msg.params[0]}`);
});
};

7
src/clients/irc/join.ts Normal file
View File

@ -0,0 +1,7 @@
import { Bot, Message } from "../../types";
export default (bot: Bot) => {
bot._cmd.set("JOIN", (msg: Message) => { // Handle join
bot.send(`WHO ${msg.params[0]}`);
});
};

View File

@ -1,14 +0,0 @@
export default bot => {
bot._cmd.set("372", msg => { // motd_entry
bot.server.motd += `${msg.params[1]}\n`;
});
bot._cmd.set("375", msg => { // motd_start
bot.server.motd = `${msg.params[1]}\n`;
});
bot._cmd.set("376", msg => { // motd_end
bot.server.motd += `${msg.params[1]}\n`;
bot.emit("data", ["motd", bot.server.motd]);
});
};

16
src/clients/irc/motd.ts Normal file
View File

@ -0,0 +1,16 @@
import { Bot, Message } from "../../types";
export default (bot: Bot) => {
bot._cmd.set("372", (msg: Message) => { // MOTD Entry
bot.server.motd += `${msg.params[1]}\n`;
});
bot._cmd.set("375", (msg: Message) => { // MOTD Start
bot.server.motd = `${msg.params[1]}\n`;
});
bot._cmd.set("376", (msg: Message) => { // MOTD End
bot.server.motd += `${msg.params[1]}\n`;
bot.emit("data", ["motd", bot.server.motd]);
});
};

View File

@ -1,5 +1,7 @@
export default bot => {
bot._cmd.set("PRIVMSG", msg => { // privmsg
import { Bot, Message } from "../../types";
export default (bot: Bot) => {
bot._cmd.set("PRIVMSG", (msg: Message) => { // Handle PRIVMSG
if(msg.params[1] === "\u0001VERSION\u0001")
return bot.emit("data", ["ctcp:version", bot.reply(msg)]);
else if(msg.params[1].match(/^\u0001PING .*\u0001/i))
@ -8,7 +10,7 @@ export default bot => {
bot.emit("data", ["message", bot.reply(msg)]);
});
bot._cmd.set("NOTICE", msg => { // notice
bot._cmd.set("NOTICE", (msg: Message) => { // Handle NOTICE
bot.emit("data", ["notice", msg.params[1]]);
});
};

View File

@ -1,8 +0,0 @@
export default bot => {
bot._cmd.set("NICK", msg => { // nickchange
let prefix = bot.parsePrefix(msg.prefix);
if (bot.server.user.has(prefix.nick))
bot.server.user.delete(prefix.nick);
bot.whois(msg.params[0], true); // force
});
};

12
src/clients/irc/nick.ts Normal file
View File

@ -0,0 +1,12 @@
import { Bot, Message, User } from "../../types";
export default (bot: Bot) => {
bot._cmd.set("NICK", (msg: Message) => { // Handle nick change
const prefix: User = bot.parsePrefix(msg.prefix);
if(bot.server.user.has(prefix.nick))
bot.server.user.delete(prefix.nick);
bot.whois(msg.params[0], true); // Force whois
});
};

View File

@ -1,5 +0,0 @@
export default bot => {
bot._cmd.set("PART", msg => { // part
//delete this.server.user[msg.params[0]];
});
};

7
src/clients/irc/part.ts Normal file
View File

@ -0,0 +1,7 @@
import { Bot, Message } from "../../types";
export default (bot: Bot) => {
bot._cmd.set("PART", (msg: Message) => { // Handle part
delete bot.server.user[msg.params[0]];
});
};

View File

@ -1,5 +0,0 @@
export default bot => {
bot._cmd.set("PING", msg => { // ping
bot.send(`PONG ${msg.params.join``}`);
});
};

7
src/clients/irc/ping.ts Normal file
View File

@ -0,0 +1,7 @@
import { Bot, Message } from "../../types";
export default (bot: Bot) => {
bot._cmd.set("PING", (msg: Message) => { // Handle PING
bot.send(`PONG ${msg.params.join('')}`);
});
};

View File

@ -1,6 +0,0 @@
export default bot => {
bot._cmd.set("464", msg => { // motd_entry
if (bot.options.password.length > 0 && !bot.options.sasl)
bot.send(`PASS ${bot.options.password}`);
});
};

View File

@ -0,0 +1,8 @@
import { Bot, Message } from "../../types";
export default (bot: Bot) => {
bot._cmd.set("464", (msg: Message) => { // Handle password request
if(bot.options.password.length > 0 && !bot.options.sasl)
bot.send(`PASS ${bot.options.password}`);
});
};

View File

@ -1,6 +0,0 @@
export default bot => {
bot._cmd.set("001", msg => { // welcome
bot.join(bot.options.channels);
bot.emit("data", ["connected", msg.params[1]]);
});
};

View File

@ -0,0 +1,8 @@
import { Bot, Message } from "../../types";
export default (bot: Bot) => {
bot._cmd.set("001", (msg: Message) => { // Handle welcome
bot.join(bot.options.channels);
bot.emit("data", ["connected", msg.params[1]]);
});
};

View File

@ -1,20 +0,0 @@
const max = 400;
let whois = [];
let chan;
export default bot => {
bot._cmd.set("352", msg => { // who_entry
chan = msg.params[1];
whois.push(msg.params[5]);
});
bot._cmd.set("315", msg => { // who_end
bot.server.channel.set(chan, whois);
whois = [...new Set(whois)];
Array(Math.ceil(whois.length / 10)).fill().map(_ => whois.splice(0, 10)).forEach(l => {
//console.log(l);
bot.whois(l);
});
whois = [];
});
};

21
src/clients/irc/who.ts Normal file
View File

@ -0,0 +1,21 @@
import { Bot, Message } from "../../types";
const max = 400;
let whois: string[] = [];
let chan: string;
export default (bot: Bot) => {
bot._cmd.set("352", (msg: Message) => { // Handle WHO entry
chan = msg.params[1];
whois.push(msg.params[5]);
});
bot._cmd.set("315", (msg: Message) => { // Handle WHO end
bot.server.channel.set(chan, whois);
whois = [...new Set(whois)];
Array(Math.ceil(whois.length / 10)).fill(undefined).map(() => whois.splice(0, 10)).forEach((l: string[]) => {
bot.whois(l);
});
whois = [];
});
};

View File

@ -1,80 +0,0 @@
export default bot => {
bot._cmd.set("307", msg => { // whois_identified (ircd-hybrid)
let tmpuser = {};
if (bot.server.user.has(msg.params[1]))
tmpuser = bot.server.user.get(msg.params[1]);
tmpuser.account = msg.params[1];
tmpuser.registered = true;
bot.server.user.set(msg.params[1], tmpuser);
});
bot._cmd.set("311", msg => { // whois_userdata
let tmpuser = {};
if (bot.server.user.has(msg.params[1]))
tmpuser = bot.server.user.get(msg.params[1]);
tmpuser.nickname = msg.params[1];
tmpuser.username = msg.params[2];
tmpuser.hostname = msg.params[3];
tmpuser.realname = msg.params[5];
tmpuser.prefix = `${msg.params[1]}!${msg.params[2]}@${msg.params[3]}`;
bot.server.user.set(msg.params[1], tmpuser);
});
bot._cmd.set("313", msg => { // whois_oper
let tmpuser = {};
if (bot.server.user.has(msg.params[1]))
tmpuser = bot.server.user.get(msg.params[1]);
tmpuser.oper = true;
bot.server.user.set(msg.params[1], tmpuser);
});
bot._cmd.set("318", msg => { // whois_end
let tmpuser = {};
//bot.emit("data", ["info", `whois < ${msg.params[1]}`]);
if (bot.server.user.has(msg.params[1]))
tmpuser = bot.server.user.get(msg.params[1]);
tmpuser = {
nickname: tmpuser.nickname || false,
username: tmpuser.username || false,
hostname: tmpuser.hostname || false,
realname: tmpuser.realname || false,
account: tmpuser.account || false,
prefix: tmpuser.prefix || false,
registered: tmpuser.registered || false,
oper: tmpuser.oper || false,
channels: tmpuser.channels || [],
cached: ~~(Date.now() / 1000)
};
bot.server.user.set(msg.params[1], tmpuser);
if(msg.params[0] == msg.params[1]) {
bot.server.me = tmpuser;
bot.server.user.delete(msg.params[1]);
}
});
bot._cmd.set("319", msg => { // whois_chanlist
let tmpchan = new Map()
, tmpuser = {};
if (bot.server.user.has(msg.params[1])) {
tmpuser = bot.server.user.get(msg.params[1]);
if (tmpuser.channels)
tmpchan = new Map(tmpuser.channels);
}
let chans = msg.params[2].trim().split(" ");
for (let chan in chans) {
chan = chans[chan].split("#");
tmpchan.set(`#${chan[1]}`, chan[0]);
}
tmpuser.channels = tmpchan;
bot.server.user.set(msg.params[1], tmpuser);
});
bot._cmd.set("330", msg => { // whois_authed_as (snircd)
let tmpuser = {};
if (bot.server.user.has(msg.params[1]))
tmpuser = bot.server.user.get(msg.params[1]);
tmpuser.account = msg.params[2];
tmpuser.registered = true;
bot.server.user.set(msg.params[1], tmpuser);
});
};

69
src/clients/irc/whois.ts Normal file
View File

@ -0,0 +1,69 @@
import { Bot, Message, User } from "../../types";
export default (bot: Bot) => {
bot._cmd.set("307", (msg: Message) => { // whois_identified (ircd-hybrid)
let tmpuser: User = bot.server.user.get(msg.params[1]) || {};
tmpuser.account = msg.params[1];
tmpuser.registered = true;
bot.server.user.set(msg.params[1], tmpuser);
});
bot._cmd.set("311", (msg: Message) => { // whois_userdata
let tmpuser: User = bot.server.user.get(msg.params[1]) || {};
tmpuser.nickname = msg.params[1];
tmpuser.username = msg.params[2];
tmpuser.hostname = msg.params[3];
tmpuser.realname = msg.params[5];
tmpuser.prefix = `${msg.params[1]}!${msg.params[2]}@${msg.params[3]}`;
bot.server.user.set(msg.params[1], tmpuser);
});
bot._cmd.set("313", (msg: Message) => { // whois_oper
let tmpuser: User = bot.server.user.get(msg.params[1]) || {};
tmpuser.oper = true;
bot.server.user.set(msg.params[1], tmpuser);
});
bot._cmd.set("318", (msg: Message) => { // whois_end
let tmpuser: User = bot.server.user.get(msg.params[1]) || {};
tmpuser = {
nick: tmpuser.nick || false,
nickname: tmpuser.nickname || false,
username: tmpuser.username || false,
hostname: tmpuser.hostname || false,
realname: tmpuser.realname || false,
account: tmpuser.account || false,
prefix: tmpuser.prefix || false,
registered: tmpuser.registered || false,
oper: tmpuser.oper || false,
channels: tmpuser.channels || [],
cached: Math.floor(Date.now() / 1000),
};
bot.server.user.set(msg.params[1], tmpuser);
if(msg.params[0] === msg.params[1]) {
bot.server.me = tmpuser;
bot.server.user.delete(msg.params[1]);
}
});
bot._cmd.set("319", (msg: Message) => { // whois_chanlist
let tmpchan = new Map<string, string>();
let tmpuser: User = bot.server.user.get(msg.params[1]) || {};
if(tmpuser.channels)
tmpchan = new Map(tmpuser.channels);
const chans = msg.params[2].trim().split(" ");
chans.forEach((chan) => {
const [flags, name] = chan.split("#");
tmpchan.set(`#${name}`, flags);
});
tmpuser.channels = tmpchan;
bot.server.user.set(msg.params[1], tmpuser);
});
bot._cmd.set("330", (msg: Message) => { // whois_authed_as (snircd)
let tmpuser: User = bot.server.user.get(msg.params[1]) || {};
tmpuser.account = msg.params[2];
tmpuser.registered = true;
bot.server.user.set(msg.params[1], tmpuser);
});
};

View File

@ -1,217 +0,0 @@
import https from "https";
import url from "url";
import EventEmitter from "events";
import fetch from "flumm-fetch";
export default class slack extends EventEmitter {
constructor(options) {
super();
this.options = options || {};
this.token = options.token || null;
this.set = this.options.set || "all";
this.network = "Slack";
this.api = "https://slack.com/api";
this.socket = null;
this.interval = null;
this.server = {
set: this.set,
channel: new Map(),
user: new Map(),
wss: {
url: null,
socket: null
},
me: {}
};
return (async () => {
await this.connect();
return this;
})();
}
async connect() {
const res = await (await fetch(`${this.api}/rtm.start?token=${this.token}`)).json();
if (!res.ok)
return this.emit("data", [ "error", res.description ]); // more infos
res.channels?.forEach(channel => this.server.channel.set(channel.id, channel.name));
res.users?.forEach(user => this.server.user.set(user.id, {
account: user.name,
nickname: user.real_name
}));
this.server.wss.url = url.parse(res.url);
https.get({
hostname: this.server.wss.url.host,
path: this.server.wss.url.path,
port: 443,
headers: {
"Upgrade": "websocket",
"Connection": "Upgrade",
"Sec-WebSocket-Version": 13,
"Sec-WebSocket-Key": new Buffer.from(Array(16).fill().map(e => Math.round(Math.random() * 0xFF))).toString("base64")
}
}).on("upgrade", (_, sock) => {
this.server.wss.socket = sock;
this.server.wss.socket.setDefaultEncoding("utf-8");
this.interval = setInterval(async () => await this.ping(), 3e4); // 30 seconds lul
this.server.wss.socket.on("data", async data => {
try {
if (data.length < 2)
throw "payload too short";
if (data[1] & 0x80)
throw "we no accept masked data";
let offset = 2;
let length = data[1] & 0x7F;
if (length === 126) {
offset = 4;
length = data.readUInt16BE(2);
}
else if(length === 127)
throw "payload too long";
if (data.length < length + offset)
throw "payload shorter than given length";
//console.log(data, offset, length);
data = JSON.parse(data.slice(offset, length + offset).toString().replace(/\0+$/, "")); // trim null bytes at the end
//console.log(data, data.type);
if (data.type !== "message")
return false;
await Promise.all([this.getChannel(data.channel), this.getUser(data.user)]);
return this.emit("data", [ "message", this.reply(data) ]);
}
catch(err) {
this.emit("data", [ "error", err ]);
}
})
.on("end", async () => {
this.emit("data", [ "debug", "stream ended" ]);
await this.reconnect();
})
.on("error", async err => {
this.emit("data", [ "error", err ]);
await this.reconnect();
});
});
}
async reconnect() {
this.server.wss.url = null;
this.server.wss.socket = null;
clearTimeout(this.interval);
this.emit("data", [ "info", "reconnecting slack" ]);
return await this.connect();
}
async getChannel(channelId) {
if (this.server.channel.has(channelId))
return this.server.channel.get(channelId);
const res = await (await fetch(`${this.api}/conversations.info?channel=${channelId}&token=${this.token}`)).json();
this.server.channel.set(channelId, res.channel.name);
return res.channel.name;
}
async getUser(userId) {
if (this.server.user.has(userId))
return this.server.user.get(userId);
const res = await (await fetch(`${this.api}/users.info?user=${userId}&token=${this.token}`)).json();
this.server.user.set(userId, {
account: res.user.name,
nickname: res.user.real_name
});
}
async send(channel, text) {
text = Array.isArray(text) ? text.join("\n") : text;
text = text.includes("\n") ? "```" + text + "```" : text;
await this.write({
type: "message",
channel: channel,
text: this.format(text)
});
}
async ping() {
return await this.write({
type: "ping"
});
}
async write(json) {
const msg = JSON.stringify(json);
const payload = Buffer.from(msg);
if(payload.length > 2 ** 14) // 16KB limit
throw this.emit("data", [ "error", "message too long, slack limit reached" ]);
let frame_length = 6;
let frame_payload_length = payload.length;
if(payload.length > 125) {
frame_length += 2;
frame_payload_length = 126;
}
const frame = Buffer.alloc(frame_length);
// set mask bit but leave mask key empty (= 0), so we don't have to mask the message
frame.writeUInt16BE(0x8180 | frame_payload_length);
if(frame_length > 6)
frame.writeUInt16BE(payload.length, 2);
if (!this.server.wss.socket)
await this.reconnect();
try {
this.server.wss.socket.cork();
this.server.wss.socket.write(frame);
this.server.wss.socket.write(Buffer.from(msg));
this.server.wss.socket.uncork();
} catch(err) {
console.error(err);
}
}
reply(tmp) {
return {
type: "slack",
network: "Slack",
channel: this.server.channel.get(tmp.channel), // get channelname
channelid: tmp.channel,
user: {
prefix: `${this.server.user.get(tmp.user).account}!${tmp.user}@${this.network}`, // get username
nick: this.server.user.get(tmp.user).nickname, // get username
username: this.server.user.get(tmp.user).nickname, // get username
account: this.server.user.get(tmp.user).account
},
self: this.server,
message: tmp.text,
time: ~~(Date.now() / 1000),
raw: tmp,
reply: msg => this.send(tmp.channel, msg),
replyAction: msg => this.send(tmp.channel, `[i]${msg}[/i]`),
replyNotice: msg => this.send(tmp.channel, msg)
};
}
format(msg) {
return msg.toString()
.replace(/\[b\](.*?)\[\/b\]/g, "*$1*") // bold
.replace(/\[s\](.*?)\[\/s\]/g, "~$1~") // strike
.replace(/\[i\](.*?)\[\/i\]/g, "_$1_") // italic
.replace(/\[color=(.*?)](.*?)\[\/color\]/g, "$2")
;
}
}

284
src/clients/slack.ts Normal file
View File

@ -0,0 +1,284 @@
import https from "node:https";
import net from "node:net";
import url from "node:url";
import EventEmitter from "node:events";
interface SlackOptions {
token: string;
set?: string;
}
interface User {
account: string;
nickname: string;
}
interface ServerInfo {
set: string;
channel: Map<string, string>;
user: Map<string, User>;
wss: {
url: url.UrlWithStringQuery | null;
socket: net.Socket | null;
};
me: Record<string, any>;
}
interface SlackMessage {
type: string;
channel: string;
user: string;
text: string;
}
interface SlackRTMStartResponse {
ok: boolean;
url?: string;
channels?: { id: string; name: string }[];
users?: { id: string; name: string; real_name: string }[];
description?: string;
}
interface SlackEvents {
data: [string | [string, any]];
error: [string];
message: [SlackMessage];
}
export default class slack extends EventEmitter {
private options: Required<SlackOptions>;
private token: string;
private api: string = "https://slack.com/api";
private interval: NodeJS.Timeout | null = null;
private server: ServerInfo;
private reconnectAttempts = 0;
emit<K extends keyof SlackEvents>(event: K, ...args: SlackEvents[K]): boolean {
return super.emit(event, ...args);
}
on<K extends keyof SlackEvents>(event: K, listener: (...args: SlackEvents[K]) => void): this {
return super.on(event, listener);
}
constructor(options: SlackOptions) {
super();
this.options = {
set: "all",
...options,
};
this.token = this.options.token;
this.server = {
set: this.options.set,
channel: new Map(),
user: new Map(),
wss: {
url: null,
socket: null,
},
me: {},
};
return (async () => {
await this.connect();
return this;
})() as unknown as slack;
}
async connect(): Promise<void> {
const response = await fetch(`${this.api}/rtm.start?token=${this.token}`);
const res: SlackRTMStartResponse = await response.json();
if(!res.ok) {
this.emit("data", ["error", res.description || "Connection failed"]);
return;
}
res.channels?.forEach(channel => {
this.server.channel.set(channel.id, channel.name);
});
res.users?.forEach(user => {
this.server.user.set(user.id, {
account: user.name,
nickname: user.real_name,
});
});
if(res.url) {
this.server.wss.url = url.parse(res.url);
this.reconnectAttempts = 0;
this.initializeWebSocket();
}
else
this.emit("data", ["error", "No WebSocket URL provided"]);
}
private initializeWebSocket(): void {
https.get({
hostname: this.server.wss.url?.host,
path: this.server.wss.url?.path,
port: 443,
headers: {
Upgrade: "websocket",
Connection: "Upgrade",
"Sec-WebSocket-Version": 13,
"Sec-WebSocket-Key": Buffer.from(
Array(16)
.fill(0)
.map(() => Math.round(Math.random() * 0xff))
).toString("base64"),
}}, () => {}
)
.on("upgrade", (_, sock) => {
this.server.wss.socket = sock;
this.server.wss.socket.setEncoding("utf-8");
this.handleWebSocketEvents();
})
.on("error", err => {
this.emit("data", ["error", `Failed to establish WebSocket: ${err.message}`]);
});
}
private handleWebSocketEvents(): void {
if(!this.server.wss.socket)
return;
this.interval = setInterval(async () => await this.ping(), 3e4);
this.server.wss.socket.on("data", async (data: Buffer) => {
try {
const parsedData = this.parseData(data);
if(parsedData?.type === "message") {
await Promise.all([
this.getChannel(parsedData.channel),
this.getUser(parsedData.user),
]);
this.emit("data", ["message", this.reply(parsedData)]);
}
}
catch(err: any) {
this.emit("data", ["error", err]);
}
});
this.server.wss.socket.on("end", async () => {
this.emit("data", ["debug", "WebSocket stream ended"]);
await this.reconnect();
});
this.server.wss.socket.on("error", async (err: Error) => {
this.emit("data", ["error", err.message]);
await this.reconnect();
});
}
async reconnect(): Promise<void> {
if(this.reconnectAttempts >= 5) {
this.emit("data", ["error", "Too many reconnect attempts"]);
return;
}
this.reconnectAttempts++;
setTimeout(async () => {
this.emit("data", ["info", "Reconnecting to Slack"]);
await this.connect();
}, this.reconnectAttempts * 1e3);
}
async getChannel(channelId: string): Promise<string | undefined> {
if(this.server.channel.has(channelId))
return this.server.channel.get(channelId);
const res = await (await fetch(`${this.api}/conversations.info?channel=${channelId}&token=${this.token}`)).json() as { channel: { name: string } };
if(!res.channel)
throw new Error("Channel not found");
this.server.channel.set(channelId, res.channel.name);
return res.channel.name;
}
async getUser(userId: string): Promise<User | undefined> {
if(this.server.user.has(userId))
return this.server.user.get(userId);
const res = await (await fetch(`${this.api}/users.info?user=${userId}&token=${this.token}`)).json() as { user: { name: string; real_name: string } };
if(!res.user)
throw new Error("User not found");
const user = { account: res.user.name, nickname: res.user.real_name };
this.server.user.set(userId, user);
return user;
}
async send(channel: string, text: string | string[]): Promise<void> {
const message = Array.isArray(text) ? text.join("\n") : text;
const formatted = message.includes("\n") ? "```" + message + "```" : message;
await this.write({
type: "message",
channel: channel,
text: this.format(formatted),
});
}
async ping(): Promise<void> {
await this.write({ type: "ping" });
}
async write(json: object): Promise<void> {
const msg = JSON.stringify(json);
const payload = Buffer.from(msg);
if(payload.length > 2 ** 14) {
this.emit("data", ["error", "message too long, slack limit reached"]);
return;
}
if(!this.server.wss.socket) {
await this.reconnect();
return;
}
try {
this.server.wss.socket.cork();
this.server.wss.socket.write(payload);
this.server.wss.socket.uncork();
}
catch(err: any) {
console.error(err);
await this.reconnect();
}
}
reply(tmp: SlackMessage): any {
return {
type: "slack",
network: "Slack",
channel: this.server.channel.get(tmp.channel),
channelid: tmp.channel,
user: this.server.user.get(tmp.user),
self: this.server,
message: tmp.text,
time: ~~(Date.now() / 1000),
raw: tmp,
reply: (msg: string) => this.send(tmp.channel, msg),
replyAction: (msg: string) => this.send(tmp.channel, `[i]${msg}[/i]`),
replyNotice: (msg: string) => this.send(tmp.channel, msg),
};
}
private parseData(data: Buffer): SlackMessage | undefined {
try {
return JSON.parse(data.toString()) as SlackMessage;
}
catch(err: any) {
this.emit("data", ["error", "failed to parse data"]);
return undefined;
}
}
format(msg: string): string {
return msg.toString()
.replace(/\[b\](.*?)\[\/b\]/g, "*$1*") // bold
.replace(/\[s\](.*?)\[\/s\]/g, "~$1~") // strike
.replace(/\[i\](.*?)\[\/i\]/g, "_$1_") // italic
.replace(/\[color=(.*?)](.*?)\[\/color\]/g, "$2")
;
}
}

View File

@ -1,183 +0,0 @@
import fetch from "flumm-fetch";
import EventEmitter from "events";
const allowedFiles = [ 'audio', 'video', 'photo', 'document' ];
export default class tg extends EventEmitter {
constructor(options) {
super();
this.options = options || {};
this.token = options.token || null;
this.options.pollrate = options.pollrate || 1000;
this.set = this.options.set || "all";
this.network = "Telegram";
this.api = `https://api.telegram.org/bot${this.token}`;
this.lastUpdate = 0;
this.lastMessage = 0;
this.poller = null;
this.server = {
set: this.set,
channel: new Map(),
user: new Map(),
me: {}
};
return (async () => {
await this.connect();
await this.poll();
return this;
})();
}
async connect() {
const res = await (await fetch(`${this.api}/getMe`)).json();
if (!res.ok)
throw this.emit("data", ["error", res.description]); // more infos
this.me = res.result;
this.server.me = {
nickname: res.result.first_name,
username: res.result.username,
account: res.result.id.toString(),
prefix: `${res.result.username}!${res.result.id.toString()}`,
id: res.result.id.toString()
};
}
async getFile(file_id) {
const res = await (await fetch(`${this.api}/getFile?file_id=${file_id}`)).json();
if(!res.ok)
return false;
return `https://api.telegram.org/file/bot${this.token}/${res.result.file_path}`;
}
async poll() {
try {
const _res = await (await fetch(`${this.api}/getUpdates?offset=${this.lastUpdate}&allowed_updates=message`)).json();
if(!_res.ok)
throw { type: "tg", message: _res.description};
if(_res.result.length === 0)
return
this.lastUpdate = _res.result[_res.result.length - 1].update_id + 1;
_res.result.forEach(async res => {
if(res.hasOwnProperty("message")) {
if(res.message?.date >= ~~(Date.now() / 1000) - 10 && res.message?.message_id !== this.lastMessage) {
this.lastMessage = res.message.message_id;
if(!this.server.user.has(res.message.from.username || res.message.from.first_name)) {
this.server.user.set(res.message.from.username || res.message.from.first_name, {
nick: res.message.from.first_name,
username: res.message.from.username,
account: res.message.from.id.toString(),
prefix: `${res.message.from.username}!${res.message.from.id.toString()}@${this.network}`,
id: res.message.from.id
});
}
try {
let key;
if(key = Object.keys(res.message).filter(t => allowedFiles.includes(t))?.[0]) {
let media = res.message[key];
if(key === 'photo')
media = res.message[key][res.message[key].length - 1];
res.message.media = await this.getFile(media.file_id);
res.message.text = res.message.caption;
delete res.message[key];
}
} catch {
// no media files
}
this.emit("data", ["message", this.reply(res.message)]);
}
}
else if(res.hasOwnProperty("callback_query")) {
this.emit("data", ["callback_query", {
...res.callback_query,
editMessageText: this.editMessageText.bind(this)
}]);
}
else if(res.hasOwnProperty("inline_query")) {
this.emit("data", ["inline_query", res.inline_query]);
}
});
}
catch(err) {
if(!err.type)
this.emit("data", ["error", "tg timed out lol"]);
else if(err.type === "tg")
this.emit("data", ["error", err.message]);
await this.connect();
}
finally {
setTimeout(async () => {
await this.poll();
}, this.options.pollrate);
}
}
async editMessageText(chat_id, message_id, text, opt = {}) {
const opts = {
method: "POST",
body: {
chat_id: chat_id,
message_id: message_id,
text: text,
...opt
}
};
await fetch(`${this.api}/editMessageText`, opts);
}
async send(chatid, msg, reply = null, opt = {}) {
msg = Array.isArray(msg) ? msg.join("\n") : msg;
if (msg.length === 0 || msg.length > 2048)
return this.emit("data", ["error", "msg to short or to long lol"]);
const opts = {
method: "POST",
body: {
chat_id: chatid,
text: this.format(msg),
parse_mode: "HTML",
...opt
}
};
if (reply)
opts.body.reply_to_message_id = reply;
await fetch(`${this.api}/sendMessage`, opts);
}
async sendmsg(mode, recipient, msg) {
await this.send(recipient, msg);
}
reply(tmp) {
return {
type: "tg",
network: "Telegram",
channel: tmp.chat.title,
channelid: tmp.chat.id,
user: {
prefix: `${tmp.from.username}!${tmp.from.id}@${this.network}`,
nick: tmp.from.first_name,
username: tmp.from.username,
account: tmp.from.id.toString()
},
self: this.server,
message: tmp.text,
time: tmp.date,
raw: tmp,
media: tmp.media || null,
reply: (msg, opt = {}) => this.send(tmp.chat.id, msg, tmp.message_id, opt),
replyAction: msg => this.send(tmp.chat.id, `Uwe ${msg}`, tmp.message_id),
replyNotice: msg => this.send(tmp.chat.id, msg, tmp.message_id),
_user: this.server.user
};
}
format(msg) {
return msg.toString()
.split("&").join("&amp;")
.split("<").join("&lt;")
.split(">").join("&gt;")
.replace(/\[b\](.*?)\[\/b\]/gsm, "<b>$1</b>") // bold
.replace(/\[i\](.*?)\[\/i\]/gsm, "<i>$1</i>") // italic
.replace(/\[color=(.*?)](.*?)\[\/color\]/gsm, "$2")
.replace(/\[pre\](.*?)\[\/pre\]/gsm, "<pre>$1</pre>")
;
}
}

250
src/clients/tg.ts Normal file
View File

@ -0,0 +1,250 @@
import EventEmitter from "events";
const allowedFiles = ["audio", "video", "photo", "document"] as const;
type AllowedFileTypes = typeof allowedFiles[number];
interface TelegramOptions {
token: string;
pollrate?: number;
set?: string;
}
interface ServerInfo {
set: string;
channel: Map<string, any>;
user: Map<string, any>;
me: Record<string, any>;
}
interface TelegramResponse<T> {
ok: boolean;
result?: T;
description?: string;
}
interface TelegramMessage {
message_id: number;
from: {
id: number;
is_bot: boolean;
first_name: string;
username?: string;
};
chat: {
id: number;
title?: string;
type: string;
};
date: number;
text?: string;
caption?: string;
[key: string]: any;
}
interface TelegramUpdate {
update_id: number;
message?: TelegramMessage;
callback_query?: any;
inline_query?: any;
}
interface TelegramEvents {
data: [string | [string, any]];
error: [string];
}
export default class tg extends EventEmitter {
private options: Required<TelegramOptions>;
private token: string;
private api: string;
private lastUpdate: number = 0;
private lastMessage: number = 0;
private poller: NodeJS.Timeout | null = null;
private server: ServerInfo = {
set: "",
channel: new Map(),
user: new Map(),
me: {},
};
emit<K extends keyof TelegramEvents>(event: K, ...args: TelegramEvents[K]): boolean {
return super.emit(event, ...args);
}
on<K extends keyof TelegramEvents>(event: K, listener: (...args: TelegramEvents[K]) => void): this {
return super.on(event, listener);
}
constructor(options: TelegramOptions) {
super();
this.options = {
pollrate: 1000,
set: "all",
...options,
};
this.token = this.options.token;
this.api = `https://api.telegram.org/bot${this.token}`;
this.server.set = this.options.set;
return (async () => {
await this.connect();
await this.poll();
return this;
})() as unknown as tg;
}
async connect(): Promise<void> {
const res = await (await fetch(`${this.api}/getMe`)).json() as TelegramResponse<any>;
if(!res.ok)
throw this.emit("data", ["error", res.description ?? "Unknown error"]);
this.server.me = {
nickname: res.result.first_name,
username: res.result.username,
account: res.result.id.toString(),
prefix: `${res.result.username}!${res.result.id.toString()}`,
id: res.result.id.toString(),
};
}
async getFile(fileId: string): Promise<string | false> {
const res = await (await fetch(`${this.api}/getFile?file_id=${fileId}`)).json() as TelegramResponse<{ file_path: string }>;
if(!res.ok)
return false;
return `https://api.telegram.org/file/bot${this.token}/${res.result?.file_path}`;
}
async poll(): Promise<void> {
try {
const updates = await this.fetchUpdates();
if(!updates || updates.length === 0)
return;
this.lastUpdate = updates[updates.length - 1].update_id + 1;
for(const update of updates)
await this.processUpdate(update);
}
catch(err: any) {
await this.handlePollError(err);
}
finally {
setTimeout(() => this.poll(), this.options.pollrate);
}
}
private async fetchUpdates(): Promise<TelegramUpdate[]> {
const res = await (await fetch(`${this.api}/getUpdates?offset=${this.lastUpdate}&allowed_updates=message`)).json() as TelegramResponse<TelegramUpdate[]>;
if(!res.ok)
throw new Error(res.description || "Failed to fetch updates");
return res.result || [];
}
private async processUpdate(update: any): Promise<void> {
if(update.message)
await this.handleMessage(update.message);
else if(update.callback_query)
this.emit("data", ["callback_query", this.reply(update.callback_query.message, update.callback_query)]);
else if(update.inline_query)
this.emit("data", ["inline_query", update.inline_query]);
}
private async handleMessage(message: any): Promise<void> {
if (
message.date >= Math.floor(Date.now() / 1000) - 10 &&
message.message_id !== this.lastMessage
) {
this.lastMessage = message.message_id;
if(!this.server.user.has(message.from.username || message.from.first_name)) {
this.server.user.set(message.from.username || message.from.first_name, {
nick: message.from.first_name,
username: message.from.username,
account: message.from.id.toString(),
prefix: `${message.from.username}!${message.from.id.toString()}@Telegram`,
id: message.from.id,
});
}
try {
const fileKey = Object.keys(message).find(key =>
allowedFiles.includes(key as AllowedFileTypes)
);
if(fileKey) {
let media = message[fileKey];
if(fileKey === "photo")
media = message[fileKey][message[fileKey].length - 1];
message.media = await this.getFile(media.file_id);
message.text = message.caption;
delete message[fileKey];
}
}
catch {}
this.emit("data", ["message", this.reply(message)]);
}
}
private async handlePollError(err: any): Promise<void> {
if(!err.type)
this.emit("data", ["error", "tg timed out lol"]);
else if(err.type === "tg")
this.emit("data", ["error", err.message]);
await this.connect();
}
reply(tmp: any, opt = {}): any {
return {
type: "tg",
network: "Telegram",
channel: tmp.chat?.title,
channelid: tmp.chat?.id,
user: {
prefix: `${tmp.from.username}!${tmp.from.id}@Telegram`,
nick: tmp.from.first_name,
username: tmp.from.username,
account: tmp.from.id.toString(),
},
self: this.server,
message: tmp.text,
time: tmp.date,
raw: tmp,
media: tmp.media || null,
reply: async (msg: string, opt = {}) => await this.send(tmp.chat.id, msg, tmp.message_id, opt),
replyAction: async (msg: string, opt = {}) => await this.send(tmp.chat.id, `Action ${msg}`, tmp.message_id, opt),
};
}
async send(chatId: number, msg: string, reply: number | null = null, opt = {}): Promise<any> {
const body: { chat_id: number; text: string; parse_mode: string; reply_to_message_id?: number } = {
chat_id: chatId,
text: this.format(msg),
parse_mode: "HTML",
...opt,
};
if(reply)
body["reply_to_message_id"] = reply;
const opts = {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json"
}
};
return await (await fetch(`${this.api}/sendMessage`, opts)).json();
}
format(msg: string): string {
return msg.toString()
.split("&").join("&amp;")
.split("<").join("&lt;")
.split(">").join("&gt;")
.replace(/\[b\](.*?)\[\/b\]/gsm, "<b>$1</b>") // bold
.replace(/\[i\](.*?)\[\/i\]/gsm, "<i>$1</i>") // italic
.replace(/\[color=(.*?)](.*?)\[\/color\]/gsm, "$2")
.replace(/\[pre\](.*?)\[\/pre\]/gsm, "<pre>$1</pre>")
;
}
}

View File

@ -1,40 +0,0 @@
import fs from "fs";
import { fileURLToPath } from "url";
import { dirname } from "path";
import EventEmitter from "events";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default class cuffeo extends EventEmitter {
constructor(cfg) {
super();
this.clients = [];
this.libs = {};
return (async () => {
this.libs = await this.loadLibs();
this.clients = await this.registerClients(cfg);
return this;
})();
}
async loadLibs() {
return (await (Promise.all((await fs.promises.readdir(`${__dirname}/clients`)).filter(f => f.endsWith(".mjs")).map(async client => {
const lib = (await import(`./clients/${client}`)).default;
return { [lib.name]: lib };
})))).reduce((a, b) => ({ ...a, ...b }));
}
async registerClients(cfg) {
return cfg.filter(e => e.enabled).map(async srv => {
if(!Object.keys(this.libs).includes(srv.type))
throw new Error(`not supported client: ${srv.type}`);
const client = {
name: srv.network,
type: srv.type,
client: await new this.libs[srv.type](srv)
};
client.client.on("data", ([type, data]) => this.emit(type, data));
return client;
});
}
};

82
src/index.ts Normal file
View File

@ -0,0 +1,82 @@
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import EventEmitter from "node:events";
const __dirname = dirname(fileURLToPath(import.meta.url));
interface Config {
enabled: boolean;
type: string;
network: string;
}
interface Client {
name: string;
type: string;
client: any;
}
interface CuffeoEvents {
data: [string, any];
error: [Error];
}
export default class Cuffeo extends EventEmitter {
private clients: Client[] = [];
private libs: Record<string, any> = {};
emit<K extends keyof CuffeoEvents>(event: K, ...args: CuffeoEvents[K]): boolean {
return super.emit(event, ...args);
}
on<K extends keyof CuffeoEvents>(event: K, listener: (...args: CuffeoEvents[K]) => void): this {
return super.on(event, listener);
}
constructor(cfg: Config[]) {
super();
return (async () => {
this.libs = await this.loadLibs();
this.clients = await this.registerClients(cfg);
return this;
})() as unknown as Cuffeo;
}
private async loadLibs(): Promise<Record<string, any>> {
const clientFiles = await fs.promises.readdir(`${__dirname}/clients`);
const modules = await Promise.all(
clientFiles
.filter(f => f.endsWith(".js"))
.map(async client => {
const lib = (await import(`./clients/${client}`)).default;
return { [lib.name]: lib };
})
);
return modules.reduce((a, b) => ({ ...a, ...b }), {});
}
private async registerClients(cfg: Config[]): Promise<Client[]> {
return Promise.all(
cfg
.filter(e => e.enabled)
.map(async srv => {
if(!Object.keys(this.libs).includes(srv.type))
throw new Error(`unsupported client: ${srv.type}`);
const client: Client = {
name: srv.network,
type: srv.type,
client: await new this.libs[srv.type](srv),
};
client.client.on("data", ([type, data]: [keyof CuffeoEvents, any]) =>
this.emit(type, data)
);
return client;
})
);
}
}

33
src/test.mjs Normal file
View File

@ -0,0 +1,33 @@
import cuffeo from '../dist/index.js';
const clients = [{
"type": "irc",
"enabled": false,
"network": "n0xy",
"host": "irc.n0xy.net",
"port": 6697,
"ssl": true,
"selfSigned": false,
"sasl": false,
"nickname": "cuffeots",
"username": "cuffeots",
"realname": "cuffeo",
"channels": [
"#kbot-dev"
]
}, {
"type": "tg",
"enabled": true,
"token": "xd",
"pollrate": 1001
}];
const bot = await new cuffeo(clients);
bot.on("message", msg => {
console.log(msg);
msg.reply("pong");
});
bot.on("data", data => {
console.log(data);
});

33
src/types.d.ts vendored Normal file
View File

@ -0,0 +1,33 @@
export interface Message {
prefix: string;
params: string[];
}
export interface Bot {
username: string;
parsePrefix: (prefix: string) => User;
_cmd: Map<string, (msg: Message) => void>;
send: (message: string) => void;
server: {
channel: Map<string, string[]>;
motd: string;
user: {
[channel: string]: any
};
me?: User;
};
options: {
password: string;
sasl: boolean;
channels: string[];
};
join: (channel: string | string[]) => void;
emit: (event: string, args: any[]) => void;
reply: (msg: Message) => string;
whois: (nick: string | string[], force?: boolean) => void;
}
export interface User {
nick: string | false;
[key: string]: any;
}

15
tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2024",
"module": "ESNext",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"removeComments": true,
"declaration": true
},
"include": ["src"],
"exclude": ["node_modules"]
}