Skip to main content
Glama
README.md
**English** · [Русский](README.ru.md)

# Keys

Small smart functions, each callable in one line. Live at
**https://monoblock.casa/keys/**

```bash
pip install monokeys
```

```python
from monokeys import Keys

k = Keys()                                    # nothing to configure

print(k.alive.text("@durov"))
# alive · channel · Pavel Durov · 10 998 851 subscribers

print(k.answer.text("weather in Haifa"))
# Haifa: 27.4°C, wind 8.0 m/s, humidity 68% · source: weather
```

No signup, no access key, no quota: the client picks up the public key itself,
and that key never expires and is never metered.

## `alive` — is this Telegram link alive?

No account, no Bot API, no token: it reads the public `t.me` preview page — the
same one messengers use to draw a link card.

```python
k.alive("@durov")                  # the whole answer as an object
k.alive.members_count("@durov")    # 11005185 — just the number, already an int
k.alive.kind("ru_python")          # group
k.alive.verified("@BotFather")     # True
```

Understands every shape a link comes in: `@durov`, `t.me/durov`,
`t.me/durov/123`, `t.me/s/durov`, `t.me/+AbCdEf…`, `t.me/joinchat/…`, `DUROV`.

Returns: `is_alive`, `kind` (channel / group / bot / user), `title`,
`description`, `members_count`, `members_label`, `online_count`, `verified`,
`is_private`, `needs_request`, `restricted`, `has_preview`, `avatar_url`,
`deep_link`, `action`, `username`, `url`, `error`.

The subtlety that makes naive checks lie: Telegram answers `200` for a deleted
channel and renders a placeholder. Alive and dead differ in the markup, not in
the status code.

## `answer` — one question, many sources at once

```python
k.answer.text("who is Pavel Durov")
# Pavel Valeryevich Durov is a technology entrepreneur… · source: ddg

k.answer.text("weather in Haifa")        # Haifa: 27.4°C, wind 8.0 m/s
k.answer.text("100 usd to eur")          # 100 USD = 86.23 EUR · rate 0.86
k.answer("Haifa", sources="osm,wiki")    # only the map and Wikipedia
```

The question goes to several places **in parallel**, each answers in its own
field, and the best one is named separately along with links you can check it
against. Leave `sources` out and the key reads the question and decides for
itself: a city goes to the map, a currency to exchange rates, a package to the
package registries. General reference is always asked.

Thirteen sources, **not a single paid key**: Wikipedia, Wikidata, Wiktionary,
DuckDuckGo, OpenStreetMap, Open-Meteo, exchange rates, Crossref, arXiv,
Open Library, PyPI, npm, GitHub.

What is deliberately absent: **language models** — every free one runs into a
quota, and there is no way to check what a model says; and **search engine
results** — scraping them is against their terms, and the server's IP is shared
by a dozen other products, so one ban would take everything down.

### Ask exactly the source you want

Every source is a method of its own. Same word, different answers:

```python
k.answer.weather("Haifa")   # Haifa: 27.4°C, wind 8.0 m/s, humidity 68%
k.answer.osm("Haifa")       # Haifa, Haifa Subdistrict, Haifa District, Israel
k.answer.wiki("Haifa")      # Haifa is the third-largest city in Israel…
k.answer.wikidata("Haifa")  # Haifa — city in northern Israel
k.answer.rates("100 usd to eur")    # 100 USD = 86.23 EUR
k.answer.github("telegram")         # DrKLO/Telegram ★29798
```

Over a plain link: `?only=weather` or `?sources=weather`.

Asking for a source's field *is* asking that source: the word "weather" may not
appear in your question, and you still want the weather.

### Exchange rates that do the math

```python
k.answer.rates("100 dollars to shekels")
# 100 USD = 299.24 ILS · rate 2.99 · as of Mon, 31 Aug 2026

k.answer.rates("50 eur to ils and usd")
# 50 EUR = 173.06 ILS, 58.22 USD · rate 3.46

k.answer.rates("100 UZS to KGS")     # three-letter codes work for all 166
```

The amount, the currencies and their order are parsed out of the question:
"100 dollars **to** shekels" means USD into ILS, not the other way round. There
can be several targets. **166 currencies**, including ones the European Central
Bank does not publish at all.

