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.
- marketmyna/__init__.py +14 -0
- marketmyna/agent/__init__.py +8 -0
- marketmyna/agent/agents.py +543 -0
- marketmyna/agent/context.py +69 -0
- marketmyna/agent/dashboard.py +55 -0
- marketmyna/agent/orchestrator.py +153 -0
- marketmyna/agent/sentiment.py +150 -0
- marketmyna/agent/skills.py +119 -0
- marketmyna/ai/__init__.py +1 -0
- marketmyna/ai/portfolio_optimizer.py +330 -0
- marketmyna/analysis/__init__.py +6 -0
- marketmyna/analysis/fundamental.py +329 -0
- marketmyna/analysis/sip_optimizer.py +81 -0
- marketmyna/analysis/technical.py +321 -0
- marketmyna/api/__init__.py +1 -0
- marketmyna/api/app.py +209 -0
- marketmyna/api/deps.py +63 -0
- marketmyna/api/middlewares/__init__.py +0 -0
- marketmyna/api/middlewares/auth.py +116 -0
- marketmyna/api/middlewares/csrf.py +66 -0
- marketmyna/api/middlewares/error_handler.py +38 -0
- marketmyna/api/middlewares/slashes.py +27 -0
- marketmyna/api/v1/__init__.py +0 -0
- marketmyna/api/v1/endpoints/__init__.py +0 -0
- marketmyna/api/v1/endpoints/account.py +166 -0
- marketmyna/api/v1/endpoints/agent.py +114 -0
- marketmyna/api/v1/endpoints/bot.py +38 -0
- marketmyna/api/v1/endpoints/calendar.py +269 -0
- marketmyna/api/v1/endpoints/data_sources.py +174 -0
- marketmyna/api/v1/endpoints/health.py +10 -0
- marketmyna/api/v1/endpoints/listings.py +564 -0
- marketmyna/api/v1/endpoints/macro.py +163 -0
- marketmyna/api/v1/endpoints/portfolio.py +530 -0
- marketmyna/api/v1/endpoints/push.py +119 -0
- marketmyna/api/v1/endpoints/reports.py +49 -0
- marketmyna/api/v1/endpoints/scheduler.py +219 -0
- marketmyna/api/v1/endpoints/stocks.py +400 -0
- marketmyna/api/v1/endpoints/system_config.py +157 -0
- marketmyna/api/v1/endpoints/thesis.py +112 -0
- marketmyna/api/v1/endpoints/trade.py +709 -0
- marketmyna/api/v1/endpoints/updates.py +394 -0
- marketmyna/api/v1/endpoints/watchlists.py +179 -0
- marketmyna/api/v1/router.py +43 -0
- marketmyna/bot/__init__.py +14 -0
- marketmyna/bot/commands/__init__.py +0 -0
- marketmyna/bot/commands/analyze.py +40 -0
- marketmyna/bot/commands/ask.py +51 -0
- marketmyna/bot/commands/base.py +35 -0
- marketmyna/bot/commands/batch.py +53 -0
- marketmyna/bot/commands/common.py +84 -0
- marketmyna/bot/commands/help.py +43 -0
- marketmyna/bot/commands/markets.py +153 -0
- marketmyna/bot/commands/portfolio.py +145 -0
- marketmyna/bot/commands/research.py +323 -0
- marketmyna/bot/commands/status.py +48 -0
- marketmyna/bot/commands/trade.py +176 -0
- marketmyna/bot/dispatcher.py +95 -0
- marketmyna/bot/formatting.py +25 -0
- marketmyna/bot/models.py +27 -0
- marketmyna/bot/registry.py +50 -0
- marketmyna/bot/telegram_listener.py +227 -0
- marketmyna/cli/__init__.py +1 -0
- marketmyna/cli/scheduled_run.py +292 -0
- marketmyna/core/__init__.py +6 -0
- marketmyna/core/compliance.py +53 -0
- marketmyna/core/config.py +377 -0
- marketmyna/core/constants.py +69 -0
- marketmyna/core/home.py +22 -0
- marketmyna/core/numbers.py +28 -0
- marketmyna/core/secret_store.py +104 -0
- marketmyna/core/tenant.py +130 -0
- marketmyna/data/__init__.py +8 -0
- marketmyna/data/base.py +36 -0
- marketmyna/data/categories.py +272 -0
- marketmyna/data/china.py +360 -0
- marketmyna/data/crypto.py +200 -0
- marketmyna/data/ecb_fx.py +131 -0
- marketmyna/data/extra_sources.py +569 -0
- marketmyna/data/india.py +241 -0
- marketmyna/data/india_filings.py +343 -0
- marketmyna/data/listings.py +1314 -0
- marketmyna/data/mutual_funds.py +34 -0
- marketmyna/data/news.py +98 -0
- marketmyna/data/news_search.py +346 -0
- marketmyna/data/nse_deals.py +138 -0
- marketmyna/data/resilience.py +173 -0
- marketmyna/data/sec_facts.py +252 -0
- marketmyna/data/shariah.py +283 -0
- marketmyna/data/social/__init__.py +9 -0
- marketmyna/data/social/base.py +100 -0
- marketmyna/data/social/browser.py +92 -0
- marketmyna/data/social/hackernews.py +53 -0
- marketmyna/data/social/reddit.py +397 -0
- marketmyna/data/social/stocktwits.py +63 -0
- marketmyna/data/social/valuepickr.py +53 -0
- marketmyna/data/stocks.py +197 -0
- marketmyna/data/yahoo_price.py +48 -0
- marketmyna/launcher.py +71 -0
- marketmyna/llm/__init__.py +33 -0
- marketmyna/llm/backend_factory.py +51 -0
- marketmyna/llm/backend_registry.py +19 -0
- marketmyna/llm/channels.py +447 -0
- marketmyna/llm/json_reply.py +63 -0
- marketmyna/llm/litellm_backend.py +197 -0
- marketmyna/llm/model_catalog.py +166 -0
- marketmyna/llm/model_health.py +193 -0
- marketmyna/llm/protocol.py +111 -0
- marketmyna/llm/providers.py +128 -0
- marketmyna/llm/store.py +128 -0
- marketmyna/notifiers/__init__.py +35 -0
- marketmyna/notifiers/apprise_sender.py +43 -0
- marketmyna/notifiers/astrbot.py +47 -0
- marketmyna/notifiers/bark.py +24 -0
- marketmyna/notifiers/base.py +80 -0
- marketmyna/notifiers/dingtalk.py +54 -0
- marketmyna/notifiers/discord.py +39 -0
- marketmyna/notifiers/dispatcher.py +153 -0
- marketmyna/notifiers/email.py +99 -0
- marketmyna/notifiers/feishu.py +57 -0
- marketmyna/notifiers/googlechat.py +24 -0
- marketmyna/notifiers/gotify.py +36 -0
- marketmyna/notifiers/matrix.py +28 -0
- marketmyna/notifiers/ntfy.py +36 -0
- marketmyna/notifiers/pushbullet.py +24 -0
- marketmyna/notifiers/pushover.py +41 -0
- marketmyna/notifiers/pushplus.py +41 -0
- marketmyna/notifiers/serverchan3.py +40 -0
- marketmyna/notifiers/settings_schema.py +119 -0
- marketmyna/notifiers/signal_messenger.py +25 -0
- marketmyna/notifiers/slack.py +36 -0
- marketmyna/notifiers/teams.py +37 -0
- marketmyna/notifiers/telegram.py +81 -0
- marketmyna/notifiers/twilio.py +33 -0
- marketmyna/notifiers/webhook.py +44 -0
- marketmyna/notifiers/webpush.py +222 -0
- marketmyna/notifiers/wechat.py +40 -0
- marketmyna/notifiers/whatsapp.py +27 -0
- marketmyna/portfolio/__init__.py +6 -0
- marketmyna/portfolio/analytics.py +336 -0
- marketmyna/portfolio/manager.py +383 -0
- marketmyna/portfolio/models.py +155 -0
- marketmyna/portfolio/optimizers.py +88 -0
- marketmyna/portfolio/review.py +411 -0
- marketmyna/services/__init__.py +1 -0
- marketmyna/services/accounts.py +278 -0
- marketmyna/services/ai_access.py +94 -0
- marketmyna/services/analysis_run.py +491 -0
- marketmyna/services/big_trades.py +335 -0
- marketmyna/services/brokers/__init__.py +0 -0
- marketmyna/services/brokers/angel.py +271 -0
- marketmyna/services/brokers/finnhub.py +209 -0
- marketmyna/services/byok_detect.py +196 -0
- marketmyna/services/calendar.py +748 -0
- marketmyna/services/central_banks.py +202 -0
- marketmyna/services/commodities.py +134 -0
- marketmyna/services/compare.py +307 -0
- marketmyna/services/cot.py +148 -0
- marketmyna/services/country_macro.py +135 -0
- marketmyna/services/crude_sensitivity.py +239 -0
- marketmyna/services/crypto_mood.py +135 -0
- marketmyna/services/crypto_screens.py +226 -0
- marketmyna/services/data_export.py +91 -0
- marketmyna/services/dcf.py +220 -0
- marketmyna/services/dcf_model.py +194 -0
- marketmyna/services/delivery.py +142 -0
- marketmyna/services/discovery.py +146 -0
- marketmyna/services/dividends.py +95 -0
- marketmyna/services/earnings.py +305 -0
- marketmyna/services/econ_calendar.py +495 -0
- marketmyna/services/eia_stocks.py +115 -0
- marketmyna/services/estimates.py +109 -0
- marketmyna/services/etf.py +236 -0
- marketmyna/services/etf_rotation.py +193 -0
- marketmyna/services/etf_screens.py +196 -0
- marketmyna/services/financials.py +639 -0
- marketmyna/services/fund_ai.py +181 -0
- marketmyna/services/fund_analytics.py +371 -0
- marketmyna/services/fund_cost.py +147 -0
- marketmyna/services/fund_holdings.py +664 -0
- marketmyna/services/fund_peers.py +247 -0
- marketmyna/services/fund_rotation.py +206 -0
- marketmyna/services/fund_style.py +179 -0
- marketmyna/services/funds.py +70 -0
- marketmyna/services/hot_stocks.py +358 -0
- marketmyna/services/india_flows.py +263 -0
- marketmyna/services/india_rates.py +258 -0
- marketmyna/services/investor_lenses.py +271 -0
- marketmyna/services/leadership.py +165 -0
- marketmyna/services/live_market.py +497 -0
- marketmyna/services/live_quotes.py +295 -0
- marketmyna/services/macro.py +182 -0
- marketmyna/services/market_calendar.py +399 -0
- marketmyna/services/market_mood.py +168 -0
- marketmyna/services/market_valuation.py +231 -0
- marketmyna/services/mf_holdings.py +199 -0
- marketmyna/services/mf_insights.py +132 -0
- marketmyna/services/mf_lumpsum.py +587 -0
- marketmyna/services/mf_portfolio_ai.py +195 -0
- marketmyna/services/nse_events.py +98 -0
- marketmyna/services/overview.py +366 -0
- marketmyna/services/ownership.py +143 -0
- marketmyna/services/quality_scores.py +199 -0
- marketmyna/services/rates.py +335 -0
- marketmyna/services/reconcile.py +343 -0
- marketmyna/services/report.py +584 -0
- marketmyna/services/report_i18n.py +678 -0
- marketmyna/services/rotation.py +224 -0
- marketmyna/services/runtime_scheduler.py +548 -0
- marketmyna/services/schedule_config.py +251 -0
- marketmyna/services/schedule_time.py +39 -0
- marketmyna/services/screens.py +398 -0
- marketmyna/services/sec_mdna.py +173 -0
- marketmyna/services/shariah.py +246 -0
- marketmyna/services/sip.py +148 -0
- marketmyna/services/sip_days.py +362 -0
- marketmyna/services/smart_file.py +595 -0
- marketmyna/services/smart_pdf.py +425 -0
- marketmyna/services/smart_report.py +370 -0
- marketmyna/services/social_sentiment.py +511 -0
- marketmyna/services/thesis.py +328 -0
- marketmyna/services/translate.py +146 -0
- marketmyna/services/valuation.py +173 -0
- marketmyna/services/worker_pool.py +149 -0
- marketmyna/services/world_markets.py +208 -0
- marketmyna/storage.py +722 -0
- marketmyna/strategies/README.md +51 -0
- marketmyna/strategies/bottom_volume.yaml +49 -0
- marketmyna/strategies/box_oscillation.yaml +69 -0
- marketmyna/strategies/bull_trend.yaml +49 -0
- marketmyna/strategies/chan_theory.yaml +58 -0
- marketmyna/strategies/dragon_head.yaml +44 -0
- marketmyna/strategies/emotion_cycle.yaml +79 -0
- marketmyna/strategies/event_driven.yaml +54 -0
- marketmyna/strategies/expectation_repricing.yaml +54 -0
- marketmyna/strategies/growth_quality.yaml +54 -0
- marketmyna/strategies/hot_theme.yaml +54 -0
- marketmyna/strategies/ma_golden_cross.yaml +46 -0
- marketmyna/strategies/one_yang_three_yin.yaml +37 -0
- marketmyna/strategies/shrink_pullback.yaml +46 -0
- marketmyna/strategies/volume_breakout.yaml +47 -0
- marketmyna/strategies/wave_theory.yaml +64 -0
- marketmyna/trading/__init__.py +2 -0
- marketmyna/trading/alerts.py +356 -0
- marketmyna/trading/backtest.py +317 -0
- marketmyna/trading/charges.py +82 -0
- marketmyna/trading/chart.py +197 -0
- marketmyna/trading/indicators.py +303 -0
- marketmyna/trading/journal.py +74 -0
- marketmyna/trading/knn.py +164 -0
- marketmyna/trading/lots.py +109 -0
- marketmyna/trading/market_hours.py +66 -0
- marketmyna/trading/options.py +554 -0
- marketmyna/trading/pairs.py +94 -0
- marketmyna/trading/risk.py +170 -0
- marketmyna/trading/scanner.py +500 -0
- marketmyna/trading/volatility.py +163 -0
- marketmyna/utils/__init__.py +1 -0
- marketmyna/utils/deadline.py +36 -0
- marketmyna/utils/log_redact.py +52 -0
- marketmyna/utils/yahoo_gate.py +183 -0
- marketmyna/utils/yf_lock.py +12 -0
- marketmyna/web/apple-touch-icon.png +0 -0
- marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
- marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
- marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
- marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
- marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
- marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
- marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
- marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
- marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
- marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
- marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
- marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
- marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
- marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
- marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
- marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
- marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
- marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
- marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
- marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
- marketmyna/web/assets/app-BP1o7WZS.js +1 -0
- marketmyna/web/assets/app-BkIUKdld.js +1 -0
- marketmyna/web/assets/app-CKeSYhEt.js +1 -0
- marketmyna/web/assets/app-CN1OERYX.js +1 -0
- marketmyna/web/assets/app-DIQllha5.js +1 -0
- marketmyna/web/assets/app-DeiAW0tX.js +1 -0
- marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
- marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
- marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
- marketmyna/web/assets/common-0wvpAVpd.js +1 -0
- marketmyna/web/assets/common-3ghy53Us.js +1 -0
- marketmyna/web/assets/common-Bib2unjX.js +1 -0
- marketmyna/web/assets/common-BvSJP0w_.js +1 -0
- marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
- marketmyna/web/assets/common-C_t-iZPf.js +1 -0
- marketmyna/web/assets/compare-DMcsx70H.js +1 -0
- marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
- marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
- marketmyna/web/assets/files-CpgiZRbN.js +1 -0
- marketmyna/web/assets/format-d8InM0Zw.js +9 -0
- marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
- marketmyna/web/assets/index-CKAnq-Or.js +12 -0
- marketmyna/web/assets/index-D4U8E6LH.css +2 -0
- marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
- marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
- marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
- marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
- marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
- marketmyna/web/assets/listings-BefgXpb1.js +1 -0
- marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
- marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
- marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
- marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
- marketmyna/web/assets/markets-DPO40TTe.js +1 -0
- marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
- marketmyna/web/assets/markets-ff1e792u.js +1 -0
- marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
- marketmyna/web/assets/options-BYAZnwzW.js +1 -0
- marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
- marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
- marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
- marketmyna/web/assets/toast-DDradTZw.js +1 -0
- marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
- marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
- marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
- marketmyna/web/brand/logo-mark.webp +0 -0
- marketmyna/web/favicon.png +0 -0
- marketmyna/web/icon-192.png +0 -0
- marketmyna/web/icon-512.png +0 -0
- marketmyna/web/index.html +34 -0
- marketmyna/web/manifest.webmanifest +28 -0
- marketmyna/web/robots.txt +2 -0
- marketmyna/web/sw.js +40 -0
- marketmyna/web/theme-init.js +37 -0
- marketmyna-0.2.0.dist-info/METADATA +345 -0
- marketmyna-0.2.0.dist-info/RECORD +344 -0
- marketmyna-0.2.0.dist-info/WHEEL +5 -0
- marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
- marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
- marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
- 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)
|