feat: telegram mini app open mail from bot (#256)

This commit is contained in:
Dream Hunter
2024-05-21 02:03:06 +08:00
committed by GitHub
parent 69771fc1d1
commit 91d7896e65
21 changed files with 487 additions and 151 deletions
+54
View File
@@ -2,6 +2,60 @@ import { Context } from "hono";
import { Jwt } from "hono/utils/jwt";
import { CONSTANTS } from "../constants";
import { HonoCustomType } from "../types";
import { getIntValue, getJsonSetting } from "../utils";
import { newAddress } from "../common";
export const tgUserNewAddress = async (
c: Context<HonoCustomType>, userId: string, address: string
): Promise<{ address: string, jwt: string }> => {
if (c.env.RATE_LIMITER) {
const { success } = await c.env.RATE_LIMITER.limit(
{ key: `${CONSTANTS.TG_KV_PREFIX}:${userId}` }
)
if (!success) {
throw Error("Rate limit exceeded")
}
}
// @ts-ignore
address = address || Math.random().toString(36).substring(2, 15);
console.log(`new address: ${address}`);
const [name, domain] = address.includes("@") ? address.split("@") : [address, null];
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
throw Error("绑定地址数量已达上限");
}
// check name block list
const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
const blockList = (value || []) as string[];
if (blockList.some((item) => name.includes(item))) {
throw Error(`Name[${name}]is blocked`);
}
const res = await newAddress(c,
name || Math.random().toString(36).substring(2, 15),
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.toString());
return res;
}
export const bindTelegramAddress = async (
c: Context<HonoCustomType>, userId: string, jwt: string
): Promise<string> => {
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
if (!address) {
throw Error("无效凭证");
}
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
throw Error("绑定地址数量已达上限");
}
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.toString());
return address;
}
export const unbindTelegramAddress = async (
c: Context<HonoCustomType>, userId: string, address: string
+5 -1
View File
@@ -68,4 +68,8 @@ api.get("/admin/telegram/status", async (c) => {
api.get("/admin/telegram/settings", settings.getTelegramSettings);
api.post("/admin/telegram/settings", settings.saveTelegramSettings);
api.post("/telegram/bind_address", miniapp.getTelegramBindAddress);
api.post("/telegram/get_bind_address", miniapp.getTelegramBindAddress);
api.post("/telegram/new_address", miniapp.newTelegramAddress);
api.post("/telegram/bind_address", miniapp.bindAddress);
api.post("/telegram/unbind_address", miniapp.unbindAddress);
api.post("/telegram/get_mail", miniapp.getMail);
+108 -20
View File
@@ -2,26 +2,28 @@ import { Context } from "hono";
import { Jwt } from 'hono/utils/jwt'
import { HonoCustomType } from "../types";
import { CONSTANTS } from "../constants";
import { bindTelegramAddress, tgUserNewAddress, unbindTelegramAddress } from "./common";
import { checkCfTurnstile } from "../utils";
const encoder = new TextEncoder();
const TG_AUTH_TIMEOUT = 300;
async function getTelegramBindAddress(c: Context<HonoCustomType>): Promise<Response> {
const checkTelegramAuth = async (
c: Context<HonoCustomType>, initData: string
): Promise<string> => {
// check if the request is from telegram
const { initData } = await c.req.json();
const initDataObj = new URLSearchParams(initData);
initDataObj.sort()
const hash = initDataObj.get('hash');
initDataObj.delete("hash");
const dataToCheck = [...initDataObj.entries()].map(([key, value]) => key + "=" + value).join("\n");
const auth_date = Number(initDataObj.get('auth_date'));
// valid for 300 seconds
if (auth_date + 300 < (new Date().getTime() / 1000)) {
return c.text("OutDate initData", 400);
if (auth_date + TG_AUTH_TIMEOUT < (new Date().getTime() / 1000)) {
throw Error("Auth date expired");
}
const user = initDataObj.get('user');
if (!hash || !user) {
return c.text("Invalid initData", 400);
throw Error("Invalid initData");
}
const { id: userId } = JSON.parse(user);
const cryptoKey = await crypto.subtle.importKey(
@@ -48,23 +50,109 @@ async function getTelegramBindAddress(c: Context<HonoCustomType>): Promise<Respo
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (calcHash != hash) {
return c.text("Invalid initData", 400);
throw Error("Invalid initData");
}
// get the address list from the KV
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
const res = [];
for (const jwt of jwtList) {
try {
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
res.push({ address, jwt });
} catch (e) {
console.error(`failed to verify jwt with error: ${e}`)
continue;
return userId;
}
async function getTelegramBindAddress(c: Context<HonoCustomType>): Promise<Response> {
const { initData } = await c.req.json();
try {
const userId = await checkTelegramAuth(c, initData);
// get the address list from the KV
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
const res = [];
for (const jwt of jwtList) {
try {
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
res.push({ address, jwt });
} catch (e) {
console.error(`failed to verify jwt with error: ${e}`)
continue;
}
}
return c.json(res);
}
catch (e) {
return c.text((e as Error).message, 400);
}
}
async function newTelegramAddress(c: Context<HonoCustomType>): Promise<Response> {
const { initData, address, cf_token } = await c.req.json();
// check cf turnstile
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text("Failed to check cf turnstile", 500)
}
try {
const userId = await checkTelegramAuth(c, initData);
// get the address list from the KV
const res = await tgUserNewAddress(c, userId, address)
return c.json(res);
}
catch (e) {
return c.text((e as Error).message, 400);
}
}
async function bindAddress(c: Context<HonoCustomType>): Promise<Response> {
const { initData, jwt } = await c.req.json();
try {
const userId = await checkTelegramAuth(c, initData);
await bindTelegramAddress(c, userId, jwt);
return c.json({ success: true });
}
catch (e) {
return c.text((e as Error).message, 400);
}
}
async function unbindAddress(c: Context<HonoCustomType>): Promise<Response> {
const { initData, address } = await c.req.json();
try {
const userId = await checkTelegramAuth(c, initData);
await unbindTelegramAddress(c, userId, address);
return c.json({ success: true });
}
catch (e) {
return c.text((e as Error).message, 400);
}
}
async function getMail(c: Context<HonoCustomType>): Promise<Response> {
const { initData, mailId } = await c.req.json();
try {
const userId = await checkTelegramAuth(c, initData);
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, '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;
}
}
const result = await c.env.DB.prepare(
`SELECT * FROM raw_mails where id = ?`
).bind(mailId).first();
if (result?.address && !addressList.includes(result.address)) {
return c.text("无权查看此邮件", 403);
}
return c.json(result);
}
catch (e) {
return c.text((e as Error).message, 400);
}
return c.json(res);
}
export default {
getTelegramBindAddress
getTelegramBindAddress,
newTelegramAddress,
bindAddress,
unbindAddress,
getMail,
}
+4 -2
View File
@@ -5,16 +5,18 @@ import { CONSTANTS } from "../constants";
export class TelegramSettings {
enableAllowList: boolean;
allowList: string[];
miniAppUrl: string;
constructor(enableAllowList: boolean, allowList: string[]) {
constructor(enableAllowList: boolean, allowList: string[], miniAppUrl: string) {
this.enableAllowList = enableAllowList;
this.allowList = allowList;
this.miniAppUrl = miniAppUrl;
}
}
async function getTelegramSettings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await c.env.KV.get<TelegramSettings>(CONSTANTS.TG_KV_SETTINGS_KEY, "json");
return c.json(settings || new TelegramSettings(false, []));
return c.json(settings || new TelegramSettings(false, [], ""));
}
+24 -38
View File
@@ -7,11 +7,10 @@ import PostalMime from 'postal-mime';
import { CONSTANTS } from "../constants";
import { getIntValue, getDomains, getStringValue } from '../utils';
// @ts-ignore
import { deleteAddressWithData, newAddress } from '../common'
import { deleteAddressWithData } from '../common'
import { HonoCustomType } from "../types";
import { TelegramSettings } from "./settings";
import { unbindTelegramAddress } from "./common";
import { bindTelegramAddress, tgUserNewAddress, unbindTelegramAddress } from "./common";
const COMMANDS = [
{
@@ -48,6 +47,9 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
const bot = new Telegraf(token);
bot.use(async (ctx, next) => {
// skip non-message
if (ctx.updateType != "message") return await next();
// check if in private chat
if (ctx.chat?.type !== "private") {
return await ctx.reply("请在私聊中使用");
}
@@ -75,38 +77,23 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
const prefix = getStringValue(c.env.PREFIX)
const domains = getDomains(c);
return await ctx.reply(
"欢迎使用本机器人, 您可以点击左下角打开 mini app \n\n"
"欢迎使用本机器人, 您可以打开 mini app \n\n"
+ (prefix ? `当前已启用前缀: ${prefix}\n` : '')
+ `当前可用域名: ${JSON.stringify(domains)}\n`
+ "请使用以下命令:\n"
+ COMMANDS.map(c => `/${c.command}: ${c.description}`).join("\n")
);
});
bot.command("new", async (ctx: TgContext) => {
const userId = ctx?.message?.from?.id;
if (!userId) {
return await ctx.reply("无法获取用户信息");
}
try {
if (c.env.RATE_LIMITER) {
const { success } = await c.env.RATE_LIMITER.limit(
{ key: `${CONSTANTS.TG_KV_PREFIX}:${userId}` }
)
if (!success) {
return await ctx.reply("操作过于频繁");
}
}
// @ts-ignore
const address = ctx?.message?.text.slice("/new".length).trim() || Math.random().toString(36).substring(2, 15);
const [name, domain] = address.includes("@") ? address.split("@") : [address, null];
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, '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.toString());
const address = ctx?.message?.text.slice("/new".length).trim();
const res = await tgUserNewAddress(c, userId.toString(), address);
return await ctx.reply(`创建地址成功:\n`
+ `地址: ${res.address}\n`
+ `凭证: ${res.jwt}\n`
@@ -127,17 +114,7 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
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<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, '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.toString());
const address = await bindTelegramAddress(c, userId.toString(), jwt);
return await ctx.reply(`绑定成功:\n`
+ `地址: ${address}`
);
@@ -218,7 +195,6 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
addressList.push(address);
} catch (e) {
addressList.push("此凭证无效");
continue;
}
}
@@ -228,18 +204,27 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
if (!addressList.includes(queryAddress)) {
return await ctx.reply(`未绑定此地址 ${queryAddress}`);
}
const raw = await c.env.DB.prepare(
const { raw, id: mailId } = await c.env.DB.prepare(
`SELECT * FROM raw_mails where address = ? `
+ ` order by id desc limit 1 offset ?`
).bind(
queryAddress, mailIndex
).first<string>("raw");
const { mail } = await parseMail(raw);
).first<{ raw: string, id: string }>() || {};
const { mail } = raw ? await parseMail(raw) : { mail: "已经没有邮件了" };
const settings = await c.env.KV.get<TelegramSettings>(CONSTANTS.TG_KV_SETTINGS_KEY, "json");
const miniAppButtons = []
if (settings?.miniAppUrl && settings?.miniAppUrl?.length > 0 && mailId) {
const url = new URL(settings.miniAppUrl);
url.pathname = "/telegram_mail"
url.searchParams.set("mail_id", mailId);
miniAppButtons.push(Markup.button.webApp("OpenApp", url.toString()));
}
if (edit) {
return await ctx.editMessageText(mail || "无邮件",
{
...Markup.inlineKeyboard([
Markup.button.callback("上一条", `mail_${queryAddress}_${mailIndex - 1}`, mailIndex <= 0),
...miniAppButtons,
Markup.button.callback("下一条", `mail_${queryAddress}_${mailIndex + 1}`, !raw),
])
},
@@ -249,6 +234,7 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
{
...Markup.inlineKeyboard([
Markup.button.callback("上一条", `mail_${queryAddress}_${mailIndex - 1}`, mailIndex <= 0),
...miniAppButtons,
Markup.button.callback("下一条", `mail_${queryAddress}_${mailIndex + 1}`, !raw),
])
},
@@ -303,7 +289,7 @@ const parseMail = async (raw_mail: string | undefined | null) => {
+ `To: ${parsedEmail.to?.map(t => `${t.name}[${t.address}]`).join(" ")}\n`
+ `Subject: ${parsedEmail.subject}\n`
+ `Date: ${parsedEmail.date}\n`
+ `Content:\n${parsedEmail.text || "解析失败,请点击左下角打开 mini app 查看"}`
+ `Content:\n${parsedEmail.text || "解析失败,请打开 mini app 查看"}`
};
} catch (e) {
return {