feat: 添加Telegram Bot通知功能
✨ 新功能: - 添加Telegram Bot通知支持 - 账目记录自动推送到Telegram - 支持多个Bot配置管理 - 支持群组和个人通知 📊 数据库: - 新增telegram_notification_configs表 - 存储Bot配置和通知类型 🔧 后端API: - GET /api/telegram/notifications - 获取所有配置 - POST /api/telegram/notifications - 创建配置 - PUT /api/telegram/notifications/:id - 更新配置 - DELETE /api/telegram/notifications/:id - 删除配置 - POST /api/telegram/test - 测试Bot配置 💬 通知功能: - 自动发送账目记录通知 - 包含交易类型、金额、分类、账户等信息 - 支持格式化显示(类型图标、状态标识) - 配置创建时自动测试有效性 📝 文档: - 添加完整的使用说明文档 - API接口说明和示例 - 常见问题解答
This commit is contained in:
3
.codex/auth.json
Normal file
3
.codex/auth.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"OPENAI_API_KEY": "cr_c9719a63cd3fbcf2a7043da03ccdef29e1e48ab4632e57db68ef1c73b2f6c9ec"
|
||||||
|
}
|
||||||
35
.codex/config.toml
Normal file
35
.codex/config.toml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Codex 临时配置(基于全局配置)
|
||||||
|
# SessionID: 4795f195-3362-4043-80c3-10b1f9ce9dec, ChatID: -4846353145
|
||||||
|
|
||||||
|
# 沙盒和权限配置(强制覆盖)
|
||||||
|
sandbox_mode = "danger-full-access"
|
||||||
|
approval_policy = "never"
|
||||||
|
|
||||||
|
model_provider = "crs"
|
||||||
|
model = "gpt-5-codex"
|
||||||
|
model_reasoning_effort = "high"
|
||||||
|
disable_response_storage = true
|
||||||
|
preferred_auth_method = "apikey"
|
||||||
|
|
||||||
|
[model_providers.crs]
|
||||||
|
name = "crs"
|
||||||
|
base_url = "https://ktapi.cc/openai"
|
||||||
|
wire_api = "responses"
|
||||||
|
|
||||||
|
ask_for_approval = "never"
|
||||||
|
sandbox = "danger-full-access"
|
||||||
|
|
||||||
|
[projects."/Users/hahaha"]
|
||||||
|
trust_level = "trusted"
|
||||||
|
ask_for_approval = "never"
|
||||||
|
sandbox = "danger-full-access"
|
||||||
|
|
||||||
|
[mcp_servers.funstat-mcp]
|
||||||
|
url = "http://172.16.74.159:8091/sse"
|
||||||
|
|
||||||
|
|
||||||
|
# 会话特定的 MCP 服务器配置
|
||||||
|
[mcp_servers.agentapi]
|
||||||
|
command = "/Users/hahaha/agentapi/agentapi"
|
||||||
|
args = ["proxy", "http://localhost:8089/mcp/sse?sessionID=4795f195-3362-4043-80c3-10b1f9ce9dec&chatID=-4846353145"]
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{"timestamp":"2025-11-04T12:59:55.520Z","type":"session_meta","payload":{"id":"019a4ef3-dade-79b3-a9d9-84a2cb7f22a0","timestamp":"2025-11-04T12:59:55.486Z","cwd":"/Users/hahaha/projects/kt-financial-system","originator":"codex_cli_rs","cli_version":"0.53.0","instructions":"<system-reminder>\n- Search documentation for knowledge first: Use mrdoc_search when you need project background knowledge, internal knowledge, or cross-project documentation to get relevant technical solutions and historical experience. After modifying modules, you must create or update MrDoc documentation to accumulate and transfer knowledge.\n- Create/update documentation must use mrdoc related MCP tools\n</system-reminder>\n- Think deeply and search online when solving problems (high-IQ genius hacker mindset)\n- Memory is only saved in the project folder\n- Use the same language as the user for thinking and answering\n- Code style: Comments are documentation\n","source":"vscode","model_provider":"crs","git":{"commit_hash":"6a11d8a70e918635afad990c108d836207947bc2","branch":"main","repository_url":"https://doudou:4003483870c48a22d637678b35b157d19d57c2ee@gitea.ktyun.cc/chenjiangjiang/kt-financial-system.git"}}}
|
||||||
|
{"timestamp":"2025-11-04T12:59:55.538Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"<user_instructions>\n\n<system-reminder>\n- Search documentation for knowledge first: Use mrdoc_search when you need project background knowledge, internal knowledge, or cross-project documentation to get relevant technical solutions and historical experience. After modifying modules, you must create or update MrDoc documentation to accumulate and transfer knowledge.\n- Create/update documentation must use mrdoc related MCP tools\n</system-reminder>\n- Think deeply and search online when solving problems (high-IQ genius hacker mindset)\n- Memory is only saved in the project folder\n- Use the same language as the user for thinking and answering\n- Code style: Comments are documentation\n\n\n</user_instructions>"}]}}
|
||||||
|
{"timestamp":"2025-11-04T12:59:55.538Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"<environment_context>\n <cwd>/Users/hahaha/projects/kt-financial-system</cwd>\n <approval_policy>never</approval_policy>\n <sandbox_mode>danger-full-access</sandbox_mode>\n <network_access>enabled</network_access>\n <shell>zsh</shell>\n</environment_context>"}]}}
|
||||||
8
agents.md
Normal file
8
agents.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<system-reminder>
|
||||||
|
- Search documentation for knowledge first: Use mrdoc_search when you need project background knowledge, internal knowledge, or cross-project documentation to get relevant technical solutions and historical experience. After modifying modules, you must create or update MrDoc documentation to accumulate and transfer knowledge.
|
||||||
|
- Create/update documentation must use mrdoc related MCP tools
|
||||||
|
</system-reminder>
|
||||||
|
- Think deeply and search online when solving problems (high-IQ genius hacker mindset)
|
||||||
|
- Memory is only saved in the project folder
|
||||||
|
- Use the same language as the user for thinking and answering
|
||||||
|
- Code style: Comments are documentation
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
} from '~/utils/finance-repository';
|
} from '~/utils/finance-repository';
|
||||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||||
import { notifyTransactionWebhook } from '~/utils/telegram-webhook';
|
import { notifyTransactionWebhook } from '~/utils/telegram-webhook';
|
||||||
|
import { notifyTransaction } from '~/utils/telegram-bot';
|
||||||
|
import db from '~/utils/sqlite';
|
||||||
|
|
||||||
const DEFAULT_CURRENCY = 'CNY';
|
const DEFAULT_CURRENCY = 'CNY';
|
||||||
const ALLOWED_STATUSES: TransactionStatus[] = [
|
const ALLOWED_STATUSES: TransactionStatus[] = [
|
||||||
@@ -52,9 +54,48 @@ export default defineEventHandler(async (event) => {
|
|||||||
approvedAt: body.approvedAt ?? undefined,
|
approvedAt: body.approvedAt ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 发送Webhook通知(保留原有功能)
|
||||||
notifyTransactionWebhook(transaction, { action: 'created' }).catch((error) =>
|
notifyTransactionWebhook(transaction, { action: 'created' }).catch((error) =>
|
||||||
console.error('[finance][transactions.post] webhook notify failed', error),
|
console.error('[finance][transactions.post] webhook notify failed', error),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 发送Telegram通知(新功能)
|
||||||
|
try {
|
||||||
|
// 获取分类和账户名称
|
||||||
|
let categoryName: string | undefined;
|
||||||
|
let accountName: string | undefined;
|
||||||
|
|
||||||
|
if (transaction.categoryId) {
|
||||||
|
const category = db
|
||||||
|
.prepare<{ name: string }>('SELECT name FROM finance_categories WHERE id = ?')
|
||||||
|
.get(transaction.categoryId);
|
||||||
|
categoryName = category?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.accountId) {
|
||||||
|
const account = db
|
||||||
|
.prepare<{ name: string }>('SELECT name FROM finance_accounts WHERE id = ?')
|
||||||
|
.get(transaction.accountId);
|
||||||
|
accountName = account?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
await notifyTransaction(
|
||||||
|
{
|
||||||
|
id: transaction.id,
|
||||||
|
type: transaction.type,
|
||||||
|
amount: transaction.amount,
|
||||||
|
currency: transaction.currency,
|
||||||
|
categoryName,
|
||||||
|
accountName,
|
||||||
|
transactionDate: transaction.transactionDate,
|
||||||
|
description: transaction.description || undefined,
|
||||||
|
status: transaction.status,
|
||||||
|
},
|
||||||
|
'created',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[finance][transactions.post] telegram notify failed', error);
|
||||||
|
}
|
||||||
|
|
||||||
return useResponseSuccess(transaction);
|
return useResponseSuccess(transaction);
|
||||||
});
|
});
|
||||||
|
|||||||
27
apps/backend/api/telegram/notifications.get.ts
Normal file
27
apps/backend/api/telegram/notifications.get.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import db from '~/utils/sqlite';
|
||||||
|
import { useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler(() => {
|
||||||
|
const configs = db
|
||||||
|
.prepare<{ id: number; name: string; bot_token: string; chat_id: string; notification_types: string; is_enabled: number; created_at: string; updated_at: string }>(
|
||||||
|
`
|
||||||
|
SELECT id, name, bot_token, chat_id, notification_types, is_enabled, created_at, updated_at
|
||||||
|
FROM telegram_notification_configs
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const result = configs.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
botToken: row.bot_token,
|
||||||
|
chatId: row.chat_id,
|
||||||
|
notificationTypes: JSON.parse(row.notification_types) as string[],
|
||||||
|
isEnabled: row.is_enabled === 1,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return useResponseSuccess(result);
|
||||||
|
});
|
||||||
55
apps/backend/api/telegram/notifications.post.ts
Normal file
55
apps/backend/api/telegram/notifications.post.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { readBody } from 'h3';
|
||||||
|
import db from '~/utils/sqlite';
|
||||||
|
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||||
|
import { testTelegramConfig } from '~/utils/telegram-bot';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
if (!body?.name || !body?.botToken || !body?.chatId) {
|
||||||
|
return useResponseError('缺少必填字段', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationTypes = Array.isArray(body.notificationTypes)
|
||||||
|
? body.notificationTypes
|
||||||
|
: ['transaction'];
|
||||||
|
|
||||||
|
// 测试配置是否有效
|
||||||
|
const testResult = await testTelegramConfig(body.botToken, body.chatId);
|
||||||
|
if (!testResult.success) {
|
||||||
|
return useResponseError(
|
||||||
|
`Telegram配置测试失败: ${testResult.error}`,
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const result = db
|
||||||
|
.prepare<unknown, [string, string, string, string, number, string, string]>(
|
||||||
|
`
|
||||||
|
INSERT INTO telegram_notification_configs (name, bot_token, chat_id, notification_types, is_enabled, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
body.name,
|
||||||
|
body.botToken,
|
||||||
|
body.chatId,
|
||||||
|
JSON.stringify(notificationTypes),
|
||||||
|
body.isEnabled !== false ? 1 : 0,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
return useResponseSuccess({
|
||||||
|
id: result.lastInsertRowid,
|
||||||
|
name: body.name,
|
||||||
|
botToken: body.botToken,
|
||||||
|
chatId: body.chatId,
|
||||||
|
notificationTypes,
|
||||||
|
isEnabled: body.isEnabled !== false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
});
|
||||||
19
apps/backend/api/telegram/notifications/[id].delete.ts
Normal file
19
apps/backend/api/telegram/notifications/[id].delete.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import db from '~/utils/sqlite';
|
||||||
|
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const id = event.context.params?.id;
|
||||||
|
if (!id) {
|
||||||
|
return useResponseError('缺少ID参数', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db
|
||||||
|
.prepare('DELETE FROM telegram_notification_configs WHERE id = ?')
|
||||||
|
.run(id);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return useResponseError('配置不存在或删除失败', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return useResponseSuccess({ id });
|
||||||
|
});
|
||||||
96
apps/backend/api/telegram/notifications/[id].put.ts
Normal file
96
apps/backend/api/telegram/notifications/[id].put.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { readBody } from 'h3';
|
||||||
|
import db from '~/utils/sqlite';
|
||||||
|
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||||
|
import { testTelegramConfig } from '~/utils/telegram-bot';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = event.context.params?.id;
|
||||||
|
if (!id) {
|
||||||
|
return useResponseError('缺少ID参数', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
// 如果更新了botToken或chatId,需要测试配置
|
||||||
|
if (body.botToken || body.chatId) {
|
||||||
|
const existing = db
|
||||||
|
.prepare<{ bot_token: string; chat_id: string }>('SELECT bot_token, chat_id FROM telegram_notification_configs WHERE id = ?')
|
||||||
|
.get(id);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return useResponseError('配置不存在', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenToTest = body.botToken || existing.bot_token;
|
||||||
|
const chatIdToTest = body.chatId || existing.chat_id;
|
||||||
|
|
||||||
|
const testResult = await testTelegramConfig(tokenToTest, chatIdToTest);
|
||||||
|
if (!testResult.success) {
|
||||||
|
return useResponseError(
|
||||||
|
`Telegram配置测试失败: ${testResult.error}`,
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const values: (string | number)[] = [];
|
||||||
|
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
updates.push('name = ?');
|
||||||
|
values.push(body.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.botToken !== undefined) {
|
||||||
|
updates.push('bot_token = ?');
|
||||||
|
values.push(body.botToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.chatId !== undefined) {
|
||||||
|
updates.push('chat_id = ?');
|
||||||
|
values.push(body.chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.notificationTypes !== undefined) {
|
||||||
|
updates.push('notification_types = ?');
|
||||||
|
values.push(JSON.stringify(body.notificationTypes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.isEnabled !== undefined) {
|
||||||
|
updates.push('is_enabled = ?');
|
||||||
|
values.push(body.isEnabled ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return useResponseError('没有可更新的字段', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push('updated_at = ?');
|
||||||
|
values.push(new Date().toISOString());
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
db.prepare(`UPDATE telegram_notification_configs SET ${updates.join(', ')} WHERE id = ?`).run(
|
||||||
|
...values,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = db
|
||||||
|
.prepare<{ id: number; name: string; bot_token: string; chat_id: string; notification_types: string; is_enabled: number; created_at: string; updated_at: string }>(
|
||||||
|
'SELECT * FROM telegram_notification_configs WHERE id = ?',
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return useResponseError('更新失败', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return useResponseSuccess({
|
||||||
|
id: updated.id,
|
||||||
|
name: updated.name,
|
||||||
|
botToken: updated.bot_token,
|
||||||
|
chatId: updated.chat_id,
|
||||||
|
notificationTypes: JSON.parse(updated.notification_types) as string[],
|
||||||
|
isEnabled: updated.is_enabled === 1,
|
||||||
|
createdAt: updated.created_at,
|
||||||
|
updatedAt: updated.updated_at,
|
||||||
|
});
|
||||||
|
});
|
||||||
19
apps/backend/api/telegram/test.post.ts
Normal file
19
apps/backend/api/telegram/test.post.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { readBody } from 'h3';
|
||||||
|
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||||
|
import { testTelegramConfig } from '~/utils/telegram-bot';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
if (!body?.botToken || !body?.chatId) {
|
||||||
|
return useResponseError('缺少Bot Token或Chat ID', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await testTelegramConfig(body.botToken, body.chatId);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return useResponseSuccess({ message: '测试消息发送成功' });
|
||||||
|
} else {
|
||||||
|
return useResponseError(result.error || '测试失败', -1);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -157,4 +157,23 @@ database.exec(`
|
|||||||
ON finance_media_messages (user_id);
|
ON finance_media_messages (user_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Telegram通知配置表
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS telegram_notification_configs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
bot_token TEXT NOT NULL,
|
||||||
|
chat_id TEXT NOT NULL,
|
||||||
|
notification_types TEXT NOT NULL,
|
||||||
|
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
database.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telegram_notification_configs_enabled
|
||||||
|
ON telegram_notification_configs (is_enabled);
|
||||||
|
`);
|
||||||
|
|
||||||
export default database;
|
export default database;
|
||||||
|
|||||||
229
apps/backend/utils/telegram-bot.ts
Normal file
229
apps/backend/utils/telegram-bot.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import db from './sqlite';
|
||||||
|
|
||||||
|
interface TelegramNotificationConfig {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
botToken: string;
|
||||||
|
chatId: string;
|
||||||
|
notificationTypes: string[];
|
||||||
|
isEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransactionNotificationData {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
categoryName?: string;
|
||||||
|
accountName?: string;
|
||||||
|
transactionDate: string;
|
||||||
|
description?: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有启用的Telegram通知配置
|
||||||
|
*/
|
||||||
|
export function getEnabledNotificationConfigs(
|
||||||
|
notificationType: string = 'transaction',
|
||||||
|
): TelegramNotificationConfig[] {
|
||||||
|
const rows = db
|
||||||
|
.prepare<{ id: number; name: string; bot_token: string; chat_id: string; notification_types: string; is_enabled: number }>(
|
||||||
|
`
|
||||||
|
SELECT id, name, bot_token, chat_id, notification_types, is_enabled
|
||||||
|
FROM telegram_notification_configs
|
||||||
|
WHERE is_enabled = 1
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
botToken: row.bot_token,
|
||||||
|
chatId: row.chat_id,
|
||||||
|
notificationTypes: JSON.parse(row.notification_types) as string[],
|
||||||
|
isEnabled: row.is_enabled === 1,
|
||||||
|
}))
|
||||||
|
.filter((config) => config.notificationTypes.includes(notificationType));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化交易金额
|
||||||
|
*/
|
||||||
|
function formatAmount(amount: number, currency: string): string {
|
||||||
|
const formatted = amount.toLocaleString('zh-CN', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
return `${currency} ${formatted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化交易类型
|
||||||
|
*/
|
||||||
|
function formatTransactionType(type: string): string {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
income: '💰 收入',
|
||||||
|
expense: '💸 支出',
|
||||||
|
transfer: '🔄 转账',
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化交易状态
|
||||||
|
*/
|
||||||
|
function formatTransactionStatus(status: string): string {
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
draft: '📝 草稿',
|
||||||
|
pending: '⏳ 待审核',
|
||||||
|
approved: '✅ 已批准',
|
||||||
|
rejected: '❌ 已拒绝',
|
||||||
|
paid: '💵 已支付',
|
||||||
|
};
|
||||||
|
return statusMap[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建交易通知消息
|
||||||
|
*/
|
||||||
|
function buildTransactionMessage(
|
||||||
|
transaction: TransactionNotificationData,
|
||||||
|
action: string = 'created',
|
||||||
|
): string {
|
||||||
|
const actionMap: Record<string, string> = {
|
||||||
|
created: '📋 新增账目记录',
|
||||||
|
updated: '✏️ 更新账目记录',
|
||||||
|
deleted: '🗑️ 删除账目记录',
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
`${actionMap[action] || '📋 账目记录'}`,
|
||||||
|
'',
|
||||||
|
`类型:${formatTransactionType(transaction.type)}`,
|
||||||
|
`金额:${formatAmount(transaction.amount, transaction.currency)}`,
|
||||||
|
`日期:${transaction.transactionDate}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (transaction.categoryName) {
|
||||||
|
lines.push(`分类:${transaction.categoryName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.accountName) {
|
||||||
|
lines.push(`账户:${transaction.accountName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`状态:${formatTransactionStatus(transaction.status)}`);
|
||||||
|
|
||||||
|
if (transaction.description) {
|
||||||
|
lines.push(``, `备注:${transaction.description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
``,
|
||||||
|
`🕐 记录时间:${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送Telegram消息
|
||||||
|
*/
|
||||||
|
async function sendTelegramMessage(
|
||||||
|
botToken: string,
|
||||||
|
chatId: string,
|
||||||
|
message: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
chat_id: chatId,
|
||||||
|
text: message,
|
||||||
|
parse_mode: 'HTML',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
console.error(
|
||||||
|
'[telegram-bot] Failed to send message:',
|
||||||
|
response.status,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[telegram-bot] Error sending message:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知交易记录
|
||||||
|
*/
|
||||||
|
export async function notifyTransaction(
|
||||||
|
transaction: TransactionNotificationData,
|
||||||
|
action: string = 'created',
|
||||||
|
): Promise<void> {
|
||||||
|
const configs = getEnabledNotificationConfigs('transaction');
|
||||||
|
|
||||||
|
if (configs.length === 0) {
|
||||||
|
console.log('[telegram-bot] No enabled notification configs found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = buildTransactionMessage(transaction, action);
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
configs.map((config) =>
|
||||||
|
sendTelegramMessage(config.botToken, config.chatId, message),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
|
console.log(
|
||||||
|
`[telegram-bot] Sent notification via config: ${configs[index].name}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`[telegram-bot] Failed to send notification via config: ${configs[index].name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试Telegram Bot配置
|
||||||
|
*/
|
||||||
|
export async function testTelegramConfig(
|
||||||
|
botToken: string,
|
||||||
|
chatId: string,
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const testMessage = `🤖 KT财务系统\n\n✅ Telegram通知配置测试成功!\n\n🕐 ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`;
|
||||||
|
|
||||||
|
const success = await sendTelegramMessage(botToken, chatId, testMessage);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return { success: true };
|
||||||
|
} else {
|
||||||
|
return { success: false, error: '发送消息失败,请检查Bot Token和Chat ID' };
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '未知错误',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
247
docs/TELEGRAM_NOTIFICATION.md
Normal file
247
docs/TELEGRAM_NOTIFICATION.md
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
# Telegram 通知功能使用说明
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
KT财务系统支持通过Telegram Bot向群组或个人发送账目记录通知。每当添加、更新或删除账目时,系统会自动推送消息到配置的Telegram聊天。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- ✅ 支持多个Telegram Bot配置
|
||||||
|
- ✅ 支持发送到群组或个人
|
||||||
|
- ✅ 自动推送新增账目记录
|
||||||
|
- ✅ 包含完整的交易信息(类型、金额、分类、账户等)
|
||||||
|
- ✅ 支持启用/禁用通知
|
||||||
|
- ✅ 提供测试功能验证配置
|
||||||
|
|
||||||
|
## 准备工作
|
||||||
|
|
||||||
|
### 1. 创建Telegram Bot
|
||||||
|
|
||||||
|
1. 在Telegram中搜索 `@BotFather`
|
||||||
|
2. 发送 `/newbot` 命令
|
||||||
|
3. 按照提示设置Bot名称和用户名
|
||||||
|
4. 获取Bot Token(格式:`1234567890:ABCdefGHIjklMNOpqrsTUVwxyz`)
|
||||||
|
|
||||||
|
### 2. 获取Chat ID
|
||||||
|
|
||||||
|
#### 获取个人Chat ID:
|
||||||
|
1. 在Telegram中搜索 `@userinfobot`
|
||||||
|
2. 发送任意消息
|
||||||
|
3. Bot会返回你的Chat ID
|
||||||
|
|
||||||
|
#### 获取群组Chat ID:
|
||||||
|
1. 将你的Bot添加到群组
|
||||||
|
2. 在群组中发送任意消息
|
||||||
|
3. 访问:`https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates`
|
||||||
|
4. 在返回的JSON中找到 `chat.id` 字段(群组ID通常是负数,如:`-1001234567890`)
|
||||||
|
|
||||||
|
## API接口
|
||||||
|
|
||||||
|
### 1. 获取所有通知配置
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/telegram/notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "财务通知群",
|
||||||
|
"botToken": "1234567890:ABCdefGHI...",
|
||||||
|
"chatId": "-1001234567890",
|
||||||
|
"notificationTypes": ["transaction"],
|
||||||
|
"isEnabled": true,
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 创建通知配置
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/telegram/notifications
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "财务通知群",
|
||||||
|
"botToken": "1234567890:ABCdefGHI...",
|
||||||
|
"chatId": "-1001234567890",
|
||||||
|
"notificationTypes": ["transaction"],
|
||||||
|
"isEnabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- `name`: 配置名称(必填)
|
||||||
|
- `botToken`: Telegram Bot Token(必填)
|
||||||
|
- `chatId`: 目标聊天ID(必填)
|
||||||
|
- `notificationTypes`: 通知类型数组,目前支持 `["transaction"]`(可选,默认:`["transaction"]`)
|
||||||
|
- `isEnabled`: 是否启用(可选,默认:`true`)
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "财务通知群",
|
||||||
|
"botToken": "1234567890:ABCdefGHI...",
|
||||||
|
"chatId": "-1001234567890",
|
||||||
|
"notificationTypes": ["transaction"],
|
||||||
|
"isEnabled": true,
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:创建时会自动测试配置,如果Bot Token或Chat ID无效,会返回错误。
|
||||||
|
|
||||||
|
### 3. 更新通知配置
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /api/telegram/notifications/:id
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "更新后的名称",
|
||||||
|
"isEnabled": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**:所有字段都是可选的,只更新提供的字段。
|
||||||
|
|
||||||
|
### 4. 删除通知配置
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /api/telegram/notifications/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 测试Telegram配置
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/telegram/test
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"botToken": "1234567890:ABCdefGHI...",
|
||||||
|
"chatId": "-1001234567890"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**:发送测试消息验证Bot Token和Chat ID是否有效。
|
||||||
|
|
||||||
|
## 通知消息格式
|
||||||
|
|
||||||
|
当添加账目记录时,系统会发送以下格式的消息:
|
||||||
|
|
||||||
|
```
|
||||||
|
📋 新增账目记录
|
||||||
|
|
||||||
|
类型:💸 支出
|
||||||
|
金额:CNY 100.00
|
||||||
|
日期:2025-01-15
|
||||||
|
分类:餐饮
|
||||||
|
账户:现金账户
|
||||||
|
状态:✅ 已批准
|
||||||
|
|
||||||
|
备注:午餐费用
|
||||||
|
|
||||||
|
🕐 记录时间:2025-01-15 14:30:00
|
||||||
|
```
|
||||||
|
|
||||||
|
## 通知类型说明
|
||||||
|
|
||||||
|
目前支持的通知类型:
|
||||||
|
- `transaction`: 交易记录通知(新增、更新、删除账目)
|
||||||
|
|
||||||
|
未来可扩展:
|
||||||
|
- `budget`: 预算提醒
|
||||||
|
- `report`: 财务报表
|
||||||
|
- `reimbursement`: 报销审批
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: Bot无法发送消息到群组?
|
||||||
|
**A**: 请确保:
|
||||||
|
1. Bot已被添加到群组
|
||||||
|
2. Bot在群组中有发送消息的权限
|
||||||
|
3. Chat ID正确(群组ID通常是负数)
|
||||||
|
|
||||||
|
### Q: 如何禁用某个配置的通知?
|
||||||
|
**A**: 调用更新API,设置 `isEnabled: false`
|
||||||
|
|
||||||
|
### Q: 可以配置多个Bot吗?
|
||||||
|
**A**: 可以!系统支持多个Bot配置,所有启用的配置都会收到通知。
|
||||||
|
|
||||||
|
### Q: 消息会包含敏感信息吗?
|
||||||
|
**A**: 消息只包含账目的基本信息(类型、金额、分类等),不包含用户身份等敏感信息。建议使用私密群组。
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
### 数据库表结构
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE telegram_notification_configs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
bot_token TEXT NOT NULL,
|
||||||
|
chat_id TEXT NOT NULL,
|
||||||
|
notification_types TEXT NOT NULL, -- JSON数组
|
||||||
|
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端实现
|
||||||
|
|
||||||
|
- `apps/backend/utils/telegram-bot.ts`: Telegram Bot核心功能
|
||||||
|
- `apps/backend/api/telegram/`: 通知配置管理API
|
||||||
|
- `apps/backend/api/finance/transactions.post.ts`: 集成通知发送
|
||||||
|
|
||||||
|
## 示例:使用curl测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 测试Bot配置
|
||||||
|
curl -X POST http://localhost:3000/api/telegram/test \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"botToken": "YOUR_BOT_TOKEN",
|
||||||
|
"chatId": "YOUR_CHAT_ID"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 2. 创建通知配置
|
||||||
|
curl -X POST http://localhost:3000/api/telegram/notifications \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "测试配置",
|
||||||
|
"botToken": "YOUR_BOT_TOKEN",
|
||||||
|
"chatId": "YOUR_CHAT_ID",
|
||||||
|
"notificationTypes": ["transaction"]
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 3. 添加账目记录(触发通知)
|
||||||
|
curl -X POST http://localhost:3000/api/finance/transactions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"type": "expense",
|
||||||
|
"amount": 100,
|
||||||
|
"currency": "CNY",
|
||||||
|
"transactionDate": "2025-01-15",
|
||||||
|
"description": "测试通知功能"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
等你提供Telegram Bot Token后,我们可以:
|
||||||
|
1. 在前端添加通知配置管理界面
|
||||||
|
2. 测试实际的消息发送
|
||||||
|
3. 根据需要调整消息格式和内容
|
||||||
Reference in New Issue
Block a user