Resolve conflicts with upstream/main and update Discord bot configuration

This commit is contained in:
adminlove520
2026-01-24 20:00:35 +08:00
56 changed files with 5001 additions and 112 deletions

View File

@@ -91,12 +91,60 @@ SERPAPI_API_KEYS=your_serpapi_key_here
# 注册Pushover账号并创建应用Token https://pushover.net/apps/build
# 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权限高
#
# 方式1Discord Webhook推荐无需 Bot 账号)
# 在 Discord 频道设置 -> 集成 -> Webhook -> 新建 Webhook -> 复制 URL
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your_webhook_id/your_webhook_token
#
# 方式2Discord Bot API需要 Bot 账号和频道 ID
# 1. 创建 Bothttps://discord.com/developers/applications -> 新建应用 -> Bot -> 创建 Bot
# 2. 获取 Bot TokenBot 页面 -> 重置 Token
# 3. 获取频道 IDDiscord 开启开发者模式 -> 右键频道 -> 复制 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
# ===================================
# 是否默认启动 WebUItrue/false默认 false
WEBUI_ENABLED=false
# WebUI 监听地址(默认 127.0.0.1,如需局域网访问请改为 0.0.0.0
# WebUI 监听地址(默认 127.0.0.1Docker/Compose 场景需要 0.0.0.0 才能从宿主机访问端口映射
WEBUI_HOST=127.0.0.1
# WebUI 监听端口(默认 8000
WEBUI_PORT=8000

View File

@@ -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 }}

View File

@@ -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 管理界面

View File

@@ -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

View File

