mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-07-20 03:51:35 +08:00
feat: telegram bot (#238)
This commit is contained in:
@@ -36,7 +36,12 @@ api.post('/admin/new_address', async (c) => {
|
||||
if (!name) {
|
||||
return c.text("Please provide a name", 400)
|
||||
}
|
||||
return newAddress(c, name, domain, enablePrefix);
|
||||
try {
|
||||
const res = await newAddress(c, name, domain, enablePrefix);
|
||||
return c.json(res);
|
||||
} catch (e) {
|
||||
return c.text(`Failed create address: ${e.message}`, 400)
|
||||
}
|
||||
})
|
||||
|
||||
api.delete('/admin/delete_address/:id', async (c) => {
|
||||
|
||||
@@ -7,14 +7,14 @@ export const newAddress = async (c, name, domain, enablePrefix) => {
|
||||
name = name.replace(/[^a-zA-Z0-9.]/g, '')
|
||||
// check name length
|
||||
if (name.length < 0) {
|
||||
return c.text("Name too short", 400)
|
||||
throw new Error("Name too short")
|
||||
}
|
||||
// create address
|
||||
if (enablePrefix) {
|
||||
name = getStringValue(c.env.PREFIX) + name;
|
||||
}
|
||||
if (name.length >= 30) {
|
||||
return c.text("Name too long (max 30)", 400)
|
||||
throw new Error("Name too long (max 30)")
|
||||
}
|
||||
// check domain, generate random domain
|
||||
const domains = getDomains(c);
|
||||
@@ -28,30 +28,27 @@ export const newAddress = async (c, name, domain, enablePrefix) => {
|
||||
`INSERT INTO address(name) VALUES(?)`
|
||||
).bind(name).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to create address", 500)
|
||||
throw new Error("Failed to create address")
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes("UNIQUE")) {
|
||||
return c.text("Address already exists, please retry a new address", 400)
|
||||
throw new Error("Address already exists")
|
||||
}
|
||||
return c.text("Failed to create address", 500)
|
||||
throw new Error("Failed to create address")
|
||||
}
|
||||
let address_id = 0;
|
||||
try {
|
||||
address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ?`
|
||||
).bind(name).first("id");
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ?`
|
||||
).bind(name).first("id");
|
||||
// create jwt
|
||||
const jwt = await Jwt.sign({
|
||||
address: name,
|
||||
address_id: address_id
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
return c.json({
|
||||
jwt: jwt
|
||||
})
|
||||
return {
|
||||
jwt: jwt,
|
||||
address: name,
|
||||
}
|
||||
}
|
||||
|
||||
export const cleanup = async (c, cleanType, cleanDays) => {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
export const CONSTANTS = {
|
||||
VERSION: 'v0.4.2',
|
||||
|
||||
// DB settings
|
||||
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
|
||||
SEND_BLOCK_LIST_KEY: 'send_block_list',
|
||||
AUTO_CLEANUP_KEY: 'auto_cleanup',
|
||||
USER_SETTINGS_KEY: 'user_settings',
|
||||
|
||||
// KV
|
||||
TG_KV_PREFIX: "temp-mail-telegram"
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createMimeMessage } from "mimetext";
|
||||
import { getBooleanValue } from "./utils";
|
||||
import { sendMailToTelegram } from "./telegram_api";
|
||||
|
||||
async function email(message, env, ctx) {
|
||||
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => message.from.includes(word))) {
|
||||
@@ -20,6 +21,15 @@ async function email(message, env, ctx) {
|
||||
console.log(`Failed save message from ${message.from} to ${message.to}`);
|
||||
}
|
||||
|
||||
// send email to telegram
|
||||
try {
|
||||
await sendMailToTelegram({
|
||||
env: env,
|
||||
}, message.to, rawEmail);
|
||||
} catch (error) {
|
||||
console.log("send mail to telegram error", error);
|
||||
}
|
||||
|
||||
// auto reply email
|
||||
if (getBooleanValue(env.ENABLE_AUTO_REPLY)) {
|
||||
try {
|
||||
|
||||
@@ -106,7 +106,12 @@ api.post('/api/new_address', async (c) => {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
return newAddress(c, name, domain, true);
|
||||
try {
|
||||
const res = await newAddress(c, name, domain, true);
|
||||
return c.json(res);
|
||||
} catch (e) {
|
||||
return c.text(`Failed create address: ${e.message}`, 400)
|
||||
}
|
||||
})
|
||||
|
||||
api.delete('/api/delete_address', async (c) => {
|
||||
|
||||
49
worker/src/telegram_api/index.ts
Normal file
49
worker/src/telegram_api/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Hono, Context } from 'hono'
|
||||
import { ServerResponse } from 'node:http'
|
||||
import { Writable } from 'node:stream'
|
||||
import { newTelegramBot, initTelegramBotCommands, sendMailToTelegram } from './telegram'
|
||||
|
||||
export const api = new Hono()
|
||||
export { sendMailToTelegram }
|
||||
|
||||
api.post("/telegram/webhook", async (c: Context) => {
|
||||
const token = c.env.TELEGRAM_BOT_TOKEN;
|
||||
const bot = newTelegramBot(c, token);
|
||||
let body = null;
|
||||
const res = new Writable();
|
||||
Object.assign(res, {
|
||||
headersSent: false,
|
||||
setHeader: (name: string, value: string) => c.header(name, value),
|
||||
end: (data: any) => body = data,
|
||||
});
|
||||
const reqJson = await c.req.json();
|
||||
await bot.handleUpdate(reqJson, res as ServerResponse);
|
||||
return c.body(body);
|
||||
});
|
||||
|
||||
api.post("/admin/telegram/init", async (c: Context) => {
|
||||
if (!c.env.TELEGRAM_BOT_TOKEN || !c.env.KV) {
|
||||
return c.text("TELEGRAM_BOT_TOKEN and KV are required", 400);
|
||||
}
|
||||
const domain = new URL(c.req.url).host;
|
||||
const token = c.env.TELEGRAM_BOT_TOKEN;
|
||||
const webhookUrl = `https://${domain}/telegram/webhook`;
|
||||
console.log(`setting webhook to ${webhookUrl}`);
|
||||
const bot = newTelegramBot(c, token);
|
||||
await bot.telegram.setWebhook(webhookUrl)
|
||||
await initTelegramBotCommands(bot);
|
||||
return c.json({
|
||||
message: "webhook set successfully",
|
||||
});
|
||||
});
|
||||
|
||||
api.get("/admin/telegram/status", async (c: Context) => {
|
||||
if (!c.env.TELEGRAM_BOT_TOKEN || !c.env.KV) {
|
||||
return c.text("TELEGRAM_BOT_TOKEN and KV are required", 400);
|
||||
}
|
||||
const token = c.env.TELEGRAM_BOT_TOKEN;
|
||||
const bot = newTelegramBot(c, token);
|
||||
const info = await bot.telegram.getWebhookInfo()
|
||||
const commands = await bot.telegram.getMyCommands()
|
||||
return c.json({ info, commands });
|
||||
});
|
||||
269
worker/src/telegram_api/telegram.ts
Normal file
269
worker/src/telegram_api/telegram.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
|
||||
import { Context } from "hono";
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
import { Telegraf, Context as TgContext, Markup } from "telegraf";
|
||||
import { callbackQuery } from "telegraf/filters";
|
||||
import PostalMime from 'postal-mime';
|
||||
|
||||
import { CONSTANTS } from "../constants";
|
||||
// @ts-ignore
|
||||
import { getIntValue, getDomains, getStringValue } from '../utils';
|
||||
// @ts-ignore
|
||||
import { newAddress } from '../common'
|
||||
|
||||
const COMMANDS = [
|
||||
{
|
||||
command: "start",
|
||||
description: "开始使用"
|
||||
},
|
||||
{
|
||||
command: "new",
|
||||
description: "新建邮箱地址, 如果要自定义邮箱地址, 请输入 /new <name>@<domain>, name [a-zA-Z0-9.] 有效"
|
||||
},
|
||||
{
|
||||
command: "address",
|
||||
description: "查看邮箱地址列表"
|
||||
},
|
||||
{
|
||||
command: "bind",
|
||||
description: "绑定邮箱地址, 请输入 /bind <邮箱地址凭证>"
|
||||
},
|
||||
{
|
||||
command: "mails",
|
||||
description: "查看邮件, 请输入 /mails <邮箱地址>, 不输入地址默认查看第一个地址"
|
||||
},
|
||||
]
|
||||
|
||||
export function newTelegramBot(c: Context, token: string): Telegraf {
|
||||
const bot = new Telegraf(token);
|
||||
bot.command("start", async (ctx: TgContext) => {
|
||||
if (ctx.chat?.type !== "private") {
|
||||
return await ctx.reply("请在私聊中使用");
|
||||
}
|
||||
const prefix = getStringValue(c.env.PREFIX)
|
||||
const domains = getDomains(c);
|
||||
return await ctx.reply(
|
||||
"欢迎使用本机器人\n\n"
|
||||
+ (prefix ? `当前已启用前缀: ${prefix}\n` : '')
|
||||
+ "新建邮箱地址, 如果要自定义邮箱地址, "
|
||||
+ "请输入 /new <name>@<domain>, name [a-zA-Z0-9.] 有效\n"
|
||||
+ `当前可用域名: ${JSON.stringify(domains)}\n`
|
||||
+ "请使用以下命令:\n"
|
||||
+ COMMANDS.map(c => `/${c.command}: ${c.description}`).join("\n")
|
||||
);
|
||||
});
|
||||
bot.command("new", async (ctx: TgContext) => {
|
||||
if (ctx.chat?.type !== "private") {
|
||||
return await ctx.reply("请在私聊中使用");
|
||||
}
|
||||
const userId = ctx?.message?.from?.id;
|
||||
if (!userId) {
|
||||
return await ctx.reply("无法获取用户信息");
|
||||
}
|
||||
try {
|
||||
// @ts-ignore
|
||||
const address = ctx?.message?.text.slice("/new".length).trim();
|
||||
if (!address) {
|
||||
return await ctx.reply("请输入邮箱地址");
|
||||
}
|
||||
const [name, domain] = address.includes("@") ? address.split("@") : [address, null];
|
||||
const jwtList = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, { type: 'json' }) || [];
|
||||
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
|
||||
return await ctx.reply("绑定地址数量已达上限");
|
||||
}
|
||||
const res = await newAddress(c, name, domain, true);
|
||||
// for mail push to telegram
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, res.jwt]));
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${res.address}`, userId);
|
||||
return await ctx.reply(`创建地址成功:\n`
|
||||
+ `地址: ${res.address}\n`
|
||||
+ `凭证: ${res.jwt}\n`
|
||||
);
|
||||
} catch (e) {
|
||||
return await ctx.reply(`创建地址失败: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("bind", async (ctx: TgContext) => {
|
||||
if (ctx.chat?.type !== "private") {
|
||||
return await ctx.reply("请在私聊中使用");
|
||||
}
|
||||
const userId = ctx?.message?.from?.id;
|
||||
if (!userId) {
|
||||
return await ctx.reply("无法获取用户信息");
|
||||
}
|
||||
try {
|
||||
// @ts-ignore
|
||||
const jwt = ctx?.message?.text.slice("/bind".length).trim();
|
||||
if (!jwt) {
|
||||
return await ctx.reply("请输入凭证");
|
||||
}
|
||||
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
|
||||
if (!address) {
|
||||
return await ctx.reply("凭证无效");
|
||||
}
|
||||
const jwtList = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, { type: 'json' }) || [];
|
||||
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
|
||||
return await ctx.reply("绑定地址数量已达上限");
|
||||
}
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, jwt]));
|
||||
// for mail push to telegram
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${address}`, userId);
|
||||
return await ctx.reply(`绑定成功:\n`
|
||||
+ `地址: ${address}`
|
||||
);
|
||||
}
|
||||
catch (e) {
|
||||
return await ctx.reply(`绑定失败: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("address", async (ctx: TgContext) => {
|
||||
if (ctx.chat?.type !== "private") {
|
||||
return await ctx.reply("请在私聊中使用");
|
||||
}
|
||||
const userId = ctx?.message?.from?.id;
|
||||
if (!userId) {
|
||||
return await ctx.reply("无法获取用户信息");
|
||||
}
|
||||
try {
|
||||
const jwtList = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, { type: 'json' }) || [];
|
||||
const addressList = [];
|
||||
for (const jwt of jwtList) {
|
||||
try {
|
||||
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
|
||||
addressList.push(address);
|
||||
} catch (e) {
|
||||
addressList.push("此凭证无效");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return await ctx.reply(`地址列表:\n\n`
|
||||
+ addressList.map(a => `地址: ${a}`).join("\n")
|
||||
);
|
||||
} catch (e) {
|
||||
return await ctx.reply(`获取地址列表失败: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const queryMail = async (ctx: TgContext, queryAddress: string, mailIndex: number, edit: boolean) => {
|
||||
const userId = ctx?.message?.from?.id || ctx.callbackQuery?.message?.chat?.id;
|
||||
if (!userId) {
|
||||
return await ctx.reply("无法获取用户信息");
|
||||
}
|
||||
const jwtList = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, { type: 'json' }) || [];
|
||||
const addressList = [];
|
||||
for (const jwt of jwtList) {
|
||||
try {
|
||||
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
|
||||
addressList.push(address);
|
||||
} catch (e) {
|
||||
addressList.push("此凭证无效");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!queryAddress && addressList.length > 0) {
|
||||
queryAddress = addressList[0];
|
||||
}
|
||||
if (!addressList.includes(queryAddress)) {
|
||||
return await ctx.reply(`未绑定此地址 ${queryAddress}`);
|
||||
}
|
||||
const raw = await c.env.DB.prepare(
|
||||
`SELECT * FROM raw_mails where address = ? `
|
||||
+ ` order by id desc limit 1 offset ?`
|
||||
).bind(
|
||||
queryAddress, mailIndex
|
||||
).first("raw");
|
||||
const { mail } = await parseMail(raw);
|
||||
if (edit) {
|
||||
return await ctx.editMessageText(mail || "无邮件",
|
||||
{
|
||||
...Markup.inlineKeyboard([
|
||||
Markup.button.callback("上一条", `mail_${queryAddress}_${mailIndex - 1}`, mailIndex <= 0),
|
||||
Markup.button.callback("下一条", `mail_${queryAddress}_${mailIndex + 1}`, !raw),
|
||||
])
|
||||
},
|
||||
);
|
||||
}
|
||||
return await ctx.reply(mail || "无邮件",
|
||||
{
|
||||
...Markup.inlineKeyboard([
|
||||
Markup.button.callback("上一条", `mail_${queryAddress}_${mailIndex - 1}`, mailIndex <= 0),
|
||||
Markup.button.callback("下一条", `mail_${queryAddress}_${mailIndex + 1}`, !raw),
|
||||
])
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bot.command("mails", async ctx => {
|
||||
try {
|
||||
const queryAddress = ctx?.message?.text.slice("/mails".length).trim();
|
||||
return await queryMail(ctx, queryAddress, 0, false);
|
||||
} catch (e) {
|
||||
return await ctx.reply(`获取邮件失败: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.on(callbackQuery("data"), async ctx => {
|
||||
// Use ctx.callbackQuery.data
|
||||
try {
|
||||
const data = ctx.callbackQuery.data;
|
||||
if (data && data.startsWith("mail_") && data.split("_").length === 3) {
|
||||
const [_, queryAddress, mailIndex] = data.split("_");
|
||||
await queryMail(ctx, queryAddress, parseInt(mailIndex), true);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.log(`获取邮件失败: ${(e as Error).message}`, e);
|
||||
return await ctx.answerCbQuery(`获取邮件失败: ${(e as Error).message}`);
|
||||
}
|
||||
await ctx.answerCbQuery();
|
||||
});
|
||||
|
||||
return bot;
|
||||
}
|
||||
|
||||
|
||||
export async function initTelegramBotCommands(bot: Telegraf) {
|
||||
bot.telegram.sendMessage
|
||||
await bot.telegram.setMyCommands(COMMANDS);
|
||||
}
|
||||
|
||||
const parseMail = async (raw_mail: string) => {
|
||||
if (!raw_mail) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsedEmail = await PostalMime.parse(raw_mail);
|
||||
return {
|
||||
isHtml: false,
|
||||
mail: `From: ${parsedEmail.from ? `${parsedEmail.from.name}[${parsedEmail.from.address}]` : "无发件人"}\n`
|
||||
+ `To: ${parsedEmail.to?.map(t => `${t.name}[${t.address}]`).join(" ")}\n`
|
||||
+ `Subject: ${parsedEmail.subject}\n`
|
||||
+ `Date: ${parsedEmail.date}\n`
|
||||
+ `Content:\n${parsedEmail.text?.substring(0, 100) || "解析失败"}`
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
isHtml: false,
|
||||
mail: `解析邮件失败: ${(e as Error).message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function sendMailToTelegram(c: Context, address: string, raw_mail: string) {
|
||||
if (!c.env.TELEGRAM_BOT_TOKEN || !c.env.KV) {
|
||||
return;
|
||||
}
|
||||
const userId = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${address}`);
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
const { mail } = await parseMail(raw_mail);
|
||||
if (!mail) {
|
||||
return;
|
||||
}
|
||||
const bot = newTelegramBot(c, c.env.TELEGRAM_BOT_TOKEN);
|
||||
await bot.telegram.sendMessage(userId, mail);
|
||||
}
|
||||
@@ -47,10 +47,24 @@ export const getBooleanValue = (value) => {
|
||||
if (typeof value === "string") {
|
||||
return value === "true";
|
||||
}
|
||||
console.error("Invalid boolean value", value);
|
||||
console.error(`Failed to parse boolean value: ${value}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
export const getIntValue = (value, defaultValue = 0) => {
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return parseInt(value);
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse int value: ${value}`);
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
export const getDomains = (c) => {
|
||||
if (!c.env.DOMAINS) {
|
||||
return [];
|
||||
|
||||
@@ -9,6 +9,7 @@ import { api as userApi } from './user_api';
|
||||
import { api as adminApi } from './admin_api';
|
||||
import { api as apiV1 } from './deprecated';
|
||||
import { api as apiSendMail } from './mails_api/send_mail_api'
|
||||
import { api as telegramApi } from './telegram_api'
|
||||
|
||||
import { email } from './email';
|
||||
import { scheduled } from './scheduled';
|
||||
@@ -107,6 +108,7 @@ app.route('/', userApi)
|
||||
app.route('/', adminApi)
|
||||
app.route('/', apiV1)
|
||||
app.route('/', apiSendMail)
|
||||
app.route('/', telegramApi)
|
||||
|
||||
app.get('/', async c => c.text("OK"))
|
||||
app.get('/health_check', async c => c.text("OK"))
|
||||
|
||||
Reference in New Issue
Block a user