feat: migrate to GramJS with TS
This commit is contained in:
@@ -1,8 +1,11 @@
|
|||||||
API_ID=123456
|
API_ID=123456
|
||||||
API_HASH=change_me
|
API_HASH=change_me
|
||||||
SESSION_NAME=userbot_session
|
SESSION_NAME=userbot
|
||||||
|
SESSION_DIR=./sessions
|
||||||
USER_PHONE=+8613000000000
|
USER_PHONE=+8613000000000
|
||||||
|
TWO_FA_PASSWORD=
|
||||||
GROUP_LINKS=https://t.me/example_group
|
GROUP_LINKS=https://t.me/example_group
|
||||||
REPORT_CHAT_LINK=https://t.me/example_group
|
REPORT_CHAT_LINK=https://t.me/example_group
|
||||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||||
KEYWORDS_FILE=keywords.yaml
|
KEYWORDS_FILE=keywords.yaml
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|||||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -1,16 +1,3 @@
|
|||||||
# Python artifacts
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
*.pyd
|
|
||||||
*.log
|
|
||||||
*.sqlite3
|
|
||||||
|
|
||||||
# Virtual environments
|
|
||||||
.venv/
|
|
||||||
env/
|
|
||||||
venv/
|
|
||||||
|
|
||||||
# VSCode / IDE
|
# VSCode / IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
@@ -21,3 +8,8 @@ venv/
|
|||||||
# Secrets / sessions
|
# Secrets / sessions
|
||||||
.env
|
.env
|
||||||
*.session
|
*.session
|
||||||
|
|
||||||
|
# Node artifacts
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
|||||||
123
README.md
123
README.md
@@ -1,80 +1,79 @@
|
|||||||
# 群监控自动加群
|
# 群监控(GramJS 版)
|
||||||
|
|
||||||
使用 Telethon 登陆用户账号(User-Bot),自动加入目标群、抓取群主信息、监控消息并通过单独的 Bot 汇报关键词命中结果。
|
使用 [GramJS](https://github.com/gram-js/gramjs) 驱动的 Node.js User-Bot 自动加入目标群、识别群主并实时监听消息。一旦命中关键词,结果会通过单独的 Telegram Bot 安全汇报到指定群/频道。
|
||||||
|
|
||||||
## 功能概述
|
## 功能亮点
|
||||||
- 通过 `GROUP_LINKS` 列表批量加入群(支持公开链接与 `https://t.me/+xxxx` 邀请链接)。
|
- **GramJS 长连接**:以 Node.js 形式运行,便于使用 Chrome DevTools Protocol 调试 Node 后端。
|
||||||
- 获取群主/创建者信息并在汇报群内确认当前监控状态。
|
- **自动入群**:支持公开群链接与 `https://t.me/+xxxx` 邀请链接,必要时自动执行 `ImportChatInvite`。
|
||||||
- 长连接监听群消息,命中关键词时推送到指定群/频道。
|
- **群主洞察**:拉取 `ChannelParticipantsAdmins`,找到创建者后写入汇报,方便对齐「自动添加群主」的状态。
|
||||||
- 关键词配置独立于代码(`keywords.yaml`),可随时增删并自动热加载。
|
- **关键词热加载**:`keywords.yaml` 被修改后自动重新解析,支持子串匹配与 `(?i)`、`(?im)` 等内联正则修饰符。
|
||||||
- 汇报通道使用 Bot Token,避免 user-bot 重复发言。
|
- **Bot 汇报隔离**:监听使用 user-bot,会话由用户账号维持;真正的告警与附件提醒交由 Bot Token 发出,避免刷屏。
|
||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
- Python 3.10+
|
- Node.js 18+(建议 LTS)
|
||||||
- Telegram API 凭据(`api_id`/`api_hash`)
|
- Telegram API 凭据:`api_id` / `api_hash`
|
||||||
- 目标账号可登录的手机号(首登需要验证码)
|
- 可登录的手机号(首次运行需验证码)
|
||||||
- Bot Token(用于把监控结果发到 @kt500_bot 已在的群)
|
- Telegram Bot Token(Bot 必须已经在汇报群内)
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
1. 安装依赖:
|
1. 安装依赖:
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv && source .venv/bin/activate
|
npm install
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
```
|
||||||
2. 准备配置:
|
2. 配置环境变量:
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
按需填写:
|
填写下方参数;`SESSION_DIR` 会自动创建并持久化 `*.session` 文件。
|
||||||
- `API_ID` / `API_HASH`:来自 [my.telegram.org](https://my.telegram.org)。
|
3. 配置 `keywords.yaml`(示例见下节)。
|
||||||
- `SESSION_NAME`:本地 session 文件名,首次登录会生成 `SESSION_NAME.session`。
|
4. 启动:
|
||||||
- `USER_PHONE`:用于自动登录(可留空,运行时手输)。
|
|
||||||
- `GROUP_LINKS`:逗号分隔的群链接,可直接填 `https://t.me/+tvVm--E19cxkNWJl`。
|
|
||||||
- `REPORT_CHAT_LINK`:用于汇报的群/频道链接,如果就地汇报可与 `GROUP_LINKS` 中某一项相同。
|
|
||||||
- `TELEGRAM_BOT_TOKEN`:你的 @kt500_bot Token。
|
|
||||||
- `KEYWORDS_FILE`:关键词配置文件路径,默认 `keywords.yaml`。
|
|
||||||
3. 配置关键词:编辑 `keywords.yaml`,示例:
|
|
||||||
```yaml
|
|
||||||
keywords:
|
|
||||||
- name: promo
|
|
||||||
patterns:
|
|
||||||
- "推广"
|
|
||||||
- "广告"
|
|
||||||
regex: false
|
|
||||||
- name: join_request
|
|
||||||
patterns:
|
|
||||||
- "(?i)拉群"
|
|
||||||
- "(?i)加好友"
|
|
||||||
regex: true
|
|
||||||
```
|
|
||||||
- `regex: false` 表示普通子串匹配(自动转换小写)。
|
|
||||||
- `regex: true` 将整条 `pattern` 按正则表达式处理,可使用 `(?i)` 等修饰。
|
|
||||||
- 文件被修改后,程序会自动检测更新时间并重新加载。
|
|
||||||
4. 运行:
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m src.main
|
npm run dev # tsx 直接运行 TypeScript
|
||||||
|
# 或
|
||||||
|
npm run build && npm run start
|
||||||
```
|
```
|
||||||
- 首次运行会提示输入验证码 / 二步验证密码。
|
首次登陆会提示输入手机号验证码及二步验证密码(若有)。
|
||||||
- 成功后会看到“群监控已启用”提示,同时在 `REPORT_CHAT_LINK` 对应的群内收到确认消息。
|
|
||||||
|
|
||||||
## 工作流程
|
## 环境变量说明
|
||||||
1. **自动入群**:对每个链接先尝试 `get_entity`,若失败且为邀请链接,则执行 `ImportChatInviteRequest` 加入。加入或已在群内后会开始监听。
|
- `API_ID` / `API_HASH`:来自 [my.telegram.org](https://my.telegram.org)。
|
||||||
2. **群主识别**:调用 `GetParticipantsRequest(... ChannelParticipantsAdmins ...)` 找到 `ChannelParticipantCreator`,将结果写入汇报中,便于核对“自动添加群主”状态。
|
- `SESSION_NAME` / `SESSION_DIR`:本地 session 文件名及所在目录。
|
||||||
3. **关键词监控**:`events.NewMessage` 监听指定群,命中关键词时将群名、消息 ID、发送人、关键词、时间与截断内容推送到 Bot 所在的群。
|
- `USER_PHONE`:可选,预填手机号。
|
||||||
4. **多次触发 & 附件提醒**:文本命中会附带一则消息,若原消息含媒体,还会追加“附件提醒”。
|
- `TWO_FA_PASSWORD`:可选,账号开启二步验证时使用。
|
||||||
|
- `GROUP_LINKS`:逗号分隔的群链接列表,可混合公开链接与 `https://t.me/+xxxx`。
|
||||||
|
- `REPORT_CHAT_LINK`:汇报消息要发送到的群/频道;留空时默认取 `GROUP_LINKS` 第一项。
|
||||||
|
- `TELEGRAM_BOT_TOKEN`:Bot HTTP API Token。
|
||||||
|
- `KEYWORDS_FILE`:关键词配置路径,默认 `keywords.yaml`。
|
||||||
|
- `LOG_LEVEL`:`trace|debug|info|warn|error`,默认 `info`。
|
||||||
|
|
||||||
## 常见扩展
|
## 关键词配置
|
||||||
- **新增关键词**:直接编辑 `keywords.yaml`,保存后生效(无需重启)。
|
`keywords.yaml` 结构与旧版 Python 一致:
|
||||||
- **新增群**:在 `.env` 的 `GROUP_LINKS` 中添加链接,重启程序即可。
|
|
||||||
- **自定义汇报格式**:在 `src/group_monitor.py` 的 `_handle_new_message` 中调整 `lines` 内容。
|
```yaml
|
||||||
- **落地数据库**:可在 `_handle_new_message` 中追加写库逻辑,然后调用 `reporter.send_safe` 做通知。
|
keywords:
|
||||||
|
- name: promo
|
||||||
|
patterns:
|
||||||
|
- "推广"
|
||||||
|
- "广告"
|
||||||
|
regex: false
|
||||||
|
- name: join_request
|
||||||
|
patterns:
|
||||||
|
- "(?i)拉群"
|
||||||
|
- "(?im)^加好友"
|
||||||
|
regex: true
|
||||||
|
```
|
||||||
|
|
||||||
|
- `regex: false`:子串匹配,自动转为小写比较。
|
||||||
|
- `regex: true`:使用 JavaScript 正则。保留了 Python 示例中的 `(?i)`、`(?im)` 等内联修饰符,会自动拆解为对应的 `i/m/s` flags。
|
||||||
|
- 文件保存后会触发热加载,无需重启进程。
|
||||||
|
|
||||||
|
## 常用脚本
|
||||||
|
- `npm run dev`:tsx 直接运行 `src/main.ts`。
|
||||||
|
- `npm run build`:编译到 `dist/`,配合 `npm run start` 运行。
|
||||||
|
- `npm run lint`:`tsc --noEmit` 类型检查。
|
||||||
|
- `npm run test`:Vitest 单测,当前覆盖 `KeywordStore` 热加载逻辑。
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
- 请确保 user-bot 与 @kt500_bot 均已在 `REPORT_CHAT_LINK` 对应的群里,并授予发送消息权限。
|
- user-bot 与汇报 Bot 都必须在 `REPORT_CHAT_LINK` 对应的群/频道里,且 Bot 拥有发言权限。
|
||||||
- Telegram 对频繁入群/拉人有限制,若日志出现 `FloodWaitError`,需等待对应秒数。
|
- Telegram 对频繁入群会触发 `FLOOD_WAIT_xx`,日志会提示需要等待的秒数。
|
||||||
- Session 文件包含账户授权信息,只应保存在可信设备中。
|
- `sessions/*.session` 含账号授权信息,请妥善保管。
|
||||||
- 若长期运行,建议用 `supervisor`/`systemd` 守护,并开启日志轮转。
|
- 长期部署建议使用 `pm2 / systemd` 等守护方式并接入日志轮转。
|
||||||
|
|
||||||
## 后续工作
|
|
||||||
- 若需要把“自动添加群主”升级为主动发送好友请求,可结合 `contacts.AddContactRequest`,条件是群主公开手机号。
|
|
||||||
- 可根据需要加入异常上报(例如钉钉/企业微信)或统计报表。
|
|
||||||
|
|||||||
2508
package-lock.json
generated
Normal file
2508
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "qun-monitor",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"dev": "tsx src/main.ts",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"telegram": "^2.26.22",
|
||||||
|
"undici": "^6.19.8",
|
||||||
|
"yaml": "^2.6.1",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.9.0",
|
||||||
|
"tsx": "^4.19.1",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vitest": "^2.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
telethon==1.35.0
|
|
||||||
python-dotenv==1.0.1
|
|
||||||
PyYAML==6.0.2
|
|
||||||
httpx==0.27.2
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Optional
|
|
||||||
import os
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CredentialsConfig:
|
|
||||||
api_id: int
|
|
||||||
api_hash: str
|
|
||||||
session_name: str
|
|
||||||
phone: Optional[str]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ReportingConfig:
|
|
||||||
bot_token: str
|
|
||||||
chat_link: Optional[str]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MonitorConfig:
|
|
||||||
group_links: List[str]
|
|
||||||
keywords_file: Path
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AppConfig:
|
|
||||||
credentials: CredentialsConfig
|
|
||||||
reporting: ReportingConfig
|
|
||||||
monitor: MonitorConfig
|
|
||||||
|
|
||||||
|
|
||||||
def _comma_split(value: str) -> List[str]:
|
|
||||||
return [chunk.strip() for chunk in value.split(",") if chunk.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(env_file: str = ".env") -> AppConfig:
|
|
||||||
if Path(env_file).exists():
|
|
||||||
load_dotenv(env_file)
|
|
||||||
|
|
||||||
try:
|
|
||||||
api_id = int(os.environ["API_ID"])
|
|
||||||
api_hash = os.environ["API_HASH"]
|
|
||||||
except KeyError as exc:
|
|
||||||
raise RuntimeError("API_ID 和 API_HASH 必填。") from exc
|
|
||||||
|
|
||||||
session_name = os.environ.get("SESSION_NAME", "userbot")
|
|
||||||
phone = os.environ.get("USER_PHONE")
|
|
||||||
|
|
||||||
bot_token = os.environ.get("TELEGRAM_BOT_TOKEN")
|
|
||||||
if not bot_token:
|
|
||||||
raise RuntimeError("TELEGRAM_BOT_TOKEN 缺失,以便推送汇报。")
|
|
||||||
|
|
||||||
group_links_env = os.environ.get("GROUP_LINKS", "").strip()
|
|
||||||
if not group_links_env:
|
|
||||||
raise RuntimeError("GROUP_LINKS 至少包含一个要监听的群链接。")
|
|
||||||
group_links = _comma_split(group_links_env)
|
|
||||||
|
|
||||||
keywords_file = Path(os.environ.get("KEYWORDS_FILE", "keywords.yaml")).expanduser()
|
|
||||||
|
|
||||||
report_link = os.environ.get("REPORT_CHAT_LINK")
|
|
||||||
|
|
||||||
credentials = CredentialsConfig(
|
|
||||||
api_id=api_id,
|
|
||||||
api_hash=api_hash,
|
|
||||||
session_name=session_name,
|
|
||||||
phone=phone,
|
|
||||||
)
|
|
||||||
reporting = ReportingConfig(bot_token=bot_token, chat_link=report_link)
|
|
||||||
monitor = MonitorConfig(
|
|
||||||
group_links=group_links,
|
|
||||||
keywords_file=keywords_file,
|
|
||||||
)
|
|
||||||
return AppConfig(credentials=credentials, reporting=reporting, monitor=monitor)
|
|
||||||
101
src/config.ts
Normal file
101
src/config.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { config as loadEnv } from "dotenv";
|
||||||
|
import { existsSync, mkdirSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
const envSchema = z
|
||||||
|
.object({
|
||||||
|
API_ID: z.string(),
|
||||||
|
API_HASH: z.string().min(32),
|
||||||
|
SESSION_NAME: z.string().default("userbot"),
|
||||||
|
SESSION_DIR: z.string().default("./sessions"),
|
||||||
|
USER_PHONE: z.string().optional(),
|
||||||
|
TWO_FA_PASSWORD: z.string().optional(),
|
||||||
|
GROUP_LINKS: z.string(),
|
||||||
|
REPORT_CHAT_LINK: z.string().optional(),
|
||||||
|
TELEGRAM_BOT_TOKEN: z.string(),
|
||||||
|
KEYWORDS_FILE: z.string().default("keywords.yaml"),
|
||||||
|
CONNECTION_RETRIES: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type TelegramCredentials = {
|
||||||
|
apiId: number;
|
||||||
|
apiHash: string;
|
||||||
|
sessionName: string;
|
||||||
|
sessionFile: string;
|
||||||
|
phone?: string;
|
||||||
|
twoFactorPassword?: string;
|
||||||
|
connectionRetries: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MonitorConfig = {
|
||||||
|
groupLinks: string[];
|
||||||
|
keywordsFile: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReporterConfig = {
|
||||||
|
botToken: string;
|
||||||
|
chatLink?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppConfig = {
|
||||||
|
telegram: TelegramCredentials;
|
||||||
|
monitor: MonitorConfig;
|
||||||
|
reporter: ReporterConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureDirectory(pathname: string) {
|
||||||
|
if (!existsSync(pathname)) {
|
||||||
|
mkdirSync(pathname, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(): AppConfig {
|
||||||
|
const result = envSchema.safeParse(process.env);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(`环境变量解析失败: ${result.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.data;
|
||||||
|
const groupLinks = data.GROUP_LINKS.split(",").map((chunk) => chunk.trim()).filter(Boolean);
|
||||||
|
if (!groupLinks.length) {
|
||||||
|
throw new Error("GROUP_LINKS 至少包含一个群链接。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiId = Number(data.API_ID);
|
||||||
|
if (Number.isNaN(apiId)) {
|
||||||
|
throw new Error("API_ID 必须是数字。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionDir = resolve(process.cwd(), data.SESSION_DIR);
|
||||||
|
ensureDirectory(sessionDir);
|
||||||
|
const sessionFile = resolve(sessionDir, `${data.SESSION_NAME}.session`);
|
||||||
|
ensureDirectory(dirname(sessionFile));
|
||||||
|
|
||||||
|
const keywordsFile = resolve(process.cwd(), data.KEYWORDS_FILE);
|
||||||
|
|
||||||
|
const connectionRetries = data.CONNECTION_RETRIES ? Number(data.CONNECTION_RETRIES) : 5;
|
||||||
|
|
||||||
|
return {
|
||||||
|
telegram: {
|
||||||
|
apiId,
|
||||||
|
apiHash: data.API_HASH,
|
||||||
|
sessionName: data.SESSION_NAME,
|
||||||
|
sessionFile,
|
||||||
|
phone: data.USER_PHONE,
|
||||||
|
twoFactorPassword: data.TWO_FA_PASSWORD,
|
||||||
|
connectionRetries: Number.isFinite(connectionRetries) ? connectionRetries : 5,
|
||||||
|
},
|
||||||
|
monitor: {
|
||||||
|
groupLinks,
|
||||||
|
keywordsFile,
|
||||||
|
},
|
||||||
|
reporter: {
|
||||||
|
botToken: data.TELEGRAM_BOT_TOKEN,
|
||||||
|
chatLink: data.REPORT_CHAT_LINK || groupLinks[0],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
283
src/groupMonitor.ts
Normal file
283
src/groupMonitor.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import type { TelegramClient } from "telegram";
|
||||||
|
import { Api } from "telegram";
|
||||||
|
import { NewMessage } from "telegram/events";
|
||||||
|
import type { NewMessageEvent } from "telegram/events";
|
||||||
|
import bigInt from "big-integer";
|
||||||
|
|
||||||
|
import { KeywordStore } from "./keywords.js";
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
import { Reporter } from "./reporter.js";
|
||||||
|
import { escapeMarkdown, truncate } from "./utils/text.js";
|
||||||
|
|
||||||
|
type ChannelEntity = Api.Channel;
|
||||||
|
type RpcErrorLike = {
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractInviteHash(link: string): string | undefined {
|
||||||
|
if (!link) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const normalized = link.trim();
|
||||||
|
if (normalized.includes("t.me/+")) {
|
||||||
|
const [, hash] = normalized.split("t.me/+", 2);
|
||||||
|
return hash?.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
if (normalized.includes("t.me/joinchat/")) {
|
||||||
|
const [, hash] = normalized.split("t.me/joinchat/", 2);
|
||||||
|
return hash?.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
if (normalized.startsWith("+")) {
|
||||||
|
return normalized.slice(1);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(entity?: Api.TypeUser | Api.TypeChat | null): string {
|
||||||
|
if (!entity) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (entity instanceof Api.User) {
|
||||||
|
return [entity.firstName, entity.lastName].filter(Boolean).join(" ") || entity.username || String(entity.id);
|
||||||
|
}
|
||||||
|
if ("title" in entity && entity.title) {
|
||||||
|
return entity.title;
|
||||||
|
}
|
||||||
|
if ("username" in entity && entity.username) {
|
||||||
|
return entity.username;
|
||||||
|
}
|
||||||
|
return String("id" in entity ? entity.id : "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRpcMessage(error: unknown): string | undefined {
|
||||||
|
if (error && typeof (error as RpcErrorLike).errorMessage === "string") {
|
||||||
|
return (error as RpcErrorLike).errorMessage;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcErrorIncludes(error: unknown, code: string): boolean {
|
||||||
|
const message = getRpcMessage(error);
|
||||||
|
return Boolean(message && message.includes(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
function floodWaitSeconds(error: unknown): number | undefined {
|
||||||
|
const message = getRpcMessage(error);
|
||||||
|
if (!message) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const match = message.match(/FLOOD_(?:WAIT|TEST_PHONE_WAIT)_(\d+)/);
|
||||||
|
return match ? Number(match[1]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasChats(update: Api.TypeUpdates | Api.TypeUpdate): update is Api.Updates | Api.UpdatesCombined {
|
||||||
|
return "chats" in update;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GroupMonitor {
|
||||||
|
private readonly entities: ChannelEntity[] = [];
|
||||||
|
private readonly peerIds = new Set<string>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly client: TelegramClient,
|
||||||
|
private readonly keywordStore: KeywordStore,
|
||||||
|
private readonly reporter: Reporter,
|
||||||
|
private readonly groupLinks: string[],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
await this.ensureMemberships();
|
||||||
|
if (!this.entities.length) {
|
||||||
|
throw new Error("没有可监听的群,停止启动。");
|
||||||
|
}
|
||||||
|
await this.reporter.prepare(this.client);
|
||||||
|
this.client.addEventHandler((event) => this.handleNewMessage(event), new NewMessage({ chats: this.entities }));
|
||||||
|
logger.info("事件监听已注册,等待消息。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureMemberships() {
|
||||||
|
for (const link of this.groupLinks) {
|
||||||
|
const { entity, joinedViaInvite } = await this.resolveEntity(link);
|
||||||
|
if (!entity) {
|
||||||
|
logger.error(`无法解析群链接 ${link}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!joinedViaInvite) {
|
||||||
|
try {
|
||||||
|
await this.client.invoke(new Api.channels.JoinChannel({ channel: entity }));
|
||||||
|
logger.info(`加入群 ${entity.title} 成功`);
|
||||||
|
} catch (error) {
|
||||||
|
if (rpcErrorIncludes(error, "USER_ALREADY_PARTICIPANT")) {
|
||||||
|
logger.info(`已在群 ${entity.title} 中`);
|
||||||
|
} else if (rpcErrorIncludes(error, "FLOOD_WAIT")) {
|
||||||
|
const wait = floodWaitSeconds(error);
|
||||||
|
logger.error(`加入群 ${link} 触发 FloodWait,需要等待 ${wait ?? "未知"} 秒`);
|
||||||
|
continue;
|
||||||
|
} else if (rpcErrorIncludes(error, "INVITE_HASH_EXPIRED") || rpcErrorIncludes(error, "INVITE_HASH_INVALID")) {
|
||||||
|
logger.error(`加入群 ${link} 失败: ${getRpcMessage(error)}`);
|
||||||
|
continue;
|
||||||
|
} else if (rpcErrorIncludes(error, "CHANNELS_TOO_MUCH")) {
|
||||||
|
logger.error(`帐号加入的群过多,无法加入 ${link}`);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
logger.error(`加入群 ${link} 失败: ${(error as Error).message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerId = await this.client.getPeerId(entity);
|
||||||
|
if (this.peerIds.has(peerId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.peerIds.add(peerId);
|
||||||
|
this.entities.push(entity);
|
||||||
|
const owner = await this.discoverOwner(entity);
|
||||||
|
const ownerLabel = escapeMarkdown(owner ?? "未找到");
|
||||||
|
const escapedPeerId = escapeMarkdown(peerId);
|
||||||
|
await this.reporter.prepare(this.client);
|
||||||
|
await this.reporter.sendSafe(
|
||||||
|
[
|
||||||
|
"✅ *群监控已启用*",
|
||||||
|
`群: \`${escapeMarkdown(entity.title ?? peerId)}\``,
|
||||||
|
`ID: \`${escapedPeerId}\``,
|
||||||
|
`群主: \`${ownerLabel}\``,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async discoverOwner(entity: ChannelEntity): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const result = await this.client.invoke(
|
||||||
|
new Api.channels.GetParticipants({
|
||||||
|
channel: entity,
|
||||||
|
filter: new Api.ChannelParticipantsAdmins(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 200,
|
||||||
|
hash: bigInt.zero,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!(result instanceof Api.channels.ChannelParticipants)) {
|
||||||
|
logger.warn(`获取群主返回类型 ${result.className}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
for (const participant of result.participants) {
|
||||||
|
if (participant instanceof Api.ChannelParticipantCreator) {
|
||||||
|
const user = result.users.find((candidate) => candidate.id === participant.userId);
|
||||||
|
if (user) {
|
||||||
|
return getDisplayName(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (rpcErrorIncludes(error, "CHAT_ADMIN_REQUIRED")) {
|
||||||
|
logger.warn(`没有权限获取 ${entity.title ?? ""} 管理员信息`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`获取群主失败 ${entity.title}: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleNewMessage(event: NewMessageEvent) {
|
||||||
|
try {
|
||||||
|
const message = event.message;
|
||||||
|
const text = message?.message ?? "";
|
||||||
|
const hits = this.keywordStore.match(text);
|
||||||
|
if (!hits.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await message.getChat();
|
||||||
|
const sender = await message.getSender();
|
||||||
|
const chatLabel = escapeMarkdown(getDisplayName(chat) || message.chatId?.toString() || "unknown");
|
||||||
|
const senderName = escapeMarkdown(getDisplayName(sender));
|
||||||
|
const senderIdRaw = sender && "id" in sender ? String((sender as { id?: unknown }).id ?? "unknown") : message.senderId?.toString() ?? "unknown";
|
||||||
|
const senderId = escapeMarkdown(senderIdRaw);
|
||||||
|
const keywords = Array.from(new Set(hits.map((hit) => hit.keyword))).join(", ");
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
"⚠️ *关键词触发*",
|
||||||
|
`群: \`${chatLabel}\``,
|
||||||
|
`消息ID: \`${message.id}\``,
|
||||||
|
`用户: \`${senderName}\` (\`${senderId}\`)`,
|
||||||
|
`关键词: \`${escapeMarkdown(keywords)}\``,
|
||||||
|
`时间: \`${new Date().toISOString()}\``,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (chat instanceof Api.Channel && chat.username) {
|
||||||
|
lines.push(`链接: https://t.me/${chat.username}/${message.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = escapeMarkdown(truncate(text.trim()));
|
||||||
|
if (preview) {
|
||||||
|
lines.push("----");
|
||||||
|
lines.push(preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.reporter.sendSafe(lines.join("\n"));
|
||||||
|
|
||||||
|
if (message.media) {
|
||||||
|
await this.reporter.sendSafe(
|
||||||
|
[
|
||||||
|
"📎 *附件提醒*",
|
||||||
|
`群: \`${chatLabel}\``,
|
||||||
|
`消息ID: \`${message.id}\` 含有非文本内容`,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`处理消息失败: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveEntity(link: string): Promise<{ entity?: ChannelEntity; joinedViaInvite: boolean }> {
|
||||||
|
try {
|
||||||
|
const entity = await this.client.getEntity(link);
|
||||||
|
if (entity instanceof Api.Channel) {
|
||||||
|
return { entity, joinedViaInvite: false };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`直接解析 ${link} 失败: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteHash = extractInviteHash(link);
|
||||||
|
if (!inviteHash) {
|
||||||
|
return { joinedViaInvite: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.client.invoke(new Api.messages.ImportChatInvite({ hash: inviteHash }));
|
||||||
|
if (hasChats(result)) {
|
||||||
|
for (const chat of result.chats) {
|
||||||
|
if (chat instanceof Api.Channel) {
|
||||||
|
logger.info(`通过邀请链接加入 ${chat.title}`);
|
||||||
|
return { entity: chat, joinedViaInvite: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (rpcErrorIncludes(error, "USER_ALREADY_PARTICIPANT")) {
|
||||||
|
logger.info(`邀请链接 ${link} 显示已在群内,重试 getEntity。`);
|
||||||
|
try {
|
||||||
|
const entity = await this.client.getEntity(link);
|
||||||
|
if (entity instanceof Api.Channel) {
|
||||||
|
return { entity, joinedViaInvite: false };
|
||||||
|
}
|
||||||
|
} catch (inner) {
|
||||||
|
logger.error(`邀请链接 ${link} 解析实体失败: ${(inner as Error).message}`);
|
||||||
|
}
|
||||||
|
} else if (rpcErrorIncludes(error, "INVITE_HASH_EXPIRED") || rpcErrorIncludes(error, "INVITE_HASH_INVALID")) {
|
||||||
|
logger.error(`邀请链接 ${link} 不可用: ${getRpcMessage(error)}`);
|
||||||
|
} else {
|
||||||
|
logger.error(`通过邀请链接加入 ${link} 失败: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { joinedViaInvite: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
import logging
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from telethon import events
|
|
||||||
from telethon.errors import (
|
|
||||||
ChannelsTooMuchError,
|
|
||||||
ChatAdminRequiredError,
|
|
||||||
FloodWaitError,
|
|
||||||
InviteHashExpiredError,
|
|
||||||
InviteHashInvalidError,
|
|
||||||
UserAlreadyParticipantError,
|
|
||||||
)
|
|
||||||
from telethon.tl.functions.channels import GetParticipantsRequest, JoinChannelRequest
|
|
||||||
from telethon.tl.functions.messages import ImportChatInviteRequest
|
|
||||||
from telethon.tl.types import Channel, ChannelParticipantCreator, ChannelParticipantsAdmins
|
|
||||||
from telethon.utils import get_display_name, get_peer_id
|
|
||||||
|
|
||||||
from .keywords import KeywordStore
|
|
||||||
from .reporter import Reporter
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _escape_md(value: Optional[str]) -> str:
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
return value.replace("`", r"\`")
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_invite_hash(link: str) -> Optional[str]:
|
|
||||||
if not link:
|
|
||||||
return None
|
|
||||||
if "t.me/+" in link:
|
|
||||||
return link.split("t.me/+", 1)[1]
|
|
||||||
if "t.me/joinchat/" in link:
|
|
||||||
return link.rstrip("/").split("t.me/joinchat/", 1)[1]
|
|
||||||
if link.startswith("+"):
|
|
||||||
return link[1:]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class GroupMonitor:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
client,
|
|
||||||
keyword_store: KeywordStore,
|
|
||||||
reporter: Reporter,
|
|
||||||
group_links: List[str],
|
|
||||||
) -> None:
|
|
||||||
self.client = client
|
|
||||||
self.keyword_store = keyword_store
|
|
||||||
self.reporter = reporter
|
|
||||||
self.group_links = group_links
|
|
||||||
self.entities: List[Channel] = []
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
await self._ensure_memberships()
|
|
||||||
if not self.entities:
|
|
||||||
raise RuntimeError("没有可监听的群,停止启动。")
|
|
||||||
await self.reporter.prepare(self.client)
|
|
||||||
self.client.add_event_handler(self._handle_new_message, events.NewMessage(chats=[get_peer_id(e) for e in self.entities]))
|
|
||||||
logger.info("事件监听已注册,等待消息。")
|
|
||||||
|
|
||||||
async def _ensure_memberships(self) -> None:
|
|
||||||
for link in self.group_links:
|
|
||||||
entity, joined_via_invite = await self._resolve_entity(link)
|
|
||||||
if not entity:
|
|
||||||
logger.error("无法解析群链接 %s", link)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not isinstance(entity, Channel):
|
|
||||||
logger.warning("%s 不是超级群/频道,当前实现仅支持 Channel。", link)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not joined_via_invite:
|
|
||||||
try:
|
|
||||||
await self.client(JoinChannelRequest(entity))
|
|
||||||
logger.info("加入群 %s 成功。", entity.title)
|
|
||||||
except UserAlreadyParticipantError:
|
|
||||||
logger.info("已在群 %s 中。", entity.title)
|
|
||||||
except (ChannelsTooMuchError, FloodWaitError, InviteHashExpiredError, InviteHashInvalidError) as exc:
|
|
||||||
logger.error("加入群 %s 失败: %s", link, exc)
|
|
||||||
continue
|
|
||||||
|
|
||||||
peer_id = get_peer_id(entity)
|
|
||||||
if any(get_peer_id(e) == peer_id for e in self.entities):
|
|
||||||
continue
|
|
||||||
self.entities.append(entity)
|
|
||||||
owner = await self._discover_owner(entity)
|
|
||||||
owner_label = _escape_md(owner or "未找到")
|
|
||||||
await self.reporter.send_safe(
|
|
||||||
(
|
|
||||||
"✅ *群监控已启用*\n"
|
|
||||||
f"群: `{_escape_md(entity.title)}`\n"
|
|
||||||
f"ID: `{get_peer_id(entity)}`\n"
|
|
||||||
f"群主: `{owner_label}`"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _discover_owner(self, entity: Channel) -> Optional[str]:
|
|
||||||
try:
|
|
||||||
result = await self.client(
|
|
||||||
GetParticipantsRequest(
|
|
||||||
channel=entity,
|
|
||||||
filter=ChannelParticipantsAdmins(),
|
|
||||||
offset=0,
|
|
||||||
limit=200,
|
|
||||||
hash=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except ChatAdminRequiredError:
|
|
||||||
logger.warning("没有足够权限获取 %s 管理员信息。", entity.title)
|
|
||||||
return None
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("获取群主失败 %s: %s", entity.title, exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
creator: Optional[str] = None
|
|
||||||
for participant in result.participants:
|
|
||||||
if isinstance(participant, ChannelParticipantCreator):
|
|
||||||
owner_user = next((u for u in result.users if u.id == participant.user_id), None)
|
|
||||||
if owner_user:
|
|
||||||
creator = get_display_name(owner_user) or str(owner_user.id)
|
|
||||||
break
|
|
||||||
return creator
|
|
||||||
|
|
||||||
async def _handle_new_message(self, event: events.NewMessage.Event) -> None:
|
|
||||||
try:
|
|
||||||
chat = await event.get_chat()
|
|
||||||
sender = await event.get_sender()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("无法获取消息上下文: %s", exc)
|
|
||||||
return
|
|
||||||
|
|
||||||
text = event.raw_text or ""
|
|
||||||
hits = self.keyword_store.match(text)
|
|
||||||
if not hits:
|
|
||||||
return
|
|
||||||
|
|
||||||
keyword_summary = ", ".join(dict.fromkeys(hit.keyword for hit in hits))
|
|
||||||
sender_name = _escape_md(get_display_name(sender))
|
|
||||||
chat_name = _escape_md(getattr(chat, "title", str(event.chat_id)))
|
|
||||||
message_link = None
|
|
||||||
if getattr(chat, "username", None):
|
|
||||||
message_link = f"https://t.me/{chat.username}/{event.id}"
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
"⚠️ *关键词触发*",
|
|
||||||
f"群: `{chat_name}`",
|
|
||||||
f"消息ID: `{event.id}`",
|
|
||||||
f"用户: `{sender_name}` (`{sender.id}`)",
|
|
||||||
f"关键词: `{_escape_md(keyword_summary)}`",
|
|
||||||
f"时间: `{datetime.utcnow().isoformat()}Z`",
|
|
||||||
]
|
|
||||||
if message_link:
|
|
||||||
lines.append(f"链接: {message_link}")
|
|
||||||
preview = _escape_md(text.strip())
|
|
||||||
if preview:
|
|
||||||
if len(preview) > 500:
|
|
||||||
preview = preview[:500] + "..."
|
|
||||||
lines.append("----")
|
|
||||||
lines.append(preview)
|
|
||||||
|
|
||||||
await self.reporter.send_safe("\n".join(lines))
|
|
||||||
|
|
||||||
if event.message.media:
|
|
||||||
await self.reporter.send_safe(
|
|
||||||
(
|
|
||||||
"📎 *附件提醒*\n"
|
|
||||||
f"群: `{chat_name}`\n"
|
|
||||||
f"消息ID: `{event.id}` 含有非文本内容"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _resolve_entity(self, link: str) -> tuple[Optional[Channel], bool]:
|
|
||||||
try:
|
|
||||||
entity = await self.client.get_entity(link)
|
|
||||||
if isinstance(entity, Channel):
|
|
||||||
return entity, False
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
logger.debug("直接解析 %s 失败,尝试邀请链接流程。", link)
|
|
||||||
|
|
||||||
invite_hash = _extract_invite_hash(link)
|
|
||||||
if invite_hash:
|
|
||||||
try:
|
|
||||||
result = await self.client(ImportChatInviteRequest(invite_hash))
|
|
||||||
for chat in result.chats:
|
|
||||||
if isinstance(chat, Channel):
|
|
||||||
logger.info("通过邀请链接加入 %s", chat.title)
|
|
||||||
return chat, True
|
|
||||||
except UserAlreadyParticipantError:
|
|
||||||
logger.info("邀请链接 %s 提示已在群内,尝试再次解析实体。", link)
|
|
||||||
try:
|
|
||||||
entity = await self.client.get_entity(link)
|
|
||||||
if isinstance(entity, Channel):
|
|
||||||
return entity, False
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("邀请链接 %s 解析实体失败: %s", link, exc)
|
|
||||||
except (InviteHashExpiredError, InviteHashInvalidError) as exc:
|
|
||||||
logger.error("邀请链接 %s 不可用: %s", link, exc)
|
|
||||||
return None, False
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class KeywordEntry:
|
|
||||||
name: str
|
|
||||||
patterns: List[str]
|
|
||||||
regex: bool = False
|
|
||||||
compiled: List[re.Pattern] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class KeywordHit:
|
|
||||||
keyword: str
|
|
||||||
pattern: str
|
|
||||||
|
|
||||||
|
|
||||||
class KeywordStore:
|
|
||||||
"""加载并匹配关键词,支持热加载。"""
|
|
||||||
|
|
||||||
def __init__(self, file_path: Path):
|
|
||||||
self.file_path = file_path
|
|
||||||
self._entries: List[KeywordEntry] = []
|
|
||||||
self._last_mtime: float | None = None
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._reload(force=True)
|
|
||||||
|
|
||||||
def _reload(self, force: bool = False) -> None:
|
|
||||||
if not self.file_path.exists():
|
|
||||||
if force:
|
|
||||||
logger.warning("关键词文件 %s 不存在,暂无匹配项。", self.file_path)
|
|
||||||
self._entries = []
|
|
||||||
self._last_mtime = None
|
|
||||||
return
|
|
||||||
|
|
||||||
mtime = self.file_path.stat().st_mtime
|
|
||||||
if not force and self._last_mtime == mtime:
|
|
||||||
return
|
|
||||||
|
|
||||||
raw = yaml.safe_load(self.file_path.read_text(encoding="utf-8")) or {}
|
|
||||||
entries: List[KeywordEntry] = []
|
|
||||||
for item in raw.get("keywords", []):
|
|
||||||
name = str(item.get("name", "unnamed"))
|
|
||||||
patterns = [str(p) for p in item.get("patterns", []) if p]
|
|
||||||
if not patterns:
|
|
||||||
continue
|
|
||||||
regex = bool(item.get("regex", False))
|
|
||||||
if regex:
|
|
||||||
compiled = []
|
|
||||||
for pattern in patterns:
|
|
||||||
try:
|
|
||||||
compiled.append(re.compile(pattern))
|
|
||||||
except re.error as exc:
|
|
||||||
logger.warning("忽略非法正则 %s: %s", pattern, exc)
|
|
||||||
entry = KeywordEntry(name=name, patterns=patterns, regex=True, compiled=compiled)
|
|
||||||
else:
|
|
||||||
entry = KeywordEntry(name=name, patterns=[p.lower() for p in patterns], regex=False)
|
|
||||||
entries.append(entry)
|
|
||||||
|
|
||||||
self._entries = entries
|
|
||||||
self._last_mtime = mtime
|
|
||||||
logger.info("加载 %d 个关键词分组。", len(entries))
|
|
||||||
|
|
||||||
def _ensure_fresh(self) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._reload()
|
|
||||||
|
|
||||||
def match(self, text: str | None) -> List[KeywordHit]:
|
|
||||||
if not text:
|
|
||||||
return []
|
|
||||||
self._ensure_fresh()
|
|
||||||
payload = text.strip()
|
|
||||||
if not payload:
|
|
||||||
return []
|
|
||||||
|
|
||||||
hits: List[KeywordHit] = []
|
|
||||||
lowered = payload.lower()
|
|
||||||
for entry in self._entries:
|
|
||||||
if entry.regex:
|
|
||||||
for regex in entry.compiled:
|
|
||||||
if regex.search(payload):
|
|
||||||
hits.append(KeywordHit(keyword=entry.name, pattern=regex.pattern))
|
|
||||||
else:
|
|
||||||
for pattern in entry.patterns:
|
|
||||||
if pattern in lowered:
|
|
||||||
hits.append(KeywordHit(keyword=entry.name, pattern=pattern))
|
|
||||||
return hits
|
|
||||||
129
src/keywords.ts
Normal file
129
src/keywords.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
|
const INLINE_FLAG_PATTERN = /^\(\?([ims]+)\)/i;
|
||||||
|
|
||||||
|
type KeywordDefinition = {
|
||||||
|
name: string;
|
||||||
|
patterns: string[];
|
||||||
|
regex: boolean;
|
||||||
|
compiled: RegExp[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KeywordHit = {
|
||||||
|
keyword: string;
|
||||||
|
pattern: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关键词存储与匹配逻辑,对应 Python 版本的 KeywordStore。
|
||||||
|
*/
|
||||||
|
export class KeywordStore {
|
||||||
|
private entries: KeywordDefinition[] = [];
|
||||||
|
private lastMtime?: number;
|
||||||
|
|
||||||
|
constructor(private readonly filePath: string) {
|
||||||
|
this.reload(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private reload(force = false) {
|
||||||
|
if (!existsSync(this.filePath)) {
|
||||||
|
if (force) {
|
||||||
|
logger.warn(`关键词文件 ${this.filePath} 不存在`);
|
||||||
|
this.entries = [];
|
||||||
|
this.lastMtime = undefined;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mtime = statSync(this.filePath).mtimeMs;
|
||||||
|
if (!force && this.lastMtime === mtime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = readFileSync(this.filePath, "utf-8");
|
||||||
|
const raw = (parse(content) ?? {}) as { keywords?: Array<Record<string, unknown>> };
|
||||||
|
|
||||||
|
const parsed: KeywordDefinition[] = [];
|
||||||
|
for (const entry of raw.keywords ?? []) {
|
||||||
|
const name = String(entry.name ?? "unnamed");
|
||||||
|
const patterns = Array.isArray(entry.patterns) ? entry.patterns.map((p) => String(p)).filter(Boolean) : [];
|
||||||
|
if (!patterns.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const regex = Boolean(entry.regex);
|
||||||
|
if (regex) {
|
||||||
|
const compiled: RegExp[] = [];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const created = buildRegExp(pattern);
|
||||||
|
if (created) {
|
||||||
|
compiled.push(created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed.push({ name, patterns, regex: true, compiled });
|
||||||
|
} else {
|
||||||
|
parsed.push({ name, patterns: patterns.map((p) => p.toLowerCase()), regex: false, compiled: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.entries = parsed;
|
||||||
|
this.lastMtime = mtime;
|
||||||
|
logger.info(`加载 ${parsed.length} 个关键词分组`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureFresh() {
|
||||||
|
try {
|
||||||
|
this.reload();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`刷新关键词失败: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match(text?: string | null): KeywordHit[] {
|
||||||
|
if (!text) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
this.ensureFresh();
|
||||||
|
const payload = text.trim();
|
||||||
|
if (!payload) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const lower = payload.toLowerCase();
|
||||||
|
const hits: KeywordHit[] = [];
|
||||||
|
for (const entry of this.entries) {
|
||||||
|
if (entry.regex) {
|
||||||
|
for (const regex of entry.compiled) {
|
||||||
|
if (regex.test(payload)) {
|
||||||
|
hits.push({ keyword: entry.name, pattern: regex.source });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const pattern of entry.patterns) {
|
||||||
|
if (lower.includes(pattern)) {
|
||||||
|
hits.push({ keyword: entry.name, pattern });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRegExp(pattern: string): RegExp | undefined {
|
||||||
|
let source = pattern;
|
||||||
|
let flags = "";
|
||||||
|
const match = pattern.match(INLINE_FLAG_PATTERN);
|
||||||
|
if (match) {
|
||||||
|
const inlineFlags = Array.from(new Set(match[1].toLowerCase().split("")));
|
||||||
|
flags = inlineFlags.filter((flag) => ["i", "m", "s"].includes(flag)).join("");
|
||||||
|
source = pattern.slice(match[0].length);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new RegExp(source, flags);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`忽略非法正则 ${pattern}: ${(error as Error).message}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/logger.ts
Normal file
35
src/logger.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const LEVELS = {
|
||||||
|
trace: 10,
|
||||||
|
debug: 20,
|
||||||
|
info: 30,
|
||||||
|
warn: 40,
|
||||||
|
error: 50,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type LevelName = keyof typeof LEVELS;
|
||||||
|
|
||||||
|
const envLevel = (process.env.LOG_LEVEL ?? "info").toLowerCase();
|
||||||
|
const threshold = LEVELS[(envLevel as LevelName) || "info"] ?? LEVELS.info;
|
||||||
|
|
||||||
|
function emit(level: LevelName, message: string, meta?: unknown) {
|
||||||
|
if (LEVELS[level] < threshold) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = meta ? `${message} ${JSON.stringify(meta)}` : message;
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
if (level === "error") {
|
||||||
|
console.error(`${ts} [${level.toUpperCase()}] ${payload}`);
|
||||||
|
} else if (level === "warn") {
|
||||||
|
console.warn(`${ts} [${level.toUpperCase()}] ${payload}`);
|
||||||
|
} else {
|
||||||
|
console.log(`${ts} [${level.toUpperCase()}] ${payload}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
trace: (message: string, meta?: unknown) => emit("trace", message, meta),
|
||||||
|
debug: (message: string, meta?: unknown) => emit("debug", message, meta),
|
||||||
|
info: (message: string, meta?: unknown) => emit("info", message, meta),
|
||||||
|
warn: (message: string, meta?: unknown) => emit("warn", message, meta),
|
||||||
|
error: (message: string, meta?: unknown) => emit("error", message, meta),
|
||||||
|
};
|
||||||
76
src/main.py
76
src/main.py
@@ -1,76 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import signal
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from telethon import TelegramClient
|
|
||||||
|
|
||||||
from .config import load_config
|
|
||||||
from .group_monitor import GroupMonitor
|
|
||||||
from .keywords import KeywordStore
|
|
||||||
from .reporter import Reporter
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_phone(preconfigured: Optional[str]) -> str:
|
|
||||||
if preconfigured:
|
|
||||||
return preconfigured
|
|
||||||
phone = input("请输入 Telegram 手机号(含国家码,如 +8613712345678):").strip()
|
|
||||||
if not phone:
|
|
||||||
raise RuntimeError("未提供手机号,无法完成首次登录。")
|
|
||||||
return phone
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
|
||||||
config = load_config()
|
|
||||||
|
|
||||||
client = TelegramClient(
|
|
||||||
session=config.credentials.session_name,
|
|
||||||
api_id=config.credentials.api_id,
|
|
||||||
api_hash=config.credentials.api_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
await client.start(phone=_resolve_phone(config.credentials.phone))
|
|
||||||
logger.info("User-Bot 登录完成。")
|
|
||||||
|
|
||||||
keyword_store = KeywordStore(config.monitor.keywords_file)
|
|
||||||
reporter = Reporter(
|
|
||||||
bot_token=config.reporting.bot_token,
|
|
||||||
chat_link=config.reporting.chat_link or config.monitor.group_links[0],
|
|
||||||
)
|
|
||||||
|
|
||||||
monitor = GroupMonitor(
|
|
||||||
client=client,
|
|
||||||
keyword_store=keyword_store,
|
|
||||||
reporter=reporter,
|
|
||||||
group_links=config.monitor.group_links,
|
|
||||||
)
|
|
||||||
|
|
||||||
await monitor.start()
|
|
||||||
logger.info("监控已启动,等待消息...")
|
|
||||||
|
|
||||||
stop_event = asyncio.Event()
|
|
||||||
|
|
||||||
def _handle_sig(*_args):
|
|
||||||
stop_event.set()
|
|
||||||
|
|
||||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
||||||
try:
|
|
||||||
asyncio.get_running_loop().add_signal_handler(sig, _handle_sig)
|
|
||||||
except NotImplementedError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
await stop_event.wait()
|
|
||||||
logger.info("收到退出信号,断开连接。")
|
|
||||||
await client.disconnect()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
36
src/main.ts
Normal file
36
src/main.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { loadConfig } from "./config.js";
|
||||||
|
import { GroupMonitor } from "./groupMonitor.js";
|
||||||
|
import { KeywordStore } from "./keywords.js";
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
import { Reporter } from "./reporter.js";
|
||||||
|
import { createTelegramClient } from "./telegramClient.js";
|
||||||
|
|
||||||
|
async function waitForExit() {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const handler = () => {
|
||||||
|
process.off("SIGINT", handler);
|
||||||
|
process.off("SIGTERM", handler);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
process.on("SIGINT", handler);
|
||||||
|
process.on("SIGTERM", handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const config = loadConfig();
|
||||||
|
const client = await createTelegramClient(config.telegram);
|
||||||
|
const keywordStore = new KeywordStore(config.monitor.keywordsFile);
|
||||||
|
const reporter = new Reporter(config.reporter);
|
||||||
|
const monitor = new GroupMonitor(client, keywordStore, reporter, config.monitor.groupLinks);
|
||||||
|
await monitor.start();
|
||||||
|
logger.info("监控已启动,等待消息...");
|
||||||
|
await waitForExit();
|
||||||
|
logger.info("收到退出信号,断开连接。");
|
||||||
|
await client.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
logger.error(`程序异常退出: ${(error as Error).stack ?? (error as Error).message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from telethon import utils as tg_utils
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Reporter:
|
|
||||||
bot_token: str
|
|
||||||
chat_link: Optional[str]
|
|
||||||
chat_id: Optional[int] = None
|
|
||||||
timeout: float = 10.0
|
|
||||||
|
|
||||||
async def prepare(self, client) -> None:
|
|
||||||
if self.chat_id:
|
|
||||||
return
|
|
||||||
if not self.chat_link:
|
|
||||||
raise RuntimeError("REPORT_CHAT_LINK 未设置,无法汇报。")
|
|
||||||
entity = await client.get_entity(self.chat_link)
|
|
||||||
self.chat_id = tg_utils.get_peer_id(entity)
|
|
||||||
logger.info("汇报目标 chat_id=%s 来自 %s", self.chat_id, self.chat_link)
|
|
||||||
|
|
||||||
async def send(self, text: str, parse_mode: str = "Markdown") -> None:
|
|
||||||
if not self.chat_id:
|
|
||||||
raise RuntimeError("Reporter 还未完成 prepare,无法发送。")
|
|
||||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
|
||||||
payload = {
|
|
||||||
"chat_id": self.chat_id,
|
|
||||||
"text": text,
|
|
||||||
"parse_mode": parse_mode,
|
|
||||||
"disable_web_page_preview": True,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
||||||
response = await client.post(url, json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
if not data.get("ok", False):
|
|
||||||
raise RuntimeError(f"Bot API 返回失败: {data}")
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("发送汇报失败: %s", exc)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def send_safe(self, text: str, parse_mode: str = "Markdown") -> None:
|
|
||||||
try:
|
|
||||||
await self.send(text, parse_mode=parse_mode)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("忽略汇报发送异常")
|
|
||||||
63
src/reporter.ts
Normal file
63
src/reporter.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { TelegramClient } from "telegram";
|
||||||
|
import { request } from "undici";
|
||||||
|
|
||||||
|
import type { ReporterConfig } from "./config.js";
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Telegram Bot API 推送事件,与原实现保持一致。
|
||||||
|
*/
|
||||||
|
export class Reporter {
|
||||||
|
private chatId?: string;
|
||||||
|
|
||||||
|
constructor(private readonly config: ReporterConfig, private readonly timeout = DEFAULT_TIMEOUT) {}
|
||||||
|
|
||||||
|
async prepare(client: TelegramClient) {
|
||||||
|
if (this.chatId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.config.chatLink) {
|
||||||
|
throw new Error("REPORT_CHAT_LINK 未设置,无法推送汇报。");
|
||||||
|
}
|
||||||
|
const entity = await client.getEntity(this.config.chatLink);
|
||||||
|
this.chatId = await client.getPeerId(entity);
|
||||||
|
logger.info(`汇报 chat_id=${this.chatId} link=${this.config.chatLink}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(text: string, parseMode: "MarkdownV2" | "Markdown" = "MarkdownV2") {
|
||||||
|
if (!this.chatId) {
|
||||||
|
throw new Error("Reporter 还未完成 prepare。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://api.telegram.org/bot${this.config.botToken}/sendMessage`;
|
||||||
|
const payload = {
|
||||||
|
chat_id: this.chatId,
|
||||||
|
text,
|
||||||
|
parse_mode: parseMode,
|
||||||
|
disable_web_page_preview: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
bodyTimeout: this.timeout,
|
||||||
|
headersTimeout: this.timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = (await response.body.json()) as { ok: boolean };
|
||||||
|
if (!body.ok) {
|
||||||
|
throw new Error(`Bot API 调用失败: ${JSON.stringify(body)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendSafe(text: string, parseMode?: "MarkdownV2" | "Markdown") {
|
||||||
|
try {
|
||||||
|
await this.send(text, parseMode);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`忽略汇报发送异常: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/telegramClient.ts
Normal file
53
src/telegramClient.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { TelegramClient } from "telegram";
|
||||||
|
import { StringSession } from "telegram/sessions";
|
||||||
|
|
||||||
|
import type { TelegramCredentials } from "./config.js";
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
import { prompt } from "./utils/prompt.js";
|
||||||
|
|
||||||
|
function loadSessionString(filePath: string): string {
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return readFileSync(filePath, "utf-8").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSessionString(filePath: string, session: string) {
|
||||||
|
writeFileSync(filePath, session, { encoding: "utf-8" });
|
||||||
|
logger.info(`会话写入 ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePhone(preconfigured?: string): Promise<string> {
|
||||||
|
if (preconfigured) {
|
||||||
|
return preconfigured;
|
||||||
|
}
|
||||||
|
const phone = await prompt("请输入 Telegram 手机号 (含国家码)");
|
||||||
|
if (!phone) {
|
||||||
|
throw new Error("未提供手机号,无法完成登录。\n请设置 USER_PHONE 或在提示时输入。");
|
||||||
|
}
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTelegramClient(credentials: TelegramCredentials): Promise<TelegramClient> {
|
||||||
|
const session = new StringSession(loadSessionString(credentials.sessionFile));
|
||||||
|
const client = new TelegramClient(session, credentials.apiId, credentials.apiHash, {
|
||||||
|
connectionRetries: credentials.connectionRetries,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.start({
|
||||||
|
phoneNumber: async () => await resolvePhone(credentials.phone),
|
||||||
|
password: async () => credentials.twoFactorPassword ?? (await prompt("请输入二步验证密码 (如无直接回车)")),
|
||||||
|
phoneCode: async () => await prompt("请输入短信/Telegram 验证码"),
|
||||||
|
onError: (error) => logger.error(`登录失败: ${error.message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("User-Bot 登录完成。");
|
||||||
|
const serialized = client.session.save();
|
||||||
|
if (typeof serialized === "string" && serialized) {
|
||||||
|
saveSessionString(credentials.sessionFile, serialized);
|
||||||
|
} else {
|
||||||
|
logger.warn("会话未能序列化,跳过持久化。");
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
29
src/utils/prompt.ts
Normal file
29
src/utils/prompt.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import readline from "node:readline";
|
||||||
|
|
||||||
|
type PromptOptions = {
|
||||||
|
defaultValue?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal readline prompt helper,为登录流程提供交互输入。
|
||||||
|
*/
|
||||||
|
export function prompt(question: string, options: PromptOptions = {}): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { defaultValue } = options;
|
||||||
|
const q = question.trim().endsWith(":") ? question : `${question.trim()}: `;
|
||||||
|
rl.question(q, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
const trimmed = answer.trim();
|
||||||
|
if (!trimmed && defaultValue) {
|
||||||
|
resolve(defaultValue);
|
||||||
|
} else {
|
||||||
|
resolve(trimmed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
16
src/utils/text.ts
Normal file
16
src/utils/text.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Telegram MarkdownV2 兼容的简单转义。
|
||||||
|
*/
|
||||||
|
export function escapeMarkdown(value?: string | null): string {
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.replace(/[`*_\[\]()~>#+\-=|{}.!]/g, (match) => `\\${match}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(value: string, limit = 500): string {
|
||||||
|
if (value.length <= limit) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return `${value.slice(0, limit)}...`;
|
||||||
|
}
|
||||||
58
tests/keywords.spec.ts
Normal file
58
tests/keywords.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { KeywordStore } from "../src/keywords.js";
|
||||||
|
|
||||||
|
describe("KeywordStore", () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "keywords-test-"));
|
||||||
|
const keywordsFile = join(tempDir, "keywords.yaml");
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
writeFileSync(
|
||||||
|
keywordsFile,
|
||||||
|
[
|
||||||
|
"keywords:",
|
||||||
|
" - name: promo",
|
||||||
|
" patterns:",
|
||||||
|
' - "推广"',
|
||||||
|
" regex: false",
|
||||||
|
" - name: regex",
|
||||||
|
" patterns:",
|
||||||
|
' - "(?i)验证码"',
|
||||||
|
" regex: true",
|
||||||
|
].join("\n"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("匹配子串与正则,并在文件更新后热加载", async () => {
|
||||||
|
const store = new KeywordStore(keywordsFile);
|
||||||
|
const firstHits = store.match("这是一个推广内容");
|
||||||
|
expect(firstHits.some((hit) => hit.keyword === "promo")).toBe(true);
|
||||||
|
|
||||||
|
const regexHits = store.match("验证码123");
|
||||||
|
expect(regexHits.some((hit) => hit.keyword === "regex")).toBe(true);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
writeFileSync(
|
||||||
|
keywordsFile,
|
||||||
|
[
|
||||||
|
"keywords:",
|
||||||
|
" - name: new",
|
||||||
|
" patterns:",
|
||||||
|
' - "新品"',
|
||||||
|
].join("\n"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = store.match("这是一条新品");
|
||||||
|
expect(updated.some((hit) => hit.keyword === "new")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2021", "DOM"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": ".",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src", "tests", "vitest.config.ts"],
|
||||||
|
"exclude": ["dist", "node_modules"]
|
||||||
|
}
|
||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["tests/**/*.spec.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user