Skip to content

Commit 184084e

Browse files
authored
feat: stock-to-email group routing (Fix ZhuLinsen#268) (ZhuLinsen#313)
- Add STOCK_GROUP_N + EMAIL_GROUP_N config for routing reports to different emails - Market review sends to all configured email receivers - Minimal changes: optional params on send_to_email and send(), new helpers
1 parent 6339cfc commit 184084e

9 files changed

Lines changed: 150 additions & 11 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ BRAVE_API_KEYS=your_brave_key_here
9393
# EMAIL_PASSWORD=your_email_auth_code
9494
# EMAIL_RECEIVERS=receiver@example.com # 可选,留空则发给自己
9595
#
96+
# 【方式四扩展】股票分组发往不同邮箱(Issue #268,可选)
97+
# 配置后,不同股票组的报告发送到对应邮箱;大盘复盘发往所有配置邮箱
98+
# STOCK_GROUP_1=600519,300750
99+
# EMAIL_GROUP_1=user1@example.com
100+
# STOCK_GROUP_2=002594,AAPL
101+
# EMAIL_GROUP_2=user2@example.com
102+
#
96103
# 【方式五】自定义 Webhook(支持多个,逗号分隔)
97104
# 适用于:钉钉、Discord、Slack、Bark、自建服务等任意支持 POST JSON 的 Webhook
98105
# 系统会自动识别常见服务并使用对应格式

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
| `EMAIL_PASSWORD` | 邮箱授权码(非登录密码) | 可选 |
9797
| `EMAIL_RECEIVERS` | 收件人邮箱(多个用逗号分隔,留空则发给自己) | 可选 |
9898
| `EMAIL_SENDER_NAME` | 邮件发件人显示名称(默认:daily_stock_analysis股票分析助手) | 可选 |
99+
| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | 股票分组发往不同邮箱(如 `STOCK_GROUP_1=600519,300750` `EMAIL_GROUP_1=user1@example.com`| 可选 |
99100
| `PUSHPLUS_TOKEN` | PushPlus Token([获取地址](https://www.pushplus.plus),国内推送服务) | 可选 |
100101
| `SERVERCHAN3_SENDKEY` | Server酱³ Sendkey([获取地址](https://sc3.ft07.com/),手机APP推送服务) | 可选 |
101102
| `CUSTOM_WEBHOOK_URLS` | 自定义 Webhook(支持钉钉等,多个用逗号分隔) | 可选 |

bot/commands/market.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def _run_market_review(self, message: BotMessage) -> None:
107107
if review_report:
108108
# 推送结果
109109
report_content = f"🎯 **大盘复盘**\n\n{review_report}"
110-
notifier.send(report_content)
110+
notifier.send(report_content, email_send_to_all=True)
111111
logger.info("[MarketCommand] 大盘复盘完成并已推送")
112112
else:
113113
logger.warning("[MarketCommand] 大盘复盘返回空结果")

docs/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)
66
版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)
77

8+
## [Unreleased]
9+
10+
### 新增
11+
- 📧 **股票分组发往不同邮箱** (Issue #268)
12+
- 支持 `STOCK_GROUP_N` + `EMAIL_GROUP_N` 配置,不同股票组报告发送到对应邮箱
13+
- 大盘复盘发往所有配置的邮箱
14+
815
## [3.0.5] - 2026-02-08
916

1017
### 修复

docs/full-guide.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ daily_stock_analysis/
168168
| `EMAIL_PASSWORD` | 邮箱授权码(非登录密码) | 可选 |
169169
| `EMAIL_RECEIVERS` | 收件人邮箱(逗号分隔,留空发给自己) | 可选 |
170170
| `EMAIL_SENDER_NAME` | 发件人显示名称 | 可选 |
171+
| `STOCK_GROUP_N` / `EMAIL_GROUP_N` | 股票分组发往不同邮箱(Issue #268),如 `STOCK_GROUP_1=600519,300750``EMAIL_GROUP_1=user1@example.com` 配对 | 可选 |
171172
| `CUSTOM_WEBHOOK_URLS` | 自定义 Webhook(逗号分隔) | 可选 |
172173
| `CUSTOM_WEBHOOK_BEARER_TOKEN` | 自定义 Webhook Bearer Token | 可选 |
173174
| `PUSHOVER_USER_KEY` | Pushover 用户 Key | 可选 |
@@ -414,6 +415,16 @@ crontab -e
414415
- 163 邮箱:smtp.163.com:465
415416
- Gmail:smtp.gmail.com:587
416417

418+
**股票分组发往不同邮箱**(Issue #268,可选):
419+
配置 `STOCK_GROUP_N``EMAIL_GROUP_N` 可实现不同股票组的报告发送到不同邮箱,例如多人共享分析时互不干扰。大盘复盘会发往所有配置的邮箱。
420+
421+
```bash
422+
STOCK_GROUP_1=600519,300750
423+
EMAIL_GROUP_1=user1@example.com
424+
STOCK_GROUP_2=002594,AAPL
425+
EMAIL_GROUP_2=user2@example.com
426+
```
427+
417428
### 自定义 Webhook
418429

419430
支持任意 POST JSON 的 Webhook,包括:

src/config.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
"""
1212

1313
import os
14+
import re
1415
from pathlib import Path
15-
from typing import List, Optional
16+
from typing import List, Optional, Tuple
1617
from dotenv import load_dotenv, dotenv_values
1718
from dataclasses import dataclass, field
1819

@@ -99,7 +100,11 @@ class Config:
99100
email_sender_name: str = "daily_stock_analysis股票分析助手" # 发件人显示名称
100101
email_password: Optional[str] = None # 邮箱密码/授权码
101102
email_receivers: List[str] = field(default_factory=list) # 收件人列表(留空则发给自己)
102-
103+
104+
# Stock-to-email group routing (Issue #268): STOCK_GROUP_N + EMAIL_GROUP_N
105+
# When configured, each group's report is sent to that group's emails only.
106+
stock_email_groups: List[Tuple[List[str], List[str]]] = field(default_factory=list)
107+
103108
# Pushover 配置(手机/桌面推送通知)
104109
pushover_user_key: Optional[str] = None # 用户 Key(https://pushover.net 获取)
105110
pushover_api_token: Optional[str] = None # 应用 API Token
@@ -370,6 +375,7 @@ def _load_from_env(cls) -> 'Config':
370375
email_sender_name=os.getenv('EMAIL_SENDER_NAME', 'daily_stock_analysis股票分析助手'),
371376
email_password=os.getenv('EMAIL_PASSWORD'),
372377
email_receivers=[r.strip() for r in os.getenv('EMAIL_RECEIVERS', '').split(',') if r.strip()],
378+
stock_email_groups=cls._parse_stock_email_groups(),
373379
pushover_user_key=os.getenv('PUSHOVER_USER_KEY'),
374380
pushover_api_token=os.getenv('PUSHOVER_API_TOKEN'),
375381
pushplus_token=os.getenv('PUSHPLUS_TOKEN'),
@@ -442,6 +448,33 @@ def _load_from_env(cls) -> 'Config':
442448
circuit_breaker_cooldown=int(os.getenv('CIRCUIT_BREAKER_COOLDOWN', '300'))
443449
)
444450

451+
@classmethod
452+
def _parse_stock_email_groups(cls) -> List[Tuple[List[str], List[str]]]:
453+
"""
454+
Parse STOCK_GROUP_N and EMAIL_GROUP_N from environment.
455+
Returns [(stocks, emails), ...] ordered by group index.
456+
"""
457+
groups: dict = {}
458+
stock_re = re.compile(r'^STOCK_GROUP_(\d+)$', re.IGNORECASE)
459+
email_re = re.compile(r'^EMAIL_GROUP_(\d+)$', re.IGNORECASE)
460+
for key in os.environ:
461+
m = stock_re.match(key)
462+
if m:
463+
idx = int(m.group(1))
464+
val = os.environ[key].strip()
465+
groups.setdefault(idx, {})['stocks'] = [c.strip() for c in val.split(',') if c.strip()]
466+
m = email_re.match(key)
467+
if m:
468+
idx = int(m.group(1))
469+
val = os.environ[key].strip()
470+
groups.setdefault(idx, {})['emails'] = [e.strip() for e in val.split(',') if e.strip()]
471+
result = []
472+
for idx in sorted(groups.keys()):
473+
g = groups[idx]
474+
if 'stocks' in g and 'emails' in g and g['stocks'] and g['emails']:
475+
result.append((g['stocks'], g['emails']))
476+
return result
477+
445478
@classmethod
446479
def _resolve_realtime_source_priority(cls) -> str:
447480
"""

src/core/market_review.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def run_market_review(
6767
# 添加标题
6868
report_content = f"🎯 大盘复盘\n\n{review_report}"
6969

70-
success = notifier.send(report_content)
70+
success = notifier.send(report_content, email_send_to_all=True)
7171
if success:
7272
logger.info("大盘复盘推送成功")
7373
else:

src/core/pipeline.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import logging
1515
import time
1616
import uuid
17+
from collections import defaultdict
1718
from concurrent.futures import ThreadPoolExecutor, as_completed
1819
from datetime import date
1920
from typing import List, Dict, Any, Optional, Tuple
@@ -568,7 +569,7 @@ def process_single_stock(
568569
report_content = self.notifier.generate_single_stock_report(result)
569570
logger.info(f"[{code}] 使用精简报告格式")
570571

571-
if self.notifier.send(report_content):
572+
if self.notifier.send(report_content, email_stock_codes=[code]):
572573
logger.info(f"[{code}] 单股推送成功")
573574
else:
574575
logger.warning(f"[{code}] 单股推送失败")
@@ -736,6 +737,7 @@ def _send_notifications(self, results: List[AnalysisResult], skip_push: bool = F
736737

737738
# 其他渠道:发完整报告(避免自定义 Webhook 被 wechat 截断逻辑污染)
738739
non_wechat_success = False
740+
stock_email_groups = getattr(self.config, 'stock_email_groups', []) or []
739741
for channel in channels:
740742
if channel == NotificationChannel.WECHAT:
741743
continue
@@ -744,7 +746,31 @@ def _send_notifications(self, results: List[AnalysisResult], skip_push: bool = F
744746
elif channel == NotificationChannel.TELEGRAM:
745747
non_wechat_success = self.notifier.send_to_telegram(report) or non_wechat_success
746748
elif channel == NotificationChannel.EMAIL:
747-
non_wechat_success = self.notifier.send_to_email(report) or non_wechat_success
749+
if stock_email_groups:
750+
code_to_emails: Dict[str, Optional[List[str]]] = {}
751+
for r in results:
752+
if r.code not in code_to_emails:
753+
emails = []
754+
for stocks, emails_list in stock_email_groups:
755+
if r.code in stocks:
756+
emails.extend(emails_list)
757+
code_to_emails[r.code] = list(dict.fromkeys(emails)) if emails else None
758+
emails_to_results: Dict[Optional[Tuple], List] = defaultdict(list)
759+
for r in results:
760+
recs = code_to_emails.get(r.code)
761+
key = tuple(recs) if recs else None
762+
emails_to_results[key].append(r)
763+
for key, group_results in emails_to_results.items():
764+
grp_report = self.notifier.generate_dashboard_report(group_results)
765+
if key is None:
766+
non_wechat_success = self.notifier.send_to_email(grp_report) or non_wechat_success
767+
else:
768+
non_wechat_success = (
769+
self.notifier.send_to_email(grp_report, receivers=list(key))
770+
or non_wechat_success
771+
)
772+
else:
773+
non_wechat_success = self.notifier.send_to_email(report) or non_wechat_success
748774
elif channel == NotificationChannel.CUSTOM:
749775
non_wechat_success = self.notifier.send_to_custom(report) or non_wechat_success
750776
elif channel == NotificationChannel.PUSHPLUS:

src/notification.py

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,9 @@ def __init__(self, source_message: Optional[BotMessage] = None):
160160
'password': config.email_password,
161161
'receivers': config.email_receivers or ([config.email_sender] if config.email_sender else []),
162162
}
163-
163+
# Stock-to-email group routing (Issue #268)
164+
self._stock_email_groups = getattr(config, 'stock_email_groups', None) or []
165+
164166
# Pushover 配置
165167
self._pushover_config = {
166168
'user_key': getattr(config, 'pushover_user_key', None),
@@ -274,6 +276,43 @@ def _is_astrbot_configured(self) -> bool:
274276
def _is_email_configured(self) -> bool:
275277
"""检查邮件配置是否完整(只需邮箱和授权码)"""
276278
return bool(self._email_config['sender'] and self._email_config['password'])
279+
280+
def get_receivers_for_stocks(self, stock_codes: List[str]) -> List[str]:
281+
"""
282+
Look up email receivers for given stock codes based on stock_email_groups.
283+
Returns union of receivers for all matching groups; falls back to default if none match.
284+
"""
285+
if not stock_codes or not self._stock_email_groups:
286+
return self._email_config['receivers']
287+
seen: set = set()
288+
result: List[str] = []
289+
for stocks, emails in self._stock_email_groups:
290+
for code in stock_codes:
291+
if code in stocks:
292+
for e in emails:
293+
if e not in seen:
294+
seen.add(e)
295+
result.append(e)
296+
break
297+
return result if result else self._email_config['receivers']
298+
299+
def get_all_email_receivers(self) -> List[str]:
300+
"""
301+
Return union of all configured email receivers (all groups + default).
302+
Used for market review which should go to everyone.
303+
"""
304+
seen: set = set()
305+
result: List[str] = []
306+
for _, emails in self._stock_email_groups:
307+
for e in emails:
308+
if e not in seen:
309+
seen.add(e)
310+
result.append(e)
311+
for e in self._email_config['receivers']:
312+
if e not in seen:
313+
seen.add(e)
314+
result.append(e)
315+
return result
277316

278317
def _is_pushover_configured(self) -> bool:
279318
"""检查 Pushover 配置是否完整"""
@@ -1782,13 +1821,16 @@ def _post_payload(payload: Dict[str, Any]) -> bool:
17821821

17831822
return _post_payload(text_payload)
17841823

1785-
def send_to_email(self, content: str, subject: Optional[str] = None) -> bool:
1824+
def send_to_email(
1825+
self, content: str, subject: Optional[str] = None, receivers: Optional[List[str]] = None
1826+
) -> bool:
17861827
"""
17871828
通过 SMTP 发送邮件(自动识别 SMTP 服务器)
17881829
17891830
Args:
17901831
content: 邮件内容(支持 Markdown,会转换为 HTML)
17911832
subject: 邮件主题(可选,默认自动生成)
1833+
receivers: 收件人列表(可选,默认使用配置的 receivers)
17921834
17931835
Returns:
17941836
是否发送成功
@@ -1799,7 +1841,7 @@ def send_to_email(self, content: str, subject: Optional[str] = None) -> bool:
17991841

18001842
sender = self._email_config['sender']
18011843
password = self._email_config['password']
1802-
receivers = self._email_config['receivers']
1844+
receivers = receivers or self._email_config['receivers']
18031845

18041846
try:
18051847
# 生成主题
@@ -3063,14 +3105,21 @@ def _send_astrbot(self, content: str) -> bool:
30633105
logger.error(f"AstrBot 发送异常: {e}")
30643106
return False
30653107

3066-
def send(self, content: str) -> bool:
3108+
def send(
3109+
self,
3110+
content: str,
3111+
email_stock_codes: Optional[List[str]] = None,
3112+
email_send_to_all: bool = False
3113+
) -> bool:
30673114
"""
30683115
统一发送接口 - 向所有已配置的渠道发送
30693116
30703117
遍历所有已配置的渠道,逐一发送消息
30713118
30723119
Args:
30733120
content: 消息内容(Markdown 格式)
3121+
email_stock_codes: 股票代码列表(可选,用于邮件渠道路由到对应分组邮箱,Issue #268)
3122+
email_send_to_all: 邮件是否发往所有配置邮箱(用于大盘复盘等无股票归属的内容)
30743123
30753124
Returns:
30763125
是否至少有一个渠道发送成功
@@ -3100,7 +3149,12 @@ def send(self, content: str) -> bool:
31003149
elif channel == NotificationChannel.TELEGRAM:
31013150
result = self.send_to_telegram(content)
31023151
elif channel == NotificationChannel.EMAIL:
3103-
result = self.send_to_email(content)
3152+
receivers = None
3153+
if email_send_to_all and self._stock_email_groups:
3154+
receivers = self.get_all_email_receivers()
3155+
elif email_stock_codes and self._stock_email_groups:
3156+
receivers = self.get_receivers_for_stocks(email_stock_codes)
3157+
result = self.send_to_email(content, receivers=receivers)
31043158
elif channel == NotificationChannel.PUSHOVER:
31053159
result = self.send_to_pushover(content)
31063160
elif channel == NotificationChannel.PUSHPLUS:

0 commit comments

Comments
 (0)