diff --git a/n8n-bale-publicchat-file-fetch/BALE-METRO-FILE-AGENT-DOCS.md b/n8n-bale-publicchat-file-fetch/BALE-METRO-FILE-AGENT-DOCS.md new file mode 100644 index 0000000..dc2ded8 --- /dev/null +++ b/n8n-bale-publicchat-file-fetch/BALE-METRO-FILE-AGENT-DOCS.md @@ -0,0 +1,174 @@ +# Bale Metro File Agent — Technical Documentation + +**What this is:** an n8n workflow that takes a natural-language chat request, figures out which file the user wants, and pulls it out of a *public* Bale bot's conversation history — something the official Bale Bot API cannot do, because a bot token can never see or act inside a chat it doesn't own. + +**Status:** working proof of concept. Hardcoded, single-target, built to demonstrate a mechanism — not a production service. Every hardcoded value and known limitation is called out explicitly in this doc so nothing is overstated. + +--- + +## 1. The problem this solves + +The original brief: *"you can build this for your own channel, where your bot is admin — but what about a public bot or channel your bot can't be admin of?"* + +The Bale **Bot API** (`getUpdates`, `sendMessage`, `getFile`, etc. — the same shape as Telegram's Bot API) only works within chats the bot account is a member/admin of, and only sees activity from the moment it joined onward. There is no bot-token mechanism to: + +- open an arbitrary public bot's chat, +- press its buttons, +- or read its message history. + +That's a hard protocol boundary, not a missing feature to configure around. Bots generally cannot message other bots or act as a user inside someone else's conversation. + +## 2. The solution: client auth instead of bot auth + +[`aiobale`](https://github.com/) is an unofficial, community-maintained Python library that implements Bale's actual client protocol — the same websocket/RPC protocol the official Bale app and web client use — rather than the smaller public Bot API. Its `Client` class logs in as a **real phone-number account** (SMS verification code, exactly like opening Bale on a new device), not a bot token. + +Once authenticated, that account can do anything a human tapping around the app could: open any public bot's chat, press its buttons, read full history, download files — because from Bale's perspective it *is* a person using the app, not a bot. + +This is the core trade-off to be upfront about: **it isn't the "optimal" or intended integration pattern** (it's a real user session, not a first-class bot integration), but it is the only mechanism that can act inside a chat the operator doesn't control. + +## 3. Two approaches considered + +### Route 1 — press the button, wait for the reply +Send the exact button-label text (e.g. `نقشه خطوط`) into the bot's chat and wait for its next message. Simple and reliable, but it can only ever fetch **one hardcoded thing** — there's no way to decide *which* button to press from a natural-language request without a separate keyword→button mapping table. + +Implemented in `fetch_metro_file.py` (kept in the repo as the minimal working example — this is what proved the mechanism worked at all, before the "decide what to fetch" layer was added). + +### Route 2 — search history instead of sending a new command *(the one actually used)* +Since the client account is authenticated as a real user, it can call `client.iter_messages()` to read the **entire history** of that bot chat — every file it has ever sent, across every button ever pressed, each with its original caption. Instead of guessing which button maps to a request, the script searches what's already there for a filename/caption/text match. + +This is the meaningfully better answer to "can it decide": it doesn't need a hardcoded button map, and it generalizes to anything the bot has ever sent, not just one fixed target. + +Implemented in `fetch_metro_smart.py` — this is the script the live n8n workflow calls. + +**Honest limitation:** Route 2 can only find files the bot has *already sent at least once*. If nothing matching the keyword has ever appeared in that chat, the search correctly returns "no match" — it does not fall back to pressing a button to fetch something new. Combining both routes (search first, press a button as fallback) is a natural next step, not yet built. + +## 4. Architecture + +``` +n8n (Docker, Linux host) Bale servers +┌─────────────────────────────┐ +│ When chat message received │ user types a request in the n8n chat panel +└──────────────┬──────────────┘ + ▼ +┌─────────────────────────────┐ +│ Parse request (LLM) │ HTTP POST to BazaarLink's chat-completions +│ │ endpoint. Forces a single-word JSON reply, +│ │ and explicitly instructs the model to +│ │ translate the keyword into Persian, since +│ │ the bot's captions are Persian regardless +│ │ of what language the user typed in. +└──────────────┬──────────────┘ + ▼ +┌─────────────────────────────┐ +│ Extract keyword │ Code node: strips markdown fences, parses +│ │ the model's JSON reply into {keyword}. +└──────────────┬──────────────┘ + ▼ +┌─────────────────────────────┐ +│ Run client-auth fetch │──────▶ fetch_metro_smart.py (Python, runs +│ (Execute Command node) │ INSIDE the n8n container) +└──────────────┬──────────────┘ │ + │ │ aiobale Client, logged in as + │ │ a real Bale account (session + │ │ reused from an earlier SMS + │ │ login — no re-auth per run) + │ ▼ + │ iter_messages() over the metro bot's + │ chat history → match filename/caption/ + │ text against the keyword → download + │ any match to /mnt/storage/bale-downloads + ▼ +┌─────────────────────────────┐ +│ Parse results │ Code node: reads the script's stdout +│ │ ({"files": [...], "matched": bool}) back +│ │ into a normal n8n item. +└──────────────┬──────────────┘ + ▼ +┌─────────────────────────────┐ +│ Save to disk │ Read/Write File node in "read" mode. +│ (visual only) │ The file is ALREADY on disk (the Python +│ │ script wrote it) — this node just reads +│ │ it back so n8n's UI shows the binary +│ │ preview + Download button. +└──────────────────────────────┘ +``` + +**Key point for explaining this out loud:** n8n itself never touches Bale's protocol or the file bytes in transit. It shells out to a Python process that does all of that, and only reads back a small JSON string (a list of file paths) over stdout. The **Save to disk** node's job is purely cosmetic — showing the file nicely in n8n's UI — not actually saving anything. + +## 5. Why n8n + Python, not n8n alone + +n8n's Code nodes run JavaScript, and there is no JavaScript library for Bale's client protocol. `aiobale` is Python-only, and its `Client` requires a persistent async connection with a completed SMS login — not something that fits inside a stateless per-execution node. So the design bridges the two: n8n orchestrates the natural-language layer (LLM parsing, decision routing, presenting output), and a standalone Python script does the one thing only it can do (speak to Bale as a logged-in user). + +## 6. Infrastructure notes (the parts that broke, and why) + +These are worth knowing even though they're now resolved — they're the kind of thing an interviewer might ask about directly. + +### n8n's Execute Command node is disabled by default (v2+) +Newer n8n images ship with `NODES_EXCLUDE` blocking `Execute Command` for security reasons. Fixed by setting `NODES_EXCLUDE: "[]"` in the container's environment. + +### n8n's v2 image dropped `apk` +The official `n8nio/n8n` image is Alpine-based but no longer ships the `apk` package manager itself, so `RUN apk add python3` fails with `apk: not found`. Worked around by copying the `apk` binary and its support files in from a plain `alpine:latest` image in a multi-stage `Dockerfile`, then installing Python 3 and `aiobale-py` into a venv on top of the official n8n image. + +### `aiobale` history-scanning bug on large verified bots +`iter_messages()` on the metro bot's chat (596K subscribers) threw a `pydantic.ValidationError`: certain historical messages carry a dict payload (almost certainly a view/seen-tracking structure) in the exact field `aiobale` expects to be plain text (`TextMessage.value: str`). This only affects **history scans**, not live replies — Route 1 never hit it because it never calls `load_history`. + +**Fix applied** (top of `fetch_metro_smart.py`): loosen that one field's type at runtime and force pydantic to rebuild its validation schema: + +```python +import aiobale.types.message_content as _mc +_mc.TextMessage.model_fields["value"].annotation = Any +_mc.TextMessage.model_rebuild(force=True) +``` + +Downstream code then defensively checks `isinstance(msg_text, str)` before using it, since the field can now legitimately hold a dict instead of text. + +**Scope of the fix, stated honestly:** this patches the *specific* field/shape that actually broke, based on a real traceback — it is not a guarantee that every possible message shape in Bale's protocol is now safe. A different historical message with a different unexpected shape could still throw a fresh, different validation error. This is a known category of risk with unofficial, reverse-engineered protocol libraries. + +## 7. File inventory + +| File | Purpose | +|---|---| +| `fetch_metro_file.py` | Route 1 (button press + wait) — the first working proof that client auth could talk to a public bot at all. | +| `fetch_metro_smart.py` | Route 2 (history search) — what the live n8n workflow actually calls. Includes the `aiobale` monkeypatch and result deduplication. | +| `fetch_by_description.py` | Earlier variant built for scanning *your own* channel's history (not the metro bot) — kept for reference, not part of the current demo. | +| `bale-metro-file-agent-clientauth.json` | The n8n workflow: chat trigger → LLM keyword parse → Execute Command → parse results → visual "Save to disk". | +| `Dockerfile` | Adds Python 3 + `aiobale-py` on top of the official `n8nio/n8n` image, working around the missing-`apk` issue. | +| `docker-compose.yaml` | Original compose file with two changes: `image:` → `build: .`, and `NODES_EXCLUDE: "[]"` added. | +| `metro_session.bale` | The authenticated session file (JWT + auth metadata) from the one-time phone/SMS login. Lives at `/mnt/storage/bale-downloads/` so it survives container rebuilds via the volume mount. | + +## 8. Hardcoded values (everything that would need to change for a different target) + +This was built deliberately hardcoded end-to-end for demo purposes — no credential management, no multi-target config. To point this at a different bot/channel, these are the values to change: + +- `TARGET_CHAT_ID` (in `fetch_metro_smart.py`) — the numeric `uid` from that chat's `web.bale.ai/chat?uid=...` URL. +- `TARGET_CHAT_TYPE` — `ChatType.BOT` for a bot chat, `ChatType.CHANNEL` for a channel, `ChatType.PRIVATE` for a DM. +- `PHONE_NUMBER` — only used as a fallback if the session file is ever missing/expired and a fresh interactive login is triggered. +- The BazaarLink API key in the workflow JSON's **Parse request (LLM)** node. + +## 9. Reproduction steps (from a clean checkout) + +1. Place `Dockerfile` next to your `docker-compose.yaml`. +2. In `docker-compose.yaml`: change `image: n8nio/n8n:latest` to `build: .`, and add `NODES_EXCLUDE: "[]"` under the `n8n` service's `environment:`. +3. `docker compose up -d --build` +4. One-time only, on a machine with a terminal/TTY (the container itself has none by default for interactive input): run the login flow to produce `metro_session.bale`, then copy that file into `/mnt/storage/bale-downloads/` (the path the scripts and the volume mount both expect). +5. Copy `fetch_metro_smart.py` into `/mnt/storage/bale-downloads/`. +6. Sanity-check in isolation, before involving n8n at all: + ```bash + docker exec -it n8n python3 /mnt/storage/bale-downloads/fetch_metro_smart.py خطوط + ``` + Expect `{"files": ["...tehran-subway1.jpg"], "matched": true}`. +7. Import `bale-metro-file-agent-clientauth.json` into n8n (**Workflows → Import from File**). +8. Open the workflow's chat panel and type a request in any language, e.g. *"get me the metro map."* + +## 10. What this honestly proves vs. doesn't + +**Proven:** +- Client auth (not bot auth) can access a chat/bot the operator doesn't control. +- A natural-language request can be turned into a targeted search over that chat's real history, in a different language than the underlying content. +- The mechanism survives real infrastructure friction (container security defaults, missing OS package manager, an upstream library bug) — all diagnosed and fixed with evidence (tracebacks), not guesswork. + +**Not proven / explicitly out of scope for this demo:** +- Generalization to *any* bot/channel without manually changing the hardcoded target — no dynamic bot discovery. +- Combining Route 1 and Route 2 (falling back to pressing a button when history search comes up empty). +- Production concerns: no credential vaulting, no per-chat request locking for concurrent use, no retry/backoff, no handling for further undiscovered `aiobale` parsing edge cases beyond the one hit here. +- The account used for client auth is a real personal Bale account — rate limits, terms-of-service exposure, and account-level risk apply in a way they wouldn't for an official bot integration. Worth stating plainly if asked: this trades "it works against public chats" for "it's not the sanctioned integration path." diff --git a/n8n-bale-publicchat-file-fetch/json/Bale Metro File Agent (client-auth demo).json b/n8n-bale-publicchat-file-fetch/json/Bale Metro File Agent (client-auth demo).json new file mode 100644 index 0000000..43224e5 --- /dev/null +++ b/n8n-bale-publicchat-file-fetch/json/Bale Metro File Agent (client-auth demo).json @@ -0,0 +1,176 @@ +{ + "name": "Bale Metro File Agent (client-auth demo)", + "nodes": [ + { + "parameters": { + "options": {} + }, + "id": "6bb427a1-bb35-4742-93e6-ebb320950224", + "name": "When chat message received", + "type": "@n8n/n8n-nodes-langchain.chatTrigger", + "typeVersion": 1.1, + "position": [ + 3344, + 384 + ], + "webhookId": "fdae82fa-cb2d-4d9a-83ba-4ae9bddd0a6e" + }, + { + "parameters": { + "method": "POST", + "url": "https://api.bazaarlink.ai/v1/chat/completions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "Bearer sk-bl-RszuRzm4SBYwRapwenmPDyHMNZnxVkZ3w8UxtNPArWTFos23" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: \"auto:free\", messages: [ { role: \"system\", content: \"You extract a single short search keyword describing the file the user wants. IMPORTANT: the files you are searching are Bale bot messages with Persian (Farsi) captions and filenames -- no matter what language the user writes in, output the keyword translated into a plausible Persian word likely to appear in that caption (e.g. a request for 'the metro map' should become the keyword 'نقشه' or 'خطوط', not 'map'). Reply with ONLY compact JSON like {\\\"keyword\\\":\\\"خطوط\\\"}. No prose, no markdown fences.\" }, { role: \"user\", content: $json.chatInput } ] }) }}", + "options": {} + }, + "id": "47b99f54-f43e-4a16-b281-5a20bf8150a5", + "name": "Parse request (LLM)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 3792, + 384 + ] + }, + { + "parameters": { + "jsCode": "let raw = $input.first().json.choices[0].message.content.trim();\nraw = raw.replace(/^```json\\s*/i, '').replace(/^```\\s*/i, '').replace(/```\\s*$/i, '');\nconst parsed = JSON.parse(raw);\nreturn [{ json: parsed }];" + }, + "id": "1156dfdb-4cae-49fb-92c2-cfaa35ff86e0", + "name": "Extract keyword", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4016, + 384 + ] + }, + { + "parameters": { + "command": "=python3 /mnt/storage/bale-downloads/fetch_metro_smart.py \"{{ $json.keyword }}\"" + }, + "id": "ede1882a-c5df-4074-a1a0-4bfac8dca00e", + "name": "Run client-auth fetch", + "type": "n8n-nodes-base.executeCommand", + "typeVersion": 1, + "position": [ + 4240, + 384 + ], + "notes": "Runs fetch_metro_smart.py, passing the LLM-parsed keyword through. The script searches this bot chat's full history for a document whose filename/caption matches the keyword -- it does NOT send a new command, it decides from what's already there." + }, + { + "parameters": { + "jsCode": "const result = JSON.parse($input.first().json.stdout);\nconst files = result.files || [];\nreturn [{ json: {\n found: files.length,\n files,\n summary: files.length\n ? `Found ${files.length} file(s):\\n` + files.join('\\n')\n : 'No file in this chat\\'s history matched that description.'\n} }];" + }, + "id": "18f2ad30-f652-45a1-a825-b57183d498ca", + "name": "Parse results", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4464, + 384 + ] + }, + { + "parameters": { + "fileSelector": "={{ $json.files[0] }}", + "options": {} + }, + "id": "95ca5a02-c89c-4b90-b476-69f1b3382de9", + "name": "Save to disk", + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1, + "position": [ + 4688, + 384 + ], + "notes": "The file is already on disk (the Python script wrote it). This node just reads it back so n8n shows the binary preview + Download button, same as the original Bot-API demo -- purely visual." + } + ], + "pinData": {}, + "connections": { + "When chat message received": { + "main": [ + [ + { + "node": "Parse request (LLM)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse request (LLM)": { + "main": [ + [ + { + "node": "Extract keyword", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract keyword": { + "main": [ + [ + { + "node": "Run client-auth fetch", + "type": "main", + "index": 0 + } + ] + ] + }, + "Run client-auth fetch": { + "main": [ + [ + { + "node": "Parse results", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse results": { + "main": [ + [ + { + "node": "Save to disk", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "availableInMCP": false + }, + "versionId": "a1d3946d-dfda-478a-93eb-e11cb88d4722", + "meta": { + "instanceId": "a8618b8efe9a0b8c68300d12dc12f140ace3ebe855b80198b7819ca3e0922fe9" + }, + "nodeGroups": [], + "id": "L9iWiHBHGxQZDvGU", + "tags": [] +} \ No newline at end of file diff --git a/n8n-bale-publicchat-file-fetch/script/fetch_metro_smart.py b/n8n-bale-publicchat-file-fetch/script/fetch_metro_smart.py new file mode 100644 index 0000000..7eb7a85 --- /dev/null +++ b/n8n-bale-publicchat-file-fetch/script/fetch_metro_smart.py @@ -0,0 +1,92 @@ +""" +fetch_metro_smart.py -- decides which file to grab, instead of always +sending the same button. + +Route 2: since we're authenticated as a real user (not a bot token), we can +read the FULL history of this bot chat, not just react to new messages. +Every file the bot has ever sent you (from any button you've pressed before) +is sitting in that history with a caption. So instead of guessing which +button maps to which request, we search what's already there. + +Usage (n8n passes the LLM-parsed keyword as the one argument): + python3 fetch_metro_smart.py "خطوط" + +Prints one line of JSON to stdout: {"files": [...], "matched": true/false} +""" + +import asyncio +import json +import sys +import pathlib +from typing import Any + +from aiobale import Client, Dispatcher +from aiobale.enums import ChatType + +# ---- upstream library workaround --------------------------------------- +# aiobale hard-types TextMessage.value as `str`, but Bale's servers attach a +# view/seen-tracking payload (a dict) to some messages from large verified +# bots -- history scans on those chats crash with a pydantic ValidationError +# before we ever see the message. This loosens that one field so parsing +# succeeds; matches() below coerces to str defensively since the value can +# now legitimately be a dict instead of text. +import aiobale.types.message_content as _mc + +_mc.TextMessage.model_fields["value"].annotation = Any +_mc.TextMessage.model_rebuild(force=True) +# ------------------------------------------------------------------------- + +# ---- hardcoded demo config -------------------------------------------- +SESSION_FILE = "/mnt/storage/bale-downloads/metro_session.bale" +PHONE_NUMBER = 989381393442 +TARGET_CHAT_ID = 1381242617 # متروی تهران و حومه +TARGET_CHAT_TYPE = ChatType.BOT +DOWNLOAD_DIR = pathlib.Path("/mnt/storage/bale-downloads") +MAX_MESSAGES = 200 # how far back into this chat's history to scan +# ------------------------------------------------------------------------- + +DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) + +dp = Dispatcher() +client = Client(dp, session_file=SESSION_FILE, phone_number=PHONE_NUMBER) + + +def matches(doc, msg, keyword: str) -> bool: + keyword = (keyword or "").lower().strip() + if not keyword: + return False # no keyword = match nothing (avoid grabbing everything by accident) + caption = doc.caption.content if doc.caption else "" + msg_text = msg.text + if not isinstance(msg_text, str): + msg_text = "" # some messages carry a non-text payload in this field -- see workaround above + haystack = " ".join(filter(None, [doc.name or "", caption or "", msg_text])).lower() + return keyword in haystack + + +async def main(keyword: str): + await client.start(run_in_background=True) + await asyncio.sleep(2) + + saved = [] + try: + async for msg in client.iter_messages( + chat_id=TARGET_CHAT_ID, chat_type=TARGET_CHAT_TYPE, limit=MAX_MESSAGES + ): + doc = msg.document + if not doc: + continue + if matches(doc, msg, keyword): + dest = DOWNLOAD_DIR / (doc.name or f"{msg.message_id}.bin") + if str(dest) in saved: + continue # already matched this exact file earlier in history + await msg.download(destination=dest) + saved.append(str(dest)) + finally: + await client.stop() + + print(json.dumps({"files": saved, "matched": len(saved) > 0})) + + +if __name__ == "__main__": + kw = sys.argv[1] if len(sys.argv) > 1 else "" + asyncio.run(main(kw)) \ No newline at end of file