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,128 @@
|
|
|
1
|
+
"""Provider catalogue for the Settings page: which AI providers the app
|
|
2
|
+
knows, where to get a key, and a live "which models can this key use"
|
|
3
|
+
lookup - so model names come from the provider, not from a list in this
|
|
4
|
+
code that goes stale.
|
|
5
|
+
|
|
6
|
+
Returned model ids are already in litellm form ("groq/openai/gpt-oss-120b"),
|
|
7
|
+
and the one model_catalog.recommend() would pick is marked "recommended".
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Iterable, Optional
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
_TIMEOUT = 15
|
|
17
|
+
|
|
18
|
+
# id, label, config key field, where to get a key, free-tier note
|
|
19
|
+
PROVIDERS = [
|
|
20
|
+
{"id": "gemini", "label": "Google Gemini", "key_field": "gemini_api_key",
|
|
21
|
+
"key_url": "https://aistudio.google.com/app/apikey",
|
|
22
|
+
"note": "Free tier with per-model rate limits - a good default."},
|
|
23
|
+
{"id": "groq", "label": "Groq", "key_field": "groq_api_key",
|
|
24
|
+
"key_url": "https://console.groq.com/keys",
|
|
25
|
+
"note": "Free tier, very fast - good as the fast model or a fallback."},
|
|
26
|
+
{"id": "openrouter", "label": "OpenRouter", "key_field": "openrouter_api_key",
|
|
27
|
+
"key_url": "https://openrouter.ai/keys",
|
|
28
|
+
"note": "One key for many models; free models are marked but rate-limited."},
|
|
29
|
+
{"id": "openai", "label": "OpenAI", "key_field": "openai_api_key",
|
|
30
|
+
"key_url": "https://platform.openai.com/api-keys", "note": "Paid."},
|
|
31
|
+
{"id": "anthropic", "label": "Anthropic", "key_field": "anthropic_api_key",
|
|
32
|
+
"key_url": "https://console.anthropic.com/settings/keys", "note": "Paid."},
|
|
33
|
+
{"id": "deepseek", "label": "DeepSeek", "key_field": "deepseek_api_key",
|
|
34
|
+
"key_url": "https://platform.deepseek.com/api_keys", "note": "Paid, low cost."},
|
|
35
|
+
{"id": "mistral", "label": "Mistral", "key_field": "mistral_api_key",
|
|
36
|
+
"key_url": "https://console.mistral.ai/api-keys", "note": "Free experiment tier available."},
|
|
37
|
+
{"id": "cerebras", "label": "Cerebras", "key_field": "cerebras_api_key",
|
|
38
|
+
"key_url": "https://cloud.cerebras.ai/platform", "note": "Free tier with a large daily token allowance - a strong fallback."},
|
|
39
|
+
{"id": "nvidia_nim", "label": "NVIDIA NIM", "key_field": "nvidia_api_key",
|
|
40
|
+
"key_url": "https://build.nvidia.com/settings/api-keys", "note": "Free developer credits for hosted open models."},
|
|
41
|
+
{"id": "ollama", "label": "Ollama (local)", "key_field": None,
|
|
42
|
+
"key_url": "https://ollama.com/download", "note": "Runs on your machine: free and private."},
|
|
43
|
+
]
|
|
44
|
+
PROVIDER_IDS = {p["id"] for p in PROVIDERS}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def list_models(provider: str, api_key: Optional[str] = None, base_url: Optional[str] = None,
|
|
48
|
+
include_old: bool = True, hide: Iterable[str] = ()) -> list[dict]:
|
|
49
|
+
"""[{id, label, free, recommended}] for models this key can use to
|
|
50
|
+
generate text; the recommended one first.
|
|
51
|
+
|
|
52
|
+
include_old=False leaves out models a newer stable release of the same
|
|
53
|
+
line replaces (model_catalog.superseded), models the provider marks
|
|
54
|
+
inactive, and `hide` (litellm ids found retired by model_health)."""
|
|
55
|
+
from .model_catalog import bare, is_text_model, recommend, superseded
|
|
56
|
+
|
|
57
|
+
models = [m for m in _list_models(provider, api_key, base_url) if is_text_model(bare(m["id"]))]
|
|
58
|
+
if not include_old:
|
|
59
|
+
gone = superseded(bare(m["id"]) for m in models)
|
|
60
|
+
hidden = set(hide)
|
|
61
|
+
models = [m for m in models if m.get("active", True) and bare(m["id"]) not in gone and m["id"] not in hidden]
|
|
62
|
+
for m in models:
|
|
63
|
+
m.pop("active", None)
|
|
64
|
+
best = recommend(provider, [bare(m["id"]) for m in models])
|
|
65
|
+
for m in models:
|
|
66
|
+
m["recommended"] = best is not None and bare(m["id"]) == best
|
|
67
|
+
return sorted(models, key=lambda m: not m["recommended"])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _list_models(provider: str, api_key: Optional[str] = None, base_url: Optional[str] = None) -> list[dict]:
|
|
71
|
+
if provider == "gemini":
|
|
72
|
+
resp = requests.get("https://generativelanguage.googleapis.com/v1beta/models",
|
|
73
|
+
params={"key": api_key, "pageSize": 200}, timeout=_TIMEOUT)
|
|
74
|
+
resp.raise_for_status()
|
|
75
|
+
out = []
|
|
76
|
+
for m in resp.json().get("models", []):
|
|
77
|
+
if "generateContent" not in (m.get("supportedGenerationMethods") or []):
|
|
78
|
+
continue
|
|
79
|
+
name = m["name"].split("/", 1)[-1]
|
|
80
|
+
out.append({"id": f"gemini/{name}", "label": m.get("displayName") or name, "free": None})
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
if provider == "openrouter":
|
|
84
|
+
# Public list; pricing tells which models are free.
|
|
85
|
+
resp = requests.get("https://openrouter.ai/api/v1/models", timeout=_TIMEOUT)
|
|
86
|
+
resp.raise_for_status()
|
|
87
|
+
out = []
|
|
88
|
+
for m in resp.json().get("data", []):
|
|
89
|
+
pricing = m.get("pricing") or {}
|
|
90
|
+
free = str(pricing.get("prompt")) in ("0", "0.0") and str(pricing.get("completion")) in ("0", "0.0")
|
|
91
|
+
out.append({"id": f"openrouter/{m['id']}", "label": m.get("name") or m["id"], "free": free})
|
|
92
|
+
return sorted(out, key=lambda m: (not m["free"], m["label"].lower()))
|
|
93
|
+
|
|
94
|
+
if provider == "ollama":
|
|
95
|
+
base = (base_url or "http://localhost:11434").rstrip("/")
|
|
96
|
+
resp = requests.get(f"{base}/api/tags", timeout=_TIMEOUT)
|
|
97
|
+
resp.raise_for_status()
|
|
98
|
+
return [{"id": f"ollama/{m['name']}", "label": m["name"], "free": True} for m in resp.json().get("models", [])]
|
|
99
|
+
|
|
100
|
+
if provider == "anthropic":
|
|
101
|
+
resp = requests.get("https://api.anthropic.com/v1/models", timeout=_TIMEOUT,
|
|
102
|
+
headers={"x-api-key": api_key or "", "anthropic-version": "2023-06-01"})
|
|
103
|
+
resp.raise_for_status()
|
|
104
|
+
return [{"id": f"anthropic/{m['id']}", "label": m.get("display_name") or m["id"], "free": False}
|
|
105
|
+
for m in resp.json().get("data", [])]
|
|
106
|
+
|
|
107
|
+
# OpenAI-style /models lists.
|
|
108
|
+
urls = {
|
|
109
|
+
"groq": "https://api.groq.com/openai/v1/models",
|
|
110
|
+
"openai": "https://api.openai.com/v1/models",
|
|
111
|
+
"deepseek": "https://api.deepseek.com/models",
|
|
112
|
+
"mistral": "https://api.mistral.ai/v1/models",
|
|
113
|
+
"cerebras": "https://api.cerebras.ai/v1/models",
|
|
114
|
+
"nvidia_nim": "https://integrate.api.nvidia.com/v1/models",
|
|
115
|
+
}
|
|
116
|
+
if provider not in urls:
|
|
117
|
+
raise ValueError(f"Unknown provider '{provider}'")
|
|
118
|
+
resp = requests.get(urls[provider], headers={"Authorization": f"Bearer {api_key or ''}"}, timeout=_TIMEOUT)
|
|
119
|
+
resp.raise_for_status()
|
|
120
|
+
skip = ("whisper", "tts", "embedding", "embed", "moderation", "dall-e", "image", "audio", "transcribe", "guard")
|
|
121
|
+
out = []
|
|
122
|
+
for m in resp.json().get("data", []):
|
|
123
|
+
mid = m.get("id", "")
|
|
124
|
+
if not mid or any(word in mid.lower() for word in skip):
|
|
125
|
+
continue
|
|
126
|
+
out.append({"id": f"{provider}/{mid}", "label": mid, "free": True if provider in ("groq", "cerebras") else None,
|
|
127
|
+
"active": m.get("active") is not False})
|
|
128
|
+
return sorted(out, key=lambda m: m["label"].lower())
|
marketmyna/llm/store.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Response cache and usage counters for LLM calls (sqlite, shared by the
|
|
2
|
+
API process and the scheduler's child processes).
|
|
3
|
+
|
|
4
|
+
The cache key covers everything that shapes the answer - model tier,
|
|
5
|
+
system prompt, prompt, and generation limits - so a cached reply is only
|
|
6
|
+
reused for a byte-identical request. Prompts embed the current price and
|
|
7
|
+
indicator values, so a new trading day naturally misses the cache.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import logging
|
|
14
|
+
from datetime import date, datetime, timedelta
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from ..utils.log_redact import SECRETS as _SECRETS
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
DEFAULT_CACHE_HOURS = 6.0
|
|
22
|
+
|
|
23
|
+
# GenerationErrorCode value -> the reason shown in Settings -> AI usage.
|
|
24
|
+
ERROR_KINDS = {
|
|
25
|
+
"model_unavailable": "retired",
|
|
26
|
+
"rate_limited": "quota",
|
|
27
|
+
"auth_failed": "key",
|
|
28
|
+
"timeout": "timeout",
|
|
29
|
+
"invalid_response": "bad answer",
|
|
30
|
+
"unknown": "outage",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def clean_error(exc, limit: int = 300) -> str:
|
|
35
|
+
"""One line of an error's text, with anything that looks like an API key
|
|
36
|
+
masked (provider errors can echo the request URL, ?key=... included)."""
|
|
37
|
+
return _SECRETS.sub("***", " ".join(str(exc).split()))[:limit]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def error_kind(exc) -> str:
|
|
41
|
+
code = getattr(getattr(exc, "error_code", None), "value", "")
|
|
42
|
+
return ERROR_KINDS.get(code, "other")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LLMStore:
|
|
46
|
+
def __init__(self, config):
|
|
47
|
+
from ..storage import DatabaseManager, get_db_url
|
|
48
|
+
|
|
49
|
+
self._db = DatabaseManager.instance(get_db_url(config=config))
|
|
50
|
+
hours = getattr(config, "llm_cache_hours", None)
|
|
51
|
+
self.cache_hours = DEFAULT_CACHE_HOURS if hours is None else float(hours)
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def key(tier: str, system_prompt: Optional[str], prompt: str, max_tokens, temperature) -> str:
|
|
55
|
+
raw = "\x1f".join([tier, system_prompt or "", prompt, str(max_tokens), str(temperature)])
|
|
56
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
57
|
+
|
|
58
|
+
# ---- cache
|
|
59
|
+
def get(self, key: str) -> Optional[tuple[str, str]]:
|
|
60
|
+
if self.cache_hours <= 0:
|
|
61
|
+
return None
|
|
62
|
+
from ..storage import LLMCacheRecord
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
with self._db.get_session() as session:
|
|
66
|
+
record = session.get(LLMCacheRecord, key)
|
|
67
|
+
if record and record.created_at >= datetime.now() - timedelta(hours=self.cache_hours):
|
|
68
|
+
return record.text, record.model or ""
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
logger.debug("LLM cache read failed: %s", exc)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def put(self, key: str, text: str, model: str) -> None:
|
|
74
|
+
if self.cache_hours <= 0 or not text:
|
|
75
|
+
return
|
|
76
|
+
from ..storage import LLMCacheRecord
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
with self._db.get_session() as session:
|
|
80
|
+
session.merge(LLMCacheRecord(key=key, text=text, model=model, created_at=datetime.now()))
|
|
81
|
+
# Keep the table small: drop answers as soon as they are past the TTL.
|
|
82
|
+
cutoff = datetime.now() - timedelta(hours=self.cache_hours)
|
|
83
|
+
session.query(LLMCacheRecord).filter(LLMCacheRecord.created_at < cutoff).delete(
|
|
84
|
+
synchronize_session=False)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
logger.debug("LLM cache write failed: %s", exc)
|
|
87
|
+
|
|
88
|
+
# ---- usage
|
|
89
|
+
def record(self, route: str, *, ok: bool = True, cached: bool = False, usage: Optional[dict] = None,
|
|
90
|
+
error: Optional[Exception] = None) -> None:
|
|
91
|
+
from ..storage import LLMUsageRecord
|
|
92
|
+
|
|
93
|
+
usage = usage or {}
|
|
94
|
+
try:
|
|
95
|
+
with self._db.get_session() as session:
|
|
96
|
+
today = date.today().isoformat()
|
|
97
|
+
row = session.query(LLMUsageRecord).filter_by(day=today, route=route).first()
|
|
98
|
+
if row is None:
|
|
99
|
+
row = LLMUsageRecord(day=today, route=route, calls=0, failures=0, cache_hits=0,
|
|
100
|
+
prompt_tokens=0, completion_tokens=0)
|
|
101
|
+
session.add(row)
|
|
102
|
+
if cached:
|
|
103
|
+
row.cache_hits = (row.cache_hits or 0) + 1
|
|
104
|
+
elif ok:
|
|
105
|
+
row.calls = (row.calls or 0) + 1
|
|
106
|
+
row.prompt_tokens = (row.prompt_tokens or 0) + int(usage.get("prompt_tokens") or 0)
|
|
107
|
+
row.completion_tokens = (row.completion_tokens or 0) + int(usage.get("completion_tokens") or 0)
|
|
108
|
+
else:
|
|
109
|
+
row.failures = (row.failures or 0) + 1
|
|
110
|
+
if error is not None:
|
|
111
|
+
row.last_error_kind = error_kind(error)
|
|
112
|
+
row.last_error = clean_error(error)
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
logger.debug("LLM usage write failed: %s", exc)
|
|
115
|
+
|
|
116
|
+
def usage(self, days: int = 7) -> list[dict]:
|
|
117
|
+
from ..storage import LLMUsageRecord
|
|
118
|
+
|
|
119
|
+
since = (date.today() - timedelta(days=days - 1)).isoformat()
|
|
120
|
+
with self._db.get_session() as session:
|
|
121
|
+
rows = session.query(LLMUsageRecord).filter(LLMUsageRecord.day >= since).all()
|
|
122
|
+
return [
|
|
123
|
+
{"day": r.day, "route": r.route, "calls": r.calls or 0, "failures": r.failures or 0,
|
|
124
|
+
"cache_hits": r.cache_hits or 0, "prompt_tokens": r.prompt_tokens or 0,
|
|
125
|
+
"completion_tokens": r.completion_tokens or 0, "last_error_kind": r.last_error_kind,
|
|
126
|
+
"last_error": r.last_error}
|
|
127
|
+
for r in sorted(rows, key=lambda r: (r.day, r.route), reverse=True)
|
|
128
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Notification channel senders and dispatcher."""
|
|
2
|
+
|
|
3
|
+
from .astrbot import AstrbotSender
|
|
4
|
+
from .dingtalk import DingtalkSender
|
|
5
|
+
from .discord import DiscordSender
|
|
6
|
+
from .dispatcher import NotificationDispatcher
|
|
7
|
+
from .email import EmailSender
|
|
8
|
+
from .feishu import FeishuSender
|
|
9
|
+
from .gotify import GotifySender
|
|
10
|
+
from .ntfy import NtfySender
|
|
11
|
+
from .pushover import PushoverSender
|
|
12
|
+
from .pushplus import PushplusSender
|
|
13
|
+
from .serverchan3 import Serverchan3Sender
|
|
14
|
+
from .slack import SlackSender
|
|
15
|
+
from .telegram import TelegramSender
|
|
16
|
+
from .webhook import WebhookSender
|
|
17
|
+
from .wechat import WechatSender
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AstrbotSender",
|
|
21
|
+
"DingtalkSender",
|
|
22
|
+
"DiscordSender",
|
|
23
|
+
"EmailSender",
|
|
24
|
+
"FeishuSender",
|
|
25
|
+
"GotifySender",
|
|
26
|
+
"NotificationDispatcher",
|
|
27
|
+
"NtfySender",
|
|
28
|
+
"PushoverSender",
|
|
29
|
+
"PushplusSender",
|
|
30
|
+
"Serverchan3Sender",
|
|
31
|
+
"SlackSender",
|
|
32
|
+
"TelegramSender",
|
|
33
|
+
"WebhookSender",
|
|
34
|
+
"WechatSender",
|
|
35
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Apprise sender: one field that reaches 100+ services (LINE, Viber,
|
|
2
|
+
Mastodon, Zulip, Home Assistant, Rocket.Chat, SMS gateways, ...) through
|
|
3
|
+
Apprise URLs - see https://github.com/caronc/apprise/wiki.
|
|
4
|
+
|
|
5
|
+
Optional dependency: `pip install apprise`. Without it the channel is
|
|
6
|
+
reported as not configured (and logged) rather than failing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AppriseSender:
|
|
17
|
+
def __init__(self, config):
|
|
18
|
+
self._urls = [u for u in (getattr(config, "apprise_urls", None) or []) if u]
|
|
19
|
+
|
|
20
|
+
def is_configured(self) -> bool:
|
|
21
|
+
if not self._urls:
|
|
22
|
+
return False
|
|
23
|
+
try:
|
|
24
|
+
import apprise # noqa: F401
|
|
25
|
+
except ImportError:
|
|
26
|
+
logger.warning("apprise_urls is set but the apprise package isn't installed (pip install apprise)")
|
|
27
|
+
return False
|
|
28
|
+
return True
|
|
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
|
+
import apprise
|
|
34
|
+
|
|
35
|
+
client = apprise.Apprise()
|
|
36
|
+
for url in self._urls:
|
|
37
|
+
if not client.add(url):
|
|
38
|
+
logger.error("Apprise rejected URL (check its format): %s", url.split("://")[0] + "://…")
|
|
39
|
+
try:
|
|
40
|
+
return bool(client.notify(body=message, title=title or DEFAULT_TITLE))
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
logger.error("Apprise send failed: %s", exc)
|
|
43
|
+
return False
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Self-hosted AstrBot webhook notification sender."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from .base import DEFAULT_TIMEOUT, logger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AstrbotSender:
|
|
17
|
+
def __init__(self, config):
|
|
18
|
+
self._url = getattr(config, "astrbot_url", None)
|
|
19
|
+
self._token = getattr(config, "astrbot_token", None)
|
|
20
|
+
|
|
21
|
+
def is_configured(self) -> bool:
|
|
22
|
+
return bool(self._url)
|
|
23
|
+
|
|
24
|
+
def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
|
|
25
|
+
if not self.is_configured():
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
payload = {"content": f"{title}\n\n{message}" if title else message}
|
|
29
|
+
headers = {"Content-Type": "application/json"}
|
|
30
|
+
|
|
31
|
+
if self._token:
|
|
32
|
+
timestamp = str(int(time.time()))
|
|
33
|
+
payload_json = json.dumps(payload, sort_keys=True)
|
|
34
|
+
sign_data = f"{timestamp}.{payload_json}".encode("utf-8")
|
|
35
|
+
signature = hmac.new(self._token.encode("utf-8"), sign_data, hashlib.sha256).hexdigest()
|
|
36
|
+
headers["X-Signature"] = signature
|
|
37
|
+
headers["X-Timestamp"] = timestamp
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
resp = requests.post(self._url, json=payload, headers=headers, timeout=timeout)
|
|
41
|
+
if resp.status_code == 200:
|
|
42
|
+
return True
|
|
43
|
+
logger.error("AstrBot send failed: HTTP %s %s", resp.status_code, resp.text[:200])
|
|
44
|
+
return False
|
|
45
|
+
except requests.RequestException as exc:
|
|
46
|
+
logger.error("AstrBot send failed: %s", exc)
|
|
47
|
+
return False
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Bark iOS push sender (https://github.com/Finb/Bark): the device URL
|
|
2
|
+
shown in the Bark app, e.g. https://api.day.app/yourkey."""
|
|
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
|
+
BARK_MAX_LENGTH = 3500
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BarkSender:
|
|
14
|
+
def __init__(self, config):
|
|
15
|
+
self._url = (getattr(config, "bark_url", None) or "").rstrip("/")
|
|
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
|
+
payload = {"title": title or DEFAULT_TITLE, "body": truncate(message, BARK_MAX_LENGTH), "group": "MarketMyna"}
|
|
24
|
+
return http_ok("Bark", "POST", self._url, json=payload, timeout=timeout)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Shared helpers for notification channel senders.
|
|
2
|
+
|
|
3
|
+
Each sender follows the same small contract: ``is_configured()`` and
|
|
4
|
+
``send(message, title=None, timeout=10.0) -> bool``. A channel that fails
|
|
5
|
+
must return False rather than raise, so one broken channel never blocks
|
|
6
|
+
the others (see NotificationDispatcher).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
DEFAULT_TITLE = "MarketMyna Alert"
|
|
17
|
+
DEFAULT_TIMEOUT = 10.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
TRUNCATION_NOTE = "\n…\n(Shortened to fit this channel. Open MarketMyna for the full report.)"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def utf8_len(text: str) -> int:
|
|
24
|
+
return len(text.encode("utf-8"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def truncate(text: str, max_length: int, measure: Callable[[str], int] = len) -> str:
|
|
28
|
+
"""Cap message length for platforms with a hard limit, saying so when it
|
|
29
|
+
cuts (so a reader knows sections are missing). `measure` = utf8_len for
|
|
30
|
+
platforms that count bytes."""
|
|
31
|
+
if measure(text) <= max_length:
|
|
32
|
+
return text
|
|
33
|
+
note = TRUNCATION_NOTE if max_length >= 400 else "…"
|
|
34
|
+
budget = max_length - measure(note)
|
|
35
|
+
cut = text[:budget]
|
|
36
|
+
while cut and measure(cut) > budget: # multi-byte characters
|
|
37
|
+
cut = cut[: max(1, len(cut) - max(1, (measure(cut) - budget) // 2))]
|
|
38
|
+
cut = cut.rsplit("\n", 1)[0] if "\n" in cut[-200:] and note != "…" else cut # end on a whole line
|
|
39
|
+
return cut.rstrip() + note
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def split_message(text: str, limit: int, measure: Callable[[str], int] = len) -> list[str]:
|
|
43
|
+
"""Split on line boundaries into chunks of at most `limit` (chars, or
|
|
44
|
+
bytes with measure=utf8_len); a single over-long line is hard-cut."""
|
|
45
|
+
chunks: list[str] = []
|
|
46
|
+
current = ""
|
|
47
|
+
for line in text.split("\n"):
|
|
48
|
+
while measure(line) > limit:
|
|
49
|
+
if current:
|
|
50
|
+
chunks.append(current)
|
|
51
|
+
current = ""
|
|
52
|
+
piece = line[:limit]
|
|
53
|
+
while measure(piece) > limit:
|
|
54
|
+
piece = piece[:-1]
|
|
55
|
+
chunks.append(piece)
|
|
56
|
+
line = line[len(piece):]
|
|
57
|
+
candidate = f"{current}\n{line}" if current else line
|
|
58
|
+
if measure(candidate) > limit:
|
|
59
|
+
chunks.append(current)
|
|
60
|
+
current = line
|
|
61
|
+
else:
|
|
62
|
+
current = candidate
|
|
63
|
+
if current:
|
|
64
|
+
chunks.append(current)
|
|
65
|
+
return chunks or [""]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def http_ok(channel: str, method: str, url: str, *, timeout: float = DEFAULT_TIMEOUT, **kwargs) -> bool:
|
|
69
|
+
"""One HTTP call for a sender: True on 2xx, False (logged) otherwise."""
|
|
70
|
+
import requests
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
resp = requests.request(method, url, timeout=timeout, **kwargs)
|
|
74
|
+
except requests.RequestException as exc:
|
|
75
|
+
logger.error("%s send failed: %s", channel, exc)
|
|
76
|
+
return False
|
|
77
|
+
if 200 <= resp.status_code < 300:
|
|
78
|
+
return True
|
|
79
|
+
logger.error("%s send failed: HTTP %s %s", channel, resp.status_code, resp.text[:200])
|
|
80
|
+
return False
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""DingTalk custom robot webhook notification sender."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import time
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger, truncate, utf8_len
|
|
15
|
+
|
|
16
|
+
DINGTALK_MAX_LENGTH = 19000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DingtalkSender:
|
|
20
|
+
def __init__(self, config):
|
|
21
|
+
self._webhook_url = getattr(config, "dingtalk_webhook_url", None)
|
|
22
|
+
self._secret = getattr(config, "dingtalk_secret", None)
|
|
23
|
+
|
|
24
|
+
def is_configured(self) -> bool:
|
|
25
|
+
return bool(self._webhook_url)
|
|
26
|
+
|
|
27
|
+
def _signed_url(self) -> str:
|
|
28
|
+
if not self._secret:
|
|
29
|
+
return self._webhook_url
|
|
30
|
+
|
|
31
|
+
timestamp = str(round(time.time() * 1000))
|
|
32
|
+
string_to_sign = f"{timestamp}\n{self._secret}".encode("utf-8")
|
|
33
|
+
hmac_code = hmac.new(self._secret.encode("utf-8"), string_to_sign, digestmod=hashlib.sha256).digest()
|
|
34
|
+
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
|
|
35
|
+
joiner = "&" if "?" in self._webhook_url else "?"
|
|
36
|
+
return f"{self._webhook_url}{joiner}timestamp={timestamp}&sign={sign}"
|
|
37
|
+
|
|
38
|
+
def send(self, message: str, title: Optional[str] = None, *, timeout: float = DEFAULT_TIMEOUT) -> bool:
|
|
39
|
+
if not self.is_configured():
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
text = truncate(f"### {title or DEFAULT_TITLE}\n\n{message}", DINGTALK_MAX_LENGTH, measure=utf8_len) # 20000-byte cap
|
|
43
|
+
payload = {"msgtype": "markdown", "markdown": {"title": title or DEFAULT_TITLE, "text": text}}
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
resp = requests.post(self._signed_url(), json=payload, timeout=timeout)
|
|
47
|
+
result = resp.json()
|
|
48
|
+
if result.get("errcode") == 0:
|
|
49
|
+
return True
|
|
50
|
+
logger.error("DingTalk send failed: %s", result)
|
|
51
|
+
return False
|
|
52
|
+
except (requests.RequestException, ValueError) as exc:
|
|
53
|
+
logger.error("DingTalk send failed: %s", exc)
|
|
54
|
+
return False
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Discord Incoming Webhook notification sender."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .base import DEFAULT_TIMEOUT, DEFAULT_TITLE, logger, split_message
|
|
11
|
+
|
|
12
|
+
DISCORD_MAX_LENGTH = 2000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DiscordSender:
|
|
16
|
+
def __init__(self, config):
|
|
17
|
+
self._webhook_url = getattr(config, "discord_webhook_url", None)
|
|
18
|
+
|
|
19
|
+
def is_configured(self) -> bool:
|
|
20
|
+
return bool(self._webhook_url)
|
|
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
|
+
text = f"**{title or DEFAULT_TITLE}**\n{message}"
|
|
27
|
+
# Long reports go out as several messages instead of being cut off.
|
|
28
|
+
for i, chunk in enumerate(split_message(text, DISCORD_MAX_LENGTH)):
|
|
29
|
+
if i:
|
|
30
|
+
time.sleep(0.5) # webhook rate limit: 5 requests / 2 s
|
|
31
|
+
try:
|
|
32
|
+
resp = requests.post(self._webhook_url, json={"content": chunk}, timeout=timeout)
|
|
33
|
+
except requests.RequestException as exc:
|
|
34
|
+
logger.error("Discord send failed: %s", exc)
|
|
35
|
+
return False
|
|
36
|
+
if not 200 <= resp.status_code < 300:
|
|
37
|
+
logger.error("Discord send failed: HTTP %s %s", resp.status_code, resp.text[:200])
|
|
38
|
+
return False
|
|
39
|
+
return True
|