### Works in two languages

The language is detected from the question itself: Cyrillic goes to Russian
sources, Latin to English ones. Hints and topic words are covered on both sides:

```python
k.answer.text("погода в Хайфе")  ==  k.answer.text("weather in Haifa")   # both go to weather
k.answer.text("где находится Хайфа") == k.answer.text("where is Haifa")  # both go to the map
```

### How `wiki` differs from `wikidata`

They are different things, useful in different situations:

| | what it is | for "Haifa" |
| --- | --- | --- |
| `wiki` | an article summary written by people — connected prose, a few sentences | Haifa is the third-largest city in Israel, after Jerusalem and Tel Aviv, with a mixed Jewish-Arab population… |
| `wikidata` | a structured fact: a label plus one line of "what kind of thing this is", plus a Q-code | Haifa — city in northern Israel, third-largest in the country |

Put simply: `wiki` is for reading, `wikidata` is for parsing. The second one is
shorter, identical across languages, and useful when you need the **type** of a
thing rather than its description.

## `dev` — a vulnerability, a domain or an address

```python
k.dev.text("CVE-2021-44228")
# cve CVE-2021-44228 · critical · Apache Log4j2 2.0-beta9 through 2.15.0 …

k.dev.text("github.com")
# domain github.com · MarkMonitor Inc. · expires 2026-10-09 · 140.82.121.3

k.dev.text("8.8.8.8")
# ip 8.8.8.8 · Ashburn United States · Google LLC
```

The key works out what you gave it: a CVE number, a domain and an IP address
look nothing like each other. Three things a developer looks up constantly,
opening three different sites every time.

Open sources, no keys: CIRCL (a CVE database mirror), RDAP instead of the
retired whois, DNS over Cloudflare, the Wayback Machine, ip-api.

## `crypto` — what a coin costs

```python
k.crypto.text("bitcoin")     # Bitcoin · 78 293 USD · 24h +0.08% · rank 1
k.crypto("ETH", vs="usd")
```

Deliberately separate from ordinary exchange rates: a currency has one central
bank number per day, a coin has a price in several currencies at once, a 24-hour
move and a market cap — and all of it changes by the minute.

## `time` — what time it is there, and what time yours is there

```python
k.time('Haifa')
# Haifa: 00:15, 08.09.2026 · Tuesday · Asia/Jerusalem

k.time('Haifa', to='New York', at='15:00')
# Haifa: 15:00, 08.09.2026 · Tuesday · Asia/Jerusalem · New York: 08:00 · 7 h behind
```

A question about time almost never ends at «what time is it». The real one is
«my call is at 15:00, what is that for them» — and you do that arithmetic by
hand every single time. Daylight saving is already accounted for, and if the
answer lands on the next day there, the key says so:

```python
k.time('Haifa', to='Tokyo', at='23:00', only='to_time,next_day')
# '05:00 · True'
```

## `weather` — weather in words

```python
k.weather('Haifa')
# Haifa: partly cloudy · 27.6°C ≈32.8°C · 27.2°C…31.4°C · ☔0%

k.weather('Berlin', when='tomorrow', only='description,umbrella')
# 'overcast · True'
```

Numbers answer the wrong question. People ask «do I need an umbrella» and get
code 61 and 83% humidity. Here it is the other way round: weather in words,
apparent temperature next to the real one, chance of rain and a straight answer
about the umbrella. Forecast up to seven days. «Tomorrow» is counted in the
place's own timezone, not our server's.

## `read` — a page as text

```python
article = k.read('https://peps.python.org/pep-0008/')
article.text        # 'Indentation\\n\\nUse 4 spaces per indentation level...'
article.words       # 1010
article.title       # 'PEP 8 – Style Guide for Python Code'
article.published   # '2001-07-05'
```

118 KB of markup become 1010 words of text. Menus, banners, the footer and the
«read next» block stay out. The encoding is taken from the page itself — plenty
of sites still ship cp1251, and trusting the HTTP header alone gives you mojibake.

When there is no connected text, the key says so instead of passing a list of
links off as an article:

