Skip to content

Commit 5c1eec8

Browse files
Merge pull request #31 from AlexandrLisitsa/fix/meterbots-session-check
Weekly check that MeterBots' Telegram session is logged in
2 parents ae83b99 + 274bc50 commit 5c1eec8

10 files changed

Lines changed: 226 additions & 4 deletions

File tree

‎HomeAssistant/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ read before it goes live, and a `git revert` if it was wrong.
4444
shows the meter's day and night readings, and one button sends them through
4545
YASNO's Telegram bot
4646
([`docs/electricity-reading-submission.md`](docs/electricity-reading-submission.md)).
47+
Every Monday `packages/meterbots_session.yaml` asks MeterBots whether its
48+
Telegram session is still logged in, and notifies if not, so a dead session
49+
is found before the month-end ask.
4750
- **Two air conditioners**: an infrared one through [`../IRBridge`](../IRBridge)
4851
and a networked one ([`docs/climate-dashboard.md`](docs/climate-dashboard.md)).
4952
- **Google Home and Gemini**: entities exposed through the Google Assistant
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# ---------------------------------------------------------------------------
2+
# Is MeterBots' Telegram session still logged in? Asked every Monday at 12:00.
3+
#
4+
# Both monthly readings (gas_submit.yaml, electricity_submit.yaml) go out
5+
# through one logged-in Telegram user in MeterBots (LXC 105). That session
6+
# can end without anyone noticing: terminated in Telegram -> Settings ->
7+
# Devices, revoked by Telegram, or ended by Telegram's own "terminate
8+
# inactive sessions" for a session that is used about once a month. Its file
9+
# stays behind, so MeterBots' /health keeps saying "session: true".
10+
#
11+
# Without this, the first sign is the monthly ask on the 1st, with three or
12+
# four days of the window left to log in again with a phone code. Asked
13+
# weekly, it is found days or weeks earlier. The check is also an authorised
14+
# request, which counts as the session being used.
15+
#
16+
# rest_command.meterbots_session MeterBots' GET /session (the gas token
17+
# opens it); one round trip to Telegram,
18+
# nothing sent to any bot
19+
#
20+
# secrets.yaml: meterbots_session_url http://<meterbots-ip>:8770/session
21+
# gasbot_token MeterBots' GASBOT_TOKEN (shared with
22+
# gas_submit.yaml)
23+
#
24+
# Logging in again: MeterBots/docs/deployment.md.
25+
# ---------------------------------------------------------------------------
26+
27+
rest_command:
28+
meterbots_session:
29+
url: !secret meterbots_session_url
30+
method: GET
31+
headers:
32+
X-Gasbot-Token: !secret gasbot_token
33+
# It waits out a running bot walk (one conversation at a time).
34+
timeout: 300
35+
36+
automation:
37+
- id: meterbots_session_check
38+
alias: 'MeterBots: is the Telegram session still logged in'
39+
mode: single
40+
trigger:
41+
- platform: time
42+
at: "12:00:00"
43+
condition:
44+
- condition: time
45+
weekday: mon
46+
action:
47+
- service: rest_command.meterbots_session
48+
response_variable: sess
49+
continue_on_error: true
50+
- variables:
51+
ok: >-
52+
{{ sess is defined and sess.status == 200 and sess.content is mapping
53+
and (sess.content.ok | default(false)) }}
54+
why: >-
55+
{% if ok %}{% elif sess is not defined %}MeterBots did not answer, or refused (see the HA log)
56+
{%- elif sess.content is mapping and sess.content.error is defined %}{{ sess.content.error }}
57+
{%- else %}MeterBots answered {{ sess.status }}{% endif %}
58+
- if:
59+
- condition: template
60+
value_template: "{{ not ok }}"
61+
then:
62+
- service: notify.household
63+
data:
64+
title: '🤖 MeterBots cannot reach Telegram'
65+
message: >-
66+
{{ why }}. The monthly gas and electricity readings go out
67+
through this session, so log MeterBots in again before the
68+
1st (MeterBots/docs/deployment.md).
69+
data:
70+
channel: MeterBots

‎HomeAssistant/examples/secrets.yaml.example‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ gasbot_submit_url: "http://<meterbots-ip>:8770/gas/bot/submit"
4141
yasnobot_token: "<same value as YASNOBOT_TOKEN>"
4242
yasnobot_status_url: "http://<meterbots-ip>:8770/yasno/bot/status"
4343
yasnobot_submit_url: "http://<meterbots-ip>:8770/yasno/bot/submit"
44+
# The weekly "still logged in?" check (packages/meterbots_session.yaml);
45+
# opened with gasbot_token.
46+
meterbots_session_url: "http://<meterbots-ip>:8770/session"
4447
# Optional: where gas_submit.py fetches the photo, and the token MeterCam
4548
# asks for (its METERCAM_TOKEN).
4649
metercam_url: "http://<metercam-ip>:8770"

