Refactor bot. (#8)

This commit is contained in:
dada513 2022-06-07 11:08:49 +02:00 committed by GitHub
parent e0374bea36
commit 259d540e6f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 491 additions and 300 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
node_modules/
/index.js
dist
.env

View file

@ -1,169 +0,0 @@
import type { Client, Message } from 'discord.js';
import fetch from 'node-fetch';
type Commands = {
[cmd: string]: (c: Client, e: Message) => void | Promise<void>;
};
export const commands: Commands = {
'!ping': async (c, e) => {
await e.reply(`${c.ws.ping}ms`);
},
'!why': async (c, e) => {
await e.reply({
embeds: [
{
title: 'Why PolyMC exists',
description:
'https://polymc.org/wiki/overview/faq/#why-did-our-community-choose-to-fork\nhttps://polymc.org/news/moving-on/',
color: 'GREYPLE',
},
],
});
},
'!paths': async (c, e) => {
await e.reply({
embeds: [
{
title: 'Data directories',
description: 'Where PolyMC stores your data (e.g. instances)',
color: 'AQUA',
fields: [
{
name: 'Portable (Windows / Linux)',
value: 'In the PolyMC folder',
},
{
name: 'Windows',
value: '`%APPDATA%/PolyMC`',
},
{
name: 'macOS',
value: '`~/Library/Application Support/PolyMC`',
},
{ name: 'Linux', value: '`~/.local/share/PolyMC`' },
{
name: 'Flatpak',
value: '`~/.var/app/org.polymc.PolyMC/data/PolyMC`',
},
],
},
],
});
},
'!cursed': async (c, e) => {
await e.reply({
embeds: [
{
title: "What's wrong with CurseForge?",
description: `
CurseForge added a new option to block third party clients like PolyMC from accessing mod files, and they started to enforce this option lately.
We can't allow you to download those mods directly from PolyMC, but PolyMC 1.3.1 and higher have a workaround to let modpacks work: letting you to download those opted out mods manually.
We highly encourage asking authors that opted out to stop doing so.
`.trim(),
color: 'ORANGE',
},
],
});
},
'!migrate': async (c, e) => {
await e.reply('https://polymc.org/wiki/getting-started/migrating-multimc/');
},
'!build': async (c, e) => {
await e.reply('https://polymc.org/wiki/development/build-instructions/');
},
'!java': async (c, e) => {
await e.reply('https://polymc.org/wiki/getting-started/installing-java/');
},
'!eta': async (c, e) => {
await e.reply('Sometime');
},
'!members': async (c, e) => {
const mems = await e.guild?.members.fetch().then((r) => r.toJSON());
if (!mems) return;
await e.reply({
embeds: [
{
title: `${mems.length} total members!`,
description: `${
mems.filter(
(m) =>
m.presence?.status === 'online' ||
m.presence?.status === 'idle' ||
m.presence?.status === 'dnd'
).length
} online members, and ${
mems.filter((m) => m.presence?.status === 'invisible').length
} members that are pretending to be offline`,
color: 'GOLD',
},
],
});
},
'!stars': async (c, e) => {
const count = await fetch('https://api.github.com/repos/PolyMC/PolyMC')
.then((r) => r.json() as Promise<{ stargazers_count: number }>)
.then((j) => j.stargazers_count);
await e.reply({
embeds: [
{
title: `${count} total stars!`,
color: 'GOLD',
},
],
});
},
// '!polycatgen': async (c, e) => {
// if (!e.guild) return;
// if (
// e.channelId !== POLYCAT_CHANNEL_ID &&
// process.env.NODE_ENV !== 'development'
// )
// return;
// await e.guild.emojis.fetch();
// const polycat = e.guild.emojis.cache.find(
// (emoji) => emoji.name?.toLowerCase() === 'polycat'
// );
// await e.reply(
// `.\n${polycat}${polycat}${polycat}${polycat}${polycat}\n${polycat}${polycat}${polycat}${polycat}${polycat}\n${polycat}${polycat}${polycat}${polycat}${polycat}\n${polycat}${polycat}${polycat}${polycat}${polycat}\n${polycat}${polycat}${polycat}${polycat}${polycat}`
// );
// },
'!piracy': async (c, e) => {
await e.reply({
embeds: [
{
title: "We don't tolerate piracy!",
description:
"PolyMC has always been legal, legitimate & appropriate. We don't and never will have features such as offline login without an official account.",
color: 'DARK_RED',
},
],
});
},
};
export const aliases: { [a: string]: string } = {
'!curse': '!cursed',
'!curseforge': '!cursed',
'!cf': '!cursed',
'!diff': '!why',
'!migr': '!migrate',
'!j': '!java',
'!multimc': '!migrate',
};

121
index.ts
View file

@ -1,121 +0,0 @@
import { Client, Intents } from 'discord.js';
import { commands, aliases } from './commands';
import * as BuildConfig from './constants';
import { isBad } from './badLinks';
import { green, bold, blue, underline, yellow } from 'kleur/colors';
import urlRegex from 'url-regex';
import removeMarkdown from 'remove-markdown';
const client = new Client({
intents: [
Intents.FLAGS.GUILDS,
Intents.FLAGS.GUILD_MESSAGES,
Intents.FLAGS.DIRECT_MESSAGES,
Intents.FLAGS.GUILD_MEMBERS,
Intents.FLAGS.GUILD_PRESENCES,
Intents.FLAGS.GUILD_MESSAGE_REACTIONS,
Intents.FLAGS.GUILD_BANS,
],
});
client.login(process.env.DISCORD_TOKEN);
client.once('ready', async () => {
console.log(green('Discord bot ready!'));
if (process.env.NODE_ENV !== 'development')
console.warn(yellow(bold('Running in production mode!')));
console.log(
'Invite link:',
blue(
underline(
client.generateInvite({
scopes: ['bot'],
permissions: ['ADMINISTRATOR'],
})
)
)
);
const POLYMC_GUILD = await client.guilds.fetch(BuildConfig.GUILD_ID);
const DEBUG_CHANNEL = POLYMC_GUILD.channels.cache.get(
BuildConfig.DEBUG_CHANNEL_ID
);
if (!DEBUG_CHANNEL || !DEBUG_CHANNEL.isText()) throw new Error();
DEBUG_CHANNEL.send({
embeds: [
{
title: 'Started!',
description: new Date().toISOString(),
color: 'AQUA',
},
],
});
client.on('messageCreate', async (e) => {
if (!e.content) return;
if (!e.channel.isText()) return;
if (e.author === client.user) return;
if (
process.env.NODE_ENV === 'development' &&
e.channelId !== BuildConfig.DEBUG_CHANNEL_ID
) {
return;
} else if (
process.env.NODE_ENV !== 'development' &&
e.channelId === BuildConfig.DEBUG_CHANNEL_ID
) {
return;
}
// phishing link filter
{
const urlMatches = [...e.content.matchAll(urlRegex())];
if (urlMatches.length) {
console.log('Found links in message from', e.author.tag);
for (const match of urlMatches) {
console.log('[link]', match[0]);
if (await isBad(match[0])) {
await e.reply({
embeds: [
{
title: 'Hold on!',
description:
'There seems to be a phishing / malware link in your message.',
color: 'RED',
},
],
});
return;
}
}
}
}
// neat
{
const cleanContent = removeMarkdown(e.content).toLowerCase();
if (cleanContent.split(' ').includes('neat')) {
console.log('[neat]', cleanContent);
await e.reply('Neat is a mod by Vazkii.');
}
}
const cmd = e.content.split(' ')[0];
if (!cmd.startsWith('!')) return;
let func = commands[cmd];
func = func ?? commands[aliases[cmd]];
if (func !== undefined) {
await func(client, e);
}
});
});

View file

@ -1,15 +1,17 @@
{
"name": "polly",
"version": "0.0.1",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"dev": "NODE_ENV=development nodemon --watch index.ts --exec esno index.ts",
"build": "esbuild index.ts --format=cjs --platform=node --target=node17 --minify --bundle --sourcemap --external:discord.js --outdir=dist",
"dev": "NODE_ENV=development nodemon --ext ts,json --watch src --exec esno src/index.ts",
"build": "tsc",
"lint": "eslint **/*.ts"
},
"dependencies": {
"@cliqz/adblocker": "^1.23.8",
"discord-command-parser": "^1.5.3",
"discord.js": "^13.7.0",
"dotenv": "^16.0.1",
"kleur": "^4.1.4",
"node-fetch": "^3.2.4",
"remove-markdown": "^0.5.0",
@ -17,7 +19,7 @@
},
"devDependencies": {
"@esbuild-plugins/node-resolve": "^0.1.4",
"@types/remove-markdown": "^0.3.1",
"@types/bad-words": "^3.0.1",
"@types/node": "^17.0.38",
"@typescript-eslint/eslint-plugin": "^5.25.0",
"@typescript-eslint/parser": "^5.25.0",

32
src/commands/help.ts Normal file
View file

@ -0,0 +1,32 @@
import { MessageEmbed } from 'discord.js';
import { commands } from '..';
import { Command } from '..';
export const cmd: Command = {
name: 'help',
desc: 'Shows this menu.',
exec: async (e) => {
const embed = new MessageEmbed()
.setTitle('Help Menu')
.setColor('DARK_GREEN');
let comman = commands;
comman.sort((x, y) => {
return x.name == 'help' ? -1 : y.name == 'help' ? 1 : 0;
});
for (const i in comman) {
const cmd = comman[i];
const resp = [];
if (cmd.desc) {
resp.push(cmd.desc);
}
if (cmd.aliases && cmd.aliases[0]) {
resp.push(`**Aliases**: ${cmd.aliases.join(', ')}`);
}
if (cmd.examples && cmd.examples[0]) {
resp.push(`**Examples**: \n${cmd.examples.join('\n> ')}`);
}
embed.addField('!' + cmd.name, resp.join('\n'));
}
return e.reply({ embeds: [embed] });
},
};

28
src/commands/members.ts Normal file
View file

@ -0,0 +1,28 @@
import { Command } from '../index';
export const cmd: Command = {
name: 'members',
desc: 'Shows the amount of online users in PolyMC Discord',
aliases: ['mems', 'memcount'],
exec: async (e) => {
const memes = await e.guild?.members.fetch().then((r) => r.toJSON());
if (!memes) return;
return e.reply({
embeds: [
{
title: `${memes.length} total members!`,
description: `${
memes.filter(
(m) =>
m.presence?.status === 'online' ||
m.presence?.status === 'idle' ||
m.presence?.status === 'dnd'
).length
} online members`,
color: 'GOLD',
},
],
});
},
};

10
src/commands/ping.ts Normal file
View file

@ -0,0 +1,10 @@
import { Command } from '../index';
export const cmd: Command = {
name: 'ping',
desc: 'Shows the ping of the bot',
aliases: ['test'],
exec: async (e) => {
return await e.reply(`${e.client.ws.ping}ms`);
},
};

20
src/commands/stars.ts Normal file
View file

@ -0,0 +1,20 @@
import { Command } from '../index';
export const cmd: Command = {
name: 'stars',
desc: 'Shows the number of stars in PolyMC',
aliases: ['star', 'stargazers'],
exec: async (e) => {
const count = await fetch('https://api.github.com/repos/PolyMC/PolyMC')
.then((r) => r.json() as Promise<{ stargazers_count: number }>)
.then((j) => j.stargazers_count);
return e.reply({
embeds: [
{
title: `${count} total stars!`,
color: 'GOLD',
},
],
});
},
};

27
src/commands/tags.ts Normal file
View file

@ -0,0 +1,27 @@
import { MessageEmbed } from 'discord.js';
import { Command } from '../index';
import { tags } from '../index';
export const cmd: Command = {
name: 'tags',
desc: 'Lists the tags available',
exec: async (e) => {
const em = new MessageEmbed().setTitle('tags').setColor('DARK_GREEN');
for (let i in tags) {
const tag = tags[i];
let text = '';
if (tag.aliases && tag.aliases[0]) {
text += '**Aliases**: ' + tag.aliases.join(', ');
}
if (tag.text) {
text += tag.text;
} else if (tag.embed) {
text += '\n[embedded message]';
}
em.addField(tag.name, text);
}
return e.reply({ embeds: [em] });
},
};

34
src/filters.ts Normal file
View file

@ -0,0 +1,34 @@
import * as BuildConfig from './constants';
import { Message } from 'discord.js';
import { isBad } from './badLinks';
import urlRegex from 'url-regex';
// true if message is ok, false if filtered
export async function filterMessage(e: Message): Promise<boolean> {
// url matcher
const urlMatches = [...e.content.matchAll(urlRegex())];
if (urlMatches.length) {
console.log('Found links in message from', e.author.tag);
for (const match of urlMatches) {
console.log('[link]', match[0]);
if (await isBad(match[0])) {
await e.reply({
embeds: [
{
title: 'Hold on!',
description:
'There seems to be a phishing / malware link in your message.',
color: 'RED',
},
],
});
return false;
}
}
}
return true;
}

157
src/index.ts Normal file
View file

@ -0,0 +1,157 @@
import {
Client,
Intents,
Message,
MessageEmbed,
MessageEmbedOptions,
} from 'discord.js';
import * as BuildConfig from './constants';
import { filterMessage } from './filters';
import { green, bold, blue, underline, yellow } from 'kleur/colors';
import * as parser from 'discord-command-parser';
import fs from 'fs';
import path, { dirname } from 'path';
import { SuccessfulParsedMessage } from 'discord-command-parser';
import dotenv from 'dotenv';
import { parseLog } from './mclogs';
dotenv.config();
export interface Command {
name: string;
aliases?: Array<string>;
desc?: string;
examples?: Array<string>;
exec(
m: Message,
p: SuccessfulParsedMessage<Message<boolean>>
): Promise<Message> | Promise<any> | any;
}
type Commands = Array<Command>;
export let commands: Commands = [];
interface Tag {
name: string;
aliases?: Array<string>;
text?: string;
embed?: MessageEmbedOptions;
}
type Tags = Array<Tag>;
export const tags: Tags = JSON.parse(
fs.readFileSync(path.join(__dirname, 'tags.json'), 'utf8')
);
const client = new Client({
intents: [
Intents.FLAGS.GUILDS,
Intents.FLAGS.GUILD_MESSAGES,
Intents.FLAGS.DIRECT_MESSAGES,
Intents.FLAGS.GUILD_MEMBERS,
Intents.FLAGS.GUILD_PRESENCES,
Intents.FLAGS.GUILD_MESSAGE_REACTIONS,
Intents.FLAGS.GUILD_BANS,
],
});
const dir = fs.readdirSync(path.join(__dirname, '/commands'));
for (const i in dir) {
const cmdName = dir[i];
const cmd: Command = require(path.join(__dirname, '/commands/', cmdName)).cmd;
commands.push(cmd);
}
client.once('ready', async () => {
console.log(green('Discord bot ready!'));
if (process.env.NODE_ENV !== 'development')
console.warn(yellow(bold('Running in production mode!')));
console.log(
'Invite link:',
blue(
underline(
client.generateInvite({
scopes: ['bot'],
permissions: ['ADMINISTRATOR'],
})
)
)
);
const POLYMC_GUILD = await client.guilds.fetch(BuildConfig.GUILD_ID);
client.on('messageCreate', async (e) => {
if (!e.content) return;
if (!e.channel.isText()) return;
if (e.author === client.user) return;
if (
process.env.NODE_ENV === 'development' &&
e.channelId !== BuildConfig.DEBUG_CHANNEL_ID
) {
return;
} else if (
process.env.NODE_ENV !== 'development' &&
e.channelId === BuildConfig.DEBUG_CHANNEL_ID
) {
return;
}
const commanded = await parseMsg(e);
if (commanded) return;
const log = await parseLog(e.content);
if (log != null) {
e.reply({ embeds: [log] });
return;
}
const filtered = await filterMessage(e);
if (!filtered) {
return;
}
});
});
async function parseMsg(e: Message) {
const parsed = parser.parse(e, '!', {
allowBots: true,
});
if (!parsed.success) return false;
const cmd = commands.find(
(c) => c.name == parsed.command || c.aliases?.includes(parsed.command)
);
if (!cmd) {
const tag = tags.find(
(t) => t.name == parsed.command || t.aliases?.includes(parsed.command)
);
if (tag) {
if (tag.text) {
e.reply(tag.text);
return true;
} else if (tag.embed) {
const em = new MessageEmbed(tag.embed);
e.reply({ embeds: [em] });
return true;
}
}
return false;
}
try {
await cmd.exec(e, parsed);
} catch (err: any) {
// ts moment
const em = new MessageEmbed()
.setTitle('Error')
.setColor('RED')
.setDescription(err);
e.reply({ embeds: [em] });
}
return true;
}
client.login(process.env.DISCORD_TOKEN);

75
src/mclogs.ts Normal file
View file

@ -0,0 +1,75 @@
import { getLatest } from './version';
import { MessageEmbed } from 'discord.js';
const reg = /https\:\/\/mclo.gs\/[^ ]*/g;
type analyzer = (text: string) => Promise<Array<string> | null>;
const javaAnalyzer: analyzer = async (text) => {
if (text.includes('This instance is not compatible with Java version')) {
const xp =
/Please switch to one of the following Java versions for this instance:[\r\n]+([^\r\n]+)/g;
let ver: string;
const m = text.match(xp);
if (!m || !m[0]) {
ver = '';
} else {
ver = m[0].split('\n')[1];
}
return [
'WrongJavaVersion',
`Please switch to the following: \`${ver}\`\nFor more information, type \`!java\``,
];
}
return null;
};
const versionAnalyzer: analyzer = async (text) => {
const vers = text.match(/PolyMC version: [0-9].[0-9].[0-9]/g);
if (vers && vers[0]) {
const latest = await getLatest();
const current = vers[0].replace('PolyMC version: ', '');
if (latest != current) {
return [
'OutdatedPolyMC',
`Your installed version is ${current}, while the newest version is ${latest}.\nPlease update, for more info see https://polymc.org/download/`,
];
}
}
return null;
};
const analyzers: analyzer[] = [javaAnalyzer, versionAnalyzer];
export async function parseLog(s: string): Promise<MessageEmbed | null> {
const r = s.match(reg);
if (r == null || !r[0]) return null;
const link = r[0]; // for now only first url
const id = link.replace('https://mclo.gs/', '');
if (!id) return null;
const apiUrl = 'https://api.mclo.gs/1/raw/' + id;
let log: string;
try {
const f = await fetch(apiUrl);
if (f.status != 200) {
throw 'nope';
}
log = await f.text();
} catch (_) {
return null;
}
const embed = new MessageEmbed()
.setTitle('Log analyzer')
.setColor('DARK_GREEN')
.setDescription(`Analysis of ${link} [${apiUrl}] [ID: ${id}]`);
for (let i in analyzers) {
const analyzer = analyzers[i];
const out = await analyzer(log);
if (out) embed.addField(out[0], out[1]);
}
if (embed.fields[0]) return embed;
else {
embed.addField('Analyze failed', 'No issues found automatically');
return embed;
}
}

76
src/tags.json Normal file
View file

@ -0,0 +1,76 @@
[
{
"name": "migrate",
"text": "https://polymc.org/wiki/getting-started/migrating-multimc/",
"aliases": ["migr", "mmc"]
},
{
"name": "matrix",
"text": "https://matrix.to/#/#polymc:matrix.org"
},
{
"name": "log",
"text": "https://i.imgur.com/gsrgYzg.png"
},
{
"name": "java",
"text": "https://polymc.org/wiki/getting-started/installing-java/",
"aliases": ["j"]
},
{
"name": "paths",
"aliases": ["dirs", "locate"],
"embed": {
"title": "Data directories",
"description": "Where PolyMC stores your data (e.g. instances)",
"color": "AQUA",
"fields": [
{
"name": "Portable (Windows / Linux)",
"value": "In the PolyMC folder"
},
{
"name": "Windows",
"value": "`%APPDATA%/PolyMC`"
},
{
"name": "macOS",
"value": "`~/Library/Application Support/PolyMC`"
},
{
"name": "Linux",
"value": "`~/.local/share/PolyMC`"
},
{
"name": "Flatpak",
"value": "`~/.var/app/org.polymc.PolyMC/data/PolyMC`"
}
]
}
},
{
"name": "build",
"text": "https://polymc.org/wiki/development/build-instructions/"
},
{
"name": "eta",
"text": "Sometime"
},
{
"name": "curseforge",
"embed": {
"title": "What's wrong with CurseForge?",
"description": "CurseForge added a new option to block third party clients like PolyMC from accessing mod files, and they started to enforce this option lately. We can't allow you to download those mods directly from PolyMC, but PolyMC 1.3.1 and higher have a workaround to let modpacks work: letting you to download those opted out mods manually. We highly encourage asking authors that opted out to stop doing so.",
"color": "ORANGE"
},
"aliases": ["cf", "curse", "cursed"]
},
{
"name": "piracy",
"embed": {
"title": "We don't tolerate piracy!",
"description": "PolyMC has always been legal, legitimate & appropriate. We don't and never will have features such as offline login without an official account.",
"color": "DARK_RED"
}
}
]

11
src/version.ts Normal file
View file

@ -0,0 +1,11 @@
let cachedVer: string;
let cachedTimestamp: number;
export async function getLatest(): Promise<string> {
if (cachedVer && Date.now() - cachedTimestamp < 600000) return cachedVer; // 10min
const f = await fetch('https://api.github.com/repos/PolyMC/PolyMC/releases');
const versions = await f.json();
cachedVer = versions[0].tag_name;
cachedTimestamp = Date.now();
return versions[0].tag_name;
}

View file

@ -3,6 +3,9 @@
"strict": true,
"esModuleInterop": true,
"downlevelIteration": true,
"target": "ES5"
"target": "ES2018",
"moduleResolution": "node",
"rootDir": "src",
"outDir": "dist"
}
}

