marketmyna 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (344) hide show
  1. marketmyna/__init__.py +14 -0
  2. marketmyna/agent/__init__.py +8 -0
  3. marketmyna/agent/agents.py +543 -0
  4. marketmyna/agent/context.py +69 -0
  5. marketmyna/agent/dashboard.py +55 -0
  6. marketmyna/agent/orchestrator.py +153 -0
  7. marketmyna/agent/sentiment.py +150 -0
  8. marketmyna/agent/skills.py +119 -0
  9. marketmyna/ai/__init__.py +1 -0
  10. marketmyna/ai/portfolio_optimizer.py +330 -0
  11. marketmyna/analysis/__init__.py +6 -0
  12. marketmyna/analysis/fundamental.py +329 -0
  13. marketmyna/analysis/sip_optimizer.py +81 -0
  14. marketmyna/analysis/technical.py +321 -0
  15. marketmyna/api/__init__.py +1 -0
  16. marketmyna/api/app.py +209 -0
  17. marketmyna/api/deps.py +63 -0
  18. marketmyna/api/middlewares/__init__.py +0 -0
  19. marketmyna/api/middlewares/auth.py +116 -0
  20. marketmyna/api/middlewares/csrf.py +66 -0
  21. marketmyna/api/middlewares/error_handler.py +38 -0
  22. marketmyna/api/middlewares/slashes.py +27 -0
  23. marketmyna/api/v1/__init__.py +0 -0
  24. marketmyna/api/v1/endpoints/__init__.py +0 -0
  25. marketmyna/api/v1/endpoints/account.py +166 -0
  26. marketmyna/api/v1/endpoints/agent.py +114 -0
  27. marketmyna/api/v1/endpoints/bot.py +38 -0
  28. marketmyna/api/v1/endpoints/calendar.py +269 -0
  29. marketmyna/api/v1/endpoints/data_sources.py +174 -0
  30. marketmyna/api/v1/endpoints/health.py +10 -0
  31. marketmyna/api/v1/endpoints/listings.py +564 -0
  32. marketmyna/api/v1/endpoints/macro.py +163 -0
  33. marketmyna/api/v1/endpoints/portfolio.py +530 -0
  34. marketmyna/api/v1/endpoints/push.py +119 -0
  35. marketmyna/api/v1/endpoints/reports.py +49 -0
  36. marketmyna/api/v1/endpoints/scheduler.py +219 -0
  37. marketmyna/api/v1/endpoints/stocks.py +400 -0
  38. marketmyna/api/v1/endpoints/system_config.py +157 -0
  39. marketmyna/api/v1/endpoints/thesis.py +112 -0
  40. marketmyna/api/v1/endpoints/trade.py +709 -0
  41. marketmyna/api/v1/endpoints/updates.py +394 -0
  42. marketmyna/api/v1/endpoints/watchlists.py +179 -0
  43. marketmyna/api/v1/router.py +43 -0
  44. marketmyna/bot/__init__.py +14 -0
  45. marketmyna/bot/commands/__init__.py +0 -0
  46. marketmyna/bot/commands/analyze.py +40 -0
  47. marketmyna/bot/commands/ask.py +51 -0
  48. marketmyna/bot/commands/base.py +35 -0
  49. marketmyna/bot/commands/batch.py +53 -0
  50. marketmyna/bot/commands/common.py +84 -0
  51. marketmyna/bot/commands/help.py +43 -0
  52. marketmyna/bot/commands/markets.py +153 -0
  53. marketmyna/bot/commands/portfolio.py +145 -0
  54. marketmyna/bot/commands/research.py +323 -0
  55. marketmyna/bot/commands/status.py +48 -0
  56. marketmyna/bot/commands/trade.py +176 -0
  57. marketmyna/bot/dispatcher.py +95 -0
  58. marketmyna/bot/formatting.py +25 -0
  59. marketmyna/bot/models.py +27 -0
  60. marketmyna/bot/registry.py +50 -0
  61. marketmyna/bot/telegram_listener.py +227 -0
  62. marketmyna/cli/__init__.py +1 -0
  63. marketmyna/cli/scheduled_run.py +292 -0
  64. marketmyna/core/__init__.py +6 -0
  65. marketmyna/core/compliance.py +53 -0
  66. marketmyna/core/config.py +377 -0
  67. marketmyna/core/constants.py +69 -0
  68. marketmyna/core/home.py +22 -0
  69. marketmyna/core/numbers.py +28 -0
  70. marketmyna/core/secret_store.py +104 -0
  71. marketmyna/core/tenant.py +130 -0
  72. marketmyna/data/__init__.py +8 -0
  73. marketmyna/data/base.py +36 -0
  74. marketmyna/data/categories.py +272 -0
  75. marketmyna/data/china.py +360 -0
  76. marketmyna/data/crypto.py +200 -0
  77. marketmyna/data/ecb_fx.py +131 -0
  78. marketmyna/data/extra_sources.py +569 -0
  79. marketmyna/data/india.py +241 -0
  80. marketmyna/data/india_filings.py +343 -0
  81. marketmyna/data/listings.py +1314 -0
  82. marketmyna/data/mutual_funds.py +34 -0
  83. marketmyna/data/news.py +98 -0
  84. marketmyna/data/news_search.py +346 -0
  85. marketmyna/data/nse_deals.py +138 -0
  86. marketmyna/data/resilience.py +173 -0
  87. marketmyna/data/sec_facts.py +252 -0
  88. marketmyna/data/shariah.py +283 -0
  89. marketmyna/data/social/__init__.py +9 -0
  90. marketmyna/data/social/base.py +100 -0
  91. marketmyna/data/social/browser.py +92 -0
  92. marketmyna/data/social/hackernews.py +53 -0
  93. marketmyna/data/social/reddit.py +397 -0
  94. marketmyna/data/social/stocktwits.py +63 -0
  95. marketmyna/data/social/valuepickr.py +53 -0
  96. marketmyna/data/stocks.py +197 -0
  97. marketmyna/data/yahoo_price.py +48 -0
  98. marketmyna/launcher.py +71 -0
  99. marketmyna/llm/__init__.py +33 -0
  100. marketmyna/llm/backend_factory.py +51 -0
  101. marketmyna/llm/backend_registry.py +19 -0
  102. marketmyna/llm/channels.py +447 -0
  103. marketmyna/llm/json_reply.py +63 -0
  104. marketmyna/llm/litellm_backend.py +197 -0
  105. marketmyna/llm/model_catalog.py +166 -0
  106. marketmyna/llm/model_health.py +193 -0
  107. marketmyna/llm/protocol.py +111 -0
  108. marketmyna/llm/providers.py +128 -0
  109. marketmyna/llm/store.py +128 -0
  110. marketmyna/notifiers/__init__.py +35 -0
  111. marketmyna/notifiers/apprise_sender.py +43 -0
  112. marketmyna/notifiers/astrbot.py +47 -0
  113. marketmyna/notifiers/bark.py +24 -0
  114. marketmyna/notifiers/base.py +80 -0
  115. marketmyna/notifiers/dingtalk.py +54 -0
  116. marketmyna/notifiers/discord.py +39 -0
  117. marketmyna/notifiers/dispatcher.py +153 -0
  118. marketmyna/notifiers/email.py +99 -0
  119. marketmyna/notifiers/feishu.py +57 -0
  120. marketmyna/notifiers/googlechat.py +24 -0
  121. marketmyna/notifiers/gotify.py +36 -0
  122. marketmyna/notifiers/matrix.py +28 -0
  123. marketmyna/notifiers/ntfy.py +36 -0
  124. marketmyna/notifiers/pushbullet.py +24 -0
  125. marketmyna/notifiers/pushover.py +41 -0
  126. marketmyna/notifiers/pushplus.py +41 -0
  127. marketmyna/notifiers/serverchan3.py +40 -0
  128. marketmyna/notifiers/settings_schema.py +119 -0
  129. marketmyna/notifiers/signal_messenger.py +25 -0
  130. marketmyna/notifiers/slack.py +36 -0
  131. marketmyna/notifiers/teams.py +37 -0
  132. marketmyna/notifiers/telegram.py +81 -0
  133. marketmyna/notifiers/twilio.py +33 -0
  134. marketmyna/notifiers/webhook.py +44 -0
  135. marketmyna/notifiers/webpush.py +222 -0
  136. marketmyna/notifiers/wechat.py +40 -0
  137. marketmyna/notifiers/whatsapp.py +27 -0
  138. marketmyna/portfolio/__init__.py +6 -0
  139. marketmyna/portfolio/analytics.py +336 -0
  140. marketmyna/portfolio/manager.py +383 -0
  141. marketmyna/portfolio/models.py +155 -0
  142. marketmyna/portfolio/optimizers.py +88 -0
  143. marketmyna/portfolio/review.py +411 -0
  144. marketmyna/services/__init__.py +1 -0
  145. marketmyna/services/accounts.py +278 -0
  146. marketmyna/services/ai_access.py +94 -0
  147. marketmyna/services/analysis_run.py +491 -0
  148. marketmyna/services/big_trades.py +335 -0
  149. marketmyna/services/brokers/__init__.py +0 -0
  150. marketmyna/services/brokers/angel.py +271 -0
  151. marketmyna/services/brokers/finnhub.py +209 -0
  152. marketmyna/services/byok_detect.py +196 -0
  153. marketmyna/services/calendar.py +748 -0
  154. marketmyna/services/central_banks.py +202 -0
  155. marketmyna/services/commodities.py +134 -0
  156. marketmyna/services/compare.py +307 -0
  157. marketmyna/services/cot.py +148 -0
  158. marketmyna/services/country_macro.py +135 -0
  159. marketmyna/services/crude_sensitivity.py +239 -0
  160. marketmyna/services/crypto_mood.py +135 -0
  161. marketmyna/services/crypto_screens.py +226 -0
  162. marketmyna/services/data_export.py +91 -0
  163. marketmyna/services/dcf.py +220 -0
  164. marketmyna/services/dcf_model.py +194 -0
  165. marketmyna/services/delivery.py +142 -0
  166. marketmyna/services/discovery.py +146 -0
  167. marketmyna/services/dividends.py +95 -0
  168. marketmyna/services/earnings.py +305 -0
  169. marketmyna/services/econ_calendar.py +495 -0
  170. marketmyna/services/eia_stocks.py +115 -0
  171. marketmyna/services/estimates.py +109 -0
  172. marketmyna/services/etf.py +236 -0
  173. marketmyna/services/etf_rotation.py +193 -0
  174. marketmyna/services/etf_screens.py +196 -0
  175. marketmyna/services/financials.py +639 -0
  176. marketmyna/services/fund_ai.py +181 -0
  177. marketmyna/services/fund_analytics.py +371 -0
  178. marketmyna/services/fund_cost.py +147 -0
  179. marketmyna/services/fund_holdings.py +664 -0
  180. marketmyna/services/fund_peers.py +247 -0
  181. marketmyna/services/fund_rotation.py +206 -0
  182. marketmyna/services/fund_style.py +179 -0
  183. marketmyna/services/funds.py +70 -0
  184. marketmyna/services/hot_stocks.py +358 -0
  185. marketmyna/services/india_flows.py +263 -0
  186. marketmyna/services/india_rates.py +258 -0
  187. marketmyna/services/investor_lenses.py +271 -0
  188. marketmyna/services/leadership.py +165 -0
  189. marketmyna/services/live_market.py +497 -0
  190. marketmyna/services/live_quotes.py +295 -0
  191. marketmyna/services/macro.py +182 -0
  192. marketmyna/services/market_calendar.py +399 -0
  193. marketmyna/services/market_mood.py +168 -0
  194. marketmyna/services/market_valuation.py +231 -0
  195. marketmyna/services/mf_holdings.py +199 -0
  196. marketmyna/services/mf_insights.py +132 -0
  197. marketmyna/services/mf_lumpsum.py +587 -0
  198. marketmyna/services/mf_portfolio_ai.py +195 -0
  199. marketmyna/services/nse_events.py +98 -0
  200. marketmyna/services/overview.py +366 -0
  201. marketmyna/services/ownership.py +143 -0
  202. marketmyna/services/quality_scores.py +199 -0
  203. marketmyna/services/rates.py +335 -0
  204. marketmyna/services/reconcile.py +343 -0
  205. marketmyna/services/report.py +584 -0
  206. marketmyna/services/report_i18n.py +678 -0
  207. marketmyna/services/rotation.py +224 -0
  208. marketmyna/services/runtime_scheduler.py +548 -0
  209. marketmyna/services/schedule_config.py +251 -0
  210. marketmyna/services/schedule_time.py +39 -0
  211. marketmyna/services/screens.py +398 -0
  212. marketmyna/services/sec_mdna.py +173 -0
  213. marketmyna/services/shariah.py +246 -0
  214. marketmyna/services/sip.py +148 -0
  215. marketmyna/services/sip_days.py +362 -0
  216. marketmyna/services/smart_file.py +595 -0
  217. marketmyna/services/smart_pdf.py +425 -0
  218. marketmyna/services/smart_report.py +370 -0
  219. marketmyna/services/social_sentiment.py +511 -0
  220. marketmyna/services/thesis.py +328 -0
  221. marketmyna/services/translate.py +146 -0
  222. marketmyna/services/valuation.py +173 -0
  223. marketmyna/services/worker_pool.py +149 -0
  224. marketmyna/services/world_markets.py +208 -0
  225. marketmyna/storage.py +722 -0
  226. marketmyna/strategies/README.md +51 -0
  227. marketmyna/strategies/bottom_volume.yaml +49 -0
  228. marketmyna/strategies/box_oscillation.yaml +69 -0
  229. marketmyna/strategies/bull_trend.yaml +49 -0
  230. marketmyna/strategies/chan_theory.yaml +58 -0
  231. marketmyna/strategies/dragon_head.yaml +44 -0
  232. marketmyna/strategies/emotion_cycle.yaml +79 -0
  233. marketmyna/strategies/event_driven.yaml +54 -0
  234. marketmyna/strategies/expectation_repricing.yaml +54 -0
  235. marketmyna/strategies/growth_quality.yaml +54 -0
  236. marketmyna/strategies/hot_theme.yaml +54 -0
  237. marketmyna/strategies/ma_golden_cross.yaml +46 -0
  238. marketmyna/strategies/one_yang_three_yin.yaml +37 -0
  239. marketmyna/strategies/shrink_pullback.yaml +46 -0
  240. marketmyna/strategies/volume_breakout.yaml +47 -0
  241. marketmyna/strategies/wave_theory.yaml +64 -0
  242. marketmyna/trading/__init__.py +2 -0
  243. marketmyna/trading/alerts.py +356 -0
  244. marketmyna/trading/backtest.py +317 -0
  245. marketmyna/trading/charges.py +82 -0
  246. marketmyna/trading/chart.py +197 -0
  247. marketmyna/trading/indicators.py +303 -0
  248. marketmyna/trading/journal.py +74 -0
  249. marketmyna/trading/knn.py +164 -0
  250. marketmyna/trading/lots.py +109 -0
  251. marketmyna/trading/market_hours.py +66 -0
  252. marketmyna/trading/options.py +554 -0
  253. marketmyna/trading/pairs.py +94 -0
  254. marketmyna/trading/risk.py +170 -0
  255. marketmyna/trading/scanner.py +500 -0
  256. marketmyna/trading/volatility.py +163 -0
  257. marketmyna/utils/__init__.py +1 -0
  258. marketmyna/utils/deadline.py +36 -0
  259. marketmyna/utils/log_redact.py +52 -0
  260. marketmyna/utils/yahoo_gate.py +183 -0
  261. marketmyna/utils/yf_lock.py +12 -0
  262. marketmyna/web/apple-touch-icon.png +0 -0
  263. marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
  264. marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
  265. marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
  266. marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
  267. marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
  268. marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
  269. marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
  270. marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
  271. marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
  272. marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
  273. marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
  274. marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
  275. marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
  276. marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
  277. marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
  278. marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
  279. marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
  280. marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
  281. marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
  282. marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
  283. marketmyna/web/assets/app-BP1o7WZS.js +1 -0
  284. marketmyna/web/assets/app-BkIUKdld.js +1 -0
  285. marketmyna/web/assets/app-CKeSYhEt.js +1 -0
  286. marketmyna/web/assets/app-CN1OERYX.js +1 -0
  287. marketmyna/web/assets/app-DIQllha5.js +1 -0
  288. marketmyna/web/assets/app-DeiAW0tX.js +1 -0
  289. marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
  290. marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
  291. marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
  292. marketmyna/web/assets/common-0wvpAVpd.js +1 -0
  293. marketmyna/web/assets/common-3ghy53Us.js +1 -0
  294. marketmyna/web/assets/common-Bib2unjX.js +1 -0
  295. marketmyna/web/assets/common-BvSJP0w_.js +1 -0
  296. marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
  297. marketmyna/web/assets/common-C_t-iZPf.js +1 -0
  298. marketmyna/web/assets/compare-DMcsx70H.js +1 -0
  299. marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
  300. marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
  301. marketmyna/web/assets/files-CpgiZRbN.js +1 -0
  302. marketmyna/web/assets/format-d8InM0Zw.js +9 -0
  303. marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
  304. marketmyna/web/assets/index-CKAnq-Or.js +12 -0
  305. marketmyna/web/assets/index-D4U8E6LH.css +2 -0
  306. marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
  307. marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
  308. marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
  309. marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
  310. marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
  311. marketmyna/web/assets/listings-BefgXpb1.js +1 -0
  312. marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
  313. marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
  314. marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
  315. marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
  316. marketmyna/web/assets/markets-DPO40TTe.js +1 -0
  317. marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
  318. marketmyna/web/assets/markets-ff1e792u.js +1 -0
  319. marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
  320. marketmyna/web/assets/options-BYAZnwzW.js +1 -0
  321. marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
  322. marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
  323. marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  324. marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
  325. marketmyna/web/assets/toast-DDradTZw.js +1 -0
  326. marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
  327. marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
  328. marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
  329. marketmyna/web/brand/logo-mark.webp +0 -0
  330. marketmyna/web/favicon.png +0 -0
  331. marketmyna/web/icon-192.png +0 -0
  332. marketmyna/web/icon-512.png +0 -0
  333. marketmyna/web/index.html +34 -0
  334. marketmyna/web/manifest.webmanifest +28 -0
  335. marketmyna/web/robots.txt +2 -0
  336. marketmyna/web/sw.js +40 -0
  337. marketmyna/web/theme-init.js +37 -0
  338. marketmyna-0.2.0.dist-info/METADATA +345 -0
  339. marketmyna-0.2.0.dist-info/RECORD +344 -0
  340. marketmyna-0.2.0.dist-info/WHEEL +5 -0
  341. marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
  342. marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
  343. marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
  344. marketmyna-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,153 @@