‎HomeAssistant/tools/check_automations.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* the two kitchen helpers cannot both drive the relay
1515
* the IR phone charges below 21% on every path, and is re-checked
1616
* irbridge_send_candidate honours idx 0
17+
* the weekly MeterBots session check says why whenever it fails
1718
1819
Same approach as check_dtek_templates.py: just enough of HA's template
1920
environment to render these templates, not an HA emulator.
@@ -185,5 +186,31 @@ def walk(node):
185186
check("no idx -> the input_number", render(tpl, states=held), "47")
186187
check("empty idx -> the input_number", render(tpl, states=held, idx=""), "47")
187188

189+
# --- MeterBots session -------------------------------------------------------
190+
print("\n8. MeterBots session: weekly, and every failure says why")
191+
pkg = yaml.load((CONFIG / "packages" / "meterbots_session.yaml").read_text(encoding="utf-8"),
192+
HaLoader)
193+
auto = pkg["automation"][0]
194+
check("Mondays at 12:00",
195+
(auto["trigger"][0]["at"], auto["condition"][0]["weekday"]), ("12:00:00", "mon"))
196+
call, var_step, notify_step = auto["action"]
197+
check("a MeterBots that is down does not stop the run", call.get("continue_on_error"), True)
198+
ok_tpl, why_tpl = var_step["variables"]["ok"], var_step["variables"]["why"]
199+
cases = [
200+
("logged in", {"status": 200, "content": {"ok": True, "authorized": True}}, "True", ""),
201+
("logged out", {"status": 200, "content": {"ok": False,
202+
"error": "Telegram session is not logged in"}},
203+
"False", "Telegram session is not logged in"),
204+
("wrong token", {"status": 401, "content": {"error": "unauthorised"}}, "False", "unauthorised"),
205+
("not JSON", {"status": 502, "content": "Bad Gateway"}, "False", "MeterBots answered 502"),
206+
]
207+
for label, sess, want_ok, want_why in cases:
208+
check("%s -> ok %s" % (label, want_ok), render(ok_tpl, sess=sess), want_ok)
209+
check("%s -> why" % label, render(why_tpl, sess=sess, ok=want_ok == "True"), want_why)
210+
check("no answer at all -> not ok", render(ok_tpl), "False")
211+
check("no answer at all -> why",
212+
render(why_tpl, ok=False), "MeterBots did not answer, or refused (see the HA log)")
213+
check("only a failure notifies", notify_step["if"][0]["value_template"], "{{ not ok }}")
214+
188215
print("\n%s" % ("FAILED: " + ", ".join(FAILED) if FAILED else "all checks passed"))
189216
sys.exit(1 if FAILED else 0)

‎MeterBots/docs/api.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ MeterBots listens on `:8770` in LXC 105. Home Assistant calls it with
66
| Endpoint | Who calls it | What |
77
| --- | --- | --- |
88
| `GET /health` | Docker's healthcheck, `deploy.sh`, a human | `{status, version, bots: {gas, yasno}, session}`. `bots` says which bots have a token; `session` says whether a session file exists, not whether it is still logged in. No secrets |
9+
| `GET /session` | Home Assistant (`meterbots_session.yaml`), every Monday | `{ok: true, authorized: true}` while the Telegram session is logged in, else `{ok: false, error}`. One round trip to Telegram, nothing sent to any bot. Either bot's token opens it. Waits out a running walk |
910
| `GET /gas/bot/status` | Home Assistant (`gas_submit.yaml`) | Dry run of `@mygrmu_bot`: walks to the reading prompt and back, answers `{ok, previous, previous_date, transcript}`. Sends no reading |
1011
| `POST /gas/bot/submit` | Home Assistant, on a button press | `{"value": 2262}` (> 0, < 100000, at most 2 decimals) sends the monthly gas reading; `{ok, value, reply, transcript}` or `{ok: false, error, transcript}` |
1112
| `GET /yasno/bot/status` | Home Assistant (`electricity_submit.yaml`) | Dry run of `@Yasnoonlinebot`: walks to the reading prompt and back, answers `{ok, previous: {day, night, date}, transcript}`. Sends no reading |
@@ -21,7 +22,7 @@ Each bot has its own token, sent as its own header:
2122
| `/yasno/bot/*` | `X-Yasnobot-Token` | `YASNOBOT_TOKEN` | `yasnobot_token` |
2223

