Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ Each is off by default. The holding-news DM and the movers reply are switched on
|---|---|---|
| `/start` | DM | Greeting (and the buttons if enabled) |
| `/holdingnews` | DM, if holding news is on | Run the holding-news check now |
| `/credits` | `OWNER_USER_ID` | Provider balance (OpenRouter; xAI and z.ai have none to show) |
| `/usage` | `OWNER_USER_ID` | Requests, tokens (and % cached) and cost per model, last 24 h and 7 days |
| `/credits` | a user ID in `ADMIN_CHAT_IDS` | Provider balance (OpenRouter; xAI and z.ai have none to show) |
| `/usage` | a user ID in `ADMIN_CHAT_IDS` | Requests, tokens (and % cached) and cost per model, last 24 h and 7 days |

## Caching and token saving
**Provider prompt caching** (xAI and OpenRouter keep a conversation on the server holding its cached
Expand Down Expand Up @@ -209,6 +209,7 @@ tokens, cached % and cost. Tool servers log their tools in short wrapped lines;
token size of the tool definitions. The `Starting ...` line shows the git commit the checkout is on.

## Environment variables
### Common settings
| Variable | Meaning |
|---|---|
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather (required). |
Expand All @@ -219,21 +220,24 @@ token size of the tool definitions. The `Starting ...` line shows the git commit
| `FAST_MODEL` | Optional cheaper/faster model used for simple requests (TOKEN_SAVER only). |
| `REASONING` | Reasoning effort: low, medium or high; empty = the model's default (low on z.ai, which cannot turn thinking off). |
| `SEARCH` | on|off. Web search (and X search on xAI; z.ai's search API on z.ai). Default on. |
| `ADMIN_CHAT_IDS` | Telegram IDs of the bot's admins (comma/space separated). Your user ID gets a message when an MCP server goes down and may use `/credits` and `/usage`; a group's chat ID gets the down-server messages only. Empty (default) = no alerts and no admin commands. |

### Advanced and optional
| Variable | Meaning |
|---|---|
| `SEARCH_MODEL` | OpenRouter only: model that runs the searches (default xiaomi/mimo-v2.6-flash:online). |
| `MAX_TOKENS` | Reply cap in tokens, reasoning included (default 3000). |
| `TOKEN_SAVER` | on|off. Master switch for the token-saving heuristics (default on). |
| `HISTORY_LIMIT` | Messages of chat history in the prompt; the window is HISTORY_LIMIT to 1.5x (default 20). |
| `DB_PATH` | SQLite file for chat history (default chat_log.db). Instances may share it. |
| `BOT_TZ` | Time zone for timestamps, e.g. Australia/Melbourne (default UTC). |
| `MCP_CONFIG` | MCP server config file (default mcp_servers.json; missing = no MCP tools). |
| `ADMIN_CHAT_IDS` | Chat IDs told when an MCP server goes down (comma/space separated; empty = no alerts). |
| `OWNER_USER_ID` | Telegram user ID allowed to use /credits and /usage. |
| `MOVERS_BOTS` | Usernames of bots whose end-of-day big-movers lists get explained. Empty (default) = feature off. |
| `POST_TO_GROUPS_FROM_DM` | on|off. A group admin can have the bot post in that group from a DM (default off). |
| `TELEGRAM_DM_BUTTONS` | on|off. Preset-prompt buttons in private chats (default off). |
| `SHARESIGHT_HOLDING_NEWS_TIME` | HH:MM (BOT_TZ) for the daily holding-news check (default 08:00). |
| `SHARESIGHT_HOLDING_NEWS_RECIPIENTS` | Comma list of portfolio:telegram_username pairs to notify. Empty (default) = daily holding-news DM off. |
| `SHARESIGHT_HOLDING_NEWS_TIME` | HH:MM (BOT_TZ) for the daily holding-news check (default 08:00). |
| `PORTFOLIO_NAMES` | Comma list of Sharesight portfolio names; a message naming one is treated as a portfolio question (default: the recipients' portfolio names). |
| `TOKEN_SAVER` | on|off. Master switch for the token-saving heuristics (default on). |

## Choosing a model
Figures from search results and Artificial Analysis at the time of writing (October 2026), so
Expand Down
2 changes: 1 addition & 1 deletion lib/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
return wrapper

# Commands first: the first matching handler wins, and the catch-all would swallow them.
if st.owner_id:
if st.admin_chats:
app.add_handler(CommandHandler("credits", command(commands.credits)))
app.add_handler(CommandHandler("usage", command(commands.usage)))
app.add_handler(MessageHandler(
Expand Down
13 changes: 6 additions & 7 deletions lib/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
# Every environment variable the bot reads, with a one-line description. README.md must
# mention each name (tests/test_config.py enforces it).
ENV_VARS: dict[str, str] = {
# Common
"TELEGRAM_BOT_TOKEN": "Bot token from @BotFather (required).",
"XAI_API_KEY": "xAI key; selects the xAI provider. Set exactly one of the three keys.",
"OPENROUTER_API_KEY": "OpenRouter key; selects the OpenRouter provider.",
Expand All @@ -19,21 +20,21 @@
"FAST_MODEL": "Optional cheaper/faster model used for simple requests (TOKEN_SAVER only).",
"REASONING": "Reasoning effort: low, medium or high; empty = the model's default (low on z.ai, which cannot turn thinking off).",
"SEARCH": "on|off. Web search (and X search on xAI; z.ai's search API on z.ai). Default on.",
"ADMIN_CHAT_IDS": "Telegram IDs of the bot's admins (comma/space separated). Your user ID gets MCP-down alerts and may use /credits and /usage; a group's chat ID gets the alerts only. Empty = neither.",
# Advanced and optional
"SEARCH_MODEL": "OpenRouter only: model that runs the searches (default xiaomi/mimo-v2.6-flash:online).",
"MAX_TOKENS": "Reply cap in tokens, reasoning included (default 3000).",
"TOKEN_SAVER": "on|off. Master switch for the token-saving heuristics (default on).",
"HISTORY_LIMIT": "Messages of chat history in the prompt; the window is HISTORY_LIMIT to 1.5x (default 20).",
"DB_PATH": "SQLite file for chat history (default chat_log.db). Instances may share it.",
"BOT_TZ": "Time zone for timestamps, e.g. Australia/Melbourne (default UTC).",
"MCP_CONFIG": "MCP server config file (default mcp_servers.json; missing = no MCP tools).",
"ADMIN_CHAT_IDS": "Chat IDs told when an MCP server goes down (comma/space separated; empty = no alerts).",
"OWNER_USER_ID": "Telegram user ID allowed to use /credits and /usage.",
"MOVERS_BOTS": "Usernames of bots whose end-of-day big-movers lists get explained. Empty (default) = feature off.",
"POST_TO_GROUPS_FROM_DM": "on|off. Lets an admin of a group the bot is in make it post there from a DM (default off).",
"TELEGRAM_DM_BUTTONS": "on|off. Preset-prompt buttons in private chats (default off).",
"SHARESIGHT_HOLDING_NEWS_TIME": "HH:MM (BOT_TZ) for the daily holding-news check (default 08:00).",
"SHARESIGHT_HOLDING_NEWS_RECIPIENTS": "Comma list of portfolio:telegram_username pairs to notify. Empty (default) = daily holding-news DM off.",
"SHARESIGHT_HOLDING_NEWS_TIME": "HH:MM (BOT_TZ) for the daily holding-news check (default 08:00).",
"PORTFOLIO_NAMES": "Comma list of Sharesight portfolio names; a message naming one is treated as a portfolio question (default: the recipients' portfolio names).",
"TOKEN_SAVER": "on|off. Master switch for the token-saving heuristics (default on).",
}

DEFAULT_MODELS = {"xai": "grok-4.3", "openrouter": "z-ai/glm-5.3-flash", "zai": "glm-5.3-flash"}
Expand Down Expand Up @@ -70,8 +71,7 @@ class Settings:
db_path: str
tz: ZoneInfo
mcp_config: str
admin_chats: frozenset[int]
owner_id: int
admin_chats: frozenset[int] # user IDs (alerts + admin commands) and group chat IDs (alerts only)
movers_bots: frozenset[str]
dm_buttons: bool
post_to_groups: bool
Expand Down Expand Up @@ -165,7 +165,6 @@ def load(env: Mapping[str, str] | None = None) -> Settings:
tz=ZoneInfo(env.get("BOT_TZ", "UTC")),
mcp_config=env.get("MCP_CONFIG", "mcp_servers.json"),
admin_chats=_ids(env.get("ADMIN_CHAT_IDS", "")),
owner_id=int(env.get("OWNER_USER_ID") or 0),
movers_bots=frozenset(
u.lower().lstrip("@") for u in env.get("MOVERS_BOTS", "").replace(",", " ").split()
),
Expand Down
8 changes: 4 additions & 4 deletions lib/telegram/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@

async def _gate(ctx: Ctx, update: Update) -> bool:
"""Log the command message (these handlers take it before on_message would) and say
whether the sender is the owner."""
whether the sender is an admin (a user ID in ADMIN_CHAT_IDS)."""
await asyncio.to_thread(ctx.store.save, update.effective_message)
user = update.effective_user
return bool(user and ctx.st.owner_id and user.id == ctx.st.owner_id)
return bool(user and user.id in ctx.st.admin_chats)


async def _reply(ctx: Ctx, update: Update, text: str) -> None:
Expand All @@ -28,7 +28,7 @@ async def _reply(ctx: Ctx, update: Update, text: str) -> None:


async def credits(ctx: Ctx, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""/credits - owner only: how much provider credit is left (OpenRouter)."""
"""/credits - admins only: how much provider credit is left (OpenRouter)."""
if not await _gate(ctx, update):
return
try:
Expand All @@ -51,7 +51,7 @@ def usage_text(rows: list[tuple], label: str) -> str:


async def usage(ctx: Ctx, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""/usage - owner only: tokens and cost per model for the last 24 hours and 7 days."""
"""/usage - admins only: tokens and cost per model for the last 24 hours and 7 days."""
if not await _gate(ctx, update):
return
now = int(time.time())
Expand Down
2 changes: 1 addition & 1 deletion tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def build(**extra):

plain, st = build()
assert handler_kinds(plain) == ["MessageHandler"] and app.allowed_updates(st) == ["message", "edited_message"]
full, st = build(OWNER_USER_ID="5", SHARESIGHT_HOLDING_NEWS_RECIPIENTS="Pf:alice")
full, st = build(ADMIN_CHAT_IDS="5", SHARESIGHT_HOLDING_NEWS_RECIPIENTS="Pf:alice")
kinds = handler_kinds(full)
assert kinds == ["CommandHandler", "CommandHandler", "MessageHandler", "CallbackQueryHandler"]
assert "callback_query" in app.allowed_updates(st)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ def test_movers_helpers():


# ---- commands --------------------------------------------------------------------------
async def test_credits_and_usage_are_owner_only_and_logged(env, store):
st = config.load({**env, "OWNER_USER_ID": "5"})
async def test_credits_and_usage_are_admin_only_and_logged(env, store):
st = config.load({**env, "ADMIN_CHAT_IDS": "5, -100"})
ctx = Ctx(st, store, ScriptedBackend([]), registry(), FakeBot())
store.add_usage(-1, "m", "chat", 1, 1000, 500, 20, 0.5)
stranger = user_msg(ctx.bot, "/usage", user_id=9, mid=1)
Expand Down
Loading