feat: add i18n support for backend API and Telegram bot (#797)

* feat: add i18n support for backend API and Telegram bot

- Add comprehensive i18n support for all backend API error messages (zh/en)
- Add /lang command for Telegram bot to set language preference
- Add bilingual command descriptions for Telegram bot
- Support per-user language preference stored in KV
- Global push uses DEFAULT_LANG, user push uses saved preference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: improve Telegram bot language preference feature

- Add internationalized message for disabled language feature
- Fix hardcoded English message in /lang command
- Optimize getTgMessages calls (reduce from 3 to 1 call)
- Remove verbose comments for better code clarity
- Add TgLangFeatureDisabledMsg to i18n (zh/en)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Dream Hunter
2025-12-31 01:42:41 +08:00
committed by GitHub
parent 5e227d2b2d
commit 3ebe22115a
25 changed files with 762 additions and 276 deletions

View File

@@ -7,8 +7,7 @@ export default {
// 修改地址密码
changePassword: async (c: Context<HonoCustomType>) => {
const { new_password } = await c.req.json();
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
const { address, address_id } = c.get("jwtPayload");
// 检查功能是否启用
@@ -39,8 +38,7 @@ export default {
// 地址密码登录
login: async (c: Context<HonoCustomType>) => {
const { email, password, cf_token } = await c.req.json();
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
// 检查功能是否启用
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {

View File

@@ -1,11 +1,13 @@
import { Context } from "hono";
import { getBooleanValue } from "../utils";
import i18n from "../i18n";
export default {
getAutoReply: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_AUTO_REPLY)) {
return c.text("Auto reply is disabled", 403)
return c.text(msgs.AutoReplyDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const results = await c.env.DB.prepare(
@@ -23,17 +25,18 @@ export default {
})
},
saveAutoReply: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_AUTO_REPLY)) {
return c.text("Auto reply is disabled", 403)
return c.text(msgs.AutoReplyDisabledMsg, 403)
}
const { address } = c.get("jwtPayload");
const { auto_reply } = await c.req.json();
const { name, subject, source_prefix, message, enabled } = auto_reply;
if ((!subject || !message) && enabled) {
return c.text("Invalid subject or message", 400)
return c.text(msgs.InvalidAutoReplyMsg, 400)
}
else if (subject.length > 255 || message.length > 255) {
return c.text("Subject or message too long", 400)
return c.text(msgs.SubjectOrMessageTooLongMsg, 400)
}
const { success } = await c.env.DB.prepare(
`INSERT OR REPLACE INTO auto_reply_mails`
@@ -44,7 +47,7 @@ export default {
subject || '', message || '', enabled ? 1 : 0
).run();
if (!success) {
return c.text("Failed to auto_reply settings", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
return c.json({
success: success

View File

@@ -45,8 +45,7 @@ api.get('/api/mail/:mail_id', async (c) => {
})
api.delete('/api/mails/:id', async (c) => {
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
@@ -64,8 +63,7 @@ api.delete('/api/mails/:id', async (c) => {
api.get('/api/settings', async (c) => {
const { address, address_id } = c.get("jwtPayload")
const user_role = c.get("userRolePayload")
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
if (address_id && address_id > 0) {
try {
const db_address_id = await c.env.DB.prepare(
@@ -106,8 +104,7 @@ api.get('/api/settings', async (c) => {
})
api.post('/api/new_address', async (c) => {
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
if (getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL)
&& !c.get("userPayload")
) {
@@ -171,8 +168,7 @@ api.delete('/api/delete_address', async (c) => {
})
api.delete('/api/clear_inbox', async (c) => {
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
@@ -181,7 +177,7 @@ api.delete('/api/clear_inbox', async (c) => {
`DELETE FROM raw_mails WHERE address = ?`
).bind(address).run();
if (!success) {
return c.text("Failed to clear inbox", 500)
return c.text(msgs.FailedClearInboxMsg, 500)
}
return c.json({
success: success
@@ -189,8 +185,7 @@ api.delete('/api/clear_inbox', async (c) => {
})
api.delete('/api/clear_sent_items', async (c) => {
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
@@ -199,7 +194,7 @@ api.delete('/api/clear_sent_items', async (c) => {
`DELETE FROM sendbox WHERE address = ?`
).bind(address).run();
if (!success) {
return c.text("Failed to clear sent items", 500)
return c.text(msgs.FailedClearSentItemsMsg, 500)
}
return c.json({
success: success

View File

@@ -14,9 +14,10 @@ import { handleListQuery } from '../common'
export const api = new Hono<HonoCustomType>()
api.post('/api/requset_send_mail_access', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
const { address } = c.get("jwtPayload")
if (!address) {
return c.text("No address", 400)
return c.text(msgs.AddressNotFoundMsg, 400)
}
try {
const default_balance = getIntValue(c.env.DEFAULT_SEND_BALANCE, 0);
@@ -26,14 +27,14 @@ api.post('/api/requset_send_mail_access', async (c) => {
address, default_balance, default_balance > 0 ? 1 : 0
).run();
if (!success) {
return c.text("Failed to request send mail access", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
} catch (e) {
const message = (e as Error).message;
if (message && message.includes("UNIQUE")) {
return c.text("Already requested", 400)
return c.text(msgs.AlreadyRequestedMsg, 400)
}
return c.text("Failed to request send mail access", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
return c.json({ status: "ok" })
})
@@ -126,14 +127,15 @@ export const sendMail = async (
isAdmin?: boolean
}
): Promise<void> => {
const msgs = i18n.getMessagesbyContext(c);
if (!address) {
throw new Error("No address")
throw new Error(msgs.AddressNotFoundMsg)
}
// check domain
const mailDomain = address.split("@")[1];
const domains = getDomains(c);
if (!domains.includes(mailDomain)) {
throw new Error("Invalid domain")
throw new Error(msgs.InvalidDomainMsg)
}
const user_role = c.get("userRolePayload");
const no_limit_roles = getSplitStringListValue(c.env.NO_LIMIT_SEND_ROLE);
@@ -150,7 +152,7 @@ export const sendMail = async (
where address = ? and enabled = 1`
).bind(address).first<number>("balance");
if (!balance || balance <= 0) {
throw new Error("No balance")
throw new Error(msgs.NoBalanceMsg)
}
}
const {
@@ -158,18 +160,18 @@ export const sendMail = async (
subject, content, is_html
} = reqJson;
if (!to_mail) {
throw new Error("Invalid to mail")
throw new Error(msgs.InvalidToMailMsg)
}
// check SEND_BLOCK_LIST_KEY
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY) as string[];
if (sendBlockList && sendBlockList.some((item) => to_mail.includes(item))) {
throw new Error("to_mail address is blocked")
throw new Error(msgs.AddressBlockedMsg)
}
if (!subject) {
throw new Error("Subject is empty")
throw new Error(msgs.SubjectEmptyMsg)
}
if (!content) {
throw new Error("Content is empty")
throw new Error(msgs.ContentEmptyMsg)
}
// send to verified address list, do not update balance
@@ -202,9 +204,9 @@ export const sendMail = async (
}
else {
if (c.env.SEND_MAIL) {
throw new Error(`Please enable resend or smtp for domain ${mailDomain}. Or add ${to_mail} to verified address list`);
throw new Error(`${msgs.EnableResendOrSmtpWithVerifiedMsg} (${mailDomain})`);
}
throw new Error(`Please enable resend or smtp for domain ${mailDomain}`);
throw new Error(`${msgs.EnableResendOrSmtpMsg} (${mailDomain})`);
}
// update balance
@@ -253,11 +255,12 @@ api.post('/api/send_mail', async (c) => {
})
api.post('/external/api/send_mail', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
const { token } = await c.req.json();
try {
const { address } = await Jwt.verify(token, c.env.JWT_SECRET, "HS256");
if (!address) {
return c.text("No address", 400)
return c.text(msgs.AddressNotFoundMsg, 400)
}
const reqJson = await c.req.json();
await sendMail(c, address as string, reqJson);
@@ -289,8 +292,7 @@ api.get('/api/sendbox', async (c) => {
})
api.delete('/api/sendbox/:id', async (c) => {
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}

View File

@@ -2,13 +2,15 @@ import { Context } from "hono";
import { CONSTANTS } from "../constants";
import { AdminWebhookSettings, WebhookSettings } from "../models";
import { commonParseMail, sendWebhook } from "../common";
import i18n from "../i18n";
async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const msgs = i18n.getMessagesbyContext(c);
const { address } = c.get("jwtPayload")
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
if (adminSettings?.enableAllowList && !adminSettings?.allowList.includes(address)) {
return c.text("Webhook settings is not allowed for this user", 403);
return c.text(msgs.WebhookNotAllowedForUserMsg, 403);
}
const settings = await c.env.KV.get<WebhookSettings>(
`${CONSTANTS.WEBHOOK_KV_USER_SETTINGS_KEY}:${address}`, "json"
@@ -18,10 +20,11 @@ async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response>
async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const msgs = i18n.getMessagesbyContext(c);
const { address } = c.get("jwtPayload")
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
if (adminSettings?.enableAllowList && !adminSettings?.allowList.includes(address)) {
return c.text("Webhook settings is not allowed for this user", 403);
return c.text(msgs.WebhookNotAllowedForUserMsg, 403);
}
const settings = await c.req.json<WebhookSettings>();
await c.env.KV.put(