Metadata-Version: 2.5
Name: monokeys
Version: 0.9.0
Summary: Small smart functions in one call — run them locally or via HTTP: Telegram link checks, weather, timezones, article text, rates, CVEs. No API keys, no dependencies
Project-URL: Homepage, https://monoblock.casa/keys/
Project-URL: Documentation, https://monoblock.casa/keys/client
Project-URL: Repository, https://github.com/monorez3/keys
Author: Monoblock
License: MIT
Keywords: api,currency,mcp,search,telegram,utilities,weather,wikipedia
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Utilities
Requires-Python: >=3.9
Description-Content-Type: text/markdown

**English** · [Русский](README.ru.md)

# monokeys

A client for **Keys** — small smart functions, each callable in one line.
Standard library only: **no dependencies**, one file.

```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`.

Fields: `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…
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.

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

### 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.rates("100 usd to eur")    # 100 USD = 86.23 EUR
k.answer.github("telegram")         # DrKLO/Telegram ★29798
```

### Exchange rates that do the math

```python
k.answer.rates("100 dollars to shekels")   # 100 USD = 299.24 ILS · rate 2.99
k.answer.rates("50 eur to ils and usd")    # 50 EUR = 173.06 ILS, 58.22 USD
k.answer.rates("100 UZS to KGS")           # codes work for all 166 currencies
```

The amount, the currencies and their order are parsed out of the question.
**166 currencies**, including ones the European Central Bank does not publish.

### Two languages

The language is detected from the question itself: Cyrillic goes to Russian
sources, Latin to English ones.

## `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')
```

What the client itself offers:

```python
k.names()              # which keys exist
k.describe('weather')  # the key's full manifest
```

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.

## Three ways to call any key

```python
k.alive("@durov")                    # the whole answer: an object with fields
k.alive.members_count("@durov")      # a single field, in its own type
k.alive.text("@durov")               # a line for humans
```

## Arguments

### `Keys(...)` — the connection

| Argument | Default | What it does |
| --- | --- | --- |
| `token` | `KEYS_API_KEY`, else the public key from the server | access key; sent in a header only |
| `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 (a server refusal is not retried) |
| `user_agent` | `monokeys/<version>` | how to introduce yourself to the server |

```python
k = Keys(timeout=5, retries=2)
```

### `k.<key>(...)` — the call

| Argument | Default | What it does |
| --- | --- | --- |
| `value` | — | the main value: a link for `alive`, a question for `answer` |
| `only` | `""` | return just this field instead of the whole answer |
| `fmt` | `"json"` | `json` — fields, `text` — a line for humans, `bool` — yes/no |
| `timeout` | as the client | override the wait for a single call |
| `**params` | — | the key's other parameters, by name |

```python
k.alive("@durov", only="members_count")   # 11005185
k.alive("@durov", fmt="bool")             # 'true'
k.alive("@durov", timeout=3)              # do not wait longer than three seconds
```

### Arguments of particular keys

| key | argument | what it does |
|---|---|---|
| `answer` | `sources` | which sources to ask; several separated by commas, empty — the key picks |
| `answer` | `lang` | source language: `ru` or `en` |
| `crypto` | `vs` | which currencies to price in, comma separated |
| `time` | `to` | the second place: what time it is there |
| `time` | `at` | an hour in the first place; empty — right now |
| `weather` | `when` | today, tomorrow, day after tomorrow, or days ahead |
| `weather` | `days` | how many forecast days to return, 1 to 7 |
| `weather` | `units` | `c` — Celsius, `f` — Fahrenheit |
| `read` | `limit` | cut the text to this many characters; empty — all |

```python
k.answer('Haifa', sources='osm,wikidata')   # both answers, not one
k.answer('Haifa', sources='wiki', lang='en')
k.crypto('bitcoin', vs='usd,ils')

k.alive('@durov', only='title,members_count')   # several fields at once
# 'Pavel Durov · 11005185'
```

Ask a source in its own language: `lang='en'` with a Russian word will honestly
find nothing — and stay silent instead of inventing something.
### What you can ask the client

| Call | What comes back |
| --- | --- |
| `k.names()` | names of every available key |
| `k.fields("alive")` | which fields a key returns |
| `k.alive.fields()` | the same, shorter |
| `k.catalog(refresh=True)` | the full catalogue with descriptions, fetched anew |
| `k.call("alive", "@durov")` | call a key whose name is only known at runtime |

## The answer

`Answer` is a dict that also answers like an object:

```python
res = k.alive("@durov")
res.title == res["title"]     # the same thing
bool(res)                     # True if the key answered in the affirmative
dict(res)                     # a plain dict
```

Typos do not stay silent:

```python
res.tittle
# AttributeError: no field 'tittle' in the answer; there is: username, url, is_alive, ...

k.alive.members_cout("@durov")
# AttributeError: key 'alive' has no field 'members_cout'; there is: is_alive, kind, ...
```

## Errors

| Exception | When |
| --- | --- |
| `AccessDenied` | the key is unknown, revoked, or was sent over plain HTTP |
| `Unavailable` | the server is busy or a source did not answer — worth retrying |
| `KeysError` | everything else: no such key, garbage input |

All of them carry `.status` and `.body`. The first two subclass `KeysError`, so
one `except` catches them all.

```python
from monokeys import Keys, AccessDenied, Unavailable

try:
    res = k.alive("@durov")
except AccessDenied:
    print("the access key was not accepted")
except Unavailable:
    print("busy right now, will retry later")
```

## Methods are not hard-coded

The list of keys and their fields comes from the server, so a new key is
available at once, without updating the package:

```python
k.names()          # ['alive', 'answer']
k.no_such_key      # AttributeError listing the ones that exist
```

## Access keys

You do not need one — the public key works for everybody and is never metered.
Your own is only for those who want a kill switch of their own; the service
owner issues it, and it never expires either. Put it in `.env`:

```
KEYS_API_KEY=kx_...
```

Revoke your own key if it leaks:

```bash
curl -X POST -H "Authorization: Bearer kx_..." https://monoblock.casa/keys/token/revoke
```

## If you would rather not install anything

The very same file can simply be downloaded — the package is built from it:

```bash
curl https://monoblock.casa/keys/sdk/python > monokeys.py
```

Or skip the client entirely — a key is just a link:

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

---

Full documentation: **https://monoblock.casa/keys/client**

Source: **https://github.com/monorez3/keys**
