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,119 @@
1
+ """Shared field schema for notification-channel settings.
2
+
3
+ Single source of truth for which Config attributes exist per channel and
4
+ what kind of value each one holds. The HTTP system-config endpoint
5
+ (marketmyna/api/v1/endpoints/system_config.py) reads CHANNELS from here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # (attr on Config, label, field kind) per channel.
11
+ # kind: "text" | "password" | "int" | "bool" | "list" | "multiline"
12
+ CHANNELS = [
13
+ ("Telegram", [
14
+ ("telegram_bot_token", "Bot token", "password"),
15
+ ("telegram_chat_id", "Chat ID (send /start to your bot to get it; comma-separate several)", "text"),
16
+ ("telegram_message_thread_id", "Topic ID (optional, for forum groups)", "text"),
17
+ ]),
18
+ ("WhatsApp", [
19
+ ("whatsapp_phone", "Your WhatsApp number with country code (e.g. +919876543210)", "text"),
20
+ ("whatsapp_callmebot_apikey", "CallMeBot API key (message +34 644 51 95 23 'I allow callmebot to send me messages')", "password"),
21
+ ]),
22
+ ("Twilio SMS/WhatsApp", [
23
+ ("twilio_account_sid", "Account SID", "text"),
24
+ ("twilio_auth_token", "Auth token", "password"),
25
+ ("twilio_from", "From number (e.g. +15551234567, or whatsapp:+14155238886)", "text"),
26
+ ("twilio_to", "To numbers (comma-separated; prefix whatsapp: for WhatsApp)", "list"),
27
+ ]),
28
+ ("Discord", [
29
+ ("discord_webhook_url", "Webhook URL", "text"),
30
+ ]),
31
+ ("Slack", [
32
+ ("slack_webhook_url", "Webhook URL", "text"),
33
+ ]),
34
+ ("Email", [
35
+ ("email_sender", "Sender address", "text"),
36
+ ("email_reports_sender", "Reports & alerts sender", "text"),
37
+ ("email_username", "SMTP username", "text"),
38
+ ("email_password", "Password / app password", "password"),
39
+ ("email_receivers", "Recipients (comma-separated)", "list"),
40
+ ("email_smtp_server", "SMTP server (blank = auto-detect)", "text"),
41
+ ("email_smtp_port", "SMTP port (blank = auto-detect)", "int"),
42
+ ("email_smtp_ssl", "Force SSL (unchecked = auto-detect)", "bool"),
43
+ ]),
44
+ ("DingTalk", [
45
+ ("dingtalk_webhook_url", "Webhook URL", "text"),
46
+ ("dingtalk_secret", "Secret (optional signing)", "password"),
47
+ ]),
48
+ ("Feishu", [
49
+ ("feishu_webhook_url", "Webhook URL", "text"),
50
+ ("feishu_webhook_secret", "Secret (optional signing)", "password"),
51
+ ]),
52
+ ("WeChat", [
53
+ ("wechat_webhook_url", "Group robot webhook URL", "text"),
54
+ ]),
55
+ ("Pushover", [
56
+ ("pushover_user_key", "User key", "text"),
57
+ ("pushover_api_token", "API token", "password"),
58
+ ]),
59
+ ("ntfy", [
60
+ ("ntfy_url", "Topic URL (e.g. https://ntfy.sh/mytopic)", "text"),
61
+ ("ntfy_token", "Access token (optional)", "password"),
62
+ ]),
63
+ ("Gotify", [
64
+ ("gotify_url", "Server URL", "text"),
65
+ ("gotify_token", "App token", "password"),
66
+ ]),
67
+ ("PushPlus", [
68
+ ("pushplus_token", "Token", "password"),
69
+ ("pushplus_topic", "Topic (optional, for group push)", "text"),
70
+ ]),
71
+ ("ServerChan3", [
72
+ ("serverchan3_sendkey", "SendKey", "password"),
73
+ ]),
74
+ ("AstrBot", [
75
+ ("astrbot_url", "Instance URL", "text"),
76
+ ("astrbot_token", "Token (optional)", "password"),
77
+ ]),
78
+ ("Microsoft Teams", [
79
+ ("teams_webhook_url", "Workflows webhook URL (Teams channel > Workflows > 'Post to a channel when a webhook request is received')", "text"),
80
+ ]),
81
+ ("Google Chat", [
82
+ ("google_chat_webhook_url", "Space webhook URL (Space > Apps & integrations > Webhooks)", "text"),
83
+ ]),
84
+ ("Matrix", [
85
+ ("matrix_homeserver", "Homeserver URL (e.g. https://matrix.org)", "text"),
86
+ ("matrix_access_token", "Access token", "password"),
87
+ ("matrix_room_id", "Room ID (e.g. !abc123:matrix.org)", "text"),
88
+ ]),
89
+ ("Signal", [
90
+ ("signal_api_url", "signal-cli REST API URL (e.g. http://localhost:8080)", "text"),
91
+ ("signal_number", "Sending number linked to signal-cli (e.g. +919876543210)", "text"),
92
+ ("signal_recipients", "Recipients (comma-separated numbers or group IDs)", "list"),
93
+ ]),
94
+ ("Bark (iOS)", [
95
+ ("bark_url", "Device URL from the Bark app (e.g. https://api.day.app/yourkey)", "password"),
96
+ ]),
97
+ ("Pushbullet", [
98
+ ("pushbullet_token", "Access token", "password"),
99
+ ]),
100
+ ("Apprise", [
101
+ ("apprise_urls", "Apprise URLs for 100+ other services (comma-separated; needs 'pip install apprise')", "list"),
102
+ ]),
103
+ ("Webhook", [
104
+ ("webhook_url", "URL", "text"),
105
+ ("webhook_headers", 'Extra headers (JSON, e.g. {"Authorization": "Bearer x"})', "multiline"),
106
+ ]),
107
+ ]
108
+
109
+
110
+ def iter_fields():
111
+ """Yield (channel_name, attr, label, kind) for every field across all channels."""
112
+ for channel_name, fields in CHANNELS:
113
+ for attr, label, kind in fields:
114
+ yield channel_name, attr, label, kind
115
+
116
+
117
+ def is_secret_kind(kind: str) -> bool:
118
+ """Whether a field's raw value should never be echoed back (e.g. over HTTP)."""
119
+ return kind in ("password", "secret_list")
@@ -0,0 +1,25 @@
1
+ """Signal sender through a signal-cli REST API instance
2
+ (https://github.com/bbernhard/signal-cli-rest-api) linked to your number."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Optional
7
+
8
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, http_ok
9
+
10
+
11
+ class SignalSender:
12
+ def __init__(self, config):
13
+ self._url = (getattr(config, "signal_api_url", None) or "").rstrip("/")
14
+ self._number = getattr(config, "signal_number", None)
15
+ self._recipients = getattr(config, "signal_recipients", None) or []
16
+
17
+ def is_configured(self) -> bool:
18
+ return bool(self._url and self._number and self._recipients)
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
+ body = {"message": f"{title or DEFAULT_TITLE}\n\n{message}", "number": self._number,
24
+ "recipients": list(self._recipients)}
25
+ return http_ok("Signal", "POST", f"{self._url}/v2/send", json=body, timeout=timeout)
@@ -0,0 +1,36 @@
1
+ """Slack Incoming Webhook 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
+ SLACK_MAX_LENGTH = 39000
12
+
13
+
14
+ class SlackSender:
15
+ def __init__(self, config):
16
+ self._webhook_url = getattr(config, "slack_webhook_url", None)
17
+
18
+ def is_configured(self) -> bool:
19
+ return bool(self._webhook_url)
20
+
21
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
22
+ if not self.is_configured():
23
+ return False
24
+
25
+ text = f"*{title or DEFAULT_TITLE}*\n{message}"
26
+ text = truncate(text, SLACK_MAX_LENGTH)
27
+
28
+ try:
29
+ resp = requests.post(self._webhook_url, json={"text": text}, timeout=timeout)
30
+ if resp.status_code == 200 and resp.text == "ok":
31
+ return True
32
+ logger.error("Slack send failed: HTTP %s %s", resp.status_code, resp.text[:200])
33
+ return False
34
+ except requests.RequestException as exc:
35
+ logger.error("Slack send failed: %s", exc)
36
+ return False
@@ -0,0 +1,37 @@
1
+ """Microsoft Teams sender (Workflows / Power Automate "post to a channel
2
+ when a webhook request is received" URL, which takes an Adaptive Card)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Optional
7
+
8
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, http_ok, truncate
9
+
10
+ TEAMS_MAX_LENGTH = 20000
11
+
12
+
13
+ class TeamsSender:
14
+ def __init__(self, config):
15
+ self._url = getattr(config, "teams_webhook_url", 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
+ card = {
24
+ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
25
+ "type": "AdaptiveCard",
26
+ "version": "1.4",
27
+ "body": [
28
+ {"type": "TextBlock", "text": title or DEFAULT_TITLE, "weight": "Bolder", "size": "Medium", "wrap": True},
29
+ # Adaptive Cards treat single newlines as spaces; double them.
30
+ {"type": "TextBlock", "text": truncate(message, TEAMS_MAX_LENGTH).replace("\n", "\n\n"), "wrap": True},
31
+ ],
32
+ }
33
+ payload = {
34
+ "type": "message",
35
+ "attachments": [{"contentType": "application/vnd.microsoft.card.adaptive", "content": card}],
36
+ }
37
+ return http_ok("Teams", "POST", self._url, json=payload, timeout=timeout)
@@ -0,0 +1,81 @@
1
+ """Telegram Bot API notification sender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import re
7
+ from typing import Optional
8
+
9
+ import requests
10
+
11
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger, split_message # noqa: F401 (re-exported)
12
+
13
+ TELEGRAM_MAX_LENGTH = 4096
14
+
15
+
16
+ class TelegramSender:
17
+ def __init__(self, config):
18
+ self._bot_token = getattr(config, "telegram_bot_token", None)
19
+ # One chat ID, or several comma-separated (e.g. you and a group).
20
+ raw = str(getattr(config, "telegram_chat_id", None) or "")
21
+ self._chat_ids = [c.strip() for c in raw.split(",") if c.strip()]
22
+ # Optional: post into one topic of a forum-style group.
23
+ self._thread_id = getattr(config, "telegram_message_thread_id", None)
24
+
25
+ def is_configured(self) -> bool:
26
+ return bool(self._bot_token and self._chat_ids)
27
+
28
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
29
+ if not self.is_configured():
30
+ return False
31
+
32
+ # HTML mode with everything escaped: the title is bold and the body
33
+ # arrives verbatim (Markdown mode silently ate "_" and "*" in news / AI text).
34
+ text = f"<b>{html.escape(title or DEFAULT_TITLE)}</b>\n\n{html.escape(message, quote=False)}"
35
+ # Long reports go out as several messages instead of being cut off.
36
+ chunks = split_message(text, TELEGRAM_MAX_LENGTH)
37
+ results = [all(self._send_chunk(chat, chunk, timeout) for chunk in chunks) for chat in self._chat_ids]
38
+ return all(results)
39
+
40
+ def send_document(self, filename: str, data: bytes, caption: Optional[str] = None, *,
41
+ timeout: float = 60) -> bool:
42
+ """A file (e.g. the Smart messaging report PDF) to every chat."""
43
+ if not self.is_configured():
44
+ return False
45
+ url = f"https://api.telegram.org/bot{self._bot_token}/sendDocument"
46
+ ok = True
47
+ for chat_id in self._chat_ids:
48
+ payload = {"chat_id": chat_id}
49
+ if caption:
50
+ payload["caption"] = caption[:1024] # Telegram's caption limit
51
+ if self._thread_id:
52
+ payload["message_thread_id"] = int(self._thread_id)
53
+ try:
54
+ resp = requests.post(url, data=payload, files={"document": (filename, data)}, timeout=timeout)
55
+ if resp.status_code != 200:
56
+ logger.error("Telegram file to %s failed: HTTP %s %s", chat_id, resp.status_code, resp.text[:200])
57
+ ok = False
58
+ except requests.RequestException as exc:
59
+ logger.error("Telegram file send failed: %s", exc)
60
+ ok = False
61
+ return ok
62
+
63
+ def _send_chunk(self, chat_id: str, text: str, timeout: float) -> bool:
64
+ url = f"https://api.telegram.org/bot{self._bot_token}/sendMessage"
65
+ payload = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
66
+ if self._thread_id:
67
+ payload["message_thread_id"] = int(self._thread_id)
68
+ try:
69
+ resp = requests.post(url, json=payload, timeout=timeout)
70
+ if resp.status_code == 400 and "parse" in resp.text.lower():
71
+ # A chunk boundary split the bold title tag: resend as plain text.
72
+ payload.pop("parse_mode")
73
+ payload["text"] = html.unescape(re.sub(r"</?b>", "", text))
74
+ resp = requests.post(url, json=payload, timeout=timeout)
75
+ if resp.status_code == 200:
76
+ return True
77
+ logger.error("Telegram send to %s failed: HTTP %s %s", chat_id, resp.status_code, resp.text[:200])
78
+ return False
79
+ except requests.RequestException as exc:
80
+ logger.error("Telegram send failed: %s", exc)
81
+ return False
@@ -0,0 +1,33 @@
1
+ """Twilio sender: SMS, or WhatsApp when the numbers are "whatsapp:+..."."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, http_ok, truncate
8
+
9
+ # One SMS/WhatsApp message body on Twilio is capped at 1600 characters.
10
+ TWILIO_MAX_LENGTH = 1600
11
+
12
+
13
+ class TwilioSender:
14
+ def __init__(self, config):
15
+ self._sid = getattr(config, "twilio_account_sid", None)
16
+ self._token = getattr(config, "twilio_auth_token", None)
17
+ self._from = getattr(config, "twilio_from", None)
18
+ self._to = getattr(config, "twilio_to", None) or []
19
+
20
+ def is_configured(self) -> bool:
21
+ return bool(self._sid and self._token and self._from and self._to)
22
+
23
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
24
+ if not self.is_configured():
25
+ return False
26
+ body = truncate(f"{title or DEFAULT_TITLE}\n{message}", TWILIO_MAX_LENGTH)
27
+ url = f"https://api.twilio.com/2010-04-01/Accounts/{self._sid}/Messages.json"
28
+ results = [
29
+ http_ok("Twilio", "POST", url, data={"From": self._from, "To": to, "Body": body},
30
+ auth=(self._sid, self._token), timeout=timeout)
31
+ for to in self._to
32
+ ]
33
+ return all(results)
@@ -0,0 +1,44 @@
1
+ """Generic JSON webhook notification sender (fallback for any POST-JSON endpoint)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Optional
7
+
8
+ import requests
9
+
10
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger
11
+
12
+
13
+ class WebhookSender:
14
+ def __init__(self, config):
15
+ self._url = getattr(config, "webhook_url", None)
16
+ self._headers_json = getattr(config, "webhook_headers", None)
17
+
18
+ def is_configured(self) -> bool:
19
+ return bool(self._url)
20
+
21
+ def _headers(self) -> dict:
22
+ if not self._headers_json:
23
+ return {}
24
+ try:
25
+ return json.loads(self._headers_json)
26
+ except ValueError:
27
+ logger.error("Webhook headers config is not valid JSON, ignoring")
28
+ return {}
29
+
30
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
31
+ if not self.is_configured():
32
+ return False
33
+
34
+ payload = {"title": title or DEFAULT_TITLE, "message": message}
35
+
36
+ try:
37
+ resp = requests.post(self._url, json=payload, headers=self._headers(), timeout=timeout)
38
+ if 200 <= resp.status_code < 300:
39
+ return True
40
+ logger.error("Webhook send failed: HTTP %s %s", resp.status_code, resp.text[:200])
41
+ return False
42
+ except requests.RequestException as exc:
43
+ logger.error("Webhook send failed: %s", exc)
44
+ return False
@@ -0,0 +1,222 @@
1
+ """Browser push notifications (Web Push) for the MarketMyna web app.
2
+
3
+ Any browser that opens the app and allows notifications (Chrome, Edge,
4
+ Firefox, Android; Safari/iOS 16.4+ when the app is added to the home
5
+ screen) registers a push subscription; reports and alerts then arrive as
6
+ system notifications even when the tab is closed.
7
+
8
+ Implemented directly on `cryptography` (already a dependency) instead of
9
+ pywebpush:
10
+ - RFC 8291 message encryption ("aes128gcm" content coding),
11
+ - RFC 8292 VAPID: an ES256-signed JWT that identifies this server to the
12
+ browser vendor's push service.
13
+
14
+ The VAPID key pair is generated on first use and kept in the config
15
+ (web_push_vapid_private_key); browsers subscribe against its public key.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import hashlib
22
+ import hmac
23
+ import json
24
+ import os
25
+ import struct
26
+ import time
27
+ from datetime import datetime
28
+ from typing import Optional
29
+ from urllib.parse import urlsplit
30
+
31
+ from cryptography.hazmat.primitives import hashes, serialization
32
+ from cryptography.hazmat.primitives.asymmetric import ec
33
+ from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
34
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
35
+
36
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger, truncate
37
+
38
+ # Push services accept ~4 KB payloads; leave room for encryption overhead.
39
+ BODY_MAX_LENGTH = 2800
40
+ RECORD_SIZE = 4096
41
+ DEFAULT_CONTACT = "mailto:marketmyna@localhost"
42
+
43
+
44
+ def b64url(data: bytes) -> str:
45
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
46
+
47
+
48
+ def b64url_decode(text: str) -> bytes:
49
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
50
+
51
+
52
+ def _public_bytes(key) -> bytes:
53
+ return key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
54
+
55
+
56
+ def _hkdf(salt: bytes, ikm: bytes, info: bytes, length: int) -> bytes:
57
+ prk = hmac.new(salt, ikm, hashlib.sha256).digest()
58
+ return hmac.new(prk, info + b"\x01", hashlib.sha256).digest()[:length]
59
+
60
+
61
+ # ------------------------------------------------------------------ VAPID
62
+
63
+
64
+ def generate_vapid_private_key() -> str:
65
+ """New P-256 key as base64url of the 32-byte private scalar."""
66
+ key = ec.generate_private_key(ec.SECP256R1())
67
+ return b64url(key.private_numbers().private_value.to_bytes(32, "big"))
68
+
69
+
70
+ def load_vapid_key(private_b64: str):
71
+ value = int.from_bytes(b64url_decode(private_b64), "big")
72
+ return ec.derive_private_key(value, ec.SECP256R1())
73
+
74
+
75
+ def vapid_public_key(private_b64: str) -> str:
76
+ """The applicationServerKey browsers subscribe with."""
77
+ return b64url(_public_bytes(load_vapid_key(private_b64)))
78
+
79
+
80
+ def vapid_authorization(endpoint: str, private_b64: str, contact: str, now: Optional[float] = None) -> str:
81
+ key = load_vapid_key(private_b64)
82
+ parts = urlsplit(endpoint)
83
+ claims = {
84
+ "aud": f"{parts.scheme}://{parts.netloc}",
85
+ "exp": int((now or time.time()) + 12 * 3600),
86
+ "sub": contact if contact.startswith(("mailto:", "https://")) else f"mailto:{contact}",
87
+ }
88
+ signing_input = (
89
+ b64url(json.dumps({"typ": "JWT", "alg": "ES256"}, separators=(",", ":")).encode())
90
+ + "."
91
+ + b64url(json.dumps(claims, separators=(",", ":")).encode())
92
+ )
93
+ r, s = decode_dss_signature(key.sign(signing_input.encode(), ec.ECDSA(hashes.SHA256())))
94
+ token = f"{signing_input}.{b64url(r.to_bytes(32, 'big') + s.to_bytes(32, 'big'))}"
95
+ return f"vapid t={token}, k={b64url(_public_bytes(key))}"
96
+
97
+
98
+ # ------------------------------------------------------------- encryption
99
+
100
+
101
+ def encrypt(payload: bytes, p256dh: str, auth: str, *, salt: Optional[bytes] = None,
102
+ server_key=None) -> bytes:
103
+ """RFC 8291 aes128gcm body for one subscription (salt/server_key are
104
+ injectable only so tests can reproduce the RFC's example vector)."""
105
+ ua_public = b64url_decode(p256dh)
106
+ auth_secret = b64url_decode(auth)
107
+ server_key = server_key or ec.generate_private_key(ec.SECP256R1())
108
+ as_public = _public_bytes(server_key)
109
+ ua_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), ua_public)
110
+ shared = server_key.exchange(ec.ECDH(), ua_key)
111
+
112
+ ikm = _hkdf(auth_secret, shared, b"WebPush: info\x00" + ua_public + as_public, 32)
113
+ salt = salt or os.urandom(16)
114
+ cek = _hkdf(salt, ikm, b"Content-Encoding: aes128gcm\x00", 16)
115
+ nonce = _hkdf(salt, ikm, b"Content-Encoding: nonce\x00", 12)
116
+
117
+ # Single record: payload + 0x02 end-of-record delimiter.
118
+ ciphertext = AESGCM(cek).encrypt(nonce, payload + b"\x02", None)
119
+ header = salt + struct.pack("!IB", RECORD_SIZE, len(as_public)) + as_public
120
+ return header + ciphertext
121
+
122
+
123
+ # ----------------------------------------------------------------- sender
124
+
125
+
126
+ def ensure_vapid_key(config) -> str:
127
+ """The server's VAPID private key, generating and saving one if needed."""
128
+ key = getattr(config, "web_push_vapid_private_key", None)
129
+ if not key:
130
+ key = generate_vapid_private_key()
131
+ config.web_push_vapid_private_key = key
132
+ try:
133
+ config.save()
134
+ except Exception as exc: # the key still works for this process
135
+ logger.warning("Could not persist VAPID key: %s", exc)
136
+ return key
137
+
138
+
139
+ def _db(config):
140
+ from ..storage import DatabaseManager, get_db_url
141
+
142
+ return DatabaseManager.instance(get_db_url(config=config))
143
+
144
+
145
+ class WebPushSender:
146
+ def __init__(self, config):
147
+ self._config = config
148
+
149
+ def _subscriptions(self) -> list:
150
+ from ..storage import WebPushSubscriptionRecord, has_app_db
151
+
152
+ if not getattr(self._config, "web_push_vapid_private_key", None):
153
+ return []
154
+ try:
155
+ if not has_app_db(self._config):
156
+ return []
157
+ with _db(self._config).get_session() as session:
158
+ return [
159
+ (r.id, r.endpoint, r.p256dh, r.auth)
160
+ for r in session.query(WebPushSubscriptionRecord).all()
161
+ ]
162
+ except Exception as exc:
163
+ logger.warning("Web push subscriptions unreadable: %s", exc)
164
+ return []
165
+
166
+ def is_configured(self) -> bool:
167
+ return bool(self._subscriptions())
168
+
169
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT,
170
+ url: str = "/scheduler") -> bool:
171
+ import requests
172
+
173
+ from ..storage import WebPushSubscriptionRecord
174
+
175
+ subs = self._subscriptions()
176
+ if not subs:
177
+ return False
178
+ key = self._config.web_push_vapid_private_key
179
+ contact = getattr(self._config, "web_push_contact", None) or DEFAULT_CONTACT
180
+ payload = json.dumps({
181
+ "title": title or DEFAULT_TITLE,
182
+ "body": truncate(message, BODY_MAX_LENGTH),
183
+ "url": url,
184
+ "tag": f"marketmyna-{int(time.time())}",
185
+ }).encode()
186
+
187
+ delivered, gone = [], []
188
+ for sub_id, endpoint, p256dh, auth in subs:
189
+ try:
190
+ resp = requests.post(
191
+ endpoint,
192
+ data=encrypt(payload, p256dh, auth),
193
+ headers={
194
+ "Authorization": vapid_authorization(endpoint, key, contact),
195
+ "Content-Encoding": "aes128gcm",
196
+ "Content-Type": "application/octet-stream",
197
+ "TTL": "86400",
198
+ "Urgency": "normal",
199
+ },
200
+ timeout=timeout,
201
+ )
202
+ except Exception as exc:
203
+ logger.error("Web push to %s failed: %s", urlsplit(endpoint).netloc, exc)
204
+ continue
205
+ if resp.status_code in (404, 410):
206
+ gone.append(sub_id) # browser unsubscribed or expired
207
+ elif 200 <= resp.status_code < 300:
208
+ delivered.append(sub_id)
209
+ else:
210
+ logger.error("Web push to %s failed: HTTP %s %s", urlsplit(endpoint).netloc,
211
+ resp.status_code, resp.text[:200])
212
+
213
+ if delivered or gone:
214
+ with _db(self._config).get_session() as session:
215
+ if gone:
216
+ session.query(WebPushSubscriptionRecord).filter(WebPushSubscriptionRecord.id.in_(gone)).delete(
217
+ synchronize_session=False)
218
+ if delivered:
219
+ session.query(WebPushSubscriptionRecord).filter(
220
+ WebPushSubscriptionRecord.id.in_(delivered)).update(
221
+ {"last_success_at": datetime.now()}, synchronize_session=False)
222
+ return bool(delivered)
@@ -0,0 +1,40 @@
1
+ """WeChat Work (WeCom) group robot webhook 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, split_message, utf8_len
10
+
11
+ WECHAT_MAX_LENGTH = 4000 # WeCom counts UTF-8 bytes (limit 4096)
12
+
13
+
14
+ class WechatSender:
15
+ def __init__(self, config):
16
+ self._webhook_url = getattr(config, "wechat_webhook_url", None)
17
+
18
+ def is_configured(self) -> bool:
19
+ return bool(self._webhook_url)
20
+
21
+ def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
22
+ if not self.is_configured():
23
+ return False
24
+
25
+ # The limit is in bytes; emoji and Chinese take 3-4 each, so split by bytes.
26
+ chunks = split_message(f"**{title or DEFAULT_TITLE}**\n{message}", WECHAT_MAX_LENGTH, measure=utf8_len)
27
+ return all(self._post(chunk, timeout) for chunk in chunks)
28
+
29
+ def _post(self, text: str, timeout: float) -> bool:
30
+ payload = {"msgtype": "markdown", "markdown": {"content": text}}
31
+ try:
32
+ resp = requests.post(self._webhook_url, json=payload, timeout=timeout)
33
+ result = resp.json()
34
+ if result.get("errcode") == 0:
35
+ return True
36
+ logger.error("WeChat Work send failed: %s", result)
37
+ return False
38
+ except (requests.RequestException, ValueError) as exc:
39
+ logger.error("WeChat Work send failed: %s", exc)
40
+ return False
@@ -0,0 +1,27 @@
1
+ """WhatsApp via CallMeBot - free for personal use: message +34 644 51 95 23
2
+ "I allow callmebot to send me messages" on WhatsApp to get an API key."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Optional
7
+
8
+ from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, http_ok, truncate
9
+
10
+ # CallMeBot passes the text in the URL; keep it well under URL limits.
11
+ CALLMEBOT_MAX_LENGTH = 1500
12
+
13
+
14
+ class WhatsAppSender:
15
+ def __init__(self, config):
16
+ self._phone = getattr(config, "whatsapp_phone", None)
17
+ self._api_key = getattr(config, "whatsapp_callmebot_apikey", None)
18
+
19
+ def is_configured(self) -> bool:
20
+ return bool(self._phone and self._api_key)
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
+ text = truncate(f"*{title or DEFAULT_TITLE}*\n{message}", CALLMEBOT_MAX_LENGTH)
26
+ params = {"phone": self._phone.replace(" ", ""), "text": text, "apikey": self._api_key}
27
+ return http_ok("WhatsApp", "GET", "https://api.callmebot.com/whatsapp.php", params=params, timeout=timeout)