@@ -1,4 +1,4 @@
# 📈 A股智能分析系统
# 📈 股智能分析系统
[![GitHub stars](https://img.shields.io/github/stars/ZhuLinsen/daily_stock_analysis?style=social)](https://github.com/ZhuLinsen/daily_stock_analysis/stargazers)
[![CI](https://github.com/ZhuLinsen/daily_stock_analysis/actions/workflows/ci.yml/badge.svg)](https://github.com/ZhuLinsen/daily_stock_analysis/actions/workflows/ci.yml)
@@ -6,7 +6,9 @@
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![GitHub Actions](https://img.shields.io/badge/GitHub%20Actions-Ready-2088FF?logo=github-actions&logoColor=white)](https://github.com/features/actions)
> 🤖 基于 AI 大模型的 A/H 股自选股智能分析系统,每日自动分析并推送「决策仪表盘」到企业微信/飞书/Telegram/邮箱
> 🤖 基于 AI 大模型的 A股/港股/美股自选股智能分析系统,每日自动分析并推送「决策仪表盘」到企业微信/飞书/Telegram/邮箱
[English](./README_EN.md) | 简体中文
![运行效果演示](./sources/all_2026-01-13_221547.gif)
@@ -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
View File

@@ -0,0 +1,389 @@
# 📈 AI Stock Analysis System
[![GitHub stars](https://img.shields.io/github/stars/ZhuLinsen/daily_stock_analysis?style=social)](https://github.com/ZhuLinsen/daily_stock_analysis/stargazers)
[![CI](https://github.com/ZhuLinsen/daily_stock_analysis/actions/workflows/ci.yml/badge.svg)](https://github.com/ZhuLinsen/daily_stock_analysis/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![GitHub Actions](https://img.shields.io/badge/GitHub%20Actions-Ready-2088FF?logo=github-actions&logoColor=white)](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)
![Demo](./sources/all_2026-01-13_221547.gif)
## ✨ 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!**

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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

View 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_idat_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

View File

@@ -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("提示:未配置通知渠道,将不发送推送通知")

View File

@@ -217,14 +217,14 @@ def _is_etf_code(stock_code: str) -> bool:
def _is_hk_code(stock_code: str) -> bool:
"""
判断代码是否为港股
港股代码规则:
- 5位数字代码'00700' (腾讯控股)
- 部分港股代码可能带有前缀,如 'hk00700', 'hk1810'
Args:
stock_code: 股票代码
Returns:
True 表示是港股代码False 表示不是港股代码
"""
@@ -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 数据源实现
@@ -552,19 +584,25 @@ class AkshareFetcher(BaseFetcher):
def get_realtime_quote(self, stock_code: str) -> Optional[RealtimeQuote]:
"""
获取实时行情数据
根据代码类型自动选择数据源:
- 普通股票ak.stock_zh_a_spot_em()
- ETF 基金ak.fund_etf_spot_em()
- 港股ak.stock_hk_spot_em()
- 美股:不支持,返回 None由 YfinanceFetcher 处理)
Args:
stock_code: 股票/ETF代码
Returns:
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)
@@ -856,7 +894,12 @@ class AkshareFetcher(BaseFetcher):
ChipDistribution 对象(最新一天的数据),获取失败返回 None
"""
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/指数,无筹码分布数据")

View File

@@ -60,27 +60,51 @@ class YfinanceFetcher(BaseFetcher):
def _convert_stock_code(self, stock_code: str) -> str:
"""
转换股票代码为 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()
# 去除可能的后缀
code = code.replace('.SH', '').replace('.sh', '')
# 根据代码前缀判断市场
if '.SS' in code or '.SZ' in code or '.HK' in code:
return code
# 去除可能的 .SH 后缀
code = code.replace('.SH', '')
# A股根据代码前缀判断市场
if code.startswith(('600', '601', '603', '688')):
return f"{code}.SS"
elif code.startswith(('000', '002', '300')):

View File

@@ -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
View 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 列表,限制敏感操作
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

BIN
docs/bot/add-group-bot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
docs/bot/appkey.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
docs/bot/configbot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

View 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
应用开发 > 企业内部应用 > 钉钉应用 > 创建应用 > 添加应用能力 > 机器人
### 添加机器人
![img.png](add-dingding-bot.png)
### 配置机器人使用 Stream模式
![configbot.png](configbot.png)
### 获取应用凭证
![img.png](appkey.png)
### 配置钉钉凭证
把钉钉应用凭证配置到配置文件中
![img.png](envconfig.png)
### 发布应用
![img.png](img.png)
![img.png](group.png)
![img.png](add-group-bot.png)
### 往下滚动会看到增加的企业机器人
![img_1.png](img_1.png)
### 测试机器人命令
![img_3.png](img_3.png)

BIN
docs/bot/envconfig.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -0,0 +1,20 @@
# 飞书机器人配置
## 创建应用
https://open.feishu.cn/document/develop-an-echo-bot/introduction
![img_6.png](img_6.png)
![img_8.png](img_8.png)
## 获取密钥
![img_7.png](img_7.png)
## 发布应用
![img_5.png](img_5.png)
## 在飞书中打开应用
![img_9.png](img_9.png)
## 消息交互
![img_10.png](img_10.png)

BIN
docs/bot/group.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
docs/bot/img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
docs/bot/img_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
docs/bot/img_10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

BIN
docs/bot/img_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
docs/bot/img_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
docs/bot/img_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

BIN
docs/bot/img_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
docs/bot/img_6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
docs/bot/img_7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
docs/bot/img_8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
docs/bot/img_9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

90
main.py
View File

@@ -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(
@@ -424,29 +427,29 @@ class StockAnalysisPipeline:
return "巨量"
def process_single_stock(
self,
code: str,
self,
code: str,
skip_analysis: bool = False,
single_stock_notify: bool = False,
report_type: ReportType = ReportType.SIMPLE
) -> Optional[AnalysisResult]:
"""
处理单只股票的完整流程
包括:
1. 获取数据
2. 保存数据
3. AI 分析
4. 单股推送(可选,#55
此方法会被线程池调用,需要处理好异常
Args:
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] = []
@@ -551,21 +560,29 @@ class StockAnalysisPipeline:
# 提交任务
future_to_code = {
executor.submit(
self.process_single_stock,
code,
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:
@@ -826,7 +844,13 @@ def run_full_analysis(
dry_run=args.dry_run,
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}")

View File

@@ -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
@@ -151,7 +156,10 @@ class NotificationService:
'user_key': getattr(config, 'pushover_user_key', None),
'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]:
"""
@@ -212,7 +223,11 @@ class NotificationService:
# Pushover
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,29 +489,81 @@ 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,
self,
results: List[AnalysisResult],
report_date: Optional[str] = None
) -> str:
"""
生成 Markdown 格式的日报(详细版)
Args:
results: 分析结果列表
report_date: 报告日期(默认今天)
Returns:
Markdown 格式的日报内容
"""
if report_date is None:
report_date = datetime.now().strftime('%Y-%m-%d')
# 标题
report_lines = [
f"# 📅 {report_date} A股自选股智能分析报告",
f"# 📅 {report_date}智能分析报告",
"",
f"> 共分析 **{len(results)}** 只股票 | 报告生成时间:{datetime.now().strftime('%H:%M:%S')}",
"",
@@ -688,42 +762,58 @@ class NotificationService:
return ('观望', '', '观望')
def generate_dashboard_report(
self,
self,
results: List[AnalysisResult],
report_date: Optional[str] = None
) -> str:
"""
生成决策仪表盘格式的日报(详细版)
格式:市场概览 + 重要信息 + 核心结论 + 数据透视 + 作战计划
Args:
results: 分析结果列表
report_date: 报告日期(默认今天)
Returns:
Markdown 格式的决策仪表盘日报
"""
if report_date is None:
report_date = datetime.now().strftime('%Y-%m-%d')
# 按评分排序(高分在前)
sorted_results = sorted(results, key=lambda x: x.sentiment_score, reverse=True)
# 统计信息
buy_count = sum(1 for r in results if r.operation_advice in ['买入', '加仓', '强烈买入'])
sell_count = sum(1 for r in results if r.operation_advice in ['卖出', '减仓', '强烈卖出'])
hold_count = sum(1 for r in results if r.operation_advice in ['持有', '观望'])
report_lines = [
f"# 🎯 {report_date} 决策仪表盘",
"",
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:
signal_text, signal_emoji, signal_tag = self._get_signal_level(result)
@@ -1091,26 +1181,26 @@ class NotificationService:
def generate_wechat_summary(self, results: List[AnalysisResult]) -> str:
"""
生成企业微信精简版日报控制在4000字符内
Args:
results: 分析结果列表
Returns:
精简版 Markdown 内容
"""
report_date = datetime.now().strftime('%Y-%m-%d')
# 按评分排序
sorted_results = sorted(results, key=lambda x: x.sentiment_score, reverse=True)
# 统计
buy_count = sum(1 for r in results if r.operation_advice in ['买入', '加仓', '强烈买入'])
sell_count = sum(1 for r in results if r.operation_advice in ['卖出', '减仓', '强烈卖出'])
hold_count = sum(1 for r in results if r.operation_advice in ['持有', '观望'])
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)
@@ -2641,6 +2731,308 @@ class NotificationService:
"message": content,
"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:
"""

View File

@@ -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
View 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 "$@"

View File

@@ -173,13 +173,16 @@ 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

View File

@@ -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

View File

@@ -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(

View File

@@ -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"
/>

View File

@@ -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)