1
+ """Fan-out dispatcher that sends a message to every configured channel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from .apprise_sender import AppriseSender
8
+ from .astrbot import AstrbotSender
9
+ from .bark import BarkSender
10
+ from .base import logger
11
+ from .dingtalk import DingtalkSender
12
+ from .discord import DiscordSender
13
+ from .email import EmailSender
14
+ from .feishu import FeishuSender
15
+ from .googlechat import GoogleChatSender
16
+ from .gotify import GotifySender
17
+ from .matrix import MatrixSender
18
+ from .ntfy import NtfySender
19
+ from .pushbullet import PushbulletSender
20
+ from .pushover import PushoverSender
21
+ from .pushplus import PushplusSender
22
+ from .serverchan3 import Serverchan3Sender
23
+ from .signal_messenger import SignalSender
24
+ from .slack import SlackSender
25
+ from .teams import TeamsSender
26
+ from .telegram import TelegramSender
27
+ from .twilio import TwilioSender
28
+ from .webhook import WebhookSender
29
+ from .webpush import WebPushSender
30
+ from .wechat import WechatSender
31
+ from .whatsapp import WhatsAppSender
32
+
33
+ # Message purposes and the Config field that routes each one.
34
+ PURPOSES = {
35
+ "report": "notification_report_channels",
36
+ "alert": "notification_alert_channels",
37
+ "system_error": "notification_system_error_channels",
38
+ }
39
+
40
+
41
+ class NotificationDispatcher:
42
+ """Owns one instance of every sender and fans a message out to all configured ones."""
43
+
44
+ def __init__(self, config):
45
+ self._config = config
46
+ self._senders = {
47
+ "telegram": TelegramSender(config),
48
+ "discord": DiscordSender(config),
49
+ "slack": SlackSender(config),
50
+ "email": EmailSender(config),
51
+ "dingtalk": DingtalkSender(config),
52
+ "feishu": FeishuSender(config),
53
+ "wechat": WechatSender(config),
54
+ "pushover": PushoverSender(config),
55
+ "ntfy": NtfySender(config),
56
+ "gotify": GotifySender(config),
57
+ "pushplus": PushplusSender(config),
58
+ "serverchan3": Serverchan3Sender(config),
59
+ "astrbot": AstrbotSender(config),
60
+ "webhook": WebhookSender(config),
61
+ "webpush": WebPushSender(config),
62
+ "whatsapp": WhatsAppSender(config),
63
+ "twilio": TwilioSender(config),
64
+ "teams": TeamsSender(config),
65
+ "googlechat": GoogleChatSender(config),
66
+ "matrix": MatrixSender(config),
67
+ "signal": SignalSender(config),
68
+ "bark": BarkSender(config),
69
+ "pushbullet": PushbulletSender(config),
70
+ "apprise": AppriseSender(config),
71
+ }
72
+
73
+ def channel_names(self) -> list[str]:
74
+ return list(self._senders)
75
+
76
+ def configured_channels(self, purpose: Optional[str] = None) -> list[str]:
77
+ """Configured channels, narrowed to the ones routed for `purpose`
78
+ ("report", "alert", "system_error") when a route is set."""
79
+ configured = [name for name, sender in self._senders.items() if sender.is_configured()]
80
+ route = getattr(self._config, PURPOSES[purpose], None) if purpose in PURPOSES else None
81
+ if route:
82
+ wanted = {str(name).strip().lower() for name in route}
83
+ configured = [name for name in configured if name in wanted]
84
+ return configured
85
+
86
+ # In accounts mode a normal user's messages may only reach their own
87
+ # devices: web push (subscriptions are user-scoped) and their linked
88
+ # Telegram chats. The owner's channels in config.json are the owner's.
89
+ USER_CHANNELS = ("webpush",)
90
+
91
+ def _owner_channels_allowed(self) -> bool:
92
+ from ..services.accounts import is_owner_scope
93
+
94
+ try:
95
+ return is_owner_scope(self._config)
96
+ except Exception:
97
+ logger.exception("Could not resolve the platform owner; using user channels only")
98
+ return False
99
+
100
+ def _sender_for(self, name: str, purpose: Optional[str]):
101
+ """The channel's sender; reports and alerts by email come from the reports address."""
102
+ from ..services.accounts import report_sender
103
+
104
+ from_address = report_sender(self._config, purpose) if name == "email" else None
105
+ return EmailSender(self._config, from_address=from_address) if from_address else self._senders.get(name)
106
+
107
+ def send_all(self, message: str, title: Optional[str] = None, purpose: Optional[str] = None) -> dict[str, bool]:
108
+ results: dict[str, bool] = {}
109
+ if purpose == "system_error" or self._owner_channels_allowed(): # system errors are for the owner
110
+ allowed = set(self.configured_channels(purpose))
111
+ else:
112
+ allowed = {n for n in self.USER_CHANNELS if self._senders[n].is_configured()}
113
+ for name, sender in self._senders.items():
114
+ if name not in allowed:
115
+ continue
116
+ sender = self._sender_for(name, purpose)
117
+ try:
118
+ results[name] = bool(sender.send(message, title=title))
119
+ except Exception as exc: # a channel must never take the others down with it
120
+ logger.error("Notifier %s raised unexpectedly: %s", name, exc)
121
+ results[name] = False
122
+ return results
123
+
124
+ def send_each(self, messages: dict[str, tuple], purpose: Optional[str] = None,
125
+ files: Optional[dict[str, list[tuple[str, bytes]]]] = None) -> dict[str, bool]:
126
+ """Send each channel its own (title, message[, html]) - e.g. the same
127
+ report in the language that channel's reader prefers. The optional
128
+ html body is only used by email. files: "email" / "telegram" ->
129
+ (filename, bytes) pairs, attached to the email and sent as Telegram
130
+ documents. Unknown or unconfigured channels are skipped."""
131
+ files = files or {}
132
+ results: dict[str, bool] = {}
133
+ owner = self._owner_channels_allowed()
134
+ for name, payload in messages.items():
135
+ title, message = payload[0], payload[1]
136
+ html = payload[2] if len(payload) > 2 else None
137
+ sender = self._sender_for(name, purpose)
138
+ if sender is None or not sender.is_configured():
139
+ continue
140
+ if not owner and name not in self.USER_CHANNELS:
141
+ continue
142
+ try:
143
+ if name == "email" and (html or files.get("email")):
144
+ results[name] = bool(sender.send(message, title=title, html=html, attachments=files.get("email")))
145
+ else:
146
+ results[name] = bool(sender.send(message, title=title))
147
+ if name == "telegram" and results[name]:
148
+ for filename, data in files.get("telegram") or []:
149
+ results[name] = sender.send_document(filename, data) and results[name]
150
+ except Exception as exc:
151
+ logger.error("Notifier %s raised unexpectedly: %s", name, exc)
152
+ results[name] = False
153
+ return results
@@ -0,0 +1,99 @@
1
+ """SMTP email notification sender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import smtplib
6
+ from email.mime.application import MIMEApplication
7
+ from email.mime.multipart import MIMEMultipart
8
+ from email.mime.text import MIMEText
9
+ from email.utils import formataddr
10
+ from typing import Optional
11
+
12
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger
13
+
14
+ SMTP_PRESETS = {
15
+ "qq.com": ("smtp.qq.com", 465, True),
16
+ "foxmail.com": ("smtp.qq.com", 465, True),
17
+ "163.com": ("smtp.163.com", 465, True),
18
+ "126.com": ("smtp.126.com", 465, True),
19
+ "gmail.com": ("smtp.gmail.com", 587, False),
20
+ "outlook.com": ("smtp-mail.outlook.com", 587, False),
21
+ "hotmail.com": ("smtp-mail.outlook.com", 587, False),
22
+ }
23
+
24
+
25
+
26
+ # Attachment file extension -> MIME subtype (application/...).
27
+ ATTACHMENT_TYPES = {"pdf": "pdf", "xlsx": "vnd.openxmlformats-officedocument.spreadsheetml.sheet"}
28
+
29
+ class EmailSender:
30
+ def __init__(self, config, from_address: Optional[str] = None, from_name: Optional[str] = None):
31
+ self._sender = getattr(config, "email_sender", None)
32
+ # Logs in as email_username (or the sender); sends as from_address if given.
33
+ self._login = getattr(config, "email_username", None) or self._sender
34
+ self._from = from_address or self._sender
35
+ self._from_name = from_name or DEFAULT_TITLE
36
+ self._password = getattr(config, "email_password", None)
37
+ self._receivers = getattr(config, "email_receivers", None) or (
38
+ [self._sender] if self._sender else []
39
+ )
40
+ self._smtp_server = getattr(config, "email_smtp_server", None)
41
+ self._smtp_port = getattr(config, "email_smtp_port", None)
42
+ self._smtp_ssl = getattr(config, "email_smtp_ssl", None)
43
+
44
+ def is_configured(self) -> bool:
45
+ return bool(self._sender and self._login and self._password and self._receivers)
46
+
47
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT,
48
+ html: Optional[str] = None, attachments: Optional[list[tuple[str, bytes]]] = None) -> bool:
49
+ """attachments: (filename, bytes) pairs; the type comes from the extension (PDF, Excel)."""
50
+ if not self.is_configured():
51
+ return False
52
+
53
+ subject = title or DEFAULT_TITLE
54
+ domain = self._login.split("@")[-1].lower()
55
+ server_host, port, use_ssl = SMTP_PRESETS.get(domain, (None, None, None))
56
+ server_host = self._smtp_server or server_host or f"smtp.{domain}"
57
+ port = self._smtp_port or port or 465
58
+ use_ssl = self._smtp_ssl if self._smtp_ssl is not None else (use_ssl if use_ssl is not None else True)
59
+
60
+ body = MIMEMultipart("alternative")
61
+ body.attach(MIMEText(message, "plain", "utf-8"))
62
+ if html:
63
+ # multipart/alternative: mail apps show the HTML table, others the text.
64
+ body.attach(MIMEText(html, "html", "utf-8"))
65
+ if attachments:
66
+ msg = MIMEMultipart("mixed")
67
+ msg.attach(body)
68
+ for name, data in attachments:
69
+ part = MIMEApplication(data, _subtype=ATTACHMENT_TYPES.get(name.rsplit(".", 1)[-1].lower(), "octet-stream"))
70
+ part.add_header("Content-Disposition", "attachment", filename=name)
71
+ msg.attach(part)
72
+ else:
73
+ msg = body
74
+ msg["Subject"] = subject
75
+ msg["From"] = formataddr((self._from_name, self._from))
76
+ msg["To"] = ", ".join(self._receivers)
77
+
78
+ server = None
79
+ try:
80
+ if use_ssl:
81
+ server = smtplib.SMTP_SSL(server_host, port, timeout=timeout)
82
+ else:
83
+ server = smtplib.SMTP(server_host, port, timeout=timeout)
84
+ server.starttls()
85
+ server.login(self._login, self._password)
86
+ server.send_message(msg)
87
+ return True
88
+ except smtplib.SMTPException as exc:
89
+ logger.error("Email send failed: %s", exc)
90
+ return False
91
+ except OSError as exc:
92
+ logger.error("Email send failed: %s", exc)
93
+ return False
94
+ finally:
95
+ if server is not None:
96
+ try:
97
+ server.quit()
98
+ except Exception:
99
+ pass
@@ -0,0 +1,57 @@
1
+ """Feishu/Lark custom bot webhook notification sender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import time
9
+ from typing import Optional
10
+
11
+ import requests
12
+
13
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger, truncate
14
+
15
+ FEISHU_MAX_LENGTH = 19000
16
+
17
+
18
+ class FeishuSender:
19
+ def __init__(self, config):
20
+ self._webhook_url = getattr(config, "feishu_webhook_url", None)
21
+ self._secret = getattr(config, "feishu_webhook_secret", None)
22
+
23
+ def is_configured(self) -> bool:
24
+ return bool(self._webhook_url)
25
+
26
+ def _security_fields(self) -> dict:
27
+ if not self._secret:
28
+ return {}
29
+ timestamp = str(int(time.time()))
30
+ string_to_sign = f"{timestamp}\n{self._secret}"
31
+ sign = base64.b64encode(
32
+ hmac.new(string_to_sign.encode("utf-8"), digestmod=hashlib.sha256).digest()
33
+ ).decode("utf-8")
34
+ return {"timestamp": timestamp, "sign": sign}
35
+
36
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
37
+ if not self.is_configured():
38
+ return False
39
+
40
+ text = truncate(f"{title or DEFAULT_TITLE}\n\n{message}", FEISHU_MAX_LENGTH)
41
+ payload = {"msg_type": "text", "content": {"text": text}}
42
+ payload.update(self._security_fields())
43
+
44
+ try:
45
+ resp = requests.post(self._webhook_url, json=payload, timeout=timeout)
46
+ if resp.status_code != 200:
47
+ logger.error("Feishu send failed: HTTP %s", resp.status_code)
48
+ return False
49
+ result = resp.json()
50
+ code = result.get("code", result.get("StatusCode"))
51
+ if code == 0:
52
+ return True
53
+ logger.error("Feishu send failed: %s", result)
54
+ return False
55
+ except (requests.RequestException, ValueError) as exc:
56
+ logger.error("Feishu send failed: %s", exc)
57
+ return False
@@ -0,0 +1,24 @@
1
+ """Google Chat space incoming-webhook sender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, http_ok, split_message
8
+
9
+ GOOGLE_CHAT_MAX_LENGTH = 4000
10
+
11
+
12
+ class GoogleChatSender:
13
+ def __init__(self, config):
14
+ self._url = getattr(config, "google_chat_webhook_url", None)
15
+
16
+ def is_configured(self) -> bool:
17
+ return bool(self._url)
18
+
19
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
20
+ if not self.is_configured():
21
+ return False
22
+ # Long reports go out as several messages instead of being cut off.
23
+ chunks = split_message(f"*{title or DEFAULT_TITLE}*\n{message}", GOOGLE_CHAT_MAX_LENGTH)
24
+ return all(http_ok("Google Chat", "POST", self._url, json={"text": c}, timeout=timeout) for c in chunks)
@@ -0,0 +1,36 @@
1
+ """Gotify self-hosted push notification sender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import requests
8
+
9
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger
10
+
11
+
12
+ class GotifySender:
13
+ def __init__(self, config):
14
+ self._url = getattr(config, "gotify_url", None)
15
+ self._token = getattr(config, "gotify_token", None)
16
+
17
+ def is_configured(self) -> bool:
18
+ return bool(self._url and self._token)
19
+
20
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
21
+ if not self.is_configured():
22
+ return False
23
+
24
+ endpoint = f"{self._url.rstrip('/')}/message"
25
+ headers = {"X-Gotify-Key": self._token}
26
+ payload = {"title": title or DEFAULT_TITLE, "message": message}
27
+
28
+ try:
29
+ resp = requests.post(endpoint, json=payload, headers=headers, timeout=timeout)
30
+ if 200 <= resp.status_code < 300:
31
+ return True
32
+ logger.error("Gotify send failed: HTTP %s %s", resp.status_code, resp.text[:200])
33
+ return False
34
+ except requests.RequestException as exc:
35
+ logger.error("Gotify send failed: %s", exc)
36
+ return False
@@ -0,0 +1,28 @@
1
+ """Matrix room sender (any homeserver, e.g. matrix.org or Element)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from typing import Optional
8
+ from urllib.parse import quote
9
+
10
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, http_ok
11
+
12
+
13
+ class MatrixSender:
14
+ def __init__(self, config):
15
+ self._homeserver = (getattr(config, "matrix_homeserver", None) or "").rstrip("/")
16
+ self._token = getattr(config, "matrix_access_token", None)
17
+ self._room = getattr(config, "matrix_room_id", None)
18
+
19
+ def is_configured(self) -> bool:
20
+ return bool(self._homeserver and self._token and self._room)
21
+
22
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
23
+ if not self.is_configured():
24
+ return False
25
+ txn = f"marketmyna-{int(time.time())}-{uuid.uuid4().hex[:8]}"
26
+ url = f"{self._homeserver}/_matrix/client/v3/rooms/{quote(self._room, safe='')}/send/m.room.message/{txn}"
27
+ body = {"msgtype": "m.text", "body": f"{title or DEFAULT_TITLE}\n\n{message}"}
28
+ return http_ok("Matrix", "PUT", url, json=body, headers={"Authorization": f"Bearer {self._token}"}, timeout=timeout)
@@ -0,0 +1,36 @@
1
+ """ntfy.sh (or self-hosted) topic notification sender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import requests
8
+
9
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger
10
+
11
+
12
+ class NtfySender:
13
+ def __init__(self, config):
14
+ self._url = getattr(config, "ntfy_url", None)
15
+ self._token = getattr(config, "ntfy_token", None)
16
+
17
+ def is_configured(self) -> bool:
18
+ return bool(self._url)
19
+
20
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
21
+ if not self.is_configured():
22
+ return False
23
+
24
+ headers = {"X-Title": title or DEFAULT_TITLE}
25
+ if self._token:
26
+ headers["Authorization"] = f"Bearer {self._token}"
27
+
28
+ try:
29
+ resp = requests.post(self._url, data=message.encode("utf-8"), headers=headers, timeout=timeout)
30
+ if 200 <= resp.status_code < 300:
31
+ return True
32
+ logger.error("ntfy send failed: HTTP %s %s", resp.status_code, resp.text[:200])
33
+ return False
34
+ except requests.RequestException as exc:
35
+ logger.error("ntfy send failed: %s", exc)
36
+ return False
@@ -0,0 +1,24 @@
1
+ """Pushbullet sender: push to your phones and browsers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, http_ok
8
+
9
+
10
+ class PushbulletSender:
11
+ def __init__(self, config):
12
+ self._token = getattr(config, "pushbullet_token", None)
13
+
14
+ def is_configured(self) -> bool:
15
+ return bool(self._token)
16
+
17
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
18
+ if not self.is_configured():
19
+ return False
20
+ return http_ok(
21
+ "Pushbullet", "POST", "https://api.pushbullet.com/v2/pushes",
22
+ json={"type": "note", "title": title or DEFAULT_TITLE, "body": message},
23
+ headers={"Access-Token": self._token}, timeout=timeout,
24
+ )
@@ -0,0 +1,41 @@
1
+ """Pushover notification sender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import requests
8
+
9
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger, truncate
10
+
11
+ PUSHOVER_MAX_LENGTH = 1024
12
+
13
+
14
+ class PushoverSender:
15
+ def __init__(self, config):
16
+ self._user_key = getattr(config, "pushover_user_key", None)
17
+ self._api_token = getattr(config, "pushover_api_token", None)
18
+
19
+ def is_configured(self) -> bool:
20
+ return bool(self._user_key and self._api_token)
21
+
22
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
23
+ if not self.is_configured():
24
+ return False
25
+
26
+ payload = {
27
+ "token": self._api_token,
28
+ "user": self._user_key,
29
+ "message": truncate(message, PUSHOVER_MAX_LENGTH),
30
+ "title": title or DEFAULT_TITLE,
31
+ }
32
+
33
+ try:
34
+ resp = requests.post("https://api.pushover.net/1/messages.json", data=payload, timeout=timeout)
35
+ if resp.status_code == 200 and resp.json().get("status") == 1:
36
+ return True
37
+ logger.error("Pushover send failed: HTTP %s %s", resp.status_code, resp.text[:200])
38
+ return False
39
+ except (requests.RequestException, ValueError) as exc:
40
+ logger.error("Pushover send failed: %s", exc)
41
+ return False
@@ -0,0 +1,41 @@
1
+ """PushPlus (pushplus.plus) notification sender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import requests
8
+
9
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger
10
+
11
+
12
+ class PushplusSender:
13
+ def __init__(self, config):
14
+ self._token = getattr(config, "pushplus_token", None)
15
+ self._topic = getattr(config, "pushplus_topic", None)
16
+
17
+ def is_configured(self) -> bool:
18
+ return bool(self._token)
19
+
20
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
21
+ if not self.is_configured():
22
+ return False
23
+
24
+ payload = {
25
+ "token": self._token,
26
+ "title": title or DEFAULT_TITLE,
27
+ "content": message,
28
+ "template": "markdown",
29
+ }
30
+ if self._topic:
31
+ payload["topic"] = self._topic
32
+
33
+ try:
34
+ resp = requests.post("http://www.pushplus.plus/send", json=payload, timeout=timeout)
35
+ if resp.status_code == 200 and resp.json().get("code") == 200:
36
+ return True
37
+ logger.error("PushPlus send failed: HTTP %s %s", resp.status_code, resp.text[:200])
38
+ return False
39
+ except (requests.RequestException, ValueError) as exc:
40
+ logger.error("PushPlus send failed: %s", exc)
41
+ return False
@@ -0,0 +1,40 @@
1
+ """Server酱³ (ServerChan3, sctapi.ftqq.com) notification sender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Optional
7
+
8
+ import requests
9
+
10
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger
11
+
12
+
13
+ class Serverchan3Sender:
14
+ def __init__(self, config):
15
+ self._sendkey = getattr(config, "serverchan3_sendkey", None)
16
+
17
+ def is_configured(self) -> bool:
18
+ return bool(self._sendkey)
19
+
20
+ def _endpoint(self) -> str:
21
+ match = re.match(r"sctp(\d+)t", self._sendkey)
22
+ if match:
23
+ return f"https://{match.group(1)}.push.ft07.com/send/{self._sendkey}.send"
24
+ return f"https://sctapi.ftqq.com/{self._sendkey}.send"
25
+
26
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
27
+ if not self.is_configured():
28
+ return False
29
+
30
+ payload = {"title": title or DEFAULT_TITLE, "desp": message}
31
+
32
+ try:
33
+ resp = requests.post(self._endpoint(), json=payload, timeout=timeout)
34
+ if resp.status_code == 200:
35
+ return True
36
+ logger.error("ServerChan3 send failed: HTTP %s %s", resp.status_code, resp.text[:200])
37
+ return False
38
+ except requests.RequestException as exc:
39
+ logger.error("ServerChan3 send failed: %s", exc)
40
+ return False