Resolve conflicts with upstream/main and update Discord bot configuration
50
.env.example
@@ -92,11 +92,59 @@ SERPAPI_API_KEYS=your_serpapi_key_here
|
||||
# PUSHOVER_USER_KEY=your_user_key
|
||||
# PUSHOVER_API_TOKEN=your_api_token
|
||||
#
|
||||
# 【方式七】PushPlus 配置(国内推送服务,推荐)
|
||||
# 注册PushPlus账号并获取Token https://www.pushplus.plus
|
||||
# PUSHPLUS_TOKEN=your_pushplus_token
|
||||
#
|
||||
# 【方式八】Discord 配置
|
||||
# 支持两种方式:Webhook(推荐,配置简单)和 Bot API(权限高)
|
||||
#
|
||||
# 方式1:Discord Webhook(推荐,无需 Bot 账号)
|
||||
# 在 Discord 频道设置 -> 集成 -> Webhook -> 新建 Webhook -> 复制 URL
|
||||
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your_webhook_id/your_webhook_token
|
||||
#
|
||||
# 方式2:Discord Bot API(需要 Bot 账号和频道 ID)
|
||||
# 1. 创建 Bot:https://discord.com/developers/applications -> 新建应用 -> Bot -> 创建 Bot
|
||||
# 2. 获取 Bot Token:Bot 页面 -> 重置 Token
|
||||
# 3. 获取频道 ID:Discord 开启开发者模式 -> 右键频道 -> 复制 ID
|
||||
# DISCORD_BOT_TOKEN=your_bot_token_here
|
||||
# DISCORD_MAIN_CHANNEL_ID=your_channel_id_here
|
||||
#
|
||||
# 【高级配置】消息长度限制(字节)
|
||||
# 超过限制会自动分批发送,一般无需修改
|
||||
# FEISHU_MAX_BYTES=20000 # 飞书限制约 20KB,默认 20000 字节
|
||||
# WECHAT_MAX_BYTES=4000 # 企业微信限制 4096 字节,默认 4000 字节
|
||||
|
||||
# ===================================
|
||||
# 单股推送配置(可选)
|
||||
# ===================================
|
||||
# 单股推送模式:每分析完一只股票立即推送,而不是汇总后推送
|
||||
# SINGLE_STOCK_NOTIFY=false
|
||||
#
|
||||
# 报告类型:simple(精简) 或 full(完整)
|
||||
# Docker环境下如果推送内容不完整,可以设置为 full
|
||||
# REPORT_TYPE=simple
|
||||
|
||||
# ===================================
|
||||
# 分析间隔配置(可选)
|
||||
# ===================================
|
||||
# 个股分析和大盘分析之间的延迟时间(秒)
|
||||
# 用于避免触发 Gemini 等 AI API 的限流
|
||||
# ANALYSIS_DELAY=0
|
||||
|
||||
# 应用 AppKey(与 Webhook 模式共用)
|
||||
DINGTALK_APP_KEY=xxxx
|
||||
# 应用 AppSecret(与 Webhook 模式共用)
|
||||
DINGTALK_APP_SECRET=xxxx
|
||||
# 启用 Stream 模式
|
||||
DINGTALK_STREAM_ENABLED=true
|
||||
|
||||
# 飞书应用机器人配置
|
||||
FEISHU_APP_ID=xxxx
|
||||
FEISHU_APP_SECRET=xxxx
|
||||
# 启用长连接模式
|
||||
FEISHU_STREAM_ENABLED=true
|
||||
|
||||
# 数据库路径
|
||||
DATABASE_PATH=./data/stock_analysis.db
|
||||
|
||||
@@ -123,7 +171,7 @@ DEBUG=false
|
||||
# ===================================
|
||||
# 是否默认启动 WebUI(true/false,默认 false)
|
||||
WEBUI_ENABLED=false
|
||||
# WebUI 监听地址(默认 127.0.0.1,如需局域网访问请改为 0.0.0.0)
|
||||
# WebUI 监听地址(默认 127.0.0.1;Docker/Compose 场景需要 0.0.0.0 才能从宿主机访问端口映射)
|
||||
WEBUI_HOST=127.0.0.1
|
||||
# WebUI 监听端口(默认 8000)
|
||||
WEBUI_PORT=8000
|
||||
|
||||
6
.github/workflows/daily_analysis.yml
vendored
@@ -77,7 +77,11 @@ jobs:
|
||||
# 方式五:自定义 Webhook(支持钉钉、Discord、Slack、Bark等,多个用逗号分隔)
|
||||
CUSTOM_WEBHOOK_URLS: ${{ secrets.CUSTOM_WEBHOOK_URLS }}
|
||||
CUSTOM_WEBHOOK_BEARER_TOKEN: ${{ secrets.CUSTOM_WEBHOOK_BEARER_TOKEN }}
|
||||
# 方式六:飞书文档
|
||||
# 方式六:Discord(支持 Webhook 和 Bot API)
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
|
||||
DISCORD_MAIN_CHANNEL_ID: ${{ secrets.DISCORD_MAIN_CHANNEL_ID }}
|
||||
# 方式七:飞书文档
|
||||
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
|
||||
FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }}
|
||||
FEISHU_FOLDER_TOKEN: ${{ secrets.FEISHU_FOLDER_TOKEN }}
|
||||
|
||||
25
CHANGELOG.md
@@ -7,6 +7,31 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 新增
|
||||
- 🇺🇸 美股支持 ([#18](https://github.com/ZhuLinsen/daily_stock_analysis/issues/18))
|
||||
- 支持 1-5 个大写字母的美股代码(如 `AAPL`, `TSLA`, `GOOGL`)
|
||||
- 支持特殊股票类别(如 `BRK.B`)
|
||||
- 基于 YfinanceFetcher 获取数据(Yahoo Finance)
|
||||
- WebUI、Bot命令、API接口全面适配
|
||||
- 📄 国际化文档
|
||||
- 新增英文版 README ([README_EN.md](./README_EN.md))
|
||||
- 支持中英双语文档切换
|
||||
- 📲 PushPlus 推送支持([#38](https://github.com/ZhuLinsen/daily_stock_analysis/issues/38))
|
||||
- 支持国内 PushPlus 推送服务
|
||||
- 通过 `PUSHPLUS_TOKEN` 配置
|
||||
- 📊 通知格式优化([#112](https://github.com/ZhuLinsen/daily_stock_analysis/issues/112))
|
||||
- 在通知开头添加所有股票的评分摘要
|
||||
- 便于快速查看整体分析结果
|
||||
- ⏱️ 分析间隔配置([#128](https://github.com/ZhuLinsen/daily_stock_analysis/issues/128))
|
||||
- 新增 `ANALYSIS_DELAY` 环境变量
|
||||
- 在个股之间添加延迟(避免并发触发API限流)
|
||||
- 在个股分析和大盘分析之间添加延迟
|
||||
- 完全解决 Gemini API 429错误问题
|
||||
- 📄 报告类型配置([#119](https://github.com/ZhuLinsen/daily_stock_analysis/issues/119))
|
||||
- 新增 `REPORT_TYPE` 环境变量(simple/full)
|
||||
- 修复 Docker 环境下单股推送不完整的问题
|
||||
- 支持选择精简版或完整版报告
|
||||
|
||||
### 计划中
|
||||
- Web 管理界面
|
||||
|
||||
|
||||
@@ -22,12 +22,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
COPY requirements.txt .
|
||||
|
||||
# 安装 Python 依赖
|
||||
RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 复制应用代码
|
||||
COPY *.py ./
|
||||
COPY data_provider/ ./data_provider/
|
||||
COPY web/ ./web/
|
||||
COPY bot/ ./bot/
|
||||
|
||||
# 创建数据目录
|
||||
RUN mkdir -p /app/data /app/logs /app/reports
|
||||
|
||||
15
README.md
@@ -1,4 +1,4 @@
|
||||
# 📈 A股智能分析系统
|
||||
# 📈 股票智能分析系统
|
||||
|
||||
[](https://github.com/ZhuLinsen/daily_stock_analysis/stargazers)
|
||||
[](https://github.com/ZhuLinsen/daily_stock_analysis/actions/workflows/ci.yml)
|
||||
@@ -6,7 +6,9 @@
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://github.com/features/actions)
|
||||
|
||||
> 🤖 基于 AI 大模型的 A/H 股自选股智能分析系统,每日自动分析并推送「决策仪表盘」到企业微信/飞书/Telegram/邮箱
|
||||
> 🤖 基于 AI 大模型的 A股/港股/美股自选股智能分析系统,每日自动分析并推送「决策仪表盘」到企业微信/飞书/Telegram/邮箱
|
||||
|
||||
[English](./README_EN.md) | 简体中文
|
||||
|
||||

|
||||
|
||||
@@ -70,9 +72,12 @@
|
||||
| `EMAIL_SENDER` | 发件人邮箱(如 `xxx@qq.com`) | 可选 |
|
||||
| `EMAIL_PASSWORD` | 邮箱授权码(非登录密码) | 可选 |
|
||||
| `EMAIL_RECEIVERS` | 收件人邮箱(多个用逗号分隔,留空则发给自己) | 可选 |
|
||||
| `PUSHPLUS_TOKEN` | PushPlus Token([获取地址](https://www.pushplus.plus),国内推送服务) | 可选 |
|
||||
| `CUSTOM_WEBHOOK_URLS` | 自定义 Webhook(支持钉钉等,多个用逗号分隔) | 可选 |
|
||||
| `CUSTOM_WEBHOOK_BEARER_TOKEN` | 自定义 Webhook 的 Bearer Token(用于需要认证的 Webhook) | 可选 |
|
||||
| `SINGLE_STOCK_NOTIFY` | 单股推送模式:设为 `true` 则每分析完一只股票立即推送 | 可选 |
|
||||
| `REPORT_TYPE` | 报告类型:`simple`(精简) 或 `full`(完整),Docker环境推荐设为 `full` | 可选 |
|
||||
| `ANALYSIS_DELAY` | 个股分析和大盘分析之间的延迟(秒),避免API限流,如 `10` | 可选 |
|
||||
|
||||
> *注:至少配置一个渠道,配置多个则同时推送
|
||||
>
|
||||
@@ -82,7 +87,7 @@
|
||||
|
||||
| Secret 名称 | 说明 | 必填 |
|
||||
|------------|------|:----:|
|
||||
| `STOCK_LIST` | 自选股代码,如 `600519,300750,002594` | ✅ |
|
||||
| `STOCK_LIST` | 自选股代码,如 `600519,hk00700,AAPL,TSLA` | ✅ |
|
||||
| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) 搜索 API(新闻搜索) | 推荐 |
|
||||
| `BOCHA_API_KEYS` | [博查搜索](https://open.bocha.cn/) Web Search API(中文搜索优化,支持AI摘要,多个key用逗号分隔) | 可选 |
|
||||
| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/) 备用搜索 | 可选 |
|
||||
@@ -218,7 +223,7 @@ daily_stock_analysis/
|
||||
- [x] 邮件通知(SMTP)
|
||||
- [x] 自定义 Webhook(支持钉钉、Discord、Slack、Bark 等)
|
||||
- [x] iOS/Android 推送(Pushover)
|
||||
|
||||
- [x] 钉钉机器人 (已支持命令交互 >> [相关配置](docs/bot/dingding-bot-config.md))
|
||||
### 🤖 AI 模型支持
|
||||
- [x] Google Gemini(主力,免费额度)
|
||||
- [x] OpenAI 兼容 API(支持 GPT-4/DeepSeek/通义千问/Claude/文心一言 等)
|
||||
@@ -238,7 +243,7 @@ daily_stock_analysis/
|
||||
- [x] 港股支持
|
||||
- [x] Web 管理界面 (简易版)
|
||||
- [ ] 历史分析回测
|
||||
- [ ] 美股支持
|
||||
- [x] 美股支持
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
|
||||
389
README_EN.md
Normal file
@@ -0,0 +1,389 @@
|
||||
# 📈 AI Stock Analysis System
|
||||
|
||||
[](https://github.com/ZhuLinsen/daily_stock_analysis/stargazers)
|
||||
[](https://github.com/ZhuLinsen/daily_stock_analysis/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://github.com/features/actions)
|
||||
|
||||
> 🤖 AI-powered stock analysis system for A-shares, Hong Kong stocks, and US stocks. Automatically analyzes your watchlist daily and sends "Decision Dashboard" to WeChat Work/Feishu/Telegram/Email
|
||||
|
||||
English | [简体中文](./README.md)
|
||||
|
||||

|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
### 🎯 Core Capabilities
|
||||
- **AI Decision Dashboard** - One-sentence core conclusion + precise entry/exit points + checklist
|
||||
- **Multi-dimensional Analysis** - Technical analysis + chip distribution + sentiment intelligence + real-time quotes
|
||||
- **Market Review** - Daily market overview, sector performance, northbound capital flow
|
||||
- **Multi-channel Push** - Support WeChat Work, Feishu, Telegram, Email (auto-detection)
|
||||
- **Zero-cost Deployment** - Free to run on GitHub Actions, no server required
|
||||
- **💰 Free Gemini API** - Google AI Studio provides free quota, sufficient for personal use
|
||||
- **🔄 Multi-model Support** - Supports OpenAI-compatible APIs (DeepSeek, Qwen, etc.) as backup
|
||||
|
||||
### 📊 Data Sources
|
||||
- **Market Data**: AkShare (free), Tushare, Baostock, YFinance
|
||||
- **News Search**: Tavily, SerpAPI, Bocha
|
||||
- **AI Analysis**:
|
||||
- Primary: Google Gemini (gemini-3-flash-preview) — [Get it free](https://aistudio.google.com/)
|
||||
- Backup: OpenAI-compatible API (DeepSeek, Qwen, Moonshot, etc.)
|
||||
|
||||
### 🌍 Supported Markets
|
||||
- **A-shares** - Shanghai & Shenzhen Stock Exchanges
|
||||
- **Hong Kong Stocks** - HKEX
|
||||
- **US Stocks** - NYSE, NASDAQ
|
||||
|
||||
### 🛡️ Built-in Trading Philosophy
|
||||
- ❌ **No Chasing Highs** - Auto mark "Danger" when price deviation > 5%
|
||||
- ✅ **Trend Trading** - Only trade in bull alignment (MA5 > MA10 > MA20)
|
||||
- 📍 **Precise Levels** - Entry price, stop loss, target price
|
||||
- 📋 **Checklist** - Every condition marked with ✅⚠️❌
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Option 1: GitHub Actions (Recommended, Zero Cost)
|
||||
|
||||
**No server needed, runs automatically every day!**
|
||||
|
||||
#### 1. Fork this repository (⭐ Star it too!)
|
||||
|
||||
Click the `Fork` button in the upper right corner
|
||||
|
||||
#### 2. Configure Secrets
|
||||
|
||||
Go to your forked repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret`
|
||||
|
||||
**AI Model Configuration (Choose one)**
|
||||
|
||||
| Secret Name | Description | Required |
|
||||
|------------|------|:----:|
|
||||
| `GEMINI_API_KEY` | Get free API key from [Google AI Studio](https://aistudio.google.com/) | ✅* |
|
||||
| `OPENAI_API_KEY` | OpenAI-compatible API Key (supports DeepSeek, Qwen, etc.) | Optional |
|
||||
| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com/v1`) | Optional |
|
||||
| `OPENAI_MODEL` | Model name (e.g., `deepseek-chat`) | Optional |
|
||||
|
||||
> *Note: Configure at least one of `GEMINI_API_KEY` or `OPENAI_API_KEY`
|
||||
|
||||
**Notification Channel Configuration (Can configure multiple, all will receive notifications)**
|
||||
|
||||
| Secret Name | Description | Required |
|
||||
|------------|------|:----:|
|
||||
| `WECHAT_WEBHOOK_URL` | WeChat Work Webhook URL | Optional |
|
||||
| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL | Optional |
|
||||
| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token (Get from @BotFather) | Optional |
|
||||
| `TELEGRAM_CHAT_ID` | Telegram Chat ID | Optional |
|
||||
| `EMAIL_SENDER` | Sender email (e.g., `xxx@qq.com`) | Optional |
|
||||
| `EMAIL_PASSWORD` | Email authorization code (not login password) | Optional |
|
||||
| `EMAIL_RECEIVERS` | Receiver emails (comma-separated, leave empty to send to yourself) | Optional |
|
||||
| `PUSHPLUS_TOKEN` | PushPlus Token ([Get it here](https://www.pushplus.plus), Chinese push service) | Optional |
|
||||
| `CUSTOM_WEBHOOK_URLS` | Custom Webhook URLs (supports DingTalk, etc., comma-separated) | Optional |
|
||||
|
||||
> *Note: Configure at least one channel, multiple channels will all receive notifications
|
||||
|
||||
**Stock List Configuration**
|
||||
|
||||
| Secret Name | Description | Required |
|
||||
|------------|------|:----:|
|
||||
| `STOCK_LIST` | Watchlist codes, e.g., `600519,AAPL,hk00700` | ✅ |
|
||||
| `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news) | Recommended |
|
||||
| `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/) Backup search | Optional |
|
||||
| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/) Token | Optional |
|
||||
|
||||
**Stock Code Format**
|
||||
|
||||
| Market | Format | Examples |
|
||||
|--------|--------|----------|
|
||||
| A-shares | 6-digit number | `600519`, `000001`, `300750` |
|
||||
| HK Stocks | hk + 5-digit number | `hk00700`, `hk09988` |
|
||||
| US Stocks | 1-5 uppercase letters | `AAPL`, `TSLA`, `GOOGL` |
|
||||
|
||||
#### 3. Enable Actions
|
||||
|
||||
Go to `Actions` tab → Click `I understand my workflows, go ahead and enable them`
|
||||
|
||||
#### 4. Manual Test
|
||||
|
||||
`Actions` → `Daily Stock Analysis` → `Run workflow` → Select mode → `Run workflow`
|
||||
|
||||
#### 5. Done!
|
||||
|
||||
The system will:
|
||||
- Run automatically at scheduled time (default: 18:00 Beijing Time)
|
||||
- Send analysis reports to all configured channels
|
||||
- Save reports locally
|
||||
|
||||
---
|
||||
|
||||
### Option 2: Local Deployment
|
||||
|
||||
#### 1. Clone Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ZhuLinsen/daily_stock_analysis.git
|
||||
cd daily_stock_analysis
|
||||
```
|
||||
|
||||
#### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
# Create virtual environment (recommended)
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
#### 3. Configure Environment Variables
|
||||
|
||||
```bash
|
||||
# Copy configuration template
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env file
|
||||
nano .env # or use any editor
|
||||
```
|
||||
|
||||
Configure the following:
|
||||
|
||||
```bash
|
||||
# AI Model (Choose one)
|
||||
GEMINI_API_KEY=your_gemini_api_key_here
|
||||
|
||||
# Stock Watchlist (Mixed markets supported)
|
||||
STOCK_LIST=600519,AAPL,hk00700
|
||||
|
||||
# Notification Channel (Choose at least one)
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token
|
||||
TELEGRAM_CHAT_ID=your_chat_id
|
||||
|
||||
# News Search (Optional)
|
||||
TAVILY_API_KEYS=your_tavily_key
|
||||
```
|
||||
|
||||
#### 4. Run
|
||||
|
||||
```bash
|
||||
# One-time analysis
|
||||
python main.py
|
||||
|
||||
# Scheduled mode (runs daily at 18:00)
|
||||
python main.py --schedule
|
||||
|
||||
# Analyze specific stocks
|
||||
python main.py --stocks AAPL,TSLA,GOOGL
|
||||
|
||||
# Market review only
|
||||
python main.py --market-review
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 Supported Notification Channels
|
||||
|
||||
### 1️⃣ Telegram (Recommended for international users)
|
||||
|
||||
1. Talk to [@BotFather](https://t.me/BotFather) → `/newbot` → Get Bot Token
|
||||
2. Get Chat ID: Send message to [@userinfobot](https://t.me/userinfobot)
|
||||
3. Configure:
|
||||
```bash
|
||||
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||
TELEGRAM_CHAT_ID=123456789
|
||||
```
|
||||
|
||||
### 2️⃣ Email (Universal)
|
||||
|
||||
1. Get authorization code (e.g., Gmail App Password)
|
||||
2. Configure:
|
||||
```bash
|
||||
EMAIL_SENDER=your_email@gmail.com
|
||||
EMAIL_PASSWORD=your_app_password
|
||||
EMAIL_RECEIVERS=receiver@example.com # Optional
|
||||
```
|
||||
|
||||
### 3️⃣ WeChat Work / Feishu (For Chinese users)
|
||||
|
||||
WeChat Work:
|
||||
```bash
|
||||
WECHAT_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
|
||||
```
|
||||
|
||||
Feishu:
|
||||
```bash
|
||||
FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/xxx
|
||||
```
|
||||
|
||||
### 4️⃣ PushPlus (Chinese mobile push)
|
||||
|
||||
```bash
|
||||
PUSHPLUS_TOKEN=your_token_here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Sample Output
|
||||
|
||||
### Decision Dashboard Format
|
||||
|
||||
```markdown
|
||||
# 🎯 2026-01-24 Decision Dashboard
|
||||
|
||||
> Total **3** stocks analyzed | 🟢Buy:1 🟡Hold:1 🔴Sell:1
|
||||
|
||||
## 📊 Analysis Summary
|
||||
|
||||
🟢 **AAPL(Apple Inc.)**: Buy | Score 85 | Strong Bullish
|
||||
🟡 **600519(Kweichow Moutai)**: Hold | Score 65 | Bullish
|
||||
🔴 **TSLA(Tesla)**: Sell | Score 35 | Bearish
|
||||
|
||||
---
|
||||
|
||||
## 🟢 AAPL (Apple Inc.)
|
||||
|
||||
### 📰 Key Information
|
||||
**💭 Sentiment**: Positive news on iPhone 16 sales
|
||||
**📊 Earnings**: Q1 2024 earnings beat expectations
|
||||
|
||||
### 📌 Core Conclusion
|
||||
|
||||
**🟢 Buy** | Strong Bullish
|
||||
|
||||
> **One-sentence Decision**: Strong technical setup with positive catalyst, ideal entry point
|
||||
|
||||
⏰ **Time Sensitivity**: Within this week
|
||||
|
||||
| Position | Action |
|
||||
|----------|--------|
|
||||
| 🆕 **No Position** | Buy at pullback |
|
||||
| 💼 **With Position** | Continue holding |
|
||||
|
||||
### 📊 Data Perspective
|
||||
|
||||
**MA Alignment**: MA5>MA10>MA20 | Bull Trend: ✅ Yes | Trend Strength: 85/100
|
||||
|
||||
| Price Metrics | Value |
|
||||
|--------------|-------|
|
||||
| Current | $185.50 |
|
||||
| MA5 | $183.20 |
|
||||
| MA10 | $180.50 |
|
||||
| MA20 | $177.80 |
|
||||
| Bias (MA5) | +1.26% ✅ Safe |
|
||||
| Support | $183.20 |
|
||||
| Resistance | $190.00 |
|
||||
|
||||
**Volume**: Ratio 1.8 (Moderate increase) | Turnover 2.3%
|
||||
💡 *Volume confirms bullish momentum*
|
||||
|
||||
### 🎯 Action Plan
|
||||
|
||||
**📍 Sniper Points**
|
||||
|
||||
| Level Type | Price |
|
||||
|-----------|-------|
|
||||
| 🎯 Ideal Entry | $183-184 |
|
||||
| 🔵 Secondary Entry | $180-181 |
|
||||
| 🛑 Stop Loss | $177 |
|
||||
| 🎊 Target | $195 |
|
||||
|
||||
**💰 Position Sizing**: 20-30% of portfolio
|
||||
- Entry Plan: Enter in 2-3 batches
|
||||
- Risk Control: Strict stop loss at $177
|
||||
|
||||
**✅ Checklist**
|
||||
|
||||
- ✅ Bull trend confirmed
|
||||
- ✅ Price near MA5 support
|
||||
- ✅ Volume confirms trend
|
||||
- ⚠️ Monitor market volatility
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Advanced Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# === Analysis Behavior ===
|
||||
ANALYSIS_DELAY=10 # Delay between analysis (seconds) to avoid API rate limit
|
||||
REPORT_TYPE=full # Report type: simple/full
|
||||
SINGLE_STOCK_NOTIFY=true # Push immediately after each stock analysis
|
||||
|
||||
# === Schedule ===
|
||||
SCHEDULE_ENABLED=true # Enable scheduled task
|
||||
SCHEDULE_TIME=18:00 # Daily run time (HH:MM, 24-hour format)
|
||||
MARKET_REVIEW_ENABLED=true # Enable market review
|
||||
|
||||
# === Data Source ===
|
||||
TUSHARE_TOKEN=your_token # Tushare Pro (priority data source if configured)
|
||||
|
||||
# === System ===
|
||||
MAX_WORKERS=3 # Concurrent threads (3 recommended to avoid blocking)
|
||||
DEBUG=false # Enable debug logging
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- [Complete Configuration Guide](docs/full-guide.md)
|
||||
- [Bot Command Reference](docs/bot-command.md)
|
||||
- [Feishu Bot Setup](docs/bot/feishu-bot-config.md)
|
||||
- [DingTalk Bot Setup](docs/bot/dingding-bot-config.md)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please:
|
||||
|
||||
1. Fork this repository
|
||||
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
This tool is for **informational and educational purposes only**. The analysis results are generated by AI and should not be considered as investment advice. Stock market investments carry risk, and you should:
|
||||
|
||||
- Do your own research before making investment decisions
|
||||
- Understand that past performance does not guarantee future results
|
||||
- Only invest money you can afford to lose
|
||||
- Consult with a licensed financial advisor for personalized advice
|
||||
|
||||
The developers of this tool are not liable for any financial losses resulting from the use of this software.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- [AkShare](https://github.com/akfamily/akshare) - Stock data source
|
||||
- [Google Gemini](https://ai.google.dev/) - AI analysis engine
|
||||
- [Tavily](https://tavily.com/) - News search API
|
||||
- All contributors who helped improve this project
|
||||
|
||||
---
|
||||
|
||||
## 📞 Contact
|
||||
|
||||
- GitHub Issues: [Report bugs or request features](https://github.com/ZhuLinsen/daily_stock_analysis/issues)
|
||||
- Discussions: [Join discussions](https://github.com/ZhuLinsen/daily_stock_analysis/discussions)
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ by AI enthusiasts | Star ⭐ this repo if you find it useful!**
|
||||
38
analyzer.py
@@ -31,6 +31,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# 股票名称映射(常见股票)
|
||||
STOCK_NAME_MAP = {
|
||||
# === A股 ===
|
||||
'600519': '贵州茅台',
|
||||
'000001': '平安银行',
|
||||
'300750': '宁德时代',
|
||||
@@ -46,6 +47,43 @@ STOCK_NAME_MAP = {
|
||||
'600900': '长江电力',
|
||||
'601166': '兴业银行',
|
||||
'600028': '中国石化',
|
||||
|
||||
# === 美股 ===
|
||||
'AAPL': '苹果',
|
||||
'TSLA': '特斯拉',
|
||||
'MSFT': '微软',
|
||||
'GOOGL': '谷歌A',
|
||||
'GOOG': '谷歌C',
|
||||
'AMZN': '亚马逊',
|
||||
'NVDA': '英伟达',
|
||||
'META': 'Meta',
|
||||
'AMD': 'AMD',
|
||||
'INTC': '英特尔',
|
||||
'BABA': '阿里巴巴',
|
||||
'PDD': '拼多多',
|
||||
'JD': '京东',
|
||||
'BIDU': '百度',
|
||||
'NIO': '蔚来',
|
||||
'XPEV': '小鹏汽车',
|
||||
'LI': '理想汽车',
|
||||
'COIN': 'Coinbase',
|
||||
'MSTR': 'MicroStrategy',
|
||||
|
||||
# === 港股 (5位数字) ===
|
||||
'00700': '腾讯控股',
|
||||
'03690': '美团',
|
||||
'01810': '小米集团',
|
||||
'09988': '阿里巴巴',
|
||||
'09618': '京东集团',
|
||||
'09888': '百度集团',
|
||||
'01024': '快手',
|
||||
'00981': '中芯国际',
|
||||
'02015': '理想汽车',
|
||||
'09868': '小鹏汽车',
|
||||
'00005': '汇丰控股',
|
||||
'01299': '友邦保险',
|
||||
'00941': '中国移动',
|
||||
'00883': '中国海洋石油',
|
||||
}
|
||||
|
||||
|
||||
|
||||
44
bot/__init__.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
机器人命令触发系统
|
||||
===================================
|
||||
|
||||
通过 @机器人 或发送命令触发股票分析等功能。
|
||||
支持飞书、钉钉、企业微信、Telegram 等多平台。
|
||||
|
||||
模块结构:
|
||||
- models.py: 统一的消息/响应模型
|
||||
- dispatcher.py: 命令分发器
|
||||
- commands/: 命令处理器
|
||||
- platforms/: 平台适配器
|
||||
- handler.py: Webhook 处理器
|
||||
|
||||
使用方式:
|
||||
1. 配置环境变量(各平台的 Token 等)
|
||||
2. 启动 WebUI 服务
|
||||
3. 在各平台配置 Webhook URL:
|
||||
- 飞书: http://your-server/bot/feishu
|
||||
- 钉钉: http://your-server/bot/dingtalk
|
||||
- 企业微信: http://your-server/bot/wecom
|
||||
- Telegram: http://your-server/bot/telegram
|
||||
|
||||
支持的命令:
|
||||
- /analyze <股票代码> - 分析指定股票
|
||||
- /market - 大盘复盘
|
||||
- /batch - 批量分析自选股
|
||||
- /help - 显示帮助
|
||||
- /status - 系统状态
|
||||
"""
|
||||
|
||||
from bot.models import BotMessage, BotResponse, ChatType, WebhookResponse
|
||||
from bot.dispatcher import CommandDispatcher, get_dispatcher
|
||||
|
||||
__all__ = [
|
||||
'BotMessage',
|
||||
'BotResponse',
|
||||
'ChatType',
|
||||
'WebhookResponse',
|
||||
'CommandDispatcher',
|
||||
'get_dispatcher',
|
||||
]
|
||||
34
bot/commands/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
命令处理器模块
|
||||
===================================
|
||||
|
||||
包含所有机器人命令的实现。
|
||||
"""
|
||||
|
||||
from bot.commands.base import BotCommand
|
||||
from bot.commands.help import HelpCommand
|
||||
from bot.commands.status import StatusCommand
|
||||
from bot.commands.analyze import AnalyzeCommand
|
||||
from bot.commands.market import MarketCommand
|
||||
from bot.commands.batch import BatchCommand
|
||||
|
||||
# 所有可用命令(用于自动注册)
|
||||
ALL_COMMANDS = [
|
||||
HelpCommand,
|
||||
StatusCommand,
|
||||
AnalyzeCommand,
|
||||
MarketCommand,
|
||||
BatchCommand,
|
||||
]
|
||||
|
||||
__all__ = [
|
||||
'BotCommand',
|
||||
'HelpCommand',
|
||||
'StatusCommand',
|
||||
'AnalyzeCommand',
|
||||
'MarketCommand',
|
||||
'BatchCommand',
|
||||
'ALL_COMMANDS',
|
||||
]
|
||||
106
bot/commands/analyze.py
Normal file
@@ -0,0 +1,106 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
股票分析命令
|
||||
===================================
|
||||
|
||||
分析指定股票,调用 AI 生成分析报告。
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from bot.commands.base import BotCommand
|
||||
from bot.models import BotMessage, BotResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnalyzeCommand(BotCommand):
|
||||
"""
|
||||
股票分析命令
|
||||
|
||||
分析指定股票代码,生成 AI 分析报告并推送。
|
||||
|
||||
用法:
|
||||
/analyze 600519 - 分析贵州茅台
|
||||
/analyze 600519 full - 分析并生成完整报告
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "analyze"
|
||||
|
||||
@property
|
||||
def aliases(self) -> List[str]:
|
||||
return ["a", "分析", "查"]
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "分析指定股票"
|
||||
|
||||
@property
|
||||
def usage(self) -> str:
|
||||
return "/analyze <股票代码> [full]"
|
||||
|
||||
def validate_args(self, args: List[str]) -> Optional[str]:
|
||||
"""验证参数"""
|
||||
if not args:
|
||||
return "请输入股票代码"
|
||||
|
||||
code = args[0].lower()
|
||||
|
||||
# 验证股票代码格式
|
||||
# A股:6位数字
|
||||
# 港股:hk + 5位数字
|
||||
# 美股:1-5个大写字母
|
||||
is_a_stock = re.match(r'^\d{6}$', code)
|
||||
is_hk_stock = re.match(r'^hk\d{5}$', code)
|
||||
is_us_stock = re.match(r'^[A-Z]{1,5}(\.[A-Z])?$', code.upper())
|
||||
|
||||
if not (is_a_stock or is_hk_stock or is_us_stock):
|
||||
return f"无效的股票代码: {code}(A股6位数字 / 港股hk+5位数字 / 美股1-5个字母)"
|
||||
|
||||
return None
|
||||
|
||||
def execute(self, message: BotMessage, args: List[str]) -> BotResponse:
|
||||
"""执行分析命令"""
|
||||
code = args[0].lower()
|
||||
|
||||
# 检查是否需要完整报告
|
||||
report_type = "full"
|
||||
# if len(args) > 1 and args[1].lower() in ["full", "完整", "详细"]:
|
||||
# report_type = "full"
|
||||
logger.info(f"[AnalyzeCommand] 分析股票: {code}, 报告类型: {report_type}")
|
||||
|
||||
try:
|
||||
# 调用分析服务
|
||||
from web.services import get_analysis_service
|
||||
from enums import ReportType
|
||||
|
||||
service = get_analysis_service()
|
||||
|
||||
# 提交异步分析任务
|
||||
result = service.submit_analysis(
|
||||
code=code,
|
||||
report_type=ReportType.from_str(report_type),
|
||||
source_message=message
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
task_id = result.get("task_id", "")
|
||||
return BotResponse.markdown_response(
|
||||
f"✅ **分析任务已提交**\n\n"
|
||||
f"• 股票代码: `{code}`\n"
|
||||
f"• 报告类型: {ReportType.from_str(report_type).display_name}\n"
|
||||
f"• 任务 ID: `{task_id[:20]}...`\n\n"
|
||||
f"分析完成后将自动推送结果。"
|
||||
)
|
||||
else:
|
||||
error = result.get("error", "未知错误")
|
||||
return BotResponse.error_response(f"提交分析任务失败: {error}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[AnalyzeCommand] 执行失败: {e}")
|
||||
return BotResponse.error_response(f"分析失败: {str(e)[:100]}")
|
||||
128
bot/commands/base.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
命令基类
|
||||
===================================
|
||||
|
||||
定义命令处理器的抽象基类,所有命令都必须继承此类。
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional
|
||||
|
||||
from bot.models import BotMessage, BotResponse
|
||||
|
||||
|
||||
class BotCommand(ABC):
|
||||
"""
|
||||
命令处理器抽象基类
|
||||
|
||||
所有命令都必须继承此类并实现抽象方法。
|
||||
|
||||
使用示例:
|
||||
class MyCommand(BotCommand):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "mycommand"
|
||||
|
||||
@property
|
||||
def aliases(self) -> List[str]:
|
||||
return ["mc", "我的命令"]
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "这是我的命令"
|
||||
|
||||
@property
|
||||
def usage(self) -> str:
|
||||
return "/mycommand [参数]"
|
||||
|
||||
def execute(self, message: BotMessage, args: List[str]) -> BotResponse:
|
||||
return BotResponse.text_response("命令执行成功")
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""
|
||||
命令名称(不含前缀)
|
||||
|
||||
例如 "analyze",用户输入 "/analyze" 触发
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def aliases(self) -> List[str]:
|
||||
"""
|
||||
命令别名列表
|
||||
|
||||
例如 ["a", "分析"],用户输入 "/a" 或 "分析" 也能触发
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def description(self) -> str:
|
||||
"""命令描述(用于帮助信息)"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def usage(self) -> str:
|
||||
"""
|
||||
使用说明(用于帮助信息)
|
||||
|
||||
例如 "/analyze <股票代码>"
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def hidden(self) -> bool:
|
||||
"""
|
||||
是否在帮助列表中隐藏
|
||||
|
||||
默认 False,设为 True 则不显示在 /help 列表中
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def admin_only(self) -> bool:
|
||||
"""
|
||||
是否仅管理员可用
|
||||
|
||||
默认 False,设为 True 则需要管理员权限
|
||||
"""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, message: BotMessage, args: List[str]) -> BotResponse:
|
||||
"""
|
||||
执行命令
|
||||
|
||||
Args:
|
||||
message: 原始消息对象
|
||||
args: 命令参数列表(已分割)
|
||||
|
||||
Returns:
|
||||
BotResponse 响应对象
|
||||
"""
|
||||
pass
|
||||
|
||||
def validate_args(self, args: List[str]) -> Optional[str]:
|
||||
"""
|
||||
验证参数
|
||||
|
||||
子类可重写此方法进行参数校验。
|
||||
|
||||
Args:
|
||||
args: 命令参数列表
|
||||
|
||||
Returns:
|
||||
如果参数有效返回 None,否则返回错误信息
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
"""获取帮助文本"""
|
||||
return f"**{self.name}** - {self.description}\n用法: `{self.usage}`"
|
||||
120
bot/commands/batch.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
批量分析命令
|
||||
===================================
|
||||
|
||||
批量分析自选股列表中的所有股票。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import List
|
||||
|
||||
from bot.commands.base import BotCommand
|
||||
from bot.models import BotMessage, BotResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BatchCommand(BotCommand):
|
||||
"""
|
||||
批量分析命令
|
||||
|
||||
批量分析配置中的自选股列表,生成汇总报告。
|
||||
|
||||
用法:
|
||||
/batch - 分析所有自选股
|
||||
/batch 3 - 只分析前3只
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "batch"
|
||||
|
||||
@property
|
||||
def aliases(self) -> List[str]:
|
||||
return ["b", "批量", "全部"]
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "批量分析自选股"
|
||||
|
||||
@property
|
||||
def usage(self) -> str:
|
||||
return "/batch [数量]"
|
||||
|
||||
@property
|
||||
def admin_only(self) -> bool:
|
||||
"""批量分析需要管理员权限(防止滥用)"""
|
||||
return False # 可以根据需要设为 True
|
||||
|
||||
def execute(self, message: BotMessage, args: List[str]) -> BotResponse:
|
||||
"""执行批量分析命令"""
|
||||
from config import get_config
|
||||
|
||||
config = get_config()
|
||||
config.refresh_stock_list()
|
||||
|
||||
stock_list = config.stock_list
|
||||
|
||||
if not stock_list:
|
||||
return BotResponse.error_response(
|
||||
"自选股列表为空,请先配置 STOCK_LIST"
|
||||
)
|
||||
|
||||
# 解析数量参数
|
||||
limit = None
|
||||
if args:
|
||||
try:
|
||||
limit = int(args[0])
|
||||
if limit <= 0:
|
||||
return BotResponse.error_response("数量必须大于0")
|
||||
except ValueError:
|
||||
return BotResponse.error_response(f"无效的数量: {args[0]}")
|
||||
|
||||
# 限制分析数量
|
||||
if limit:
|
||||
stock_list = stock_list[:limit]
|
||||
|
||||
logger.info(f"[BatchCommand] 开始批量分析 {len(stock_list)} 只股票")
|
||||
|
||||
# 在后台线程中执行分析
|
||||
thread = threading.Thread(
|
||||
target=self._run_batch_analysis,
|
||||
args=(stock_list, message),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return BotResponse.markdown_response(
|
||||
f"✅ **批量分析任务已启动**\n\n"
|
||||
f"• 分析数量: {len(stock_list)} 只\n"
|
||||
f"• 股票列表: {', '.join(stock_list[:5])}"
|
||||
f"{'...' if len(stock_list) > 5 else ''}\n\n"
|
||||
f"分析完成后将自动推送汇总报告。"
|
||||
)
|
||||
|
||||
def _run_batch_analysis(self, stock_list: List[str], message: BotMessage) -> None:
|
||||
"""后台执行批量分析"""
|
||||
try:
|
||||
from config import get_config
|
||||
from main import StockAnalysisPipeline
|
||||
|
||||
config = get_config()
|
||||
|
||||
# 创建分析管道
|
||||
pipeline = StockAnalysisPipeline(config=config)
|
||||
|
||||
# 执行分析(会自动推送汇总报告)
|
||||
results = pipeline.run(
|
||||
stock_codes=stock_list,
|
||||
dry_run=False,
|
||||
send_notification=True
|
||||
)
|
||||
|
||||
logger.info(f"[BatchCommand] 批量分析完成,成功 {len(results)} 只")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[BatchCommand] 批量分析失败: {e}")
|
||||
logger.exception(e)
|
||||
127
bot/commands/help.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
帮助命令
|
||||
===================================
|
||||
|
||||
显示可用命令列表和使用说明。
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from bot.commands.base import BotCommand
|
||||
from bot.models import BotMessage, BotResponse
|
||||
|
||||
|
||||
class HelpCommand(BotCommand):
|
||||
"""
|
||||
帮助命令
|
||||
|
||||
显示所有可用命令的列表和使用说明。
|
||||
也可以查看特定命令的详细帮助。
|
||||
|
||||
用法:
|
||||
/help - 显示所有命令
|
||||
/help analyze - 显示 analyze 命令的详细帮助
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "help"
|
||||
|
||||
@property
|
||||
def aliases(self) -> List[str]:
|
||||
return ["h", "帮助", "?"]
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "显示帮助信息"
|
||||
|
||||
@property
|
||||
def usage(self) -> str:
|
||||
return "/help [命令名]"
|
||||
|
||||
def execute(self, message: BotMessage, args: List[str]) -> BotResponse:
|
||||
"""执行帮助命令"""
|
||||
# 延迟导入避免循环依赖
|
||||
from bot.dispatcher import get_dispatcher
|
||||
|
||||
dispatcher = get_dispatcher()
|
||||
|
||||
# 如果指定了命令名,显示该命令的详细帮助
|
||||
if args:
|
||||
cmd_name = args[0]
|
||||
command = dispatcher.get_command(cmd_name)
|
||||
|
||||
if command is None:
|
||||
return BotResponse.error_response(f"未知命令: {cmd_name}")
|
||||
|
||||
# 构建详细帮助
|
||||
help_text = self._format_command_help(command, dispatcher.command_prefix)
|
||||
return BotResponse.markdown_response(help_text)
|
||||
|
||||
# 显示所有命令列表
|
||||
commands = dispatcher.list_commands(include_hidden=False)
|
||||
prefix = dispatcher.command_prefix
|
||||
|
||||
help_text = self._format_help_list(commands, prefix)
|
||||
return BotResponse.markdown_response(help_text)
|
||||
|
||||
def _format_help_list(self, commands: List[BotCommand], prefix: str) -> str:
|
||||
"""格式化命令列表"""
|
||||
lines = [
|
||||
"📚 **股票分析助手 - 命令帮助**",
|
||||
"",
|
||||
"可用命令:",
|
||||
"",
|
||||
]
|
||||
|
||||
for cmd in commands:
|
||||
# 命令名和别名
|
||||
aliases_str = ""
|
||||
if cmd.aliases:
|
||||
# 过滤掉中文别名,只显示英文别名
|
||||
en_aliases = [a for a in cmd.aliases if a.isascii()]
|
||||
if en_aliases:
|
||||
aliases_str = f" ({', '.join(prefix + a for a in en_aliases[:2])})"
|
||||
|
||||
lines.append(f"• {prefix}{cmd.name}{aliases_str} - {cmd.description}")
|
||||
lines.append("")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"---",
|
||||
f"💡 输入 {prefix}help <命令名> 查看详细用法",
|
||||
"",
|
||||
"**示例:**",
|
||||
"",
|
||||
f"• {prefix}analyze 301023 - 奕帆传动",
|
||||
"",
|
||||
f"• {prefix}market - 查看大盘复盘",
|
||||
"",
|
||||
f"• {prefix}batch - 批量分析自选股",
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_command_help(self, command: BotCommand, prefix: str) -> str:
|
||||
"""格式化单个命令的详细帮助"""
|
||||
lines = [
|
||||
f"📖 **{prefix}{command.name}** - {command.description}",
|
||||
"",
|
||||
f"**用法:** `{command.usage}`",
|
||||
"",
|
||||
]
|
||||
|
||||
# 别名
|
||||
if command.aliases:
|
||||
aliases = [f"`{prefix}{a}`" if a.isascii() else f"`{a}`" for a in command.aliases]
|
||||
lines.append(f"**别名:** {', '.join(aliases)}")
|
||||
lines.append("")
|
||||
|
||||
# 权限
|
||||
if command.admin_only:
|
||||
lines.append("⚠️ **需要管理员权限**")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
116
bot/commands/market.py
Normal file
@@ -0,0 +1,116 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
大盘复盘命令
|
||||
===================================
|
||||
|
||||
执行大盘复盘分析,生成市场概览报告。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import List
|
||||
|
||||
from bot.commands.base import BotCommand
|
||||
from bot.models import BotMessage, BotResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MarketCommand(BotCommand):
|
||||
"""
|
||||
大盘复盘命令
|
||||
|
||||
执行大盘复盘分析,包括:
|
||||
- 主要指数表现
|
||||
- 板块热点
|
||||
- 市场情绪
|
||||
- 后市展望
|
||||
|
||||
用法:
|
||||
/market - 执行大盘复盘
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "market"
|
||||
|
||||
@property
|
||||
def aliases(self) -> List[str]:
|
||||
return ["m", "大盘", "复盘", "行情"]
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "大盘复盘分析"
|
||||
|
||||
@property
|
||||
def usage(self) -> str:
|
||||
return "/market"
|
||||
|
||||
def execute(self, message: BotMessage, args: List[str]) -> BotResponse:
|
||||
"""执行大盘复盘命令"""
|
||||
logger.info(f"[MarketCommand] 开始大盘复盘分析")
|
||||
|
||||
# 在后台线程中执行复盘(避免阻塞)
|
||||
thread = threading.Thread(
|
||||
target=self._run_market_review,
|
||||
args=(message,),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return BotResponse.markdown_response(
|
||||
"✅ **大盘复盘任务已启动**\n\n"
|
||||
"正在分析:\n"
|
||||
"• 主要指数表现\n"
|
||||
"• 板块热点分析\n"
|
||||
"• 市场情绪判断\n"
|
||||
"• 后市展望\n\n"
|
||||
"分析完成后将自动推送结果。"
|
||||
)
|
||||
|
||||
def _run_market_review(self, message: BotMessage) -> None:
|
||||
"""后台执行大盘复盘"""
|
||||
try:
|
||||
from config import get_config
|
||||
from notification import NotificationService
|
||||
from market_analyzer import MarketAnalyzer
|
||||
from search_service import SearchService
|
||||
from analyzer import GeminiAnalyzer
|
||||
|
||||
config = get_config()
|
||||
notifier = NotificationService(source_message=message)
|
||||
|
||||
# 初始化搜索服务
|
||||
search_service = None
|
||||
if config.bocha_api_keys or config.tavily_api_keys or config.serpapi_keys:
|
||||
search_service = SearchService(
|
||||
bocha_keys=config.bocha_api_keys,
|
||||
tavily_keys=config.tavily_api_keys,
|
||||
serpapi_keys=config.serpapi_keys
|
||||
)
|
||||
|
||||
# 初始化 AI 分析器
|
||||
analyzer = None
|
||||
if config.gemini_api_key or config.openai_api_key:
|
||||
analyzer = GeminiAnalyzer()
|
||||
|
||||
# 执行复盘
|
||||
market_analyzer = MarketAnalyzer(
|
||||
search_service=search_service,
|
||||
analyzer=analyzer
|
||||
)
|
||||
|
||||
review_report = market_analyzer.run_daily_review()
|
||||
|
||||
if review_report:
|
||||
# 推送结果
|
||||
report_content = f"🎯 **大盘复盘**\n\n{review_report}"
|
||||
notifier.send(report_content)
|
||||
logger.info("[MarketCommand] 大盘复盘完成并已推送")
|
||||
else:
|
||||
logger.warning("[MarketCommand] 大盘复盘返回空结果")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MarketCommand] 大盘复盘失败: {e}")
|
||||
logger.exception(e)
|
||||
145
bot/commands/status.py
Normal file
@@ -0,0 +1,145 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
状态命令
|
||||
===================================
|
||||
|
||||
显示系统运行状态和配置信息。
|
||||
"""
|
||||
|
||||
import platform
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from bot.commands.base import BotCommand
|
||||
from bot.models import BotMessage, BotResponse
|
||||
|
||||
|
||||
class StatusCommand(BotCommand):
|
||||
"""
|
||||
状态命令
|
||||
|
||||
显示系统运行状态,包括:
|
||||
- 服务状态
|
||||
- 配置信息
|
||||
- 可用功能
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "status"
|
||||
|
||||
@property
|
||||
def aliases(self) -> List[str]:
|
||||
return ["s", "状态", "info"]
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "显示系统状态"
|
||||
|
||||
@property
|
||||
def usage(self) -> str:
|
||||
return "/status"
|
||||
|
||||
def execute(self, message: BotMessage, args: List[str]) -> BotResponse:
|
||||
"""执行状态命令"""
|
||||
from config import get_config
|
||||
|
||||
config = get_config()
|
||||
|
||||
# 收集状态信息
|
||||
status_info = self._collect_status(config)
|
||||
|
||||
# 格式化输出
|
||||
text = self._format_status(status_info, message.platform)
|
||||
|
||||
return BotResponse.markdown_response(text)
|
||||
|
||||
def _collect_status(self, config) -> dict:
|
||||
"""收集系统状态信息"""
|
||||
status = {
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
||||
"platform": platform.system(),
|
||||
"stock_count": len(config.stock_list),
|
||||
"stock_list": config.stock_list[:5], # 只显示前5个
|
||||
}
|
||||
|
||||
# AI 配置状态
|
||||
status["ai_gemini"] = bool(config.gemini_api_key)
|
||||
status["ai_openai"] = bool(config.openai_api_key)
|
||||
|
||||
# 搜索服务状态
|
||||
status["search_bocha"] = len(config.bocha_api_keys) > 0
|
||||
status["search_tavily"] = len(config.tavily_api_keys) > 0
|
||||
status["search_serpapi"] = len(config.serpapi_keys) > 0
|
||||
|
||||
# 通知渠道状态
|
||||
status["notify_wechat"] = bool(config.wechat_webhook_url)
|
||||
status["notify_feishu"] = bool(config.feishu_webhook_url)
|
||||
status["notify_telegram"] = bool(config.telegram_bot_token and config.telegram_chat_id)
|
||||
status["notify_email"] = bool(config.email_sender and config.email_password)
|
||||
|
||||
return status
|
||||
|
||||
def _format_status(self, status: dict, platform: str) -> str:
|
||||
"""格式化状态信息"""
|
||||
# 状态图标
|
||||
def icon(enabled: bool) -> str:
|
||||
return "✅" if enabled else "❌"
|
||||
|
||||
lines = [
|
||||
"📊 **股票分析助手 - 系统状态**",
|
||||
"",
|
||||
f"🕐 时间: {status['timestamp']}",
|
||||
f"🐍 Python: {status['python_version']}",
|
||||
f"💻 平台: {status['platform']}",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"**📈 自选股配置**",
|
||||
f"• 股票数量: {status['stock_count']} 只",
|
||||
]
|
||||
|
||||
if status['stock_list']:
|
||||
stocks_preview = ", ".join(status['stock_list'])
|
||||
if status['stock_count'] > 5:
|
||||
stocks_preview += f" ... 等 {status['stock_count']} 只"
|
||||
lines.append(f"• 股票列表: {stocks_preview}")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"**🤖 AI 分析服务**",
|
||||
f"• Gemini API: {icon(status['ai_gemini'])}",
|
||||
f"• OpenAI API: {icon(status['ai_openai'])}",
|
||||
"",
|
||||
"**🔍 搜索服务**",
|
||||
f"• Bocha: {icon(status['search_bocha'])}",
|
||||
f"• Tavily: {icon(status['search_tavily'])}",
|
||||
f"• SerpAPI: {icon(status['search_serpapi'])}",
|
||||
"",
|
||||
"**📢 通知渠道**",
|
||||
f"• 企业微信: {icon(status['notify_wechat'])}",
|
||||
f"• 飞书: {icon(status['notify_feishu'])}",
|
||||
f"• Telegram: {icon(status['notify_telegram'])}",
|
||||
f"• 邮件: {icon(status['notify_email'])}",
|
||||
])
|
||||
|
||||
# AI 服务总体状态
|
||||
ai_available = status['ai_gemini'] or status['ai_openai']
|
||||
if ai_available:
|
||||
lines.extend([
|
||||
"",
|
||||
"---",
|
||||
"✅ **系统就绪,可以开始分析!**",
|
||||
])
|
||||
else:
|
||||
lines.extend([
|
||||
"",
|
||||
"---",
|
||||
"⚠️ **AI 服务未配置,分析功能不可用**",
|
||||
"请配置 Gemini 或 OpenAI API Key",
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
342
bot/dispatcher.py
Normal file
@@ -0,0 +1,342 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
命令分发器
|
||||
===================================
|
||||
|
||||
负责解析命令、匹配处理器、分发执行。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Optional, Type, Callable
|
||||
|
||||
from bot.models import BotMessage, BotResponse
|
||||
from bot.commands.base import BotCommand
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""
|
||||
简单的频率限制器
|
||||
|
||||
基于滑动窗口算法,限制每个用户的请求频率。
|
||||
"""
|
||||
|
||||
def __init__(self, max_requests: int = 10, window_seconds: int = 60):
|
||||
"""
|
||||
Args:
|
||||
max_requests: 窗口内最大请求数
|
||||
window_seconds: 窗口时间(秒)
|
||||
"""
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self._requests: Dict[str, List[float]] = defaultdict(list)
|
||||
|
||||
def is_allowed(self, user_id: str) -> bool:
|
||||
"""
|
||||
检查用户是否允许请求
|
||||
|
||||
Args:
|
||||
user_id: 用户标识
|
||||
|
||||
Returns:
|
||||
是否允许
|
||||
"""
|
||||
now = time.time()
|
||||
window_start = now - self.window_seconds
|
||||
|
||||
# 清理过期记录
|
||||
self._requests[user_id] = [
|
||||
t for t in self._requests[user_id]
|
||||
if t > window_start
|
||||
]
|
||||
|
||||
# 检查是否超限
|
||||
if len(self._requests[user_id]) >= self.max_requests:
|
||||
return False
|
||||
|
||||
# 记录本次请求
|
||||
self._requests[user_id].append(now)
|
||||
return True
|
||||
|
||||
def get_remaining(self, user_id: str) -> int:
|
||||
"""获取剩余可用请求数"""
|
||||
now = time.time()
|
||||
window_start = now - self.window_seconds
|
||||
|
||||
# 清理过期记录
|
||||
self._requests[user_id] = [
|
||||
t for t in self._requests[user_id]
|
||||
if t > window_start
|
||||
]
|
||||
|
||||
return max(0, self.max_requests - len(self._requests[user_id]))
|
||||
|
||||
|
||||
class CommandDispatcher:
|
||||
"""
|
||||
命令分发器
|
||||
|
||||
职责:
|
||||
1. 注册和管理命令处理器
|
||||
2. 解析消息中的命令和参数
|
||||
3. 分发命令到对应处理器
|
||||
4. 处理未知命令和错误
|
||||
|
||||
使用示例:
|
||||
dispatcher = CommandDispatcher()
|
||||
dispatcher.register(AnalyzeCommand())
|
||||
dispatcher.register(HelpCommand())
|
||||
|
||||
response = dispatcher.dispatch(message)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
command_prefix: str = "/",
|
||||
rate_limit_requests: int = 10,
|
||||
rate_limit_window: int = 60,
|
||||
admin_users: Optional[List[str]] = None
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
command_prefix: 命令前缀,默认 "/"
|
||||
rate_limit_requests: 频率限制:窗口内最大请求数
|
||||
rate_limit_window: 频率限制:窗口时间(秒)
|
||||
admin_users: 管理员用户 ID 列表
|
||||
"""
|
||||
self.command_prefix = command_prefix
|
||||
self.admin_users = set(admin_users or [])
|
||||
|
||||
self._commands: Dict[str, BotCommand] = {}
|
||||
self._aliases: Dict[str, str] = {}
|
||||
self._rate_limiter = RateLimiter(rate_limit_requests, rate_limit_window)
|
||||
|
||||
# 回调函数:获取帮助命令的命令列表
|
||||
self._help_command_getter: Optional[Callable] = None
|
||||
|
||||
def register(self, command: BotCommand) -> None:
|
||||
"""
|
||||
注册命令
|
||||
|
||||
Args:
|
||||
command: 命令实例
|
||||
"""
|
||||
name = command.name.lower()
|
||||
|
||||
if name in self._commands:
|
||||
logger.warning(f"[Dispatcher] 命令 '{name}' 已存在,将被覆盖")
|
||||
|
||||
self._commands[name] = command
|
||||
logger.debug(f"[Dispatcher] 注册命令: {name}")
|
||||
|
||||
# 注册别名
|
||||
for alias in command.aliases:
|
||||
alias_lower = alias.lower()
|
||||
if alias_lower in self._aliases:
|
||||
logger.warning(f"[Dispatcher] 别名 '{alias_lower}' 已存在,将被覆盖")
|
||||
self._aliases[alias_lower] = name
|
||||
logger.debug(f"[Dispatcher] 注册别名: {alias_lower} -> {name}")
|
||||
|
||||
def register_class(self, command_class: Type[BotCommand]) -> None:
|
||||
"""
|
||||
注册命令类(自动实例化)
|
||||
|
||||
Args:
|
||||
command_class: 命令类
|
||||
"""
|
||||
self.register(command_class())
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""
|
||||
注销命令
|
||||
|
||||
Args:
|
||||
name: 命令名称
|
||||
|
||||
Returns:
|
||||
是否成功注销
|
||||
"""
|
||||
name = name.lower()
|
||||
|
||||
if name not in self._commands:
|
||||
return False
|
||||
|
||||
command = self._commands.pop(name)
|
||||
|
||||
# 移除别名
|
||||
for alias in command.aliases:
|
||||
self._aliases.pop(alias.lower(), None)
|
||||
|
||||
logger.debug(f"[Dispatcher] 注销命令: {name}")
|
||||
return True
|
||||
|
||||
def get_command(self, name: str) -> Optional[BotCommand]:
|
||||
"""
|
||||
获取命令
|
||||
|
||||
支持命令名和别名查询。
|
||||
|
||||
Args:
|
||||
name: 命令名或别名
|
||||
|
||||
Returns:
|
||||
命令实例,或 None
|
||||
"""
|
||||
name = name.lower()
|
||||
|
||||
# 先查命令名
|
||||
if name in self._commands:
|
||||
return self._commands[name]
|
||||
|
||||
# 再查别名
|
||||
if name in self._aliases:
|
||||
return self._commands.get(self._aliases[name])
|
||||
|
||||
return None
|
||||
|
||||
def list_commands(self, include_hidden: bool = False) -> List[BotCommand]:
|
||||
"""
|
||||
列出所有命令
|
||||
|
||||
Args:
|
||||
include_hidden: 是否包含隐藏命令
|
||||
|
||||
Returns:
|
||||
命令列表
|
||||
"""
|
||||
commands = list(self._commands.values())
|
||||
|
||||
if not include_hidden:
|
||||
commands = [c for c in commands if not c.hidden]
|
||||
|
||||
return sorted(commands, key=lambda c: c.name)
|
||||
|
||||
def is_admin(self, user_id: str) -> bool:
|
||||
"""检查用户是否是管理员"""
|
||||
return user_id in self.admin_users
|
||||
|
||||
def add_admin(self, user_id: str) -> None:
|
||||
"""添加管理员"""
|
||||
self.admin_users.add(user_id)
|
||||
|
||||
def remove_admin(self, user_id: str) -> None:
|
||||
"""移除管理员"""
|
||||
self.admin_users.discard(user_id)
|
||||
|
||||
def dispatch(self, message: BotMessage) -> BotResponse:
|
||||
"""
|
||||
分发消息到对应命令
|
||||
|
||||
Args:
|
||||
message: 消息对象
|
||||
|
||||
Returns:
|
||||
响应对象
|
||||
"""
|
||||
# 1. 检查频率限制
|
||||
if not self._rate_limiter.is_allowed(message.user_id):
|
||||
remaining_time = self._rate_limiter.window_seconds
|
||||
return BotResponse.error_response(
|
||||
f"请求过于频繁,请 {remaining_time} 秒后再试"
|
||||
)
|
||||
|
||||
# 2. 解析命令和参数
|
||||
cmd_name, args = message.get_command_and_args(self.command_prefix)
|
||||
|
||||
if cmd_name is None:
|
||||
# 不是命令,检查是否 @了机器人
|
||||
if message.mentioned:
|
||||
return BotResponse.text_response(
|
||||
"你好!我是股票分析助手。\n"
|
||||
f"发送 `{self.command_prefix}help` 查看可用命令。"
|
||||
)
|
||||
# 非命令消息,不处理
|
||||
return BotResponse.text_response("")
|
||||
|
||||
logger.info(f"[Dispatcher] 收到命令: {cmd_name}, 参数: {args}, 用户: {message.user_name}")
|
||||
|
||||
# 3. 查找命令处理器
|
||||
command = self.get_command(cmd_name)
|
||||
|
||||
if command is None:
|
||||
return BotResponse.error_response(
|
||||
f"未知命令: {cmd_name}\n"
|
||||
f"发送 `{self.command_prefix}help` 查看可用命令。"
|
||||
)
|
||||
|
||||
# 4. 检查权限
|
||||
if command.admin_only and not self.is_admin(message.user_id):
|
||||
return BotResponse.error_response("此命令需要管理员权限")
|
||||
|
||||
# 5. 验证参数
|
||||
error_msg = command.validate_args(args)
|
||||
if error_msg:
|
||||
return BotResponse.error_response(
|
||||
f"{error_msg}\n用法: `{command.usage}`"
|
||||
)
|
||||
|
||||
# 6. 执行命令
|
||||
try:
|
||||
response = command.execute(message, args)
|
||||
logger.info(f"[Dispatcher] 命令 {cmd_name} 执行成功")
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"[Dispatcher] 命令 {cmd_name} 执行失败: {e}")
|
||||
logger.exception(e)
|
||||
return BotResponse.error_response(f"命令执行失败: {str(e)[:100]}")
|
||||
|
||||
def set_help_command_getter(self, getter: Callable) -> None:
|
||||
"""
|
||||
设置帮助命令的命令列表获取器
|
||||
|
||||
用于让 HelpCommand 获取命令列表。
|
||||
|
||||
Args:
|
||||
getter: 回调函数,返回命令列表
|
||||
"""
|
||||
self._help_command_getter = getter
|
||||
|
||||
|
||||
# 全局分发器实例
|
||||
_dispatcher: Optional[CommandDispatcher] = None
|
||||
|
||||
|
||||
def get_dispatcher() -> CommandDispatcher:
|
||||
"""
|
||||
获取全局分发器实例
|
||||
|
||||
使用单例模式,首次调用时自动初始化并注册所有命令。
|
||||
"""
|
||||
global _dispatcher
|
||||
|
||||
if _dispatcher is None:
|
||||
from config import get_config
|
||||
|
||||
config = get_config()
|
||||
|
||||
# 创建分发器
|
||||
_dispatcher = CommandDispatcher(
|
||||
command_prefix=getattr(config, 'bot_command_prefix', '/'),
|
||||
rate_limit_requests=getattr(config, 'bot_rate_limit_requests', 10),
|
||||
rate_limit_window=getattr(config, 'bot_rate_limit_window', 60),
|
||||
admin_users=getattr(config, 'bot_admin_users', []),
|
||||
)
|
||||
|
||||
# 自动注册所有命令
|
||||
from bot.commands import ALL_COMMANDS
|
||||
for command_class in ALL_COMMANDS:
|
||||
_dispatcher.register_class(command_class)
|
||||
|
||||
logger.info(f"[Dispatcher] 初始化完成,已注册 {len(_dispatcher._commands)} 个命令")
|
||||
|
||||
return _dispatcher
|
||||
|
||||
|
||||
def reset_dispatcher() -> None:
|
||||
"""重置全局分发器(主要用于测试)"""
|
||||
global _dispatcher
|
||||
_dispatcher = None
|
||||
138
bot/handler.py
Normal file
@@ -0,0 +1,138 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
Bot Webhook 处理器
|
||||
===================================
|
||||
|
||||
处理各平台的 Webhook 回调,分发到命令处理器。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, TYPE_CHECKING
|
||||
|
||||
from bot.models import WebhookResponse
|
||||
from bot.dispatcher import get_dispatcher
|
||||
from bot.platforms import ALL_PLATFORMS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bot.platforms.base import BotPlatform
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 平台实例缓存
|
||||
_platform_instances: Dict[str, 'BotPlatform'] = {}
|
||||
|
||||
|
||||
def get_platform(platform_name: str) -> Optional['BotPlatform']:
|
||||
"""
|
||||
获取平台适配器实例
|
||||
|
||||
使用缓存避免重复创建。
|
||||
|
||||
Args:
|
||||
platform_name: 平台名称
|
||||
|
||||
Returns:
|
||||
平台适配器实例,或 None
|
||||
"""
|
||||
if platform_name not in _platform_instances:
|
||||
platform_class = ALL_PLATFORMS.get(platform_name)
|
||||
if platform_class:
|
||||
_platform_instances[platform_name] = platform_class()
|
||||
else:
|
||||
logger.warning(f"[BotHandler] 未知平台: {platform_name}")
|
||||
return None
|
||||
|
||||
return _platform_instances[platform_name]
|
||||
|
||||
|
||||
def handle_webhook(
|
||||
platform_name: str,
|
||||
headers: Dict[str, str],
|
||||
body: bytes,
|
||||
query_params: Optional[Dict[str, list]] = None
|
||||
) -> WebhookResponse:
|
||||
"""
|
||||
处理 Webhook 请求
|
||||
|
||||
这是所有平台 Webhook 的统一入口。
|
||||
|
||||
Args:
|
||||
platform_name: 平台名称 (feishu, dingtalk, wecom, telegram)
|
||||
headers: HTTP 请求头
|
||||
body: 请求体原始字节
|
||||
query_params: URL 查询参数(用于某些平台的验证)
|
||||
|
||||
Returns:
|
||||
WebhookResponse 响应对象
|
||||
"""
|
||||
logger.info(f"[BotHandler] 收到 {platform_name} Webhook 请求")
|
||||
|
||||
# 检查机器人功能是否启用
|
||||
from config import get_config
|
||||
config = get_config()
|
||||
|
||||
if not getattr(config, 'bot_enabled', True):
|
||||
logger.info("[BotHandler] 机器人功能未启用")
|
||||
return WebhookResponse.success()
|
||||
|
||||
# 获取平台适配器
|
||||
platform = get_platform(platform_name)
|
||||
if not platform:
|
||||
return WebhookResponse.error(f"Unknown platform: {platform_name}", 400)
|
||||
|
||||
# 解析 JSON 数据
|
||||
try:
|
||||
data = json.loads(body.decode('utf-8')) if body else {}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"[BotHandler] JSON 解析失败: {e}")
|
||||
return WebhookResponse.error("Invalid JSON", 400)
|
||||
|
||||
logger.debug(f"[BotHandler] 请求数据: {json.dumps(data, ensure_ascii=False)[:500]}")
|
||||
|
||||
# 处理 Webhook
|
||||
message, challenge_response = platform.handle_webhook(headers, body, data)
|
||||
|
||||
# 如果是验证请求,直接返回验证响应
|
||||
if challenge_response:
|
||||
logger.info(f"[BotHandler] 返回验证响应")
|
||||
return challenge_response
|
||||
|
||||
# 如果没有消息需要处理,返回空响应
|
||||
if not message:
|
||||
logger.debug("[BotHandler] 无需处理的消息")
|
||||
return WebhookResponse.success()
|
||||
|
||||
logger.info(f"[BotHandler] 解析到消息: user={message.user_name}, content={message.content[:50]}")
|
||||
|
||||
# 分发到命令处理器
|
||||
dispatcher = get_dispatcher()
|
||||
response = dispatcher.dispatch(message)
|
||||
|
||||
# 格式化响应
|
||||
if response.text:
|
||||
webhook_response = platform.format_response(response, message)
|
||||
return webhook_response
|
||||
|
||||
return WebhookResponse.success()
|
||||
|
||||
|
||||
def handle_feishu_webhook(headers: Dict[str, str], body: bytes) -> WebhookResponse:
|
||||
"""处理飞书 Webhook"""
|
||||
return handle_webhook('feishu', headers, body)
|
||||
|
||||
|
||||
def handle_dingtalk_webhook(headers: Dict[str, str], body: bytes) -> WebhookResponse:
|
||||
"""处理钉钉 Webhook"""
|
||||
return handle_webhook('dingtalk', headers, body)
|
||||
|
||||
|
||||
def handle_wecom_webhook(headers: Dict[str, str], body: bytes) -> WebhookResponse:
|
||||
"""处理企业微信 Webhook"""
|
||||
return handle_webhook('wecom', headers, body)
|
||||
|
||||
|
||||
def handle_telegram_webhook(headers: Dict[str, str], body: bytes) -> WebhookResponse:
|
||||
"""处理 Telegram Webhook"""
|
||||
return handle_webhook('telegram', headers, body)
|
||||
179
bot/models.py
Normal file
@@ -0,0 +1,179 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
机器人消息模型
|
||||
===================================
|
||||
|
||||
定义统一的消息和响应模型,屏蔽各平台差异。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
|
||||
class ChatType(str, Enum):
|
||||
"""会话类型"""
|
||||
GROUP = "group" # 群聊
|
||||
PRIVATE = "private" # 私聊
|
||||
UNKNOWN = "unknown" # 未知
|
||||
|
||||
|
||||
class Platform(str, Enum):
|
||||
"""平台类型"""
|
||||
FEISHU = "feishu" # 飞书
|
||||
DINGTALK = "dingtalk" # 钉钉
|
||||
WECOM = "wecom" # 企业微信
|
||||
TELEGRAM = "telegram" # Telegram
|
||||
UNKNOWN = "unknown" # 未知
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotMessage:
|
||||
"""
|
||||
统一的机器人消息模型
|
||||
|
||||
将各平台的消息格式统一为此模型,便于命令处理器处理。
|
||||
|
||||
Attributes:
|
||||
platform: 平台标识
|
||||
message_id: 消息 ID(平台原始 ID)
|
||||
user_id: 发送者 ID
|
||||
user_name: 发送者名称
|
||||
chat_id: 会话 ID(群聊 ID 或私聊 ID)
|
||||
chat_type: 会话类型
|
||||
content: 消息文本内容(已去除 @机器人 部分)
|
||||
raw_content: 原始消息内容
|
||||
mentioned: 是否 @了机器人
|
||||
mentions: @的用户列表
|
||||
timestamp: 消息时间戳
|
||||
raw_data: 原始请求数据(平台特定,用于调试)
|
||||
"""
|
||||
platform: str
|
||||
message_id: str
|
||||
user_id: str
|
||||
user_name: str
|
||||
chat_id: str
|
||||
chat_type: ChatType
|
||||
content: str
|
||||
raw_content: str = ""
|
||||
mentioned: bool = False
|
||||
mentions: List[str] = field(default_factory=list)
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
raw_data: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def get_command_and_args(self, prefix: str = "/") -> tuple:
|
||||
"""
|
||||
解析命令和参数
|
||||
|
||||
Args:
|
||||
prefix: 命令前缀,默认 "/"
|
||||
|
||||
Returns:
|
||||
(command, args) 元组,如 ("analyze", ["600519"])
|
||||
如果不是命令,返回 (None, [])
|
||||
"""
|
||||
text = self.content.strip()
|
||||
|
||||
# 检查是否以命令前缀开头
|
||||
if not text.startswith(prefix):
|
||||
# 尝试匹配中文命令(无前缀)
|
||||
chinese_commands = {
|
||||
'分析': 'analyze',
|
||||
'大盘': 'market',
|
||||
'批量': 'batch',
|
||||
'帮助': 'help',
|
||||
'状态': 'status',
|
||||
}
|
||||
for cn_cmd, en_cmd in chinese_commands.items():
|
||||
if text.startswith(cn_cmd):
|
||||
args = text[len(cn_cmd):].strip().split()
|
||||
return en_cmd, args
|
||||
return None, []
|
||||
|
||||
# 去除前缀
|
||||
text = text[len(prefix):]
|
||||
|
||||
# 分割命令和参数
|
||||
parts = text.split()
|
||||
if not parts:
|
||||
return None, []
|
||||
|
||||
command = parts[0].lower()
|
||||
args = parts[1:] if len(parts) > 1 else []
|
||||
|
||||
return command, args
|
||||
|
||||
def is_command(self, prefix: str = "/") -> bool:
|
||||
"""检查消息是否是命令"""
|
||||
cmd, _ = self.get_command_and_args(prefix)
|
||||
return cmd is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotResponse:
|
||||
"""
|
||||
统一的机器人响应模型
|
||||
|
||||
命令处理器返回此模型,由平台适配器转换为平台特定格式。
|
||||
|
||||
Attributes:
|
||||
text: 回复文本
|
||||
markdown: 是否为 Markdown 格式
|
||||
at_user: 是否 @发送者
|
||||
reply_to_message: 是否回复原消息
|
||||
extra: 额外数据(平台特定)
|
||||
"""
|
||||
text: str
|
||||
markdown: bool = False
|
||||
at_user: bool = True
|
||||
reply_to_message: bool = True
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def text_response(cls, text: str, at_user: bool = True) -> 'BotResponse':
|
||||
"""创建纯文本响应"""
|
||||
return cls(text=text, markdown=False, at_user=at_user)
|
||||
|
||||
@classmethod
|
||||
def markdown_response(cls, text: str, at_user: bool = True) -> 'BotResponse':
|
||||
"""创建 Markdown 响应"""
|
||||
return cls(text=text, markdown=True, at_user=at_user)
|
||||
|
||||
@classmethod
|
||||
def error_response(cls, message: str) -> 'BotResponse':
|
||||
"""创建错误响应"""
|
||||
return cls(text=f"❌ 错误:{message}", markdown=False, at_user=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebhookResponse:
|
||||
"""
|
||||
Webhook 响应模型
|
||||
|
||||
平台适配器返回此模型,包含 HTTP 响应内容。
|
||||
|
||||
Attributes:
|
||||
status_code: HTTP 状态码
|
||||
body: 响应体(字典,将被 JSON 序列化)
|
||||
headers: 额外的响应头
|
||||
"""
|
||||
status_code: int = 200
|
||||
body: Dict[str, Any] = field(default_factory=dict)
|
||||
headers: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def success(cls, body: Optional[Dict] = None) -> 'WebhookResponse':
|
||||
"""创建成功响应"""
|
||||
return cls(status_code=200, body=body or {})
|
||||
|
||||
@classmethod
|
||||
def challenge(cls, challenge: str) -> 'WebhookResponse':
|
||||
"""创建验证响应(用于平台 URL 验证)"""
|
||||
return cls(status_code=200, body={"challenge": challenge})
|
||||
|
||||
@classmethod
|
||||
def error(cls, message: str, status_code: int = 400) -> 'WebhookResponse':
|
||||
"""创建错误响应"""
|
||||
return cls(status_code=status_code, body={"error": message})
|
||||
73
bot/platforms/__init__.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
平台适配器模块
|
||||
===================================
|
||||
|
||||
包含各平台的 Webhook 处理和消息解析逻辑。
|
||||
|
||||
支持两种接入模式:
|
||||
1. Webhook 模式:需要公网 IP,配置回调 URL
|
||||
2. Stream 模式:无需公网 IP,通过 WebSocket 长连接(钉钉、飞书支持)
|
||||
"""
|
||||
|
||||
from bot.platforms.base import BotPlatform
|
||||
from bot.platforms.dingtalk import DingtalkPlatform
|
||||
|
||||
# 所有可用平台(Webhook 模式)
|
||||
ALL_PLATFORMS = {
|
||||
'dingtalk': DingtalkPlatform,
|
||||
}
|
||||
|
||||
# 钉钉 Stream 模式(可选)
|
||||
try:
|
||||
from bot.platforms.dingtalk_stream import (
|
||||
DingtalkStreamClient,
|
||||
DingtalkStreamHandler,
|
||||
get_dingtalk_stream_client,
|
||||
start_dingtalk_stream_background,
|
||||
DINGTALK_STREAM_AVAILABLE,
|
||||
)
|
||||
except ImportError:
|
||||
DINGTALK_STREAM_AVAILABLE = False
|
||||
DingtalkStreamClient = None
|
||||
DingtalkStreamHandler = None
|
||||
get_dingtalk_stream_client = lambda: None
|
||||
start_dingtalk_stream_background = lambda: False
|
||||
|
||||
# 飞书 Stream 模式(可选)
|
||||
try:
|
||||
from bot.platforms.feishu_stream import (
|
||||
FeishuStreamClient,
|
||||
FeishuStreamHandler,
|
||||
FeishuReplyClient,
|
||||
get_feishu_stream_client,
|
||||
start_feishu_stream_background,
|
||||
FEISHU_SDK_AVAILABLE,
|
||||
)
|
||||
except ImportError:
|
||||
FEISHU_SDK_AVAILABLE = False
|
||||
FeishuStreamClient = None
|
||||
FeishuStreamHandler = None
|
||||
FeishuReplyClient = None
|
||||
get_feishu_stream_client = lambda: None
|
||||
start_feishu_stream_background = lambda: False
|
||||
|
||||
__all__ = [
|
||||
'BotPlatform',
|
||||
'DingtalkPlatform',
|
||||
'ALL_PLATFORMS',
|
||||
# 钉钉 Stream 模式
|
||||
'DingtalkStreamClient',
|
||||
'DingtalkStreamHandler',
|
||||
'get_dingtalk_stream_client',
|
||||
'start_dingtalk_stream_background',
|
||||
'DINGTALK_STREAM_AVAILABLE',
|
||||
# 飞书 Stream 模式
|
||||
'FeishuStreamClient',
|
||||
'FeishuStreamHandler',
|
||||
'FeishuReplyClient',
|
||||
'get_feishu_stream_client',
|
||||
'start_feishu_stream_background',
|
||||
'FEISHU_SDK_AVAILABLE',
|
||||
]
|
||||
153
bot/platforms/base.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
平台适配器基类
|
||||
===================================
|
||||
|
||||
定义平台适配器的抽象基类,各平台必须继承此类。
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
|
||||
from bot.models import BotMessage, BotResponse, WebhookResponse
|
||||
|
||||
|
||||
class BotPlatform(ABC):
|
||||
"""
|
||||
平台适配器抽象基类
|
||||
|
||||
负责:
|
||||
1. 验证 Webhook 请求签名
|
||||
2. 解析平台消息为统一格式
|
||||
3. 将响应转换为平台格式
|
||||
|
||||
使用示例:
|
||||
class MyPlatform(BotPlatform):
|
||||
@property
|
||||
def platform_name(self) -> str:
|
||||
return "myplatform"
|
||||
|
||||
def verify_request(self, headers, body) -> bool:
|
||||
# 验证签名逻辑
|
||||
return True
|
||||
|
||||
def parse_message(self, data) -> Optional[BotMessage]:
|
||||
# 解析消息逻辑
|
||||
return BotMessage(...)
|
||||
|
||||
def format_response(self, response, message) -> WebhookResponse:
|
||||
# 格式化响应逻辑
|
||||
return WebhookResponse.success({"text": response.text})
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def platform_name(self) -> str:
|
||||
"""
|
||||
平台标识名称
|
||||
|
||||
用于路由匹配和日志标识,如 "feishu", "dingtalk"
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def verify_request(self, headers: Dict[str, str], body: bytes) -> bool:
|
||||
"""
|
||||
验证请求签名
|
||||
|
||||
各平台有不同的签名验证机制,需要单独实现。
|
||||
|
||||
Args:
|
||||
headers: HTTP 请求头
|
||||
body: 请求体原始字节
|
||||
|
||||
Returns:
|
||||
签名是否有效
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def parse_message(self, data: Dict[str, Any]) -> Optional[BotMessage]:
|
||||
"""
|
||||
解析平台消息为统一格式
|
||||
|
||||
将平台特定的消息格式转换为 BotMessage。
|
||||
如果不是需要处理的消息类型(如事件回调),返回 None。
|
||||
|
||||
Args:
|
||||
data: 解析后的 JSON 数据
|
||||
|
||||
Returns:
|
||||
BotMessage 对象,或 None(不需要处理)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def format_response(
|
||||
self,
|
||||
response: BotResponse,
|
||||
message: BotMessage
|
||||
) -> WebhookResponse:
|
||||
"""
|
||||
将统一响应转换为平台格式
|
||||
|
||||
Args:
|
||||
response: 统一响应对象
|
||||
message: 原始消息对象(用于获取回复目标等信息)
|
||||
|
||||
Returns:
|
||||
WebhookResponse 对象
|
||||
"""
|
||||
pass
|
||||
|
||||
def handle_challenge(self, data: Dict[str, Any]) -> Optional[WebhookResponse]:
|
||||
"""
|
||||
处理平台验证请求
|
||||
|
||||
部分平台在配置 Webhook 时会发送验证请求,需要返回特定响应。
|
||||
子类可重写此方法。
|
||||
|
||||
Args:
|
||||
data: 请求数据
|
||||
|
||||
Returns:
|
||||
验证响应,或 None(不是验证请求)
|
||||
"""
|
||||
return None
|
||||
|
||||
def handle_webhook(
|
||||
self,
|
||||
headers: Dict[str, str],
|
||||
body: bytes,
|
||||
data: Dict[str, Any]
|
||||
) -> Tuple[Optional[BotMessage], Optional[WebhookResponse]]:
|
||||
"""
|
||||
处理 Webhook 请求
|
||||
|
||||
这是主入口方法,协调验证、解析等流程。
|
||||
|
||||
Args:
|
||||
headers: HTTP 请求头
|
||||
body: 请求体原始字节
|
||||
data: 解析后的 JSON 数据
|
||||
|
||||
Returns:
|
||||
(BotMessage, WebhookResponse) 元组
|
||||
- 如果是验证请求:(None, challenge_response)
|
||||
- 如果是普通消息:(message, None) - 响应将在命令处理后生成
|
||||
- 如果验证失败或无需处理:(None, error_response 或 None)
|
||||
"""
|
||||
# 1. 检查是否是验证请求
|
||||
challenge_response = self.handle_challenge(data)
|
||||
if challenge_response:
|
||||
return None, challenge_response
|
||||
|
||||
# 2. 验证请求签名
|
||||
if not self.verify_request(headers, body):
|
||||
return None, WebhookResponse.error("Invalid signature", 403)
|
||||
|
||||
# 3. 解析消息
|
||||
message = self.parse_message(data)
|
||||
|
||||
return message, None
|
||||
314
bot/platforms/dingtalk.py
Normal file
@@ -0,0 +1,314 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
钉钉平台适配器
|
||||
===================================
|
||||
|
||||
处理钉钉机器人的 Webhook 回调。
|
||||
|
||||
钉钉机器人文档:
|
||||
https://open.dingtalk.com/document/robots/robot-overview
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from bot.platforms.base import BotPlatform
|
||||
from bot.models import BotMessage, BotResponse, WebhookResponse, ChatType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DingtalkPlatform(BotPlatform):
|
||||
"""
|
||||
钉钉平台适配器
|
||||
|
||||
支持:
|
||||
- 企业内部机器人回调
|
||||
- 群机器人 Outgoing 回调
|
||||
- 消息签名验证
|
||||
|
||||
配置要求:
|
||||
- DINGTALK_APP_KEY: 应用 AppKey
|
||||
- DINGTALK_APP_SECRET: 应用 AppSecret(用于签名验证)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
from config import get_config
|
||||
config = get_config()
|
||||
|
||||
self._app_key = getattr(config, 'dingtalk_app_key', None)
|
||||
self._app_secret = getattr(config, 'dingtalk_app_secret', None)
|
||||
|
||||
@property
|
||||
def platform_name(self) -> str:
|
||||
return "dingtalk"
|
||||
|
||||
def verify_request(self, headers: Dict[str, str], body: bytes) -> bool:
|
||||
"""
|
||||
验证钉钉请求签名
|
||||
|
||||
钉钉签名算法:
|
||||
1. 获取 timestamp 和 sign
|
||||
2. 计算:base64(hmac_sha256(timestamp + "\n" + app_secret))
|
||||
3. 比对签名
|
||||
"""
|
||||
if not self._app_secret:
|
||||
logger.warning("[DingTalk] 未配置 app_secret,跳过签名验证")
|
||||
return True
|
||||
|
||||
timestamp = headers.get('timestamp', '')
|
||||
sign = headers.get('sign', '')
|
||||
|
||||
if not timestamp or not sign:
|
||||
logger.warning("[DingTalk] 缺少签名参数")
|
||||
return True # 可能是不需要签名的请求
|
||||
|
||||
# 验证时间戳(1小时内有效)
|
||||
try:
|
||||
request_time = int(timestamp)
|
||||
current_time = int(time.time() * 1000)
|
||||
if abs(current_time - request_time) > 3600 * 1000:
|
||||
logger.warning("[DingTalk] 时间戳过期")
|
||||
return False
|
||||
except ValueError:
|
||||
logger.warning("[DingTalk] 无效的时间戳")
|
||||
return False
|
||||
|
||||
# 计算签名
|
||||
string_to_sign = f"{timestamp}\n{self._app_secret}"
|
||||
hmac_code = hmac.new(
|
||||
self._app_secret.encode('utf-8'),
|
||||
string_to_sign.encode('utf-8'),
|
||||
digestmod=hashlib.sha256
|
||||
).digest()
|
||||
expected_sign = base64.b64encode(hmac_code).decode('utf-8')
|
||||
|
||||
if sign != expected_sign:
|
||||
logger.warning(f"[DingTalk] 签名验证失败")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def handle_challenge(self, data: Dict[str, Any]) -> Optional[WebhookResponse]:
|
||||
"""钉钉不需要 URL 验证"""
|
||||
return None
|
||||
|
||||
def parse_message(self, data: Dict[str, Any]) -> Optional[BotMessage]:
|
||||
"""
|
||||
解析钉钉消息
|
||||
|
||||
钉钉 Outgoing 机器人消息格式:
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "@机器人 /analyze 600519"
|
||||
},
|
||||
"msgId": "xxx",
|
||||
"createAt": "1234567890",
|
||||
"conversationType": "2", # 1=单聊, 2=群聊
|
||||
"conversationId": "xxx",
|
||||
"conversationTitle": "群名",
|
||||
"senderId": "xxx",
|
||||
"senderNick": "用户昵称",
|
||||
"senderCorpId": "xxx",
|
||||
"senderStaffId": "xxx",
|
||||
"chatbotUserId": "xxx",
|
||||
"atUsers": [{"dingtalkId": "xxx", "staffId": "xxx"}],
|
||||
"isAdmin": false,
|
||||
"sessionWebhook": "https://oapi.dingtalk.com/robot/sendBySession?session=xxx",
|
||||
"sessionWebhookExpiredTime": 1234567890
|
||||
}
|
||||
"""
|
||||
# 检查消息类型
|
||||
msg_type = data.get('msgtype', '')
|
||||
if msg_type != 'text':
|
||||
logger.debug(f"[DingTalk] 忽略非文本消息: {msg_type}")
|
||||
return None
|
||||
|
||||
# 获取消息内容
|
||||
text_content = data.get('text', {})
|
||||
raw_content = text_content.get('content', '')
|
||||
|
||||
# 提取命令(去除 @机器人)
|
||||
content = self._extract_command(raw_content)
|
||||
|
||||
# 检查是否 @了机器人
|
||||
at_users = data.get('atUsers', [])
|
||||
mentioned = len(at_users) > 0
|
||||
|
||||
# 会话类型
|
||||
conversation_type = data.get('conversationType', '')
|
||||
if conversation_type == '1':
|
||||
chat_type = ChatType.PRIVATE
|
||||
elif conversation_type == '2':
|
||||
chat_type = ChatType.GROUP
|
||||
else:
|
||||
chat_type = ChatType.UNKNOWN
|
||||
|
||||
# 创建时间
|
||||
create_at = data.get('createAt', '')
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(int(create_at) / 1000)
|
||||
except (ValueError, TypeError):
|
||||
timestamp = datetime.now()
|
||||
|
||||
# 保存 session webhook 用于回复
|
||||
session_webhook = data.get('sessionWebhook', '')
|
||||
|
||||
return BotMessage(
|
||||
platform=self.platform_name,
|
||||
message_id=data.get('msgId', ''),
|
||||
user_id=data.get('senderId', ''),
|
||||
user_name=data.get('senderNick', ''),
|
||||
chat_id=data.get('conversationId', ''),
|
||||
chat_type=chat_type,
|
||||
content=content,
|
||||
raw_content=raw_content,
|
||||
mentioned=mentioned,
|
||||
mentions=[u.get('dingtalkId', '') for u in at_users],
|
||||
timestamp=timestamp,
|
||||
raw_data={
|
||||
**data,
|
||||
'_session_webhook': session_webhook,
|
||||
},
|
||||
)
|
||||
|
||||
def _extract_command(self, text: str) -> str:
|
||||
"""
|
||||
提取命令内容(去除 @机器人)
|
||||
|
||||
钉钉的 @用户 格式通常是 @昵称 后跟空格
|
||||
"""
|
||||
# 简单处理:移除开头的 @xxx 部分
|
||||
import re
|
||||
# 匹配开头的 @xxx(中英文都可能)
|
||||
text = re.sub(r'^@[\S]+\s*', '', text.strip())
|
||||
return text.strip()
|
||||
|
||||
def format_response(
|
||||
self,
|
||||
response: BotResponse,
|
||||
message: BotMessage
|
||||
) -> WebhookResponse:
|
||||
"""
|
||||
格式化钉钉响应
|
||||
|
||||
钉钉 Outgoing 机器人可以直接在响应中返回消息。
|
||||
也可以使用 sessionWebhook 异步发送。
|
||||
|
||||
响应格式:
|
||||
{
|
||||
"msgtype": "text" | "markdown",
|
||||
"text": {"content": "xxx"},
|
||||
"markdown": {"title": "xxx", "text": "xxx"},
|
||||
"at": {"atUserIds": ["xxx"], "isAtAll": false}
|
||||
}
|
||||
"""
|
||||
if not response.text:
|
||||
return WebhookResponse.success()
|
||||
|
||||
# 构建响应
|
||||
if response.markdown:
|
||||
body = {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"title": "股票分析助手",
|
||||
"text": response.text,
|
||||
}
|
||||
}
|
||||
else:
|
||||
body = {
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": response.text,
|
||||
}
|
||||
}
|
||||
|
||||
# @发送者
|
||||
if response.at_user and message.user_id:
|
||||
body["at"] = {
|
||||
"atUserIds": [message.user_id],
|
||||
"isAtAll": False,
|
||||
}
|
||||
|
||||
return WebhookResponse.success(body)
|
||||
|
||||
def send_by_session_webhook(
|
||||
self,
|
||||
session_webhook: str,
|
||||
response: BotResponse,
|
||||
message: BotMessage
|
||||
) -> bool:
|
||||
"""
|
||||
通过 sessionWebhook 发送消息
|
||||
|
||||
适用于需要异步发送或多条消息的场景。
|
||||
|
||||
Args:
|
||||
session_webhook: 钉钉提供的会话 Webhook URL
|
||||
response: 响应对象
|
||||
message: 原始消息对象
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
if not session_webhook:
|
||||
logger.warning("[DingTalk] 没有可用的 sessionWebhook")
|
||||
return False
|
||||
|
||||
import requests
|
||||
|
||||
try:
|
||||
# 构建消息
|
||||
if response.markdown:
|
||||
payload = {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"title": "股票分析助手",
|
||||
"text": response.text,
|
||||
}
|
||||
}
|
||||
else:
|
||||
payload = {
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": response.text,
|
||||
}
|
||||
}
|
||||
|
||||
# @发送者
|
||||
if response.at_user and message.user_id:
|
||||
payload["at"] = {
|
||||
"atUserIds": [message.user_id],
|
||||
"isAtAll": False,
|
||||
}
|
||||
|
||||
# 发送请求
|
||||
resp = requests.post(
|
||||
session_webhook,
|
||||
json=payload,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
result = resp.json()
|
||||
if result.get('errcode') == 0:
|
||||
logger.info("[DingTalk] sessionWebhook 发送成功")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"[DingTalk] sessionWebhook 发送失败: {result}")
|
||||
return False
|
||||
else:
|
||||
logger.error(f"[DingTalk] sessionWebhook 请求失败: {resp.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DingTalk] sessionWebhook 发送异常: {e}")
|
||||
return False
|
||||
346
bot/platforms/dingtalk_stream.py
Normal file
@@ -0,0 +1,346 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
钉钉 Stream 模式适配器
|
||||
===================================
|
||||
|
||||
使用钉钉官方 Stream SDK 接入机器人,无需公网 IP 和 Webhook 配置。
|
||||
|
||||
优势:
|
||||
- 不需要公网 IP 或域名
|
||||
- 不需要配置 Webhook URL
|
||||
- 通过 WebSocket 长连接接收消息
|
||||
- 更简单的接入方式
|
||||
|
||||
依赖:
|
||||
pip install dingtalk-stream
|
||||
|
||||
钉钉 Stream SDK:
|
||||
https://github.com/open-dingtalk/dingtalk-stream-sdk-python
|
||||
"""
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Optional, Callable, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 尝试导入钉钉 Stream SDK
|
||||
try:
|
||||
import dingtalk_stream
|
||||
from dingtalk_stream import AckMessage
|
||||
|
||||
DINGTALK_STREAM_AVAILABLE = True
|
||||
except ImportError:
|
||||
DINGTALK_STREAM_AVAILABLE = False
|
||||
logger.warning("[DingTalk Stream] dingtalk-stream SDK 未安装,Stream 模式不可用")
|
||||
logger.warning("[DingTalk Stream] 请运行: pip install dingtalk-stream")
|
||||
|
||||
from bot.models import BotMessage, BotResponse, ChatType
|
||||
|
||||
|
||||
class DingtalkStreamHandler:
|
||||
"""
|
||||
钉钉 Stream 模式消息处理器
|
||||
|
||||
将 Stream SDK 的回调转换为统一的 BotMessage 格式,
|
||||
并调用命令分发器处理。
|
||||
"""
|
||||
|
||||
def __init__(self, on_message: Callable[[BotMessage], BotResponse]):
|
||||
"""
|
||||
Args:
|
||||
on_message: 消息处理回调函数,接收 BotMessage 返回 BotResponse
|
||||
"""
|
||||
self._on_message = on_message
|
||||
self._logger = logger
|
||||
|
||||
@staticmethod
|
||||
def _truncate_log_content(text: str, max_len: int = 200) -> str:
|
||||
cleaned = text.replace("\n", " ").strip()
|
||||
if len(cleaned) > max_len:
|
||||
return f"{cleaned[:max_len]}..."
|
||||
return cleaned
|
||||
|
||||
def _log_incoming_message(self, message: BotMessage) -> None:
|
||||
content = message.raw_content or message.content or ""
|
||||
summary = self._truncate_log_content(content)
|
||||
self._logger.info(
|
||||
"[DingTalk Stream] Incoming message: msg_id=%s user_id=%s chat_id=%s chat_type=%s content=%s",
|
||||
message.message_id,
|
||||
message.user_id,
|
||||
message.chat_id,
|
||||
getattr(message.chat_type, "value", message.chat_type),
|
||||
summary,
|
||||
)
|
||||
|
||||
if DINGTALK_STREAM_AVAILABLE:
|
||||
class _ChatbotHandler(dingtalk_stream.ChatbotHandler):
|
||||
"""内部消息处理器"""
|
||||
|
||||
def __init__(self, parent: 'DingtalkStreamHandler'):
|
||||
super().__init__()
|
||||
self._parent = parent
|
||||
self.logger = logger
|
||||
|
||||
async def process(self, callback: dingtalk_stream.CallbackMessage):
|
||||
"""处理收到的消息"""
|
||||
try:
|
||||
# 解析消息
|
||||
incoming = dingtalk_stream.ChatbotMessage.from_dict(callback.data)
|
||||
|
||||
# 转换为统一格式
|
||||
bot_message = self._parent._parse_stream_message(incoming, callback.data)
|
||||
|
||||
if bot_message:
|
||||
self._parent._log_incoming_message(bot_message)
|
||||
# 调用消息处理回调
|
||||
response = self._parent._on_message(bot_message)
|
||||
|
||||
# 发送回复
|
||||
if response and response.text:
|
||||
# 构建 @用户 前缀(群聊场景下需要在文本中包含 @用户名)
|
||||
if response.at_user and incoming.sender_nick:
|
||||
if response.markdown:
|
||||
self.reply_markdown(
|
||||
title="股票分析助手",
|
||||
text=f"@{incoming.sender_nick} " + response.text,
|
||||
incoming_message=incoming
|
||||
)
|
||||
else:
|
||||
self.reply_text(response.text, incoming)
|
||||
|
||||
return AckMessage.STATUS_OK, 'OK'
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"[DingTalk Stream] 处理消息失败: {e}")
|
||||
self.logger.exception(e)
|
||||
return AckMessage.STATUS_SYSTEM_EXCEPTION, str(e)
|
||||
|
||||
def create_handler(self) -> '_ChatbotHandler':
|
||||
"""创建 SDK 需要的处理器实例"""
|
||||
return self._ChatbotHandler(self)
|
||||
|
||||
def _parse_stream_message(self, incoming: Any, raw_data: dict) -> Optional[BotMessage]:
|
||||
"""
|
||||
解析 Stream 消息为统一格式
|
||||
|
||||
Args:
|
||||
incoming: ChatbotMessage 对象
|
||||
raw_data: 原始回调数据
|
||||
"""
|
||||
try:
|
||||
raw_data = dict(raw_data or {})
|
||||
|
||||
# 获取消息内容
|
||||
raw_content = incoming.text.content if incoming.text else ''
|
||||
|
||||
# 提取命令(去除 @机器人)
|
||||
content = self._extract_command(raw_content)
|
||||
|
||||
# 会话类型
|
||||
conversation_type = getattr(incoming, 'conversation_type', None)
|
||||
if conversation_type == '1':
|
||||
chat_type = ChatType.PRIVATE
|
||||
elif conversation_type == '2':
|
||||
chat_type = ChatType.GROUP
|
||||
else:
|
||||
chat_type = ChatType.UNKNOWN
|
||||
|
||||
# 是否 @了机器人(Stream 模式下收到的消息一般都是 @机器人的)
|
||||
mentioned = True
|
||||
|
||||
# 提取 sessionWebhook,便于异步推送
|
||||
session_webhook = (
|
||||
getattr(incoming, 'session_webhook', None)
|
||||
or raw_data.get('sessionWebhook')
|
||||
or raw_data.get('session_webhook')
|
||||
)
|
||||
if session_webhook:
|
||||
raw_data['_session_webhook'] = session_webhook
|
||||
|
||||
return BotMessage(
|
||||
platform='dingtalk',
|
||||
message_id=getattr(incoming, 'msg_id', '') or '',
|
||||
user_id=getattr(incoming, 'sender_id', '') or '',
|
||||
user_name=getattr(incoming, 'sender_nick', '') or '',
|
||||
chat_id=getattr(incoming, 'conversation_id', '') or '',
|
||||
chat_type=chat_type,
|
||||
content=content,
|
||||
raw_content=raw_content,
|
||||
mentioned=mentioned,
|
||||
mentions=[],
|
||||
timestamp=datetime.now(),
|
||||
raw_data=raw_data,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DingTalk Stream] 解析消息失败: {e}")
|
||||
return None
|
||||
|
||||
def _extract_command(self, text: str) -> str:
|
||||
"""提取命令内容(去除 @机器人)"""
|
||||
import re
|
||||
text = re.sub(r'^@[\S]+\s*', '', text.strip())
|
||||
return text.strip()
|
||||
|
||||
|
||||
class DingtalkStreamClient:
|
||||
"""
|
||||
钉钉 Stream 模式客户端
|
||||
|
||||
封装 dingtalk-stream SDK,提供简单的启动接口。
|
||||
|
||||
使用方式:
|
||||
client = DingtalkStreamClient()
|
||||
client.start() # 阻塞运行
|
||||
|
||||
# 或者在后台运行
|
||||
client.start_background()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: Optional[str] = None,
|
||||
client_secret: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
client_id: 应用 AppKey(不传则从配置读取)
|
||||
client_secret: 应用 AppSecret(不传则从配置读取)
|
||||
"""
|
||||
if not DINGTALK_STREAM_AVAILABLE:
|
||||
raise ImportError(
|
||||
"dingtalk-stream SDK 未安装。\n"
|
||||
"请运行: pip install dingtalk-stream"
|
||||
)
|
||||
|
||||
from config import get_config
|
||||
config = get_config()
|
||||
|
||||
self._client_id = client_id or getattr(config, 'dingtalk_app_key', None)
|
||||
self._client_secret = client_secret or getattr(config, 'dingtalk_app_secret', None)
|
||||
|
||||
if not self._client_id or not self._client_secret:
|
||||
raise ValueError(
|
||||
"钉钉 Stream 模式需要配置 DINGTALK_APP_KEY 和 DINGTALK_APP_SECRET"
|
||||
)
|
||||
|
||||
self._client: Optional[dingtalk_stream.DingTalkStreamClient] = None
|
||||
self._background_thread: Optional[threading.Thread] = None
|
||||
self._running = False
|
||||
|
||||
def _create_message_handler(self) -> Callable[[BotMessage], BotResponse]:
|
||||
"""创建消息处理函数"""
|
||||
|
||||
def handle_message(message: BotMessage) -> BotResponse:
|
||||
from bot.dispatcher import get_dispatcher
|
||||
dispatcher = get_dispatcher()
|
||||
return dispatcher.dispatch(message)
|
||||
|
||||
return handle_message
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
启动 Stream 客户端(阻塞)
|
||||
|
||||
此方法会阻塞当前线程,直到客户端停止。
|
||||
"""
|
||||
logger.info("[DingTalk Stream] 正在启动...")
|
||||
|
||||
# 创建凭证
|
||||
credential = dingtalk_stream.Credential(
|
||||
self._client_id,
|
||||
self._client_secret
|
||||
)
|
||||
|
||||
# 创建客户端
|
||||
self._client = dingtalk_stream.DingTalkStreamClient(credential)
|
||||
|
||||
# 注册消息处理器
|
||||
handler = DingtalkStreamHandler(self._create_message_handler())
|
||||
self._client.register_callback_handler(
|
||||
dingtalk_stream.chatbot.ChatbotMessage.TOPIC,
|
||||
handler.create_handler()
|
||||
)
|
||||
|
||||
self._running = True
|
||||
logger.info("[DingTalk Stream] 客户端已启动,等待消息...")
|
||||
|
||||
# 启动(阻塞)
|
||||
self._client.start_forever()
|
||||
|
||||
def start_background(self) -> None:
|
||||
"""
|
||||
在后台线程启动 Stream 客户端(非阻塞)
|
||||
|
||||
适用于与其他服务(如 WebUI)同时运行的场景。
|
||||
"""
|
||||
if self._background_thread and self._background_thread.is_alive():
|
||||
logger.warning("[DingTalk Stream] 客户端已在运行")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._background_thread = threading.Thread(
|
||||
target=self._run_in_background,
|
||||
daemon=True,
|
||||
name="DingtalkStreamClient"
|
||||
)
|
||||
self._background_thread.start()
|
||||
logger.info("[DingTalk Stream] 后台客户端已启动")
|
||||
|
||||
def _run_in_background(self) -> None:
|
||||
"""后台运行(处理异常和重连)"""
|
||||
while self._running:
|
||||
try:
|
||||
self.start()
|
||||
except Exception as e:
|
||||
logger.error(f"[DingTalk Stream] 运行异常: {e}")
|
||||
if self._running:
|
||||
logger.info("[DingTalk Stream] 5 秒后重连...")
|
||||
import time
|
||||
time.sleep(5)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止客户端"""
|
||||
self._running = False
|
||||
logger.info("[DingTalk Stream] 客户端已停止")
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""是否正在运行"""
|
||||
return self._running
|
||||
|
||||
|
||||
# 全局客户端实例
|
||||
_stream_client: Optional[DingtalkStreamClient] = None
|
||||
|
||||
|
||||
def get_dingtalk_stream_client() -> Optional[DingtalkStreamClient]:
|
||||
"""获取全局 Stream 客户端实例"""
|
||||
global _stream_client
|
||||
|
||||
if _stream_client is None and DINGTALK_STREAM_AVAILABLE:
|
||||
try:
|
||||
_stream_client = DingtalkStreamClient()
|
||||
except (ImportError, ValueError) as e:
|
||||
logger.warning(f"[DingTalk Stream] 无法创建客户端: {e}")
|
||||
return None
|
||||
|
||||
return _stream_client
|
||||
|
||||
|
||||
def start_dingtalk_stream_background() -> bool:
|
||||
"""
|
||||
在后台启动钉钉 Stream 客户端
|
||||
|
||||
Returns:
|
||||
是否成功启动
|
||||
"""
|
||||
client = get_dingtalk_stream_client()
|
||||
if client:
|
||||
client.start_background()
|
||||
return True
|
||||
return False
|
||||
548
bot/platforms/feishu_stream.py
Normal file
@@ -0,0 +1,548 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
飞书 Stream 模式适配器
|
||||
===================================
|
||||
|
||||
使用飞书官方 lark-oapi SDK 的 WebSocket 长连接模式接入机器人,
|
||||
无需公网 IP 和 Webhook 配置。
|
||||
|
||||
优势:
|
||||
- 不需要公网 IP 或域名
|
||||
- 不需要配置 Webhook URL
|
||||
- 通过 WebSocket 长连接接收消息
|
||||
- 更简单的接入方式
|
||||
- 内置自动重连和心跳保活
|
||||
|
||||
依赖:
|
||||
pip install lark-oapi
|
||||
|
||||
飞书长连接文档:
|
||||
https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/server-side-sdk/python--sdk/handle-events
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Optional, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 尝试导入飞书 SDK
|
||||
try:
|
||||
import lark_oapi as lark
|
||||
from lark_oapi import ws
|
||||
from lark_oapi.api.im.v1 import (
|
||||
P2ImMessageReceiveV1,
|
||||
ReplyMessageRequest,
|
||||
ReplyMessageRequestBody,
|
||||
CreateMessageRequest,
|
||||
CreateMessageRequestBody,
|
||||
)
|
||||
|
||||
FEISHU_SDK_AVAILABLE = True
|
||||
except ImportError:
|
||||
FEISHU_SDK_AVAILABLE = False
|
||||
logger.warning("[Feishu Stream] lark-oapi SDK 未安装,Stream 模式不可用")
|
||||
logger.warning("[Feishu Stream] 请运行: pip install lark-oapi")
|
||||
|
||||
from bot.models import BotMessage, BotResponse, ChatType
|
||||
|
||||
|
||||
class FeishuReplyClient:
|
||||
"""
|
||||
飞书消息回复客户端
|
||||
|
||||
使用飞书 API 发送回复消息。
|
||||
"""
|
||||
|
||||
def __init__(self, app_id: str, app_secret: str):
|
||||
"""
|
||||
Args:
|
||||
app_id: 飞书应用 ID
|
||||
app_secret: 飞书应用密钥
|
||||
"""
|
||||
if not FEISHU_SDK_AVAILABLE:
|
||||
raise ImportError("lark-oapi SDK 未安装")
|
||||
|
||||
self._client = lark.Client.builder() \
|
||||
.app_id(app_id) \
|
||||
.app_secret(app_secret) \
|
||||
.log_level(lark.LogLevel.WARNING) \
|
||||
.build()
|
||||
|
||||
def reply_text(self, message_id: str, text: str, at_user: bool = False,
|
||||
user_id: Optional[str] = None) -> bool:
|
||||
"""
|
||||
回复文本消息
|
||||
|
||||
Args:
|
||||
message_id: 原消息 ID
|
||||
text: 回复文本
|
||||
at_user: 是否 @用户
|
||||
user_id: 用户 open_id(at_user=True 时需要)
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
try:
|
||||
# 构建回复内容
|
||||
if at_user and user_id:
|
||||
content = json.dumps({"text": f"<at user_id=\"{user_id}\"></at> {text}"})
|
||||
else:
|
||||
content = json.dumps({"text": text})
|
||||
|
||||
request = ReplyMessageRequest.builder() \
|
||||
.message_id(message_id) \
|
||||
.request_body(
|
||||
ReplyMessageRequestBody.builder()
|
||||
.content(content)
|
||||
.msg_type("text")
|
||||
.build()
|
||||
) \
|
||||
.build()
|
||||
|
||||
response = self._client.im.v1.message.reply(request)
|
||||
|
||||
if not response.success():
|
||||
logger.error(
|
||||
f"[Feishu Stream] 回复消息失败: code={response.code}, "
|
||||
f"msg={response.msg}, log_id={response.get_log_id()}"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.debug(f"[Feishu Stream] 回复消息成功: message_id={message_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Feishu Stream] 回复消息异常: {e}")
|
||||
return False
|
||||
|
||||
def send_to_chat(self, chat_id: str, text: str,
|
||||
receive_id_type: str = "chat_id") -> bool:
|
||||
"""
|
||||
发送消息到指定会话
|
||||
|
||||
Args:
|
||||
chat_id: 会话 ID
|
||||
text: 消息文本
|
||||
receive_id_type: 接收者 ID 类型,默认 chat_id
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
try:
|
||||
content = json.dumps({"text": text})
|
||||
|
||||
request = CreateMessageRequest.builder() \
|
||||
.receive_id_type(receive_id_type) \
|
||||
.request_body(
|
||||
CreateMessageRequestBody.builder()
|
||||
.receive_id(chat_id)
|
||||
.content(content)
|
||||
.msg_type("text")
|
||||
.build()
|
||||
) \
|
||||
.build()
|
||||
|
||||
response = self._client.im.v1.message.create(request)
|
||||
|
||||
if not response.success():
|
||||
logger.error(
|
||||
f"[Feishu Stream] 发送消息失败: code={response.code}, "
|
||||
f"msg={response.msg}, log_id={response.get_log_id()}"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.debug(f"[Feishu Stream] 发送消息成功: chat_id={chat_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Feishu Stream] 发送消息异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class FeishuStreamHandler:
|
||||
"""
|
||||
飞书 Stream 模式消息处理器
|
||||
|
||||
将 SDK 的事件转换为统一的 BotMessage 格式,
|
||||
并调用命令分发器处理。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_message: Callable[[BotMessage], BotResponse],
|
||||
reply_client: FeishuReplyClient
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
on_message: 消息处理回调函数,接收 BotMessage 返回 BotResponse
|
||||
reply_client: 飞书回复客户端
|
||||
"""
|
||||
self._on_message = on_message
|
||||
self._reply_client = reply_client
|
||||
self._logger = logger
|
||||
|
||||
@staticmethod
|
||||
def _truncate_log_content(text: str, max_len: int = 200) -> str:
|
||||
"""截断日志内容"""
|
||||
cleaned = text.replace("\n", " ").strip()
|
||||
if len(cleaned) > max_len:
|
||||
return f"{cleaned[:max_len]}..."
|
||||
return cleaned
|
||||
|
||||
def _log_incoming_message(self, message: BotMessage) -> None:
|
||||
"""记录收到的消息日志"""
|
||||
content = message.raw_content or message.content or ""
|
||||
summary = self._truncate_log_content(content)
|
||||
self._logger.info(
|
||||
"[Feishu Stream] Incoming message: msg_id=%s user_id=%s "
|
||||
"chat_id=%s chat_type=%s content=%s",
|
||||
message.message_id,
|
||||
message.user_id,
|
||||
message.chat_id,
|
||||
getattr(message.chat_type, "value", message.chat_type),
|
||||
summary,
|
||||
)
|
||||
|
||||
def handle_message(self, event: 'P2ImMessageReceiveV1') -> None:
|
||||
"""
|
||||
处理接收到的消息事件
|
||||
|
||||
Args:
|
||||
event: 飞书消息接收事件
|
||||
"""
|
||||
try:
|
||||
# 解析消息
|
||||
bot_message = self._parse_event_message(event)
|
||||
|
||||
if bot_message is None:
|
||||
return
|
||||
|
||||
self._log_incoming_message(bot_message)
|
||||
|
||||
# 调用消息处理回调
|
||||
response = self._on_message(bot_message)
|
||||
|
||||
# 发送回复
|
||||
if response and response.text:
|
||||
self._reply_client.reply_text(
|
||||
message_id=bot_message.message_id,
|
||||
text=response.text,
|
||||
at_user=response.at_user,
|
||||
user_id=bot_message.user_id if response.at_user else None
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self._logger.error(f"[Feishu Stream] 处理消息失败: {e}")
|
||||
self._logger.exception(e)
|
||||
|
||||
def _parse_event_message(self, event: 'P2ImMessageReceiveV1') -> Optional[BotMessage]:
|
||||
"""
|
||||
解析飞书事件消息为统一格式
|
||||
|
||||
Args:
|
||||
event: P2ImMessageReceiveV1 事件对象
|
||||
"""
|
||||
try:
|
||||
event_data = event.event
|
||||
if event_data is None:
|
||||
return None
|
||||
|
||||
message_data = event_data.message
|
||||
sender_data = event_data.sender
|
||||
|
||||
if message_data is None:
|
||||
return None
|
||||
|
||||
# 只处理文本消息
|
||||
message_type = message_data.message_type or ""
|
||||
if message_type != "text":
|
||||
self._logger.debug(f"[Feishu Stream] 忽略非文本消息: {message_type}")
|
||||
return None
|
||||
|
||||
# 解析消息内容
|
||||
content_str = message_data.content or "{}"
|
||||
try:
|
||||
content_json = json.loads(content_str)
|
||||
raw_content = content_json.get("text", "")
|
||||
except json.JSONDecodeError:
|
||||
raw_content = content_str
|
||||
|
||||
# 提取命令(去除 @机器人)
|
||||
content = self._extract_command(raw_content, message_data.mentions)
|
||||
mentioned = "@" in raw_content or bool(message_data.mentions)
|
||||
|
||||
# 获取发送者信息
|
||||
user_id = ""
|
||||
if sender_data and sender_data.sender_id:
|
||||
user_id = sender_data.sender_id.open_id or sender_data.sender_id.user_id or ""
|
||||
|
||||
# 获取会话类型
|
||||
chat_type_str = message_data.chat_type or ""
|
||||
if chat_type_str == "group":
|
||||
chat_type = ChatType.GROUP
|
||||
elif chat_type_str == "p2p":
|
||||
chat_type = ChatType.PRIVATE
|
||||
else:
|
||||
chat_type = ChatType.UNKNOWN
|
||||
|
||||
# 创建时间
|
||||
create_time = message_data.create_time
|
||||
try:
|
||||
if create_time:
|
||||
timestamp = datetime.fromtimestamp(int(create_time) / 1000)
|
||||
else:
|
||||
timestamp = datetime.now()
|
||||
except (ValueError, TypeError):
|
||||
timestamp = datetime.now()
|
||||
|
||||
# 构建原始数据
|
||||
raw_data = {
|
||||
"header": {
|
||||
"event_id": event.header.event_id if event.header else "",
|
||||
"event_type": event.header.event_type if event.header else "",
|
||||
"create_time": event.header.create_time if event.header else "",
|
||||
"token": event.header.token if event.header else "",
|
||||
"app_id": event.header.app_id if event.header else "",
|
||||
},
|
||||
"event": {
|
||||
"message_id": message_data.message_id,
|
||||
"chat_id": message_data.chat_id,
|
||||
"chat_type": message_data.chat_type,
|
||||
"content": message_data.content,
|
||||
}
|
||||
}
|
||||
|
||||
return BotMessage(
|
||||
platform="feishu",
|
||||
message_id=message_data.message_id or "",
|
||||
user_id=user_id,
|
||||
user_name=user_id, # 飞书不直接返回用户名
|
||||
chat_id=message_data.chat_id or "",
|
||||
chat_type=chat_type,
|
||||
content=content,
|
||||
raw_content=raw_content,
|
||||
mentioned=mentioned,
|
||||
mentions=[m.key or "" for m in (message_data.mentions or [])],
|
||||
timestamp=timestamp,
|
||||
raw_data=raw_data,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self._logger.error(f"[Feishu Stream] 解析消息失败: {e}")
|
||||
return None
|
||||
|
||||
def _extract_command(self, text: str, mentions: list) -> str:
|
||||
"""
|
||||
提取命令内容(去除 @机器人)
|
||||
|
||||
飞书的 @用户 格式是:@_user_1, @_user_2 等
|
||||
|
||||
Args:
|
||||
text: 原始消息文本
|
||||
mentions: @提及列表
|
||||
"""
|
||||
import re
|
||||
|
||||
# 方式1: 通过 mentions 列表移除(精确匹配)
|
||||
for mention in (mentions or []):
|
||||
key = getattr(mention, 'key', '') or ''
|
||||
if key:
|
||||
text = text.replace(key, '')
|
||||
|
||||
# 方式2: 正则兜底,移除飞书 @用户 格式(@_user_N)
|
||||
# 当 mentions 为空或未正确传递时生效
|
||||
text = re.sub(r'@_user_\d+\s*', '', text)
|
||||
|
||||
# 清理多余空格
|
||||
return ' '.join(text.split())
|
||||
|
||||
|
||||
class FeishuStreamClient:
|
||||
"""
|
||||
飞书 Stream 模式客户端
|
||||
|
||||
封装 lark-oapi SDK 的 WebSocket 客户端,提供简单的启动接口。
|
||||
|
||||
使用方式:
|
||||
client = FeishuStreamClient()
|
||||
client.start() # 阻塞运行
|
||||
|
||||
# 或者在后台运行
|
||||
client.start_background()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_id: Optional[str] = None,
|
||||
app_secret: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
app_id: 应用 ID(不传则从配置读取)
|
||||
app_secret: 应用密钥(不传则从配置读取)
|
||||
"""
|
||||
if not FEISHU_SDK_AVAILABLE:
|
||||
raise ImportError(
|
||||
"lark-oapi SDK 未安装。\n"
|
||||
"请运行: pip install lark-oapi"
|
||||
)
|
||||
|
||||
from config import get_config
|
||||
config = get_config()
|
||||
|
||||
self._app_id = app_id or getattr(config, 'feishu_app_id', None)
|
||||
self._app_secret = app_secret or getattr(config, 'feishu_app_secret', None)
|
||||
|
||||
if not self._app_id or not self._app_secret:
|
||||
raise ValueError(
|
||||
"飞书 Stream 模式需要配置 FEISHU_APP_ID 和 FEISHU_APP_SECRET"
|
||||
)
|
||||
|
||||
self._ws_client: Optional[ws.Client] = None
|
||||
self._reply_client: Optional[FeishuReplyClient] = None
|
||||
self._background_thread: Optional[threading.Thread] = None
|
||||
self._running = False
|
||||
|
||||
def _create_message_handler(self) -> Callable[[BotMessage], BotResponse]:
|
||||
"""创建消息处理函数"""
|
||||
|
||||
def handle_message(message: BotMessage) -> BotResponse:
|
||||
from bot.dispatcher import get_dispatcher
|
||||
dispatcher = get_dispatcher()
|
||||
return dispatcher.dispatch(message)
|
||||
|
||||
return handle_message
|
||||
|
||||
def _create_event_handler(self) -> 'lark.EventDispatcherHandler':
|
||||
"""创建事件分发处理器"""
|
||||
# 创建回复客户端
|
||||
self._reply_client = FeishuReplyClient(self._app_id, self._app_secret)
|
||||
|
||||
# 创建消息处理器
|
||||
handler = FeishuStreamHandler(
|
||||
self._create_message_handler(),
|
||||
self._reply_client
|
||||
)
|
||||
|
||||
# 创建并注册事件处理器
|
||||
# 注意:encrypt_key 和 verification_token 在长连接模式下不是必需的
|
||||
# 但 SDK 要求传入(可以为空字符串)
|
||||
from config import get_config
|
||||
config = get_config()
|
||||
|
||||
encrypt_key = getattr(config, 'feishu_encrypt_key', '') or ''
|
||||
verification_token = getattr(config, 'feishu_verification_token', '') or ''
|
||||
|
||||
event_handler = lark.EventDispatcherHandler.builder(
|
||||
encrypt_key=encrypt_key,
|
||||
verification_token=verification_token,
|
||||
level=lark.LogLevel.WARNING
|
||||
).register_p2_im_message_receive_v1(
|
||||
handler.handle_message
|
||||
).build()
|
||||
|
||||
return event_handler
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
启动 Stream 客户端(阻塞)
|
||||
|
||||
此方法会阻塞当前线程,直到客户端停止。
|
||||
"""
|
||||
logger.info("[Feishu Stream] 正在启动...")
|
||||
|
||||
# 创建事件处理器
|
||||
event_handler = self._create_event_handler()
|
||||
|
||||
# 创建 WebSocket 客户端
|
||||
self._ws_client = ws.Client(
|
||||
app_id=self._app_id,
|
||||
app_secret=self._app_secret,
|
||||
event_handler=event_handler,
|
||||
log_level=lark.LogLevel.WARNING,
|
||||
auto_reconnect=True
|
||||
)
|
||||
|
||||
self._running = True
|
||||
logger.info("[Feishu Stream] 客户端已启动,等待消息...")
|
||||
|
||||
# 启动(阻塞)
|
||||
self._ws_client.start()
|
||||
|
||||
def start_background(self) -> None:
|
||||
"""
|
||||
在后台线程启动 Stream 客户端(非阻塞)
|
||||
|
||||
适用于与其他服务(如 WebUI)同时运行的场景。
|
||||
"""
|
||||
if self._background_thread and self._background_thread.is_alive():
|
||||
logger.warning("[Feishu Stream] 客户端已在运行")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._background_thread = threading.Thread(
|
||||
target=self._run_in_background,
|
||||
daemon=True,
|
||||
name="FeishuStreamClient"
|
||||
)
|
||||
self._background_thread.start()
|
||||
logger.info("[Feishu Stream] 后台客户端已启动")
|
||||
|
||||
def _run_in_background(self) -> None:
|
||||
"""后台运行(处理异常和重连)"""
|
||||
import time
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
self.start()
|
||||
except Exception as e:
|
||||
logger.error(f"[Feishu Stream] 运行异常: {e}")
|
||||
if self._running:
|
||||
logger.info("[Feishu Stream] 5 秒后重连...")
|
||||
time.sleep(5)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止客户端"""
|
||||
self._running = False
|
||||
logger.info("[Feishu Stream] 客户端已停止")
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""是否正在运行"""
|
||||
return self._running
|
||||
|
||||
|
||||
# 全局客户端实例
|
||||
_stream_client: Optional[FeishuStreamClient] = None
|
||||
|
||||
|
||||
def get_feishu_stream_client() -> Optional[FeishuStreamClient]:
|
||||
"""获取全局 Stream 客户端实例"""
|
||||
global _stream_client
|
||||
|
||||
if _stream_client is None and FEISHU_SDK_AVAILABLE:
|
||||
try:
|
||||
_stream_client = FeishuStreamClient()
|
||||
except (ImportError, ValueError) as e:
|
||||
logger.warning(f"[Feishu Stream] 无法创建客户端: {e}")
|
||||
return None
|
||||
|
||||
return _stream_client
|
||||
|
||||
|
||||
def start_feishu_stream_background() -> bool:
|
||||
"""
|
||||
在后台启动飞书 Stream 客户端
|
||||
|
||||
Returns:
|
||||
是否成功启动
|
||||
"""
|
||||
client = get_feishu_stream_client()
|
||||
if client:
|
||||
client.start_background()
|
||||
return True
|
||||
return False
|
||||
87
config.py
@@ -85,9 +85,23 @@ class Config:
|
||||
custom_webhook_urls: List[str] = field(default_factory=list)
|
||||
custom_webhook_bearer_token: Optional[str] = None # Bearer Token(用于需要认证的 Webhook)
|
||||
|
||||
# Discord 通知配置
|
||||
discord_bot_token: Optional[str] = None # Discord Bot Token
|
||||
discord_main_channel_id: Optional[str] = None # Discord 主频道 ID
|
||||
discord_webhook_url: Optional[str] = None # Discord Webhook URL
|
||||
|
||||
# 单股推送模式:每分析完一只股票立即推送,而不是汇总后推送
|
||||
single_stock_notify: bool = False
|
||||
|
||||
# 报告类型:simple(精简) 或 full(完整)
|
||||
report_type: str = "simple"
|
||||
|
||||
# PushPlus 推送配置
|
||||
pushplus_token: Optional[str] = None # PushPlus Token
|
||||
|
||||
# 分析间隔时间(秒)- 用于避免API限流
|
||||
analysis_delay: float = 0.0 # 个股分析与大盘分析之间的延迟
|
||||
|
||||
# 消息长度限制(字节)- 超长自动分批发送
|
||||
feishu_max_bytes: int = 20000 # 飞书限制约 20KB,默认 20000 字节
|
||||
wechat_max_bytes: int = 4000 # 企业微信限制 4096 字节,默认 4000 字节
|
||||
@@ -108,12 +122,6 @@ class Config:
|
||||
schedule_time: str = "18:00" # 每日推送时间(HH:MM 格式)
|
||||
market_review_enabled: bool = True # 是否启用大盘复盘
|
||||
|
||||
# === Discord 机器人配置 ===
|
||||
discord_bot_token: Optional[str] = None # Discord 机器人 Token
|
||||
discord_bot_status: str = "A股智能分析 | /help" # 机器人状态信息
|
||||
discord_main_channel_id: Optional[str] = None # 主频道 ID
|
||||
discord_webhook_url: Optional[str] = None # Webhook URL(可选)
|
||||
|
||||
# === 流控配置(防封禁关键参数)===
|
||||
# Akshare 请求间隔范围(秒)
|
||||
akshare_sleep_min: float = 2.0
|
||||
@@ -132,8 +140,33 @@ class Config:
|
||||
webui_host: str = "127.0.0.1"
|
||||
webui_port: int = 8000
|
||||
|
||||
# === Discord 机器人配置 ===
|
||||
discord_bot_token: Optional[str] = None # Discord 机器人 Token
|
||||
# === 机器人配置 ===
|
||||
bot_enabled: bool = True # 是否启用机器人功能
|
||||
bot_command_prefix: str = "/" # 命令前缀
|
||||
bot_rate_limit_requests: int = 10 # 频率限制:窗口内最大请求数
|
||||
bot_rate_limit_window: int = 60 # 频率限制:窗口时间(秒)
|
||||
bot_admin_users: List[str] = field(default_factory=list) # 管理员用户 ID 列表
|
||||
|
||||
# 飞书机器人(事件订阅)- 已有 feishu_app_id, feishu_app_secret
|
||||
feishu_verification_token: Optional[str] = None # 事件订阅验证 Token
|
||||
feishu_encrypt_key: Optional[str] = None # 消息加密密钥(可选)
|
||||
feishu_stream_enabled: bool = False # 是否启用 Stream 长连接模式(无需公网IP)
|
||||
|
||||
# 钉钉机器人
|
||||
dingtalk_app_key: Optional[str] = None # 应用 AppKey
|
||||
dingtalk_app_secret: Optional[str] = None # 应用 AppSecret
|
||||
dingtalk_stream_enabled: bool = False # 是否启用 Stream 模式(无需公网IP)
|
||||
|
||||
# 企业微信机器人(回调模式)
|
||||
wecom_corpid: Optional[str] = None # 企业 ID
|
||||
wecom_token: Optional[str] = None # 回调 Token
|
||||
wecom_encoding_aes_key: Optional[str] = None # 消息加解密密钥
|
||||
wecom_agent_id: Optional[str] = None # 应用 AgentId
|
||||
|
||||
# Telegram 机器人 - 已有 telegram_bot_token, telegram_chat_id
|
||||
telegram_webhook_secret: Optional[str] = None # Webhook 密钥
|
||||
|
||||
# Discord 机器人扩展配置
|
||||
discord_bot_status: str = "A股智能分析 | /help" # 机器人状态信息
|
||||
|
||||
# 单例实例存储
|
||||
@@ -216,9 +249,15 @@ class Config:
|
||||
email_receivers=[r.strip() for r in os.getenv('EMAIL_RECEIVERS', '').split(',') if r.strip()],
|
||||
pushover_user_key=os.getenv('PUSHOVER_USER_KEY'),
|
||||
pushover_api_token=os.getenv('PUSHOVER_API_TOKEN'),
|
||||
pushplus_token=os.getenv('PUSHPLUS_TOKEN'),
|
||||
custom_webhook_urls=[u.strip() for u in os.getenv('CUSTOM_WEBHOOK_URLS', '').split(',') if u.strip()],
|
||||
custom_webhook_bearer_token=os.getenv('CUSTOM_WEBHOOK_BEARER_TOKEN'),
|
||||
discord_bot_token=os.getenv('DISCORD_BOT_TOKEN'),
|
||||
discord_main_channel_id=os.getenv('DISCORD_MAIN_CHANNEL_ID'),
|
||||
discord_webhook_url=os.getenv('DISCORD_WEBHOOK_URL'),
|
||||
single_stock_notify=os.getenv('SINGLE_STOCK_NOTIFY', 'false').lower() == 'true',
|
||||
report_type=os.getenv('REPORT_TYPE', 'simple').lower(),
|
||||
analysis_delay=float(os.getenv('ANALYSIS_DELAY', '0')),
|
||||
feishu_max_bytes=int(os.getenv('FEISHU_MAX_BYTES', '20000')),
|
||||
wechat_max_bytes=int(os.getenv('WECHAT_MAX_BYTES', '4000')),
|
||||
database_path=os.getenv('DATABASE_PATH', './data/stock_analysis.db'),
|
||||
@@ -232,10 +271,29 @@ class Config:
|
||||
webui_enabled=os.getenv('WEBUI_ENABLED', 'false').lower() == 'true',
|
||||
webui_host=os.getenv('WEBUI_HOST', '127.0.0.1'),
|
||||
webui_port=int(os.getenv('WEBUI_PORT', '8000')),
|
||||
discord_bot_token=os.getenv('DISCORD_BOT_TOKEN'),
|
||||
discord_bot_status=os.getenv('DISCORD_BOT_STATUS', 'A股智能分析 | /help'),
|
||||
discord_main_channel_id=os.getenv('DISCORD_MAIN_CHANNEL_ID'),
|
||||
discord_webhook_url=os.getenv('DISCORD_WEBHOOK_URL'),
|
||||
# 机器人配置
|
||||
bot_enabled=os.getenv('BOT_ENABLED', 'true').lower() == 'true',
|
||||
bot_command_prefix=os.getenv('BOT_COMMAND_PREFIX', '/'),
|
||||
bot_rate_limit_requests=int(os.getenv('BOT_RATE_LIMIT_REQUESTS', '10')),
|
||||
bot_rate_limit_window=int(os.getenv('BOT_RATE_LIMIT_WINDOW', '60')),
|
||||
bot_admin_users=[u.strip() for u in os.getenv('BOT_ADMIN_USERS', '').split(',') if u.strip()],
|
||||
# 飞书机器人
|
||||
feishu_verification_token=os.getenv('FEISHU_VERIFICATION_TOKEN'),
|
||||
feishu_encrypt_key=os.getenv('FEISHU_ENCRYPT_KEY'),
|
||||
feishu_stream_enabled=os.getenv('FEISHU_STREAM_ENABLED', 'false').lower() == 'true',
|
||||
# 钉钉机器人
|
||||
dingtalk_app_key=os.getenv('DINGTALK_APP_KEY'),
|
||||
dingtalk_app_secret=os.getenv('DINGTALK_APP_SECRET'),
|
||||
dingtalk_stream_enabled=os.getenv('DINGTALK_STREAM_ENABLED', 'false').lower() == 'true',
|
||||
# 企业微信机器人
|
||||
wecom_corpid=os.getenv('WECOM_CORPID'),
|
||||
wecom_token=os.getenv('WECOM_TOKEN'),
|
||||
wecom_encoding_aes_key=os.getenv('WECOM_ENCODING_AES_KEY'),
|
||||
wecom_agent_id=os.getenv('WECOM_AGENT_ID'),
|
||||
# Telegram
|
||||
telegram_webhook_secret=os.getenv('TELEGRAM_WEBHOOK_SECRET'),
|
||||
# Discord 机器人扩展配置
|
||||
discord_bot_status=os.getenv('DISCORD_BOT_STATUS', 'A股智能分析 | /help')
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -302,7 +360,10 @@ class Config:
|
||||
(self.telegram_bot_token and self.telegram_chat_id) or
|
||||
(self.email_sender and self.email_password) or
|
||||
(self.pushover_user_key and self.pushover_api_token) or
|
||||
(self.custom_webhook_urls and self.custom_webhook_bearer_token)
|
||||
self.pushplus_token or
|
||||
(self.custom_webhook_urls and self.custom_webhook_bearer_token) or
|
||||
(self.discord_bot_token and self.discord_main_channel_id) or
|
||||
self.discord_webhook_url
|
||||
)
|
||||
if not has_notification:
|
||||
warnings.append("提示:未配置通知渠道,将不发送推送通知")
|
||||
|
||||
@@ -238,6 +238,38 @@ def _is_hk_code(stock_code: str) -> bool:
|
||||
return code.isdigit() and len(code) == 5
|
||||
|
||||
|
||||
def _is_us_code(stock_code: str) -> bool:
|
||||
"""
|
||||
判断代码是否为美股
|
||||
|
||||
美股代码规则:
|
||||
- 1-5个大写字母,如 'AAPL' (苹果), 'TSLA' (特斯拉)
|
||||
- 可能包含 '.' 用于特殊股票类别,如 'BRK.B' (伯克希尔B类股)
|
||||
|
||||
Args:
|
||||
stock_code: 股票代码
|
||||
|
||||
Returns:
|
||||
True 表示是美股代码,False 表示不是美股代码
|
||||
|
||||
Examples:
|
||||
>>> _is_us_code('AAPL')
|
||||
True
|
||||
>>> _is_us_code('TSLA')
|
||||
True
|
||||
>>> _is_us_code('BRK.B')
|
||||
True
|
||||
>>> _is_us_code('600519')
|
||||
False
|
||||
>>> _is_us_code('hk00700')
|
||||
False
|
||||
"""
|
||||
import re
|
||||
code = stock_code.strip().upper()
|
||||
# 美股:1-5个大写字母,可能包含一个点和字母(如 BRK.B)
|
||||
return bool(re.match(r'^[A-Z]{1,5}(\.[A-Z])?$', code))
|
||||
|
||||
|
||||
class AkshareFetcher(BaseFetcher):
|
||||
"""
|
||||
Akshare 数据源实现
|
||||
@@ -556,6 +588,8 @@ class AkshareFetcher(BaseFetcher):
|
||||
根据代码类型自动选择数据源:
|
||||
- 普通股票:ak.stock_zh_a_spot_em()
|
||||
- ETF 基金:ak.fund_etf_spot_em()
|
||||
- 港股:ak.stock_hk_spot_em()
|
||||
- 美股:不支持,返回 None(由 YfinanceFetcher 处理)
|
||||
|
||||
Args:
|
||||
stock_code: 股票/ETF代码
|
||||
@@ -564,7 +598,11 @@ class AkshareFetcher(BaseFetcher):
|
||||
RealtimeQuote 对象,获取失败返回 None
|
||||
"""
|
||||
# 根据代码类型选择不同的获取方法
|
||||
if _is_hk_code(stock_code):
|
||||
if _is_us_code(stock_code):
|
||||
# 美股不使用 Akshare,由 YfinanceFetcher 处理
|
||||
logger.debug(f"[API跳过] {stock_code} 是美股,Akshare 不支持美股实时行情")
|
||||
return None
|
||||
elif _is_hk_code(stock_code):
|
||||
return self._get_hk_realtime_quote(stock_code)
|
||||
elif _is_etf_code(stock_code):
|
||||
return self._get_etf_realtime_quote(stock_code)
|
||||
@@ -857,6 +895,11 @@ class AkshareFetcher(BaseFetcher):
|
||||
"""
|
||||
import akshare as ak
|
||||
|
||||
# 美股没有筹码分布数据(Akshare 不支持)
|
||||
if _is_us_code(stock_code):
|
||||
logger.debug(f"[API跳过] {stock_code} 是美股,无筹码分布数据")
|
||||
return None
|
||||
|
||||
# ETF/指数没有筹码分布数据
|
||||
if _is_etf_code(stock_code):
|
||||
logger.debug(f"[API跳过] {stock_code} 是 ETF/指数,无筹码分布数据")
|
||||
|
||||
@@ -61,26 +61,50 @@ class YfinanceFetcher(BaseFetcher):
|
||||
"""
|
||||
转换股票代码为 Yahoo Finance 格式
|
||||
|
||||
Yahoo Finance A 股代码格式:
|
||||
- 沪市:600519.SS (Shanghai Stock Exchange)
|
||||
- 深市:000001.SZ (Shenzhen Stock Exchange)
|
||||
Yahoo Finance 代码格式:
|
||||
- A股沪市:600519.SS (Shanghai Stock Exchange)
|
||||
- A股深市:000001.SZ (Shenzhen Stock Exchange)
|
||||
- 港股:0700.HK (Hong Kong Stock Exchange)
|
||||
- 美股:AAPL, TSLA, GOOGL (无需后缀)
|
||||
|
||||
Args:
|
||||
stock_code: 原始代码,如 '600519', '000001'
|
||||
stock_code: 原始代码,如 '600519', 'hk00700', 'AAPL'
|
||||
|
||||
Returns:
|
||||
Yahoo Finance 格式代码,如 '600519.SS', '000001.SZ'
|
||||
Yahoo Finance 格式代码
|
||||
|
||||
Examples:
|
||||
>>> fetcher._convert_stock_code('600519')
|
||||
'600519.SS'
|
||||
>>> fetcher._convert_stock_code('hk00700')
|
||||
'0700.HK'
|
||||
>>> fetcher._convert_stock_code('AAPL')
|
||||
'AAPL'
|
||||
"""
|
||||
code = stock_code.strip()
|
||||
import re
|
||||
|
||||
code = stock_code.strip().upper()
|
||||
|
||||
# 美股:1-5个大写字母(可能包含 .),直接返回
|
||||
if re.match(r'^[A-Z]{1,5}(\.[A-Z])?$', code):
|
||||
logger.debug(f"识别为美股代码: {code}")
|
||||
return code
|
||||
|
||||
# 港股:hk前缀 -> .HK后缀
|
||||
if code.startswith('HK'):
|
||||
hk_code = code[2:].lstrip('0') or '0' # 去除前导0,但保留至少一个0
|
||||
hk_code = hk_code.zfill(4) # 补齐到4位
|
||||
logger.debug(f"转换港股代码: {stock_code} -> {hk_code}.HK")
|
||||
return f"{hk_code}.HK"
|
||||
|
||||
# 已经包含后缀的情况
|
||||
if '.SS' in code.upper() or '.SZ' in code.upper():
|
||||
return code.upper()
|
||||
if '.SS' in code or '.SZ' in code or '.HK' in code:
|
||||
return code
|
||||
|
||||
# 去除可能的后缀
|
||||
code = code.replace('.SH', '').replace('.sh', '')
|
||||
# 去除可能的 .SH 后缀
|
||||
code = code.replace('.SH', '')
|
||||
|
||||
# 根据代码前缀判断市场
|
||||
# A股:根据代码前缀判断市场
|
||||
if code.startswith(('600', '601', '603', '688')):
|
||||
return f"{code}.SS"
|
||||
elif code.startswith(('000', '002', '300')):
|
||||
|
||||
@@ -28,6 +28,11 @@ x-common: &common
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
# 注意:容器内如果绑定到 127.0.0.1,宿主机端口映射将无法访问 WebUI。
|
||||
# 即使 .env 里设置了 WEBUI_HOST=127.0.0.1,这里也会强制覆盖。
|
||||
- WEBUI_HOST=0.0.0.0
|
||||
- WEBUI_PORT=8000
|
||||
|
||||
# 代理设置(如果需要)
|
||||
# - http_proxy=http://host.docker.internal:10809
|
||||
# - https_proxy=http://host.docker.internal:10809
|
||||
|
||||
265
docs/bot-command.md
Normal file
@@ -0,0 +1,265 @@
|
||||
|
||||
|
||||
## 一、整体设计
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Platforms [外部平台]
|
||||
FS[飞书]
|
||||
DT[钉钉]
|
||||
WC[企业微信(开发中)]
|
||||
TG[Telegram(开发中)]
|
||||
More[更多平台...]
|
||||
end
|
||||
|
||||
subgraph BotModule [bot/ 模块]
|
||||
WH[Webhook Server]
|
||||
Adapters[平台适配器]
|
||||
Dispatcher[命令分发器]
|
||||
Commands[命令处理器]
|
||||
end
|
||||
|
||||
subgraph Core [现有核心模块]
|
||||
AS[AnalysisService]
|
||||
MA[MarketAnalyzer]
|
||||
NS[NotificationService]
|
||||
end
|
||||
|
||||
FS -->|POST /bot/feishu| WH
|
||||
DT -->|POST /bot/dingtalk| WH
|
||||
WC -->|POST /bot/wecom| WH
|
||||
TG -->|POST /bot/telegram| WH
|
||||
|
||||
WH --> Adapters
|
||||
Adapters -->|统一消息格式| Dispatcher
|
||||
Dispatcher --> Commands
|
||||
Commands --> AS
|
||||
Commands --> MA
|
||||
Commands --> NS
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 二、目录结构
|
||||
|
||||
在项目根目录新建 `bot/` 目录:
|
||||
|
||||
```
|
||||
bot/
|
||||
├── __init__.py # 模块入口,导出主要类
|
||||
├── models.py # 统一的消息/响应模型
|
||||
├── dispatcher.py # 命令分发器(核心)
|
||||
├── commands/ # 命令处理器
|
||||
│ ├── __init__.py
|
||||
│ ├── base.py # 命令抽象基类
|
||||
│ ├── analyze.py # /analyze 股票分析
|
||||
│ ├── market.py # /market 大盘复盘
|
||||
│ ├── help.py # /help 帮助信息
|
||||
│ └── status.py # /status 系统状态
|
||||
└── platforms/ # 平台适配器
|
||||
├── __init__.py
|
||||
├── base.py # 平台抽象基类
|
||||
├── feishu.py # 飞书机器人
|
||||
├── dingtalk.py # 钉钉机器人
|
||||
├── dingtalk_stream.py # 钉钉机器人Stream
|
||||
├── wecom.py # 企业微信机器人 (开发中)
|
||||
└── telegram.py # Telegram 机器人 (开发中)
|
||||
```
|
||||
|
||||
## 三、核心抽象设计
|
||||
|
||||
### 3.1 统一消息模型 (`bot/models.py`)
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class BotMessage:
|
||||
"""统一的机器人消息模型"""
|
||||
platform: str # 平台标识: feishu/dingtalk/wecom/telegram
|
||||
user_id: str # 发送者 ID
|
||||
user_name: str # 发送者名称
|
||||
chat_id: str # 会话 ID(群聊或私聊)
|
||||
chat_type: str # 会话类型: group/private
|
||||
content: str # 消息文本内容
|
||||
raw_data: Dict # 原始请求数据(平台特定)
|
||||
timestamp: datetime # 消息时间
|
||||
mentioned: bool = False # 是否@了机器人
|
||||
|
||||
@dataclass
|
||||
class BotResponse:
|
||||
"""统一的机器人响应模型"""
|
||||
text: str # 回复文本
|
||||
markdown: bool = False # 是否为 Markdown
|
||||
at_user: bool = True # 是否@发送者
|
||||
```
|
||||
|
||||
### 3.2 平台适配器基类 (`bot/platforms/base.py`)
|
||||
|
||||
```python
|
||||
class BotPlatform(ABC):
|
||||
"""平台适配器抽象基类"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def platform_name(self) -> str:
|
||||
"""平台标识名称"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def verify_request(self, headers: Dict, body: bytes) -> bool:
|
||||
"""验证请求签名(安全校验)"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def parse_message(self, data: Dict) -> Optional[BotMessage]:
|
||||
"""解析平台消息为统一格式"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def format_response(self, response: BotResponse) -> Dict:
|
||||
"""将统一响应转换为平台格式"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 3.3 命令基类 (`bot/commands/base.py`)
|
||||
|
||||
```python
|
||||
class BotCommand(ABC):
|
||||
"""命令处理器抽象基类"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""命令名称 (如 'analyze')"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def aliases(self) -> List[str]:
|
||||
"""命令别名 (如 ['a', '分析'])"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def description(self) -> str:
|
||||
"""命令描述"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def usage(self) -> str:
|
||||
"""使用说明"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, message: BotMessage, args: List[str]) -> BotResponse:
|
||||
"""执行命令"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 3.4 命令分发器 (`bot/dispatcher.py`)
|
||||
|
||||
```python
|
||||
class CommandDispatcher:
|
||||
"""命令分发器 - 单例模式"""
|
||||
|
||||
def __init__(self):
|
||||
self._commands: Dict[str, BotCommand] = {}
|
||||
self._aliases: Dict[str, str] = {}
|
||||
|
||||
def register(self, command: BotCommand) -> None:
|
||||
"""注册命令"""
|
||||
self._commands[command.name] = command
|
||||
for alias in command.aliases:
|
||||
self._aliases[alias] = command.name
|
||||
|
||||
def dispatch(self, message: BotMessage) -> BotResponse:
|
||||
"""分发消息到对应命令"""
|
||||
# 1. 解析命令和参数
|
||||
# 2. 查找命令处理器
|
||||
# 3. 执行并返回响应
|
||||
```
|
||||
|
||||
## 四、已支持的命令
|
||||
|
||||
| 命令 | 别名 | 说明 | 示例 |
|
||||
|
||||
|------|------|------|------|
|
||||
|
||||
| /analyze | /a, 分析 | 分析指定股票 | `/analyze 600519` |
|
||||
|
||||
| /market | /m, 大盘 | 大盘复盘 | `/market` |
|
||||
|
||||
| /batch | /b, 批量 | 批量分析自选股 | `/batch` |
|
||||
|
||||
| /help | /h, 帮助 | 显示帮助信息 | `/help` |
|
||||
|
||||
| /status | /s, 状态 | 系统状态 | `/status` |
|
||||
|
||||
## 五、Webhook 路由
|
||||
|
||||
在 [web/router.py](../web/router.py) 中注册新路由:
|
||||
|
||||
```python
|
||||
# Webhook 路由
|
||||
/bot/feishu # POST - 飞书事件回调
|
||||
/bot/dingtalk # POST - 钉钉事件回调
|
||||
/bot/wecom # POST - 企业微信事件回调 (开发中)
|
||||
/bot/telegram # POST - Telegram 更新回调 (开发中)
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
在 [config.py](../config.py) 中新增机器人配置:
|
||||
|
||||
```python
|
||||
# === 机器人配置 ===
|
||||
bot_enabled: bool = False # 是否启用机器人
|
||||
bot_command_prefix: str = "/" # 命令前缀
|
||||
|
||||
# 飞书机器人(事件订阅)
|
||||
feishu_app_id: str # 已有
|
||||
feishu_app_secret: str # 已有
|
||||
feishu_verification_token: str # 新增:事件校验 Token
|
||||
feishu_encrypt_key: str # 新增:加密密钥
|
||||
|
||||
# 钉钉机器人(应用)
|
||||
dingtalk_app_key: str # 新增
|
||||
dingtalk_app_secret: str # 新增
|
||||
|
||||
# 企业微信机器人(开发中)
|
||||
wecom_token: str # 新增:回调 Token
|
||||
wecom_encoding_aes_key: str # 新增:EncodingAESKey
|
||||
|
||||
# Telegram 机器人(开发中)
|
||||
telegram_bot_token: str # 已有
|
||||
telegram_webhook_secret: str # 新增:Webhook 密钥
|
||||
```
|
||||
|
||||
## 扩展说明
|
||||
### 怎样新增一个通知平台
|
||||
|
||||
1. 在 `bot/platforms/` 创建新文件
|
||||
2. 继承 `BotPlatform` 基类
|
||||
3. 实现 `verify_request`, `parse_message`, `format_response`
|
||||
4. 在路由中注册 Webhook 端点
|
||||
|
||||
### 怎样新增新增命令
|
||||
|
||||
1. 在 `bot/commands/` 创建新文件
|
||||
2. 继承 `BotCommand` 基类
|
||||
3. 实现 `execute` 方法
|
||||
4. 在分发器中注册命令
|
||||
|
||||
## 安全相关配置
|
||||
|
||||
- 支持命令频率限制(防刷)
|
||||
- 敏感操作(如批量分析)可设置权限白名单
|
||||
|
||||
在 [config.py](../config.py) 中新增机器人安全配置:
|
||||
|
||||
```python
|
||||
bot_rate_limit_requests: int = 10 # 频率限制:窗口内最大请求数
|
||||
bot_rate_limit_window: int = 60 # 频率限制:窗口时间(秒)
|
||||
bot_admin_users: List[str] = field(default_factory=list) # 管理员用户 ID 列表,限制敏感操作
|
||||
```
|
||||
|
||||
BIN
docs/bot/add-dingding-bot.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
docs/bot/add-group-bot.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
docs/bot/appkey.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/bot/configbot.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
39
docs/bot/dingding-bot-config.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 钉钉企业机器人配置
|
||||
|
||||
## 钉钉机器人
|
||||
钉钉机器人接收消息需要使用企业机器人能力
|
||||
https://open.dingtalk.com/document/dingstart/configure-the-robot-application
|
||||
|
||||
接收消息分为 `Http模式`(需要配置公网地址) 和 `Stream模式` 两种, 推荐使用 `Stream模式`
|
||||
|
||||
创建应用步骤:https://open.dingtalk.com/document/dingstart/create-application
|
||||
|
||||
应用开发 > 企业内部应用 > 钉钉应用 > 创建应用 > 添加应用能力 > 机器人
|
||||
|
||||
### 添加机器人
|
||||
|
||||

|
||||
|
||||
### 配置机器人使用 Stream模式
|
||||
|
||||

|
||||
|
||||
### 获取应用凭证
|
||||

|
||||
|
||||
### 配置钉钉凭证
|
||||
把钉钉应用凭证配置到配置文件中
|
||||

|
||||
|
||||
### 发布应用
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### 往下滚动会看到增加的企业机器人
|
||||

|
||||
|
||||
### 测试机器人命令
|
||||

|
||||
BIN
docs/bot/envconfig.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
20
docs/bot/feishu-bot-config.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 飞书机器人配置
|
||||
|
||||
## 创建应用
|
||||
https://open.feishu.cn/document/develop-an-echo-bot/introduction
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 获取密钥
|
||||

|
||||
|
||||
## 发布应用
|
||||

|
||||
|
||||
## 在飞书中打开应用
|
||||

|
||||
|
||||
## 消息交互
|
||||

|
||||
BIN
docs/bot/group.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/bot/img.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/bot/img_1.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/bot/img_10.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
docs/bot/img_2.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
docs/bot/img_3.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/bot/img_4.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
docs/bot/img_5.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/bot/img_6.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
docs/bot/img_7.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/bot/img_8.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
docs/bot/img_9.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
72
main.py
@@ -47,6 +47,7 @@ from data_provider import DataFetcherManager
|
||||
from data_provider.akshare_fetcher import AkshareFetcher, RealtimeQuote, ChipDistribution
|
||||
from analyzer import GeminiAnalyzer, AnalysisResult, STOCK_NAME_MAP
|
||||
from notification import NotificationService, NotificationChannel, send_daily_report
|
||||
from bot.models import BotMessage
|
||||
from search_service import SearchService, SearchResponse
|
||||
from enums import ReportType
|
||||
from stock_analyzer import StockTrendAnalyzer, TrendAnalysisResult
|
||||
@@ -135,7 +136,8 @@ class StockAnalysisPipeline:
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[Config] = None,
|
||||
max_workers: Optional[int] = None
|
||||
max_workers: Optional[int] = None,
|
||||
source_message: Optional[BotMessage] = None
|
||||
):
|
||||
"""
|
||||
初始化调度器
|
||||
@@ -146,6 +148,7 @@ class StockAnalysisPipeline:
|
||||
"""
|
||||
self.config = config or get_config()
|
||||
self.max_workers = max_workers or self.config.max_workers
|
||||
self.source_message = source_message
|
||||
|
||||
# 初始化各模块
|
||||
self.db = get_db()
|
||||
@@ -153,7 +156,7 @@ class StockAnalysisPipeline:
|
||||
self.akshare_fetcher = AkshareFetcher() # 用于获取增强数据(量比、筹码等)
|
||||
self.trend_analyzer = StockTrendAnalyzer() # 趋势分析器
|
||||
self.analyzer = GeminiAnalyzer()
|
||||
self.notifier = NotificationService()
|
||||
self.notifier = NotificationService(source_message=source_message)
|
||||
|
||||
# 初始化搜索服务
|
||||
self.search_service = SearchService(
|
||||
@@ -445,7 +448,7 @@ class StockAnalysisPipeline:
|
||||
code: 股票代码
|
||||
skip_analysis: 是否跳过 AI 分析
|
||||
single_stock_notify: 是否启用单股推送模式(每分析完一只立即推送)
|
||||
report_type: 报告类型枚举
|
||||
report_type: 报告类型枚举(从配置读取,Issue #119)
|
||||
|
||||
Returns:
|
||||
AnalysisResult 或 None
|
||||
@@ -540,8 +543,14 @@ class StockAnalysisPipeline:
|
||||
|
||||
# 单股推送模式(#55):从配置读取
|
||||
single_stock_notify = getattr(self.config, 'single_stock_notify', False)
|
||||
# Issue #119: 从配置读取报告类型
|
||||
report_type_str = getattr(self.config, 'report_type', 'simple').lower()
|
||||
report_type = ReportType.FULL if report_type_str == 'full' else ReportType.SIMPLE
|
||||
# Issue #128: 从配置读取分析间隔
|
||||
analysis_delay = getattr(self.config, 'analysis_delay', 0)
|
||||
|
||||
if single_stock_notify:
|
||||
logger.info("已启用单股推送模式:每分析完一只股票立即推送")
|
||||
logger.info(f"已启用单股推送模式:每分析完一只股票立即推送(报告类型: {report_type_str})")
|
||||
|
||||
results: List[AnalysisResult] = []
|
||||
|
||||
@@ -554,18 +563,26 @@ class StockAnalysisPipeline:
|
||||
self.process_single_stock,
|
||||
code,
|
||||
skip_analysis=dry_run,
|
||||
single_stock_notify=single_stock_notify and send_notification
|
||||
single_stock_notify=single_stock_notify and send_notification,
|
||||
report_type=report_type # Issue #119: 传递报告类型
|
||||
): code
|
||||
for code in stock_codes
|
||||
}
|
||||
|
||||
# 收集结果
|
||||
for future in as_completed(future_to_code):
|
||||
for idx, future in enumerate(as_completed(future_to_code)):
|
||||
code = future_to_code[future]
|
||||
try:
|
||||
result = future.result()
|
||||
if result:
|
||||
results.append(result)
|
||||
|
||||
# Issue #128: 个股之间添加延迟,避免API限流
|
||||
# 在非最后一只股票完成后添加延迟
|
||||
if idx < len(stock_codes) - 1 and analysis_delay > 0:
|
||||
logger.debug(f"等待 {analysis_delay} 秒后继续下一只股票...")
|
||||
time.sleep(analysis_delay)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{code}] 任务执行失败: {e}")
|
||||
|
||||
@@ -622,6 +639,7 @@ class StockAnalysisPipeline:
|
||||
# 推送通知
|
||||
if self.notifier.is_available():
|
||||
channels = self.notifier.get_available_channels()
|
||||
context_success = self.notifier.send_to_context(report)
|
||||
|
||||
# 企业微信:只发精简版(平台限制)
|
||||
wechat_success = False
|
||||
@@ -647,7 +665,7 @@ class StockAnalysisPipeline:
|
||||
else:
|
||||
logger.warning(f"未知通知渠道: {channel}")
|
||||
|
||||
success = wechat_success or non_wechat_success
|
||||
success = wechat_success or non_wechat_success or context_success
|
||||
if success:
|
||||
logger.info("决策仪表盘推送成功")
|
||||
else:
|
||||
@@ -827,6 +845,12 @@ def run_full_analysis(
|
||||
send_notification=not args.no_notify
|
||||
)
|
||||
|
||||
# Issue #128: 分析间隔 - 在个股分析和大盘分析之间添加延迟
|
||||
analysis_delay = getattr(config, 'analysis_delay', 0)
|
||||
if analysis_delay > 0 and config.market_review_enabled and not args.no_market_review:
|
||||
logger.info(f"等待 {analysis_delay} 秒后执行大盘复盘(避免API限流)...")
|
||||
time.sleep(analysis_delay)
|
||||
|
||||
# 2. 运行大盘复盘(如果启用且不是仅个股模式)
|
||||
market_report = ""
|
||||
if config.market_review_enabled and not args.no_market_review:
|
||||
@@ -889,6 +913,39 @@ def run_full_analysis(
|
||||
logger.exception(f"分析流程执行失败: {e}")
|
||||
|
||||
|
||||
def start_bot_stream_clients(config: Config) -> None:
|
||||
"""Start bot stream clients when enabled in config."""
|
||||
# 启动钉钉 Stream 客户端
|
||||
if config.dingtalk_stream_enabled:
|
||||
try:
|
||||
from bot.platforms import start_dingtalk_stream_background, DINGTALK_STREAM_AVAILABLE
|
||||
if DINGTALK_STREAM_AVAILABLE:
|
||||
if start_dingtalk_stream_background():
|
||||
logger.info("[Main] Dingtalk Stream client started in background.")
|
||||
else:
|
||||
logger.warning("[Main] Dingtalk Stream client failed to start.")
|
||||
else:
|
||||
logger.warning("[Main] Dingtalk Stream enabled but SDK is missing.")
|
||||
logger.warning("[Main] Run: pip install dingtalk-stream")
|
||||
except Exception as exc:
|
||||
logger.error(f"[Main] Failed to start Dingtalk Stream client: {exc}")
|
||||
|
||||
# 启动飞书 Stream 客户端
|
||||
if getattr(config, 'feishu_stream_enabled', False):
|
||||
try:
|
||||
from bot.platforms import start_feishu_stream_background, FEISHU_SDK_AVAILABLE
|
||||
if FEISHU_SDK_AVAILABLE:
|
||||
if start_feishu_stream_background():
|
||||
logger.info("[Main] Feishu Stream client started in background.")
|
||||
else:
|
||||
logger.warning("[Main] Feishu Stream client failed to start.")
|
||||
else:
|
||||
logger.warning("[Main] Feishu Stream enabled but SDK is missing.")
|
||||
logger.warning("[Main] Run: pip install lark-oapi")
|
||||
except Exception as exc:
|
||||
logger.error(f"[Main] Failed to start Feishu Stream client: {exc}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""
|
||||
主入口函数
|
||||
@@ -929,6 +986,7 @@ def main() -> int:
|
||||
try:
|
||||
from webui import run_server_in_thread
|
||||
run_server_in_thread(host=config.webui_host, port=config.webui_port)
|
||||
start_bot_stream_clients(config)
|
||||
except Exception as e:
|
||||
logger.error(f"启动 WebUI 失败: {e}")
|
||||
|
||||
|
||||
427
notification.py
@@ -38,6 +38,7 @@ except ImportError:
|
||||
|
||||
from config import get_config
|
||||
from analyzer import AnalysisResult
|
||||
from bot.models import BotMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,8 +50,9 @@ class NotificationChannel(Enum):
|
||||
TELEGRAM = "telegram" # Telegram
|
||||
EMAIL = "email" # 邮件
|
||||
PUSHOVER = "pushover" # Pushover(手机/桌面推送)
|
||||
PUSHPLUS = "pushplus" # PushPlus(国内推送服务)
|
||||
CUSTOM = "custom" # 自定义 Webhook
|
||||
DISCORD = "discord" # Discord
|
||||
DISCORD = "discord" # Discord 机器人 (Bot)
|
||||
UNKNOWN = "unknown" # 未知
|
||||
|
||||
|
||||
@@ -95,8 +97,9 @@ class ChannelDetector:
|
||||
NotificationChannel.TELEGRAM: "Telegram",
|
||||
NotificationChannel.EMAIL: "邮件",
|
||||
NotificationChannel.PUSHOVER: "Pushover",
|
||||
NotificationChannel.PUSHPLUS: "PushPlus",
|
||||
NotificationChannel.CUSTOM: "自定义Webhook",
|
||||
NotificationChannel.DISCORD: "Discord",
|
||||
NotificationChannel.DISCORD: "Discord机器人",
|
||||
NotificationChannel.UNKNOWN: "未知渠道",
|
||||
}
|
||||
return names.get(channel, "未知渠道")
|
||||
@@ -121,13 +124,15 @@ class NotificationService:
|
||||
注意:所有已配置的渠道都会收到推送
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, source_message: Optional[BotMessage] = None):
|
||||
"""
|
||||
初始化通知服务
|
||||
|
||||
检测所有已配置的渠道,推送时会向所有渠道发送
|
||||
"""
|
||||
config = get_config()
|
||||
self._source_message = source_message
|
||||
self._context_channels: List[str] = []
|
||||
|
||||
# 各渠道的 Webhook URL
|
||||
self._wechat_url = config.wechat_webhook_url
|
||||
@@ -152,6 +157,9 @@ class NotificationService:
|
||||
'api_token': getattr(config, 'pushover_api_token', None),
|
||||
}
|
||||
|
||||
# PushPlus 配置
|
||||
self._pushplus_token = getattr(config, 'pushplus_token', None)
|
||||
|
||||
# 自定义 Webhook 配置
|
||||
self._custom_webhook_urls = getattr(config, 'custom_webhook_urls', []) or []
|
||||
self._custom_webhook_bearer_token = getattr(config, 'custom_webhook_bearer_token', None)
|
||||
@@ -177,12 +185,15 @@ class NotificationService:
|
||||
|
||||
# 检测所有已配置的渠道
|
||||
self._available_channels = self._detect_all_channels()
|
||||
if self._has_context_channel():
|
||||
self._context_channels.append("钉钉会话")
|
||||
|
||||
if not self._available_channels:
|
||||
if not self._available_channels and not self._context_channels:
|
||||
logger.warning("未配置有效的通知渠道,将不发送推送通知")
|
||||
else:
|
||||
channel_names = [ChannelDetector.get_channel_name(ch) for ch in self._available_channels]
|
||||
logger.info(f"已配置 {len(self._available_channels)} 个通知渠道:{', '.join(channel_names)}")
|
||||
channel_names.extend(self._context_channels)
|
||||
logger.info(f"已配置 {len(channel_names)} 个通知渠道:{', '.join(channel_names)}")
|
||||
|
||||
def _detect_all_channels(self) -> List[NotificationChannel]:
|
||||
"""
|
||||
@@ -213,6 +224,10 @@ class NotificationService:
|
||||
if self._is_pushover_configured():
|
||||
channels.append(NotificationChannel.PUSHOVER)
|
||||
|
||||
# PushPlus
|
||||
if self._pushplus_token:
|
||||
channels.append(NotificationChannel.PUSHPLUS)
|
||||
|
||||
# 自定义 Webhook
|
||||
if self._custom_webhook_urls:
|
||||
channels.append(NotificationChannel.CUSTOM)
|
||||
@@ -227,6 +242,13 @@ class NotificationService:
|
||||
"""检查 Telegram 配置是否完整"""
|
||||
return bool(self._telegram_config['bot_token'] and self._telegram_config['chat_id'])
|
||||
|
||||
def _is_discord_configured(self) -> bool:
|
||||
"""检查 Discord 配置是否完整(支持 Bot 或 Webhook)"""
|
||||
# 只要配置了 Webhook 或完整的 Bot Token+Channel,即视为可用
|
||||
bot_ok = bool(self._discord_config['bot_token'] and self._discord_config['channel_id'])
|
||||
webhook_ok = bool(self._discord_config['webhook_url'])
|
||||
return bot_ok or webhook_ok
|
||||
|
||||
def _is_email_configured(self) -> bool:
|
||||
"""检查邮件配置是否完整(只需邮箱和授权码)"""
|
||||
return bool(self._email_config['sender'] and self._email_config['password'])
|
||||
@@ -247,8 +269,8 @@ class NotificationService:
|
||||
return bool(self._discord_config['bot_token']) and discord_available
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""检查通知服务是否可用(至少有一个渠道)"""
|
||||
return len(self._available_channels) > 0
|
||||
"""检查通知服务是否可用(至少有一个渠道或上下文渠道)"""
|
||||
return len(self._available_channels) > 0 or self._has_context_channel()
|
||||
|
||||
def get_available_channels(self) -> List[NotificationChannel]:
|
||||
"""获取所有已配置的渠道"""
|
||||
@@ -467,7 +489,59 @@ class NotificationService:
|
||||
|
||||
def get_channel_names(self) -> str:
|
||||
"""获取所有已配置渠道的名称"""
|
||||
return ', '.join([ChannelDetector.get_channel_name(ch) for ch in self._available_channels])
|
||||
names = [ChannelDetector.get_channel_name(ch) for ch in self._available_channels]
|
||||
if self._has_context_channel():
|
||||
names.append("钉钉会话")
|
||||
return ', '.join(names)
|
||||
|
||||
def _has_context_channel(self) -> bool:
|
||||
"""判断是否存在基于消息上下文的临时渠道(如钉钉会话、飞书会话)"""
|
||||
return (
|
||||
self._extract_dingtalk_session_webhook() is not None
|
||||
or self._extract_feishu_reply_info() is not None
|
||||
)
|
||||
|
||||
def _extract_dingtalk_session_webhook(self) -> Optional[str]:
|
||||
"""从来源消息中提取钉钉会话 Webhook(用于 Stream 模式回复)"""
|
||||
if not isinstance(self._source_message, BotMessage):
|
||||
return None
|
||||
raw_data = getattr(self._source_message, "raw_data", {}) or {}
|
||||
if not isinstance(raw_data, dict):
|
||||
return None
|
||||
session_webhook = (
|
||||
raw_data.get("_session_webhook")
|
||||
or raw_data.get("sessionWebhook")
|
||||
or raw_data.get("session_webhook")
|
||||
or raw_data.get("session_webhook_url")
|
||||
)
|
||||
if not session_webhook and isinstance(raw_data.get("headers"), dict):
|
||||
session_webhook = raw_data["headers"].get("sessionWebhook")
|
||||
return session_webhook
|
||||
|
||||
def _extract_feishu_reply_info(self) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
从来源消息中提取飞书回复信息(用于 Stream 模式回复)
|
||||
|
||||
Returns:
|
||||
包含 chat_id 的字典,或 None
|
||||
"""
|
||||
if not isinstance(self._source_message, BotMessage):
|
||||
return None
|
||||
if getattr(self._source_message, "platform", "") != "feishu":
|
||||
return None
|
||||
chat_id = getattr(self._source_message, "chat_id", "")
|
||||
if not chat_id:
|
||||
return None
|
||||
return {"chat_id": chat_id}
|
||||
|
||||
def send_to_context(self, content: str) -> bool:
|
||||
"""
|
||||
向基于消息上下文的渠道发送消息(例如钉钉 Stream 会话)
|
||||
|
||||
Args:
|
||||
content: Markdown 格式内容
|
||||
"""
|
||||
return self._send_via_source_context(content)
|
||||
|
||||
def generate_daily_report(
|
||||
self,
|
||||
@@ -489,7 +563,7 @@ class NotificationService:
|
||||
|
||||
# 标题
|
||||
report_lines = [
|
||||
f"# 📅 {report_date} A股自选股智能分析报告",
|
||||
f"# 📅 {report_date} 股票智能分析报告",
|
||||
"",
|
||||
f"> 共分析 **{len(results)}** 只股票 | 报告生成时间:{datetime.now().strftime('%H:%M:%S')}",
|
||||
"",
|
||||
@@ -720,9 +794,25 @@ class NotificationService:
|
||||
"",
|
||||
f"> 共分析 **{len(results)}** 只股票 | 🟢买入:{buy_count} 🟡观望:{hold_count} 🔴卖出:{sell_count}",
|
||||
"",
|
||||
]
|
||||
|
||||
# === 新增:分析结果摘要 (Issue #112) ===
|
||||
if results:
|
||||
report_lines.extend([
|
||||
"## 📊 分析结果摘要",
|
||||
"",
|
||||
])
|
||||
for r in sorted_results:
|
||||
emoji = r.get_emoji()
|
||||
report_lines.append(
|
||||
f"{emoji} **{r.name}({r.code})**: {r.operation_advice} | "
|
||||
f"评分 {r.sentiment_score} | {r.trend_prediction}"
|
||||
)
|
||||
report_lines.extend([
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
]
|
||||
])
|
||||
|
||||
# 逐个股票的决策仪表盘
|
||||
for result in sorted_results:
|
||||
@@ -1110,7 +1200,7 @@ class NotificationService:
|
||||
avg_score = sum(r.sentiment_score for r in results) / len(results) if results else 0
|
||||
|
||||
lines = [
|
||||
f"## 📅 {report_date} A股分析报告",
|
||||
f"## 📅 {report_date} 股票分析报告",
|
||||
"",
|
||||
f"> 共 **{len(results)}** 只 | 🟢买入:{buy_count} 🟡持有:{hold_count} 🔴卖出:{sell_count} | 均分:{avg_score:.0f}",
|
||||
"",
|
||||
@@ -1850,7 +1940,7 @@ class NotificationService:
|
||||
# 生成主题
|
||||
if subject is None:
|
||||
date_str = datetime.now().strftime('%Y-%m-%d')
|
||||
subject = f"📈 A股智能分析报告 - {date_str}"
|
||||
subject = f"📈 股票智能分析报告 - {date_str}"
|
||||
|
||||
# 将 Markdown 转换为简单 HTML
|
||||
html_content = self._markdown_to_html(content)
|
||||
@@ -2642,6 +2732,308 @@ class NotificationService:
|
||||
"body": content
|
||||
}
|
||||
|
||||
def _send_via_source_context(self, content: str) -> bool:
|
||||
"""
|
||||
使用消息上下文(如钉钉/飞书会话)发送一份报告
|
||||
|
||||
主要用于从机器人 Stream 模式触发的任务,确保结果能回到触发的会话。
|
||||
"""
|
||||
success = False
|
||||
|
||||
# 尝试钉钉会话
|
||||
session_webhook = self._extract_dingtalk_session_webhook()
|
||||
if session_webhook:
|
||||
try:
|
||||
if self._send_dingtalk_chunked(session_webhook, content, max_bytes=20000):
|
||||
logger.info("已通过钉钉会话(Stream)推送报告")
|
||||
success = True
|
||||
else:
|
||||
logger.error("钉钉会话(Stream)推送失败")
|
||||
except Exception as e:
|
||||
logger.error(f"钉钉会话(Stream)推送异常: {e}")
|
||||
|
||||
# 尝试飞书会话
|
||||
feishu_info = self._extract_feishu_reply_info()
|
||||
if feishu_info:
|
||||
try:
|
||||
if self._send_feishu_stream_reply(feishu_info["chat_id"], content):
|
||||
logger.info("已通过飞书会话(Stream)推送报告")
|
||||
success = True
|
||||
else:
|
||||
logger.error("飞书会话(Stream)推送失败")
|
||||
except Exception as e:
|
||||
logger.error(f"飞书会话(Stream)推送异常: {e}")
|
||||
|
||||
return success
|
||||
|
||||
def _send_feishu_stream_reply(self, chat_id: str, content: str) -> bool:
|
||||
"""
|
||||
通过飞书 Stream 模式发送消息到指定会话
|
||||
|
||||
Args:
|
||||
chat_id: 飞书会话 ID
|
||||
content: 消息内容
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
try:
|
||||
from bot.platforms.feishu_stream import FeishuReplyClient, FEISHU_SDK_AVAILABLE
|
||||
if not FEISHU_SDK_AVAILABLE:
|
||||
logger.warning("飞书 SDK 不可用,无法发送 Stream 回复")
|
||||
return False
|
||||
|
||||
from config import get_config
|
||||
config = get_config()
|
||||
|
||||
app_id = getattr(config, 'feishu_app_id', None)
|
||||
app_secret = getattr(config, 'feishu_app_secret', None)
|
||||
|
||||
if not app_id or not app_secret:
|
||||
logger.warning("飞书 APP_ID 或 APP_SECRET 未配置")
|
||||
return False
|
||||
|
||||
# 创建回复客户端
|
||||
reply_client = FeishuReplyClient(app_id, app_secret)
|
||||
|
||||
# 飞书文本消息有长度限制,需要分批发送
|
||||
max_bytes = getattr(config, 'feishu_max_bytes', 20000)
|
||||
content_bytes = len(content.encode('utf-8'))
|
||||
|
||||
if content_bytes > max_bytes:
|
||||
return self._send_feishu_stream_chunked(reply_client, chat_id, content, max_bytes)
|
||||
|
||||
return reply_client.send_to_chat(chat_id, content)
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"导入飞书 Stream 模块失败: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"飞书 Stream 回复异常: {e}")
|
||||
return False
|
||||
|
||||
def _send_feishu_stream_chunked(
|
||||
self,
|
||||
reply_client,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
max_bytes: int
|
||||
) -> bool:
|
||||
"""
|
||||
分批发送长消息到飞书(Stream 模式)
|
||||
|
||||
Args:
|
||||
reply_client: FeishuReplyClient 实例
|
||||
chat_id: 飞书会话 ID
|
||||
content: 完整消息内容
|
||||
max_bytes: 单条消息最大字节数
|
||||
|
||||
Returns:
|
||||
是否全部发送成功
|
||||
"""
|
||||
import time
|
||||
|
||||
def get_bytes(s: str) -> int:
|
||||
return len(s.encode('utf-8'))
|
||||
|
||||
# 按段落或分隔线分割
|
||||
if "\n---\n" in content:
|
||||
sections = content.split("\n---\n")
|
||||
separator = "\n---\n"
|
||||
elif "\n### " in content:
|
||||
parts = content.split("\n### ")
|
||||
sections = [parts[0]] + [f"### {p}" for p in parts[1:]]
|
||||
separator = "\n"
|
||||
else:
|
||||
# 按行分割
|
||||
sections = content.split("\n")
|
||||
separator = "\n"
|
||||
|
||||
chunks = []
|
||||
current_chunk = []
|
||||
current_bytes = 0
|
||||
separator_bytes = get_bytes(separator)
|
||||
|
||||
for section in sections:
|
||||
section_bytes = get_bytes(section) + separator_bytes
|
||||
|
||||
if current_bytes + section_bytes > max_bytes:
|
||||
if current_chunk:
|
||||
chunks.append(separator.join(current_chunk))
|
||||
current_chunk = [section]
|
||||
current_bytes = section_bytes
|
||||
else:
|
||||
current_chunk.append(section)
|
||||
current_bytes += section_bytes
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(separator.join(current_chunk))
|
||||
|
||||
# 发送每个分块
|
||||
success = True
|
||||
for i, chunk in enumerate(chunks):
|
||||
if i > 0:
|
||||
time.sleep(0.5) # 避免请求过快
|
||||
|
||||
if not reply_client.send_to_chat(chat_id, chunk):
|
||||
success = False
|
||||
logger.error(f"飞书 Stream 分块 {i+1}/{len(chunks)} 发送失败")
|
||||
|
||||
return success
|
||||
|
||||
def send_to_pushplus(self, content: str, title: Optional[str] = None) -> bool:
|
||||
"""
|
||||
推送消息到 PushPlus
|
||||
|
||||
PushPlus API 格式:
|
||||
POST http://www.pushplus.plus/send
|
||||
{
|
||||
"token": "用户令牌",
|
||||
"title": "消息标题",
|
||||
"content": "消息内容",
|
||||
"template": "html/txt/json/markdown"
|
||||
}
|
||||
|
||||
PushPlus 特点:
|
||||
- 国内推送服务,免费额度充足
|
||||
- 支持微信公众号推送
|
||||
- 支持多种消息格式
|
||||
|
||||
Args:
|
||||
content: 消息内容(Markdown 格式)
|
||||
title: 消息标题(可选)
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
if not self._pushplus_token:
|
||||
logger.warning("PushPlus Token 未配置,跳过推送")
|
||||
return False
|
||||
|
||||
# PushPlus API 端点
|
||||
api_url = "http://www.pushplus.plus/send"
|
||||
|
||||
# 处理消息标题
|
||||
if title is None:
|
||||
date_str = datetime.now().strftime('%Y-%m-%d')
|
||||
title = f"📈 股票分析报告 - {date_str}"
|
||||
|
||||
try:
|
||||
payload = {
|
||||
"token": self._pushplus_token,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"template": "markdown" # 使用 Markdown 格式
|
||||
}
|
||||
|
||||
response = requests.post(api_url, json=payload, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get('code') == 200:
|
||||
logger.info("PushPlus 消息发送成功")
|
||||
return True
|
||||
else:
|
||||
error_msg = result.get('msg', '未知错误')
|
||||
logger.error(f"PushPlus 返回错误: {error_msg}")
|
||||
return False
|
||||
else:
|
||||
logger.error(f"PushPlus 请求失败: HTTP {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送 PushPlus 消息失败: {e}")
|
||||
return False
|
||||
|
||||
def send_to_discord(self, content: str) -> bool:
|
||||
"""
|
||||
推送消息到 Discord(支持 Webhook 和 Bot API)
|
||||
|
||||
Args:
|
||||
content: Markdown 格式的消息内容
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
# 优先使用 Webhook(配置简单,权限低)
|
||||
if self._discord_config['webhook_url']:
|
||||
return self._send_discord_webhook(content)
|
||||
|
||||
# 其次使用 Bot API(权限高,需要 channel_id)
|
||||
if self._discord_config['bot_token'] and self._discord_config['channel_id']:
|
||||
return self._send_discord_bot(content)
|
||||
|
||||
logger.warning("Discord 配置不完整,跳过推送")
|
||||
return False
|
||||
|
||||
def _send_discord_webhook(self, content: str) -> bool:
|
||||
"""
|
||||
使用 Webhook 发送消息到 Discord
|
||||
|
||||
Discord Webhook 支持 Markdown 格式
|
||||
|
||||
Args:
|
||||
content: Markdown 格式的消息内容
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
try:
|
||||
payload = {
|
||||
'content': content,
|
||||
'username': 'A股分析机器人',
|
||||
'avatar_url': 'https://picsum.photos/200'
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
self._discord_config['webhook_url'],
|
||||
json=payload,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code in [200, 204]:
|
||||
logger.info("Discord Webhook 消息发送成功")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Discord Webhook 发送失败: {response.status_code} {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Discord Webhook 发送异常: {e}")
|
||||
return False
|
||||
|
||||
def _send_discord_bot(self, content: str) -> bool:
|
||||
"""
|
||||
使用 Bot API 发送消息到 Discord
|
||||
|
||||
Args:
|
||||
content: Markdown 格式的消息内容
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
try:
|
||||
headers = {
|
||||
'Authorization': f'Bot {self._discord_config["bot_token"]}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
payload = {
|
||||
'content': content
|
||||
}
|
||||
|
||||
url = f'https://discord.com/api/v10/channels/{self._discord_config["channel_id"]}/messages'
|
||||
response = requests.post(url, json=payload, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info("Discord Bot 消息发送成功")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Discord Bot 发送失败: {response.status_code} {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Discord Bot 发送异常: {e}")
|
||||
return False
|
||||
|
||||
def send(self, content: str) -> bool:
|
||||
"""
|
||||
统一发送接口 - 向所有已配置的渠道发送
|
||||
@@ -2654,7 +3046,12 @@ class NotificationService:
|
||||
Returns:
|
||||
是否至少有一个渠道发送成功
|
||||
"""
|
||||
if not self.is_available():
|
||||
context_success = self.send_to_context(content)
|
||||
|
||||
if not self._available_channels:
|
||||
if context_success:
|
||||
logger.info("已通过消息上下文渠道完成推送(无其他通知渠道)")
|
||||
return True
|
||||
logger.warning("通知服务不可用,跳过推送")
|
||||
return False
|
||||
|
||||
@@ -2677,6 +3074,8 @@ class NotificationService:
|
||||
result = self.send_to_email(content)
|
||||
elif channel == NotificationChannel.PUSHOVER:
|
||||
result = self.send_to_pushover(content)
|
||||
elif channel == NotificationChannel.PUSHPLUS:
|
||||
result = self.send_to_pushplus(content)
|
||||
elif channel == NotificationChannel.CUSTOM:
|
||||
result = self.send_to_custom(content)
|
||||
elif channel == NotificationChannel.DISCORD:
|
||||
@@ -2695,7 +3094,7 @@ class NotificationService:
|
||||
fail_count += 1
|
||||
|
||||
logger.info(f"通知发送完成:成功 {success_count} 个,失败 {fail_count} 个")
|
||||
return success_count > 0
|
||||
return success_count > 0 or context_success
|
||||
|
||||
def _send_chunked_messages(self, content: str, max_length: int) -> bool:
|
||||
"""
|
||||
|
||||
@@ -34,7 +34,7 @@ google-search-results>=2.4.0 # SerpAPI(每月 100 次免费)
|
||||
requests>=2.31.0 # HTTP 请求
|
||||
fake-useragent>=1.4.0 # 随机 User-Agent 防封禁
|
||||
httpx[socks] # HTTP 客户端 + SOCKS 代理支持(OpenAI 可选依赖)
|
||||
|
||||
dingtalk-stream >= 0.24.3 # 钉钉 Stream SDK
|
||||
# 数据库
|
||||
# SQLite 是 Python 内置,无需额外安装
|
||||
|
||||
|
||||
360
test.sh
Executable file
@@ -0,0 +1,360 @@
|
||||
#!/bin/bash
|
||||
# ===================================
|
||||
# A股/港股/美股 智能分析系统 - 测试脚本
|
||||
# ===================================
|
||||
#
|
||||
# 使用方法:
|
||||
# ./test.sh [测试场景]
|
||||
#
|
||||
# 测试场景:
|
||||
# market - 仅大盘复盘
|
||||
# a-stock - A股个股分析(茅台、平安银行)
|
||||
# hk-stock - 港股分析(腾讯、阿里)
|
||||
# us-stock - 美股分析(苹果、特斯拉)
|
||||
# mixed - 混合市场分析
|
||||
# single - 单股模式测试
|
||||
# dry-run - 仅获取数据不分析
|
||||
# full - 完整流程测试
|
||||
# quick - 快速测试(单只股票)
|
||||
# all - 运行所有测试
|
||||
#
|
||||
# 示例:
|
||||
# ./test.sh market # 测试大盘复盘
|
||||
# ./test.sh us-stock # 测试美股分析
|
||||
# ./test.sh quick # 快速测试
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 打印带颜色的信息
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
header() {
|
||||
echo ""
|
||||
echo "=============================================="
|
||||
echo -e "${GREEN}$1${NC}"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 检查Python环境
|
||||
check_python() {
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
error "Python3 未安装"
|
||||
exit 1
|
||||
fi
|
||||
info "Python版本: $(python3 --version)"
|
||||
}
|
||||
|
||||
# 检查依赖
|
||||
check_deps() {
|
||||
info "检查依赖..."
|
||||
python3 -c "import yfinance" 2>/dev/null || { warn "yfinance 未安装,美股测试可能失败"; }
|
||||
python3 -c "import akshare" 2>/dev/null || { warn "akshare 未安装,A股/港股测试可能失败"; }
|
||||
success "依赖检查完成"
|
||||
}
|
||||
|
||||
# ==================== 测试场景 ====================
|
||||
|
||||
# 测试1: 大盘复盘
|
||||
test_market() {
|
||||
header "测试场景: 大盘复盘"
|
||||
info "运行大盘复盘分析..."
|
||||
python3 main.py --market-review --no-notify
|
||||
success "大盘复盘测试完成"
|
||||
}
|
||||
|
||||
# 测试2: A股分析
|
||||
test_a_stock() {
|
||||
header "测试场景: A股分析"
|
||||
info "分析A股: 600519(茅台), 000001(平安银行)"
|
||||
python3 main.py --stocks 600519,000001 --no-notify --no-market-review
|
||||
success "A股分析测试完成"
|
||||
}
|
||||
|
||||
# 测试3: 港股分析
|
||||
test_hk_stock() {
|
||||
header "测试场景: 港股分析"
|
||||
info "分析港股: hk00700(腾讯), hk09988(阿里)"
|
||||
python3 main.py --stocks hk00700,hk09988 --no-notify --no-market-review
|
||||
success "港股分析测试完成"
|
||||
}
|
||||
|
||||
# 测试4: 美股分析
|
||||
test_us_stock() {
|
||||
header "测试场景: 美股分析"
|
||||
info "分析美股: AAPL(苹果), TSLA(特斯拉)"
|
||||
# 允许透传参数,默认不带 --no-notify
|
||||
python3 main.py --stocks AAPL --no-market-review "$@"
|
||||
success "美股分析测试完成"
|
||||
}
|
||||
|
||||
# 测试5: 混合市场
|
||||
test_mixed() {
|
||||
header "测试场景: 混合市场分析"
|
||||
info "分析混合市场: 600519(A股), hk00700(港股), AAPL(美股)"
|
||||
python3 main.py --stocks 600519,hk00700,AAPL --no-notify --no-market-review
|
||||
success "混合市场测试完成"
|
||||
}
|
||||
|
||||
# 测试6: 单股推送模式
|
||||
test_single() {
|
||||
header "测试场景: 单股推送模式"
|
||||
info "测试单股推送模式..."
|
||||
python3 main.py --stocks 600519 --single-notify --no-notify --no-market-review
|
||||
success "单股推送模式测试完成"
|
||||
}
|
||||
|
||||
# 测试7: dry-run模式
|
||||
test_dry_run() {
|
||||
header "测试场景: Dry-Run 模式"
|
||||
info "仅获取数据,不进行AI分析..."
|
||||
python3 main.py --stocks 600519,AAPL --dry-run --no-notify
|
||||
success "Dry-Run 测试完成"
|
||||
}
|
||||
|
||||
# 测试8: 完整流程
|
||||
test_full() {
|
||||
header "测试场景: 完整流程"
|
||||
info "运行完整分析流程(个股+大盘)..."
|
||||
python3 main.py --stocks 600519 --no-notify
|
||||
success "完整流程测试完成"
|
||||
}
|
||||
|
||||
# 测试9: 快速测试
|
||||
test_quick() {
|
||||
header "测试场景: 快速测试"
|
||||
info "单只股票快速测试..."
|
||||
python3 main.py --stocks 600519 --no-notify --no-market-review
|
||||
success "快速测试完成"
|
||||
}
|
||||
|
||||
# 测试10: 代码识别测试
|
||||
test_code_recognition() {
|
||||
header "测试场景: 代码识别"
|
||||
info "测试股票代码识别逻辑..."
|
||||
|
||||
python3 << 'PYTEST'
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
from data_provider.akshare_fetcher import _is_hk_code, _is_us_code
|
||||
|
||||
test_cases = [
|
||||
# (代码, 预期HK, 预期US, 描述)
|
||||
("AAPL", False, True, "美股-苹果"),
|
||||
("TSLA", False, True, "美股-特斯拉"),
|
||||
("BRK.B", False, True, "美股-伯克希尔B"),
|
||||
("hk00700", True, False, "港股-腾讯"),
|
||||
("HK09988", True, False, "港股-阿里"),
|
||||
("600519", False, False, "A股-茅台"),
|
||||
("000001", False, False, "A股-平安"),
|
||||
]
|
||||
|
||||
print("\n股票代码识别测试:")
|
||||
print("-" * 60)
|
||||
all_pass = True
|
||||
for code, exp_hk, exp_us, desc in test_cases:
|
||||
is_hk = _is_hk_code(code)
|
||||
is_us = _is_us_code(code)
|
||||
hk_ok = is_hk == exp_hk
|
||||
us_ok = is_us == exp_us
|
||||
status = "✅" if (hk_ok and us_ok) else "❌"
|
||||
all_pass = all_pass and hk_ok and us_ok
|
||||
print(f"{status} {code:10} | HK:{is_hk:5} US:{is_us:5} | {desc}")
|
||||
|
||||
print("-" * 60)
|
||||
print(f"{'✅ 所有测试通过!' if all_pass else '❌ 有测试失败!'}")
|
||||
sys.exit(0 if all_pass else 1)
|
||||
PYTEST
|
||||
|
||||
success "代码识别测试完成"
|
||||
}
|
||||
|
||||
# 测试11: YFinance代码转换测试
|
||||
test_yfinance_convert() {
|
||||
header "测试场景: YFinance 代码转换"
|
||||
info "测试YFinance代码转换逻辑..."
|
||||
|
||||
python3 << 'PYTEST'
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
from data_provider.yfinance_fetcher import YfinanceFetcher
|
||||
|
||||
fetcher = YfinanceFetcher()
|
||||
|
||||
test_cases = [
|
||||
("AAPL", "AAPL", "美股"),
|
||||
("tsla", "TSLA", "美股小写"),
|
||||
("BRK.B", "BRK.B", "美股特殊"),
|
||||
("hk00700", "0700.HK", "港股"),
|
||||
("HK09988", "9988.HK", "港股大写"),
|
||||
("600519", "600519.SS", "A股沪市"),
|
||||
("000001", "000001.SZ", "A股深市"),
|
||||
("300750", "300750.SZ", "A股创业板"),
|
||||
]
|
||||
|
||||
print("\nYFinance 代码转换测试:")
|
||||
print("-" * 60)
|
||||
all_pass = True
|
||||
for input_code, expected, desc in test_cases:
|
||||
result = fetcher._convert_stock_code(input_code)
|
||||
status = "✅" if result == expected else "❌"
|
||||
all_pass = all_pass and (result == expected)
|
||||
print(f"{status} {input_code:10} -> {result:12} (期望: {expected:12}) | {desc}")
|
||||
|
||||
print("-" * 60)
|
||||
print(f"{'✅ 所有测试通过!' if all_pass else '❌ 有测试失败!'}")
|
||||
sys.exit(0 if all_pass else 1)
|
||||
PYTEST
|
||||
|
||||
success "YFinance 代码转换测试完成"
|
||||
}
|
||||
|
||||
# 测试12: 语法检查
|
||||
test_syntax() {
|
||||
header "测试场景: Python 语法检查"
|
||||
info "检查所有Python文件语法..."
|
||||
|
||||
python3 -m py_compile main.py config.py notification.py \
|
||||
data_provider/akshare_fetcher.py \
|
||||
data_provider/yfinance_fetcher.py \
|
||||
web/handlers.py \
|
||||
bot/commands/analyze.py
|
||||
|
||||
success "语法检查通过"
|
||||
}
|
||||
|
||||
# 测试13: Flake8 静态检查
|
||||
test_flake8() {
|
||||
header "测试场景: Flake8 静态检查"
|
||||
info "运行 Flake8 检查严重错误..."
|
||||
|
||||
if command -v flake8 &> /dev/null; then
|
||||
flake8 main.py config.py notification.py --select=F821,E999 --max-line-length=120
|
||||
success "Flake8 检查通过"
|
||||
else
|
||||
warn "Flake8 未安装,跳过检查"
|
||||
fi
|
||||
}
|
||||
|
||||
# 运行所有测试
|
||||
test_all() {
|
||||
header "运行所有测试"
|
||||
|
||||
test_syntax
|
||||
test_code_recognition
|
||||
test_yfinance_convert
|
||||
test_flake8
|
||||
|
||||
echo ""
|
||||
info "以下测试需要网络和API配置,可能会失败:"
|
||||
echo ""
|
||||
|
||||
test_dry_run || warn "Dry-Run 测试失败(可能是网络问题)"
|
||||
test_quick || warn "快速测试失败(可能是API问题)"
|
||||
|
||||
success "所有测试完成!"
|
||||
}
|
||||
|
||||
# ==================== 主程序 ====================
|
||||
|
||||
main() {
|
||||
header "A股/港股/美股 智能分析系统 - 测试"
|
||||
|
||||
check_python
|
||||
check_deps
|
||||
|
||||
case "${1:-help}" in
|
||||
market)
|
||||
test_market
|
||||
;;
|
||||
a-stock|a_stock|astock)
|
||||
test_a_stock
|
||||
;;
|
||||
hk-stock|hk_stock|hkstock|hk)
|
||||
test_hk_stock
|
||||
;;
|
||||
us-stock|us_stock|usstock|us)
|
||||
shift # 移除第一个参数(test_name)
|
||||
test_us_stock "$@"
|
||||
;;
|
||||
mixed|mix)
|
||||
test_mixed
|
||||
;;
|
||||
single)
|
||||
test_single
|
||||
;;
|
||||
dry-run|dryrun|dry)
|
||||
test_dry_run
|
||||
;;
|
||||
full)
|
||||
test_full
|
||||
;;
|
||||
quick|q)
|
||||
test_quick
|
||||
;;
|
||||
code|recognition)
|
||||
test_code_recognition
|
||||
;;
|
||||
yfinance|yf)
|
||||
test_yfinance_convert
|
||||
;;
|
||||
syntax)
|
||||
test_syntax
|
||||
;;
|
||||
flake8|lint)
|
||||
test_flake8
|
||||
;;
|
||||
all)
|
||||
test_all
|
||||
;;
|
||||
help|--help|-h|*)
|
||||
echo "使用方法: $0 [测试场景]"
|
||||
echo ""
|
||||
echo "测试场景:"
|
||||
echo " market - 仅大盘复盘"
|
||||
echo " a-stock - A股个股分析"
|
||||
echo " hk-stock - 港股分析"
|
||||
echo " us-stock - 美股分析"
|
||||
echo " mixed - 混合市场分析"
|
||||
echo " single - 单股推送模式"
|
||||
echo " dry-run - 仅获取数据"
|
||||
echo " full - 完整流程"
|
||||
echo " quick - 快速测试(推荐)"
|
||||
echo " code - 代码识别测试"
|
||||
echo " yfinance - YFinance转换测试"
|
||||
echo " syntax - 语法检查"
|
||||
echo " flake8 - 静态检查"
|
||||
echo " all - 运行所有测试"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 quick # 快速测试"
|
||||
echo " $0 us-stock # 测试美股"
|
||||
echo " $0 code # 测试代码识别"
|
||||
echo " $0 all # 运行所有测试"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -174,12 +174,15 @@ class ApiHandler:
|
||||
|
||||
code = code_list[0].strip()
|
||||
|
||||
# 验证股票代码格式:A股(6位数字) 或 港股(hk+5位数字)
|
||||
# 验证股票代码格式:A股(6位数字) / 港股(hk+5位数字) / 美股(1-5个大写字母)
|
||||
code = code.lower()
|
||||
is_valid = re.match(r'^\d{6}$', code) or re.match(r'^hk\d{5}$', code)
|
||||
if not is_valid:
|
||||
is_a_stock = re.match(r'^\d{6}$', code)
|
||||
is_hk_stock = re.match(r'^hk\d{5}$', code)
|
||||
is_us_stock = re.match(r'^[A-Z]{1,5}(\.[A-Z])?$', code.upper())
|
||||
|
||||
if not (is_a_stock or is_hk_stock or is_us_stock):
|
||||
return JsonResponse(
|
||||
{"success": False, "error": f"无效的股票代码格式: {code} (A股6位数字 或 港股hk+5位数字)"},
|
||||
{"success": False, "error": f"无效的股票代码格式: {code} (A股6位数字 / 港股hk+5位数字 / 美股1-5个字母)"},
|
||||
status=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
@@ -246,12 +249,64 @@ class ApiHandler:
|
||||
return JsonResponse({"success": True, "task": task})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Bot Webhook 处理器
|
||||
# ============================================================
|
||||
|
||||
class BotHandler:
|
||||
"""
|
||||
机器人 Webhook 处理器
|
||||
|
||||
处理各平台的机器人回调请求。
|
||||
"""
|
||||
|
||||
def handle_webhook(self, platform: str, form_data: Dict[str, list], headers: Dict[str, str], body: bytes) -> Response:
|
||||
"""
|
||||
处理 Webhook 请求
|
||||
|
||||
Args:
|
||||
platform: 平台名称 (feishu, dingtalk, wecom, telegram)
|
||||
form_data: POST 数据(已解析)
|
||||
headers: HTTP 请求头
|
||||
body: 原始请求体
|
||||
|
||||
Returns:
|
||||
Response 对象
|
||||
"""
|
||||
try:
|
||||
from bot.handler import handle_webhook
|
||||
from bot.models import WebhookResponse
|
||||
|
||||
# 调用 bot 模块处理
|
||||
webhook_response = handle_webhook(platform, headers, body)
|
||||
|
||||
# 转换为 web 响应
|
||||
return JsonResponse(
|
||||
webhook_response.body,
|
||||
status=HTTPStatus(webhook_response.status_code)
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"[BotHandler] Bot 模块未正确安装: {e}")
|
||||
return JsonResponse(
|
||||
{"error": "Bot module not available"},
|
||||
status=HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[BotHandler] 处理 {platform} Webhook 失败: {e}")
|
||||
return JsonResponse(
|
||||
{"error": str(e)},
|
||||
status=HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 处理器工厂
|
||||
# ============================================================
|
||||
|
||||
_page_handler: PageHandler | None = None
|
||||
_api_handler: ApiHandler | None = None
|
||||
_bot_handler: BotHandler | None = None
|
||||
|
||||
|
||||
def get_page_handler() -> PageHandler:
|
||||
@@ -268,3 +323,11 @@ def get_api_handler() -> ApiHandler:
|
||||
if _api_handler is None:
|
||||
_api_handler = ApiHandler()
|
||||
return _api_handler
|
||||
|
||||
|
||||
def get_bot_handler() -> BotHandler:
|
||||
"""获取 Bot 处理器实例"""
|
||||
global _bot_handler
|
||||
if _bot_handler is None:
|
||||
_bot_handler = BotHandler()
|
||||
return _bot_handler
|
||||
|
||||
@@ -18,8 +18,8 @@ from typing import Callable, Dict, List, Optional, TYPE_CHECKING, Tuple
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from web.handlers import (
|
||||
Response, HtmlResponse,
|
||||
get_page_handler, get_api_handler
|
||||
Response, HtmlResponse, JsonResponse,
|
||||
get_page_handler, get_api_handler, get_bot_handler
|
||||
)
|
||||
from web.templates import render_error_page
|
||||
|
||||
@@ -173,9 +173,17 @@ class Router:
|
||||
parsed = urlparse(request_handler.path)
|
||||
path = parsed.path
|
||||
|
||||
# 读取 POST body
|
||||
# 读取 POST body(保留原始字节用于 Bot Webhook)
|
||||
content_length = int(request_handler.headers.get("Content-Length", "0") or "0")
|
||||
raw_body = request_handler.rfile.read(content_length).decode("utf-8", errors="replace")
|
||||
raw_body_bytes = request_handler.rfile.read(content_length)
|
||||
raw_body = raw_body_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
# 检查是否是 Bot Webhook 路由
|
||||
if path.startswith("/bot/"):
|
||||
self._dispatch_bot_webhook(request_handler, path, raw_body_bytes)
|
||||
return
|
||||
|
||||
# 普通 POST 请求
|
||||
form_data = parse_qs(raw_body)
|
||||
|
||||
# 匹配路由
|
||||
@@ -194,6 +202,42 @@ class Router:
|
||||
logger.error(f"[Router] 处理 POST 请求失败: {path} - {e}")
|
||||
self._send_error(request_handler, str(e))
|
||||
|
||||
def _dispatch_bot_webhook(
|
||||
self,
|
||||
request_handler: 'BaseHTTPRequestHandler',
|
||||
path: str,
|
||||
body: bytes
|
||||
) -> None:
|
||||
"""
|
||||
分发 Bot Webhook 请求
|
||||
|
||||
Bot Webhook 需要原始 body 和 headers,与普通路由处理不同。
|
||||
|
||||
Args:
|
||||
request_handler: HTTP 请求处理器
|
||||
path: 请求路径
|
||||
body: 原始请求体字节
|
||||
"""
|
||||
# 提取平台名称:/bot/feishu -> feishu
|
||||
parts = path.strip('/').split('/')
|
||||
if len(parts) < 2:
|
||||
self._send_not_found(request_handler, path)
|
||||
return
|
||||
|
||||
platform = parts[1]
|
||||
|
||||
# 获取请求头
|
||||
headers = {key: value for key, value in request_handler.headers.items()}
|
||||
|
||||
try:
|
||||
bot_handler = get_bot_handler()
|
||||
response = bot_handler.handle_webhook(platform, {}, headers, body)
|
||||
response.send(request_handler)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Router] 处理 Bot Webhook 失败: {path} - {e}")
|
||||
self._send_error(request_handler, str(e))
|
||||
|
||||
def list_routes(self) -> List[Tuple[str, str, str]]:
|
||||
"""
|
||||
列出所有路由
|
||||
@@ -278,6 +322,39 @@ def create_default_router() -> Router:
|
||||
"查询任务状态"
|
||||
)
|
||||
|
||||
# === Bot Webhook 路由 ===
|
||||
# 注意:Bot Webhook 路由在 dispatch_post 中特殊处理
|
||||
# 这里只是为了在路由列表中显示
|
||||
# 实际请求会被 _dispatch_bot_webhook 方法处理
|
||||
|
||||
# 飞书机器人 Webhook
|
||||
router.register(
|
||||
"/bot/feishu", "POST",
|
||||
lambda form: JsonResponse({"error": "Use POST with JSON body"}),
|
||||
"飞书机器人 Webhook"
|
||||
)
|
||||
|
||||
# 钉钉机器人 Webhook
|
||||
router.register(
|
||||
"/bot/dingtalk", "POST",
|
||||
lambda form: JsonResponse({"error": "Use POST with JSON body"}),
|
||||
"钉钉机器人 Webhook"
|
||||
)
|
||||
|
||||
# 企业微信机器人 Webhook(开发中)
|
||||
# router.register(
|
||||
# "/bot/wecom", "POST",
|
||||
# lambda form: JsonResponse({"error": "Use POST with JSON body"}),
|
||||
# "企业微信机器人 Webhook"
|
||||
# )
|
||||
|
||||
# Telegram 机器人 Webhook(开发中)
|
||||
# router.register(
|
||||
# "/bot/telegram", "POST",
|
||||
# lambda form: JsonResponse({"error": "Use POST with JSON body"}),
|
||||
# "Telegram 机器人 Webhook"
|
||||
# )
|
||||
|
||||
return router
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List, Union
|
||||
|
||||
from enums import ReportType
|
||||
from bot.models import BotMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -171,7 +172,8 @@ class AnalysisService:
|
||||
def submit_analysis(
|
||||
self,
|
||||
code: str,
|
||||
report_type: Union[ReportType, str] = ReportType.SIMPLE
|
||||
report_type: Union[ReportType, str] = ReportType.SIMPLE,
|
||||
source_message: Optional[BotMessage] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
提交异步分析任务
|
||||
@@ -190,7 +192,7 @@ class AnalysisService:
|
||||
task_id = f"{code}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}"
|
||||
|
||||
# 提交到线程池
|
||||
self.executor.submit(self._run_analysis, code, task_id, report_type)
|
||||
self.executor.submit(self._run_analysis, code, task_id, report_type, source_message)
|
||||
|
||||
logger.info(f"[AnalysisService] 已提交股票 {code} 的分析任务, task_id={task_id}, report_type={report_type.value}")
|
||||
|
||||
@@ -219,7 +221,8 @@ class AnalysisService:
|
||||
self,
|
||||
code: str,
|
||||
task_id: str,
|
||||
report_type: ReportType = ReportType.SIMPLE
|
||||
report_type: ReportType = ReportType.SIMPLE,
|
||||
source_message: Optional[BotMessage] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
执行单只股票分析
|
||||
@@ -252,7 +255,11 @@ class AnalysisService:
|
||||
|
||||
# 创建分析管道
|
||||
config = get_config()
|
||||
pipeline = StockAnalysisPipeline(config=config, max_workers=1)
|
||||
pipeline = StockAnalysisPipeline(
|
||||
config=config,
|
||||
max_workers=1,
|
||||
source_message=source_message
|
||||
)
|
||||
|
||||
# 执行单只股票分析(启用单股推送)
|
||||
result = pipeline.process_single_stock(
|
||||
|
||||
@@ -922,7 +922,7 @@ def render_config_page(
|
||||
<input
|
||||
type="text"
|
||||
id="analysis_code"
|
||||
placeholder="A股 600519 / 港股 hk00700"
|
||||
placeholder="A股 600519 / 港股 hk00700 / 美股 AAPL"
|
||||
maxlength="8"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
45
webui.py
@@ -65,6 +65,42 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def _start_bot_stream_clients() -> None:
|
||||
"""启动 Bot Stream 模式客户端(如果已配置)"""
|
||||
from config import get_config
|
||||
config = get_config()
|
||||
|
||||
# 钉钉 Stream 模式
|
||||
if config.dingtalk_stream_enabled:
|
||||
try:
|
||||
from bot.platforms import start_dingtalk_stream_background, DINGTALK_STREAM_AVAILABLE
|
||||
if DINGTALK_STREAM_AVAILABLE:
|
||||
if start_dingtalk_stream_background():
|
||||
logger.info("[WebUI] 钉钉 Stream 客户端已在后台启动")
|
||||
else:
|
||||
logger.warning("[WebUI] 钉钉 Stream 客户端启动失败")
|
||||
else:
|
||||
logger.warning("[WebUI] 钉钉 Stream 模式已启用但 SDK 未安装")
|
||||
logger.warning("[WebUI] 请运行: pip install dingtalk-stream")
|
||||
except Exception as e:
|
||||
logger.error(f"[WebUI] 启动钉钉 Stream 客户端失败: {e}")
|
||||
|
||||
# 飞书 Stream 模式
|
||||
if getattr(config, 'feishu_stream_enabled', False):
|
||||
try:
|
||||
from bot.platforms import start_feishu_stream_background, FEISHU_SDK_AVAILABLE
|
||||
if FEISHU_SDK_AVAILABLE:
|
||||
if start_feishu_stream_background():
|
||||
logger.info("[WebUI] 飞书 Stream 客户端已在后台启动")
|
||||
else:
|
||||
logger.warning("[WebUI] 飞书 Stream 客户端启动失败")
|
||||
else:
|
||||
logger.warning("[WebUI] 飞书 Stream 模式已启用但 SDK 未安装")
|
||||
logger.warning("[WebUI] 请运行: pip install lark-oapi")
|
||||
except Exception as e:
|
||||
logger.error(f"[WebUI] 启动飞书 Stream 客户端失败: {e}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""
|
||||
主入口函数
|
||||
@@ -85,6 +121,15 @@ def main() -> int:
|
||||
print(" GET /task?id=xxx - 任务状态")
|
||||
print(" POST /update - 更新配置")
|
||||
print()
|
||||
print("Bot Webhooks:")
|
||||
print(" POST /bot/feishu - 飞书机器人")
|
||||
print(" POST /bot/dingtalk - 钉钉机器人")
|
||||
print(" POST /bot/wecom - 企业微信机器人")
|
||||
print(" POST /bot/telegram - Telegram 机器人")
|
||||
print()
|
||||
|
||||
# 启动 Bot Stream 客户端(如果配置了)
|
||||
_start_bot_stream_clients()
|
||||
|
||||
try:
|
||||
run_server(host=host, port=port)
|
||||
|
||||