Initial Commit

This commit is contained in:
Marcus Kida 2026-06-08 23:14:05 +02:00
commit 7f08a655d8
9 changed files with 324 additions and 0 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
.env
.git
.gitignore
__pycache__/
*.pyc

9
.env.example Normal file
View file

@ -0,0 +1,9 @@
TG_TO_WATCH=26235
CLUSTER_TO_WATCH=Cluster Name
TELEGRAM_CHANNEL=@your_channel
TELEGRAM_API_KEY=your_telegram_bot_api_key
CLUSTER_WATCH=true
# Optional: repeaterid:slot pairs that should never count as a slot match
SLOT_EXCLUDE=262399:2,262383:2
# Optional: repeater IDs whose TG8 traffic should be ignored
TG8_EXCLUDE_REPEATERS=262399

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.env
__pycache__/
*.pyc

10
Dockerfile Normal file
View file

@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["python", "-u", "main.py"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Marcus Kida
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

96
README.md Normal file
View file

@ -0,0 +1,96 @@
# BrandMeister LastHeard Telegram Bot
A small bot that listens to the [BrandMeister](https://brandmeister.network) DMR
"LastHeard" stream and posts a formatted message to a Telegram channel whenever a
relevant transmission ends. It is useful for keeping a club or regional channel
informed about activity on a specific Talkgroup or repeater cluster.
## What it does
The bot connects to the BrandMeister LastHeard Socket.IO stream and reacts to
`Session-Stop` events. It sends a Telegram message when **either** of the
following matches:
- **Mirror Talkgroup** — the transmission's destination is the Talkgroup you set
via `TG_TO_WATCH`.
- **TG8 cluster** — the transmission is on TG8 *and* came in via a repeater that
belongs to the cluster named in `CLUSTER_TO_WATCH` (only when `CLUSTER_WATCH`
is enabled). Cluster membership is resolved at startup from the BrandMeister
API.
Only recent events (stopped within the last ~20 seconds) trigger a message, so
old/replayed events are ignored.
Each Telegram message includes the source callsign, ID, name, talker alias, the
repeater/hotspot used to access the network, slot, Talkgroup, master, RSSI and
BER.
## Requirements
- A Telegram bot token (create one via [@BotFather](https://t.me/BotFather)) and
a channel the bot can post to.
- Either Docker (recommended) or Python 3.12+.
## Configuration
The bot is configured entirely through environment variables. Copy the example
file and fill in your values:
```bash
cp .env.example .env
```
| Variable | Required | Description |
| ------------------ | -------- | --------------------------------------------------------------------------- |
| `TG_TO_WATCH` | yes | Talkgroup ID to mirror to Telegram (e.g. `26235`). |
| `CLUSTER_TO_WATCH` | yes | BrandMeister cluster name used for the TG8 check (e.g. `Cluster Name`). |
| `TELEGRAM_CHANNEL` | yes | Target channel/chat ID (e.g. `@your_channel`). |
| `TELEGRAM_API_KEY` | yes | Telegram bot token from BotFather. |
| `CLUSTER_WATCH` | no | `true`/`false` — enable the TG8 cluster check. Defaults to `true`. |
| `SLOT_EXCLUDE` | no | Comma-separated `repeaterid:slot` pairs that never count as a slot match (e.g. `262399:2,262383:2`). |
| `TG8_EXCLUDE_REPEATERS`| no | Comma-separated repeater IDs whose TG8 traffic is ignored (e.g. `262399`). |
## Running
### With Docker Compose (recommended)
```bash
docker compose up -d --build
```
The service restarts automatically unless explicitly stopped. View logs with:
```bash
docker compose logs -f
```
### With plain Docker
```bash
docker build -t bm-lh .
docker run --env-file .env --restart unless-stopped bm-lh
```
### Locally with Python
```bash
pip install -r requirements.txt
set -a && source .env && set +a # load env vars into the shell
python -u main.py
```
## How it works
1. On startup the bot queries the BrandMeister API (`/v2/cluster`) to find the
cluster matching `CLUSTER_TO_WATCH`, then fetches its member repeaters
(`/v2/cluster/<id>/members`).
2. It opens a WebSocket connection to the LastHeard stream
(`https://api.brandmeister.network`, path `/lh/socket.io`) and subscribes by
emitting `join` with `everything`.
3. For every incoming `mqtt` event it parses the payload and decides whether to
forward a Telegram message based on the rules described above.
4. The connection auto-reconnects on errors.
## License
Released under the [MIT License](LICENSE).

5
compose.yml Normal file
View file

@ -0,0 +1,5 @@
services:
bm-lh:
build: .
env_file: .env
restart: unless-stopped

172
main.py Normal file
View file

@ -0,0 +1,172 @@
import socketio
import json
import time
import os
import requests
# Read variables from environment
tg_to_watch = int(os.environ["TG_TO_WATCH"])
cluster_to_watch = os.environ["CLUSTER_TO_WATCH"]
telegram_channel = os.environ["TELEGRAM_CHANNEL"]
telegram_api_key = os.environ["TELEGRAM_API_KEY"]
cluster_watch = os.environ.get("CLUSTER_WATCH", "true").lower() == "true"
def _as_int(value):
# The LastHeard stream is inconsistent about whether IDs/slots arrive as
# ints or strings, so normalise everything we compare to int.
try:
return int(value)
except (TypeError, ValueError):
return value
def _parse_pairs(raw):
# "262399:2,262383:2" -> {(262399, 2), (262383, 2)}
pairs = set()
for item in raw.split(","):
item = item.strip()
if not item:
continue
rid, _, slot = item.partition(":")
pairs.add((_as_int(rid), _as_int(slot)))
return pairs
def _parse_ids(raw):
# "262399,262383" -> {262399, 262383}
return {_as_int(item.strip()) for item in raw.split(",") if item.strip()}
# Repeater/slot combinations that should never count as a slot match, and
# repeater IDs whose TG8 traffic should be ignored. Both are optional and
# replace what used to be hardcoded repeater IDs in the event handler.
slot_exclusions = _parse_pairs(os.environ.get("SLOT_EXCLUDE", ""))
tg8_exclude_repeaters = _parse_ids(os.environ.get("TG8_EXCLUDE_REPEATERS", ""))
print(f"TG to watch: {tg_to_watch}")
print(f"Cluster to watch: {cluster_to_watch}")
print(f"Telegram channel: {telegram_channel}")
print(f"Cluster watch: {cluster_watch}")
print(f"Slot exclusions: {slot_exclusions or 'none'}")
print(f"TG8 exclude repeaters: {tg8_exclude_repeaters or 'none'}")
########################################################################
def send_telegram_message(text):
url = f"https://api.telegram.org/bot{telegram_api_key}/sendMessage"
r = requests.post(url, data={"chat_id": telegram_channel, "text": text, "parse_mode": "HTML"})
print(f"Telegram API response: {r.status_code} {r.text}")
# Read the BM cluster database.
# Note: the former endpoint /v2/cluster/byName?name=... no longer exists.
# Instead /v2/cluster returns the full list of all clusters, from which we
# pick the matching one by clusterName.
bm_clusterinfo_uri = "https://api.brandmeister.network/v2/cluster"
# Parse the response so it is easier to work with
response = requests.get(bm_clusterinfo_uri)
bm_clusters_json = response.json()
# Filter out the cluster(s) with a matching name
bm_clustermasters_json = [c for c in bm_clusters_json if c["clusterName"] == cluster_to_watch]
if not bm_clustermasters_json:
print(f"Cluster '{cluster_to_watch}' not found in the Brandmeister API")
# List of repeaters that belong to the cluster
list_of_repeater = []
# First loop: read the members of the cluster
for master in bm_clustermasters_json:
bm_master_repeaters_with_cluster_uri = "https://api.brandmeister.network/v2/cluster/" + str(master["id"]) +"/members"
response_repeater_in_cluster = requests.get(bm_master_repeaters_with_cluster_uri)
try:
repeater_list_with_cluster = (response_repeater_in_cluster.json())[0]["members"]
for repeater in repeater_list_with_cluster:
# Build the list of repeaters that belong to the cluster
list_of_repeater.append (repeater)
except:
print("Error")
# SocketIO setup for the MQTT socket handling
sio = socketio.Client(reconnection=True, reconnection_attempts=0, reconnection_delay=5, reconnection_delay_max=60)
@sio.event
def connect():
print('connected to server')
# Since the API change the stream has to be actively subscribed to,
# otherwise the server keeps the connection open but sends no data.
sio.emit("join", "everything")
print('subscribed to LastHeard stream (join/everything)')
@sio.event
def disconnect():
print('disconnected from server, reconnecting...')
@sio.on("mqtt")
def on_mqtt(data):
epoch_time = int(time.time())
# Unpack the payload into variables, which is a bit easier to work with
datapayload = (data["payload"])
jsondata = json.loads(datapayload)
payload_dst_id = jsondata["DestinationID"]
event_type = jsondata["Event"]
session_stop_time = jsondata["Stop"]
max_old_time = epoch_time - 20
# Build the message that will be sent to Telegram
telegram_message = "Callsign: <b><a href='https://brandmeister.network/index.php?page=profile&call=" +jsondata["SourceCall"] + "'>" + jsondata["SourceCall"] + "</a></b> ID: <b>" + str(jsondata["SourceID"]) + " </b> \nName: " +jsondata["SourceName"] + " \nTalkeralias: <b>" + jsondata["TalkerAlias"] + "</b> \n-----------------------------------\nAccess via: <b>" + jsondata["LinkName"] + " </b>\nVia repeater: <b><a href='https://brandmeister.network/?page=device&id=" + str(jsondata["ContextID"]) + "'>" + jsondata["LinkCall"] + "</a></b>\nSlot: " + str(jsondata["Slot"]) + " TG: " + str(jsondata["DestinationID"]) + "\nMaster: " + str(jsondata["Master"]) + "\nRSSI: <b>" + str(jsondata["RSSI"]) + " dBm</b> BER: <b>" + str(round(jsondata["BER"],2)) + "%</b>"
context_id = _as_int(jsondata["ContextID"])
event_slot = _as_int(jsondata["Slot"])
# Find the cluster member matching this event's repeater, if any.
matched_repeater = next(
(rep for rep in list_of_repeater if _as_int(rep["repeaterid"]) == context_id),
None,
)
repeater_in_cluster = matched_repeater is not None
# A slot match requires the repeater to be in the cluster, its configured
# slot to match the event slot, and the combination not to be excluded.
repeater_match_slot = (
repeater_in_cluster
and _as_int(matched_repeater["slot"]) == event_slot
and (context_id, event_slot) not in slot_exclusions
)
# TG8 cluster check
tg8_match = payload_dst_id == 8
is_session_stop = event_type == "Session-Stop"
is_recent = session_stop_time > max_old_time
# Skip TG8 messages from repeaters in the exclude list
skip_tg8_for_repeater = tg8_match and context_id in tg8_exclude_repeaters
if tg8_match and is_session_stop and is_recent and repeater_match_slot and repeater_in_cluster and cluster_watch and not skip_tg8_for_repeater:
print (jsondata)
print ("matched via TG8")
send_telegram_message(telegram_message)
# Mirror TG check
tg_match = payload_dst_id == tg_to_watch
if tg_match and is_session_stop and is_recent:
print (jsondata)
print ("matched via Mirror-TG")
send_telegram_message(telegram_message)
return
bm_url = 'https://api.brandmeister.network'
bm_path = "/lh/socket.io"
while True:
try:
sio.connect(url=bm_url, socketio_path=bm_path, transports="websocket")
sio.wait()
except Exception as e:
print(f"Connection error: {e}, retrying in 10s...")
time.sleep(10)

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
python-socketio[client]
requests
websocket-client