View file

@ -239,11 +239,6 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.34.tgz#3b0b6a50ff797280b8d000c6281d229f9c538cef"
integrity sha512-XImEz7XwTvDBtzlTnm8YvMqGW/ErMWBsKZ+hMTvnDIjGCKxwK5Xpc+c/oQjOauwq8M4OS11hEkpjX8rrI/eEgA==
"@types/remove-markdown@^0.3.1":
version "0.3.1"
resolved "https://registry.yarnpkg.com/@types/remove-markdown/-/remove-markdown-0.3.1.tgz#82bc3664c313f50f7c77f1bb59935f567689dc63"
integrity sha512-JpJNEJEsmmltyL2LdE8KRjJ0L2ad5vgLibqNj85clohT9AyTrfN6jvHxStPshDkmtcL/ShFu0p2tbY7DBS1mqQ==
"@types/node@^17.0.38":
version "17.0.38"
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.38.tgz#f8bb07c371ccb1903f3752872c89f44006132947"
@ -623,6 +618,11 @@ discord-api-types@^0.31.1:
resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.31.2.tgz#8d131e25340bd695815af3bb77128a6993c1b516"
integrity sha512-gpzXTvFVg7AjKVVJFH0oJGC0q0tO34iJGSHZNz9u3aqLxlD6LfxEs9wWVVikJqn9gra940oUTaPFizCkRDcEiA==
discord-command-parser@^1.5.3:
version "1.5.3"
resolved "https://registry.yarnpkg.com/discord-command-parser/-/discord-command-parser-1.5.3.tgz#ba27097aa0976fa9287ea81f8d8cdd82f2887317"
integrity sha512-YWgalkrbly0dJCyLw7p9SX3RC7HIxOrSTz/8vKjlmYPyyZmMCGmKwpXu6HkPXRZ20L6QqftVWigSw6fDK2zemg==
discord.js@^13.7.0:
version "13.7.0"
resolved "https://registry.yarnpkg.com/discord.js/-/discord.js-13.7.0.tgz#5172f7f5d816e2c7296015d335b54e46968d9c67"
@ -652,6 +652,11 @@ dot-prop@^5.2.0:
dependencies:
is-obj "^2.0.0"
dotenv@^16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.1.tgz#8f8f9d94876c35dac989876a5d3a82a267fdce1d"
integrity sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ==
duplexer3@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"