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