```python
k.read('https://news.ycombinator.com/', only='is_article')   # False
```

## `url` — a link card

```python
k.url('bit.ly/3xYz')
# url:         'https://example.com/article'   ← where it actually leads
# title:       'Page title'
# image:       'https://example.com/og.png'
# alive:       True
# redirected:  True
```

The same thing a messenger shows when you paste a link into a chat. Separate
from `read` on purpose: the reader needs text and pays for it by parsing the
whole page, while a card only needs the first few kilobytes.

## `email` — address validation

```python
k.email('ivan@gmial.com')
# valid: False · suggestion: 'ivan@gmail.com'
```

Syntax, a live mail server (MX), disposable domain, role mailbox — and, above
all, a typo suggestion. ZeroBounce and Hunter charge for this; here it is
DNS-over-HTTPS plus a bundled disposable-domain list, no third-party keys. With
`check_mx='0'` it answers instantly and offline.

## `pwned` — has this password leaked

```python
k.pwned('qwerty123')      # pwned: True · count: 13871714
```

Checks Have I Been Pwned (900M+ passwords) **without sending the password**.
The SHA-1 is computed where the key runs, and only the first five hex characters
leave — you can't tell which password was asked (k-anonymity). Run it locally and
the password never leaves your machine; through our server it reaches us in the
clear (we don't log it) — for a real password use it locally or pass a ready
SHA-1 in `hash=`.

## `password` — generate or rate a password

```python
k.password(length=20).password    # strong, via secrets, no network
k.password(check='qwerty123')     # weak · 10 bits · cracked instantly
```

An honest rating — entropy in bits, not «has an uppercase letter» checkboxes.

## With no server of ours at all

A key is code, not a service. The library can run it on your side: the request
goes to the source straight from your machine, with us out of the chain.

```python
from monokeys import Local

k = Local()                      # no address, no access key
k.weather('Berlin').description  # 'overcast'
k.time('Haifa', to='Tokyo', at='23:00').to_time
k.read('https://example.com/article').text
```

Everything is the same, field names and short lines included: it is literally
the same key code that runs on the server — it lives in the package, and the
server takes it from there. Two copies would drift apart on the first edit.

For async code there is `AsyncLocal` with the same calls:

```python
from monokeys import AsyncLocal

k = AsyncLocal()
weather = await k.weather('Berlin')
```

A separate class rather than guesswork: a call that returns a dict in one place
and a promise in another bites late and hard.

### Which to take

| | `Local` | `Keys` (our server) |
|---|---|---|
| speed | one hop instead of two | plus the round trip to us |
| privacy | we never see your queries | we do |
| our outages | do not matter | do |
| cache | in process memory | shared, survives restarts |
| needs outbound internet | yes | no, reaching us is enough |
| other languages | Python only | any, it is plain HTTP |

Take `Local` when in doubt. `Keys` is for when you have no direct way out, when
a cache shared across machines helps, or when the language is not Python.

Still zero dependencies: the local fetch is written on the standard library. If
`certifi` happens to be around, the root certificate list is taken from it — on
machines with a stale system list a perfectly alive site otherwise answers
«certificate has expired».

## Works with no network: timers

Not everything needs a request. A Telegram bot mutes someone for half an hour
and has to remember them in exactly half an hour — keeping that count on
someone else's server makes their uptime your correctness. So timers live on
your side: a plain sqlite file, no network, no key, no limits.

```python
from monokeys import Timers

t = Timers('bans.db')
t.set(f'unmute:{chat}:{user}', '30m', note='flood')

async for fired in t.stream():           # waits on its own, no poll loop
    chat, user = fired.id.split(':')[1:]
    await bot.restrict_chat_member(chat, user, permissions=ALL_ALLOWED)
```

Write the deadline however you like: `30`, `'30m'`, `'2h'`, `'1h30m'`,
`'15:00'`, `'2026-09-09 15:00'`, `datetime(...)`.

| call | what it does |
|---|---|
| `Timers(path)` | storage in a sqlite file; `':memory:'` for the process only |
| `t.set(id, when, note)` | set one; the same `id` moves it instead of doubling |
| `t.due()` | what has fired; delivered once |
| `t.peek()` | the same, without taking |
| `t.pending()` | who is still waiting and for how long |
| `t.cancel(id)` | drop it early — an unmute by hand |
| `t.stream()` | `async for`: waits on its own |
| `t.wait(id)` | wait for one particular timer |

Three decisions that make it behave under a bot:

* **The same `id` moves the timer.** Extending a mute means replacing the
  deadline; otherwise the unmute happens twice, the first time too early.
* **A fired timer is delivered once.** Two handlers of one bot would otherwise
  unban a person twice — the second time after a fresh ban.
* **Downtime is not lost.** The bot was down for an hour, three unmutes came
  due in that hour — it gets all three at once instead of missing them.

## Library arguments

```python
Keys(token=…, base=…, timeout=…, retries=…, user_agent=…)
k.alive(value, only=…, fmt=…, timeout=…, **params)
```

| Argument | Default | What it does |
| --- | --- | --- |
| `token` | the public key from the server | your own access key, if you want one |
| `base` | `https://monoblock.casa/keys` | server address |
| `timeout` | `20.0` | how long to wait for an answer, seconds |
| `retries` | `1` | retries on a dropped connection |
| `only` | — | return just this field |
| `fmt` | `json` | `json`, `text` (a line for humans) or `bool` |

Typos do not stay silent: `k.alive.members_cout(...)` says immediately that
there is no such field and lists the real ones. Key and field names come from
the server, so a new key is available at once, without updating the package.

Full reference with examples: **https://monoblock.casa/keys/client**

### Arguments do exactly what you ask

`only=` takes as many fields as you want — an application usually needs two or
three out of twenty, not the lot:

```
/alive/@durov?only=title,members_count,kind
→ Pavel Durov · 10971256 · channel

/alive/@durov?only=title,verified&fmt=json
→ {"title":"Pavel Durov","verified":true}
```

A single field stays a bare value, ready to drop straight into a variable. A
typo in any of the names is refused with the list of real fields.

For `answer`, the fields you name also say **who to ask**:

```
/answer/Haifa?only=weather,osm
/answer/Haifa?sources=osm,wikidata     # both answers, not just the winner
```

`lang=` picks the source language. Do not ask an English source in Russian:
English Wikipedia answers "Хайфа" with a footballer of the same name, so such
an answer is thrown away and the key honestly says nothing.

## You can skip the library entirely

A key is just a link — anything can open it:

```
https://monoblock.casa/keys/alive/@durov   ->  alive · channel · Pavel Durov · …
```

```php
echo file_get_contents("https://monoblock.casa/keys/alive/@durov");
```

Add `?fmt=json` for all the fields, `?only=members_count` for a single number,
`?fmt=bool` for a bare `true`/`false`. Every key's page carries ready-made code
for curl, Python, JavaScript, C++, Go and PHP.

## Inside an assistant

One address, and every key shows up inside Claude, Cursor or your editor as an
ordinary tool:

```json
{ "mcpServers": { "keys": { "url": "https://monoblock.casa/keys/mcp" } } }
```

## Access keys

You do not need one: the public key works for everybody and is never metered,
and the library fetches it itself from `/public-token`. It is deliberately not
baked into the package — that way it can be rotated with one command and
everyone picks up the new one without a release.

Your own key is only for those who want a kill switch of their own. The owner
issues them:

```bash
curl -X POST -H "X-Admin-Token: $KEYS_ADMIN_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"label": "forum overclockers.ru"}' https://monoblock.casa/keys/token

curl -H "X-Admin-Token: $KEYS_ADMIN_TOKEN" https://monoblock.casa/keys/tokens

curl -X POST -H "X-Admin-Token: $KEYS_ADMIN_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"id": "a11ea8e0ea55"}' https://monoblock.casa/keys/token/revoke

# forget cached answers after changing a key
curl -X POST -H "X-Admin-Token: $KEYS_ADMIN_TOKEN" https://monoblock.casa/keys/cache/clear
```

An issued key never expires and is never counted — nobody here intends to meter
anyone's requests. Instead of a quota there is a kill switch: revoke by public
id, instantly, without touching the other keys.

---

# If you want to add a key of your own

## How it is put together

A key is one folder. Out of its manifest come, by themselves: the HTTP
endpoint, the MCP tool, the documentation page, `openapi.json`, `llms.txt` and
code examples in six languages. None of that is written by hand.

```
keys/
  my-key/
    key.json      # what it does, what it takes, what it returns, how long to cache
    handler.py    # async def run(params, ctx) -> dict
```

`ctx` is the only door to the outside world: `await ctx.fetch(url)` already goes
through a per-host rate limiter. The optional `canonical(params)` brings input
to a canonical form **before** the cache — without it `durov`, `DUROV` and
`t.me/durov/123` would be three separate entries and three trips to the network.

The loader checks: `id` matches the folder name, parameter types come from a
fixed list, there is a short summary, and the field used for the one-line answer
is described in `returns`.

Note for contributors: the source comments are written in Russian, the same
language the project is developed in. Everything a user reads — this file, the
package page, the site — is English.

## Running it

```bash
pip install -r requirements.txt
python run.py            # http://127.0.0.1:8110
pytest tests/ -q         # 131 tests, offline, under two seconds
```

## Production

Docker on 8105, nginx serves it under `/keys/`, databases on the `keys-data`
volume.

```bash
tar czf keys.tgz . && scp keys.tgz ubuntu@SERVER:/tmp/
ssh ubuntu@SERVER 'rm -rf ~/keys && mkdir ~/keys && tar xzf /tmp/keys.tgz -C ~/keys \
  && cd ~/keys && docker build -t keys:latest . && docker rm -f keys \
  && docker run -d --name keys --restart unless-stopped -p 127.0.0.1:8105:8105 \
     --env-file ~/keys.env -v keys-data:/app/data keys:latest'
```

Secrets live in `~/keys.env` on the server and never reach the repository:

```
KEYS_ADMIN_TOKEN=…               # without it, key issuing is closed
KEYS_TRUSTED_PROXIES=127.0.0.1   # nginx address; without it we trust no headers
KEYS_REQUIRE_HTTPS=1             # access keys over HTTPS only
```

The app knows its own prefix through `--root-path /keys`, so links and code
samples on the pages come out correct by themselves.

## Releasing the library

No tokens involved: PyPI trusts GitHub directly over OIDC.

```bash
# bump version in clients/python/pyproject.toml, then
git tag v0.3.0 && git push origin v0.3.0
```

Tests run before the upload — a version on PyPI cannot be replaced, and broken
code can only be fixed by a new number.

## What protects an access key

| Against | How |
| --- | --- |
| Spoofing the address via a header | `X-Real-IP` is read only from proxies listed in `KEYS_TRUSTED_PROXIES` |
| Leaking a key into logs | `token=` is cut out of the request line before it is written |
| A leaked database | sha256 is stored; only the public key is kept in the clear |
| A stolen key | self-service revocation, effective immediately, not resurrected from cache |
| Silent downgrade | an unknown or revoked key gets a 401, not a quiet drop to anonymous |
| Anyone handing out keys | issuing requires `KEYS_ADMIN_TOKEN` |
| Clogging the queue | waiting on the shared tap is bounded: busy means 503, not a hanging connection |

## What the server can take

Measurements on the production machine (2 cores, 3.8 GB, a dozen other
containers alongside):

| What was measured | Result |
| --- | --- |
| One `t.me` page | ~10 KB, **75 ms** |
| Eight in parallel | **171 ms** for the whole batch |
| Parsing a page | **0.085 ms** → ~11 700 pages/sec on one core |

The CPU is not the issue — parsing is 900 times cheaper than the network trip.
There is exactly one bottleneck: how much `t.me` tolerates from our IP. So we go
out through a tap of 5 requests per second — that is not a per-user limit but
insurance against a ban. The cache relieves it: a live channel is kept for six
hours, a dead one for an hour.

---

## Who wrote this

The code was written by Claude (Opus 5) under the direction of the repository
owner: the tasks, the design decisions and every check against live data are
his; the implementation is the model's. The Telegram preview parsing came out of
his own earlier project, TG Catalog.

Commits carry `Co-Authored-By` where this applies.