2324
A wrong or missing token gets `401`, and one bot's token never opens another's
24-
routes. **With the token unset, the route refuses
25+
routes. `/session` is the exception: it walks no bot, so either token opens it. **With the token unset, the route refuses
2526
everyone**, an empty header included: filing a reading as the household is not
2627
something a stray script on the LAN may do. `/health` needs no token and
2728
carries none.

‎MeterBots/docs/deployment.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,20 @@ Whoever has `telegram.session` can read and send as the household. So:
7878
- It shows up in Telegram → Settings → Devices under the API app's name.
7979
Ending it there logs MeterBots out, and every call answers
8080
`Telegram session is not logged in` until someone logs in again.
81+
82+
## Keeping it logged in
83+
84+
The session is used about once a month, and Telegram ends sessions that sit
85+
unused. Two things keep that from surfacing at the monthly ask:
86+
87+
- **Telegram's own timer.** In the household account, Telegram → Settings →
88+
Privacy and Security → Devices → *Automatically terminate old sessions*: set
89+
it to the longest period (1 year). With 1 month, a quiet session can be
90+
ended between two monthly readings.
91+
- **The weekly check.** Every Monday at 12:00 Home Assistant calls `GET
92+
/session` (`HomeAssistant/config/packages/meterbots_session.yaml`). It asks
93+
Telegram whether the session is still authorised, which is also a use of the
94+
session, and notifies the household if it is not, or if MeterBots does not
95+
answer. `/health` cannot tell: a session ended in Telegram leaves its file.
96+
97+
To check by hand: `curl -H "X-Gasbot-Token: <token>" http://<meterbots-ip>:8770/session`.

‎MeterBots/service/app.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
a stray script on the LAN may do.
77
88
GET /health alive, which bots are configured, the session
9-
GET /gas/bot/status dry run of @mygrmu_bot, sends nothing
9+
GET /session is the Telegram session still logged in
10+
GET /gas/bot/status dry run of @mygrmu_bot, sends nothing
1011
POST /gas/bot/submit {"value": 2262}
1112
GET /yasno/bot/status dry run of @Yasnoonlinebot, sends nothing
1213
POST /yasno/bot/submit {"day": 38500, "night": 6450}
@@ -19,7 +20,7 @@
1920

2021
from . import gasbot, tgclient, yasnobot
2122

22-
VERSION = "1.1.0"
23+
VERSION = "1.2.0"
2324

2425
APP = Flask(__name__)
2526

@@ -47,6 +48,17 @@ def health():
4748
"session": tgclient.session_present()})
4849

4950

51+
@APP.route("/session")
52+
def session():
53+
"""Whether the Telegram session is still logged in: one round trip to
54+
Telegram, nothing sent to any bot. Either bot's token opens it. Home
55+
Assistant asks weekly, so a logged-out session is found weeks before the
56+
monthly ask instead of at it."""
57+
if not (gasbot_authorised() or yasnobot_authorised()):
58+
return deny()
59+
return jsonify(tgclient.session_check())
60+
61+
5062
@APP.route("/gas/bot/status")
5163
def gas_bot_status():
5264
"""Walk @mygrmu_bot to the value prompt and back; send nothing."""

‎MeterBots/service/tgclient.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,30 @@ async def conversation(api, bot_name, watch_edits=False):
209209
await client.disconnect()
210210

211211

212+
async def _session_check():
213+
from telethon import TelegramClient
214+
215+
api = api_settings()
216+
client = TelegramClient(str(SESSION), api["api_id"], api["api_hash"])
217+
await client.connect()
218+
try:
219+
if not await client.is_user_authorized():
220+
raise BotError("Telegram session is not logged in -- run the login steps")
221+
return {"ok": True, "authorized": True}
222+
finally:
223+
await client.disconnect()
224+
225+
226+
def session_check():
227+
"""Is the session still logged in? A real round trip to Telegram, unlike
228+
session_present(): a session ended in Telegram -> Settings -> Devices, or
229+
by Telegram's own "terminate inactive sessions", still leaves its file.
230+
Takes the lock like any conversation, so it waits out a running walk."""
231+
if not session_present():
232+
return {"ok": False, "error": "no session file -- run the login steps"}
233+
return blocking(_session_check)
234+
235+
212236
def blocking(coro_fn, *args):
213237
"""Run one conversation from a Flask route: locked, always JSON back."""
214238
with LOCK:

‎MeterBots/tests/test_app.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ def run(*args):
4444
return run
4545
gasbot.run = stub("gas")
4646
yasnobot.run = stub("yasno")
47+
appmod.tgclient.session_check = lambda: calls.append(("session",)) or {"ok": True,
48+
"authorized": True}
4749
return appmod.create_app().test_client(), calls
4850

4951

@@ -52,7 +54,7 @@ def run(*args):
5254
r = client.get("/health")
5355
body = r.get_json()
5456
check("200 and ok", r.status_code == 200 and body["status"] == "ok")
55-
check("carries the version", body.get("version") == "1.1.0", body)
57+
check("carries the version", body.get("version") == "1.2.0", body)
5658
check("says which bots are configured", body.get("bots") == {"gas": True, "yasno": False}, body)
5759
check("no secret in it", TOKEN not in r.get_data(as_text=True))
5860

@@ -111,6 +113,24 @@ def run(*args):
111113
client.post("/yasno/bot/submit", json={"day": 1, "night": 1},
112114
headers={"X-Yasnobot-Token": ""}).status_code == 401 and calls == [])
113115

116+
print("session")
117+
client, calls = load(TOKEN, "yasno-token")
118+
check("no token -> 401", client.get("/session").status_code == 401 and calls == [])
119+
check("wrong token -> 401",
120+
client.get("/session", headers={"X-Gasbot-Token": "nope"}).status_code == 401)
121+
r = client.get("/session", headers={"X-Gasbot-Token": TOKEN})
122+
check("the gas token opens it, and it asks Telegram",
123+
r.status_code == 200 and r.get_json() == {"ok": True, "authorized": True}
124+
and calls == [("session",)], (r.get_json(), calls))
125+
calls.clear()
126+
r = client.get("/session", headers={"X-Yasnobot-Token": "yasno-token"})
127+
check("so does the YASNO token", r.status_code == 200 and calls == [("session",)])
128+
check("and no bot is ever walked", all(c == ("session",) for c in calls))
129+
client, calls = load(None, None)
130+
check("both tokens unset: refused even with empty headers",
131+
client.get("/session", headers={"X-Gasbot-Token": "", "X-Yasnobot-Token": ""})
132+
.status_code == 401 and calls == [])
133+
114134
print("yasno submit: what reaches the bot")
115135
client, calls = load(TOKEN, "yasno-token")
116136
for body, want in [({"day": 38500, "night": 6450}, ("yasno", 38500, 6450)),

‎MeterBots/tests/test_tgclient.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,4 +215,49 @@ async def slow(name):
215215
check("two bots never talk at once: one conversation ends before the next starts",
216216
order == ["gas in", "gas out", "yasno in", "yasno out"], order)
217217

218+
print("session_check")
219+
import tempfile
220+
import types
221+
222+
events = []
223+
224+
225+
class FakeTelegramClient:
226+
authorized = True
227+
228+
def __init__(self, session, api_id, api_hash):
229+
events.append(("new", api_id))
230+
231+
async def connect(self):
232+
events.append("connect")
233+
234+
async def is_user_authorized(self):
235+
return FakeTelegramClient.authorized
236+
237+
async def disconnect(self):
238+
events.append("disconnect")
239+
240+
241+
sys.modules["telethon"] = types.SimpleNamespace(TelegramClient=FakeTelegramClient)
242+
os.environ["TELEGRAM_API_ID"], os.environ["TELEGRAM_API_HASH"] = "12345", "hash"
243+
tmp = pathlib.Path(tempfile.mkdtemp())
244+
tgclient.SESSION = tmp / "telegram"
245+
check("no session file: says so, Telegram not asked",
246+
tgclient.session_check() == {"ok": False, "error": "no session file -- run the login steps"}
247+
and events == [])
248+
(tmp / "telegram.session").write_bytes(b"x")
249+
check("logged in: authorized", tgclient.session_check() == {"ok": True, "authorized": True})
250+
check("connected once and disconnected", events == [("new", 12345), "connect", "disconnect"],
251+
events)
252+
FakeTelegramClient.authorized = False
253+
events.clear()
254+
r = tgclient.session_check()
255+
check("logged out: a failure that says to log in",
256+
not r["ok"] and "not logged in" in r["error"], r)
257+
check("and it still disconnects", events[-1] == "disconnect", events)
258+
os.environ.pop("TELEGRAM_API_ID")
259+
r = tgclient.session_check()
260+
check("no API credentials: a failure, not an exception",
261+
not r["ok"] and "TELEGRAM_API_ID" in r["error"], r)
262+
218263
print("%d checks passed" % checks)

0 commit comments

Comments
 (0)