Skip to content

telegram: send chat files as documents/photos - #84

Open
headlong42 wants to merge 9 commits into
laude-institute:mainfrom
headlong42:audel/telegram-send-file
Open

telegram: send chat files as documents/photos#84
headlong42 wants to merge 9 commits into
laude-institute:mainfrom
headlong42:audel/telegram-send-file

Conversation

@headlong42

Copy link
Copy Markdown

This slice:

  • detects file-bearing mind-log steps (filename / PNG / SVG sniff)
  • adds Bot.send_document and Bot.send_photo (multipart)
  • routes images to sendPhoto and everything else to sendDocument
  • keeps the existing source=chat delivery guard

Intentionally not in this PR: identity-local responder dest-history stub, notes, secrets, web build artifacts.

Pushed from headlong42/headlong because headlong42 has pull-only on laude-institute/headlong (direct origin push 403).

@nickjalbert nickjalbert left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @headlong42! From the review:

The file branch is placed correctly: it runs after the source == "chat" guard, the telegram-name/identity checks, the allowlist check, and dedupe, and file_payload reads only fields off the step and reduces filenames with Path(...).name, so it cannot be steered into reading files off the box. Reading the checkout, five reviewers still found the slice not ready:

  1. bin/chat (cmd_send_file, ~205-209) + telegram/src/headlong_telegram/filepayload.py — decide and implement how binary file content reaches the trajectory. Today content=$(cat ...) plus jq -Rsa drops NULs and replaces invalid UTF-8, and mindlog (json.loads) always yields str, so the isinstance(content, (bytes, bytearray)) PNG-magic path is unreachable in production and a real PNG arrives corrupted. Either add an explicit base64-encoded field that the bridge decodes, or restrict this slice to text files and delete the bytes branches plus the tests that inject bytes steps.
  2. telegram/src/headlong_telegram/outbound.py (file branch) — on ApiError from send_photo, retry the same payload with send_document before consuming the step, and widen the except to (ApiError, httpx.HTTPError). mindlog.follow persists the cursor before yielding, so today a rejected or timed-out upload is lost with no chat-visible trace, and a non-ApiError transport failure ends the outbound daemon thread.
  3. telegram/src/headlong_telegram/outbound.py — gate the file branch on the delivery target actually being authorized (e.g. require conv.user == conv.chat for private routes, or authorize conv.chat). The allowlist checks conv.user while send_photo/send_document target conv.chat, and naming.decode accepts telegram-<approved-user>-<other-chat>. The mismatch is pre-existing on the text path, so the maintainer may prefer to fix it repo-wide; either way, uploads should not ship on top of it unguarded.
  4. telegram/src/headlong_telegram/filepayload.py — key file detection on the explicit filename stamp that chat send-file writes; drop the file alias, the <svg-prefix sniff, and the FILE_TYPES/typed fallback (unreachable, since the caller already requires type == "message"). Also truncate captions to Telegram's 1024-character media-caption limit rather than 2000, since an over-long caption currently drops the whole file.
  5. telegram/tests/test_outbound.py — restore test_recent_posts_dedupe_window. It was the only coverage of RecentPosts, which is unchanged by this PR and is one of the guards the new branch depends on.

Definitely interested in this feature but please fix the above before merge.

headlong42 pushed a commit to headlong42/headlong that referenced this pull request Aug 28, 2026
Stamp files as content_b64, require an explicit filename (no SVG sniff),
drop non-DM destinations, cap captions at 1024, and restore RecentPosts
coverage.
@nickjalbert

Copy link
Copy Markdown
Contributor

@headlong42 Thanks for the iteration. The route check against the allowlist, the restored dedupe test, the httpx error handling, and the 1024 caption limit all look right.

The fix commit introduced problems of its own:

  • bin/chat now passes the whole base64 file as a single command argument. File contents become visible in the process list, and a file near the OS argument size limit fails outright. We just removed this exact pattern from the repo in keep API keys out of curl arguments #83. Stream the base64 over stdin or through a temp file instead.
  • bin/chat now runs inline python3. The core bin/ path is bash plus standard tools by design. Build the JSON with jq and the system base64 command instead.
  • outbound.py contains the same file dispatch block three times (around lines 81, 103, and 125). Only the first copy can ever run. Keep one and delete the other two.
  • Photo detection only recognizes PNG magic, so a JPEG always arrives as a document. Either recognize JPEG too or tell me that sending everything as a document is intended.

Still open from the first review:

  • File content and captions bypass strip_leaked_command. Run them through the same leak filter as text messages.
  • deploy/SECURITY.md still says the bridge is text only and drops media. Update it to describe file delivery.
  • When the final send fails, the failure is only logged and the cursor has already advanced, so the file is silently lost. At minimum, fall back to a text message saying the delivery failed.
  • The "file" alias for "filename" can still reclassify an ordinary message as an upload. Accept only the canonical key.

let me know when you make another pass.

headlong42 pushed a commit to headlong42/headlong that referenced this pull request Aug 28, 2026
Address remaining laude-institute#84 review: stamp send-file via jq --rawfile so the
payload never sits on argv, treat JPEG magic like PNG for sendPhoto,
drop the duplicated outbound file-send block, and spell the inbound vs
outbound media split in SECURITY.md.
headlong42 pushed a commit to headlong42/headlong that referenced this pull request Aug 28, 2026
…fail

Finish remaining laude-institute#84 review: accept only canonical `filename`, run text
file bodies and captions through strip_leaked_command, and fall back to
a short text notice when sendPhoto/sendDocument fails after the cursor
has already advanced.
@headlong42

Copy link
Copy Markdown
Author

Made another pass. Remaining items from the last review:

  • bin/chat send-file no longer puts file bytes on argv. It pipes base64 into jq --rawfile (no inline python).
  • JPEG magic is treated like PNG (sendPhoto); other binaries still go as documents.
  • Duplicated outbound file-send block is gone.
  • Canonical filename only — a file field no longer reclassifies an ordinary message.
  • Text file bodies and captions run through strip_leaked_command.
  • If sendPhoto/sendDocument fails after the cursor has already advanced, we fall back to a short text notice ((failed to deliver file …)) so the file isn't silently lost.
  • deploy/SECURITY.md now distinguishes inbound media (still dropped) from outbound chat send-file uploads.

Latest commit: d423537. telegram filepayload/outbound/tgfmt tests: 28 passed.

@nickjalbert

Copy link
Copy Markdown
Contributor

From review:

  1. bin/chat (cmd_send_file) + readers: file steps now carry only content_b64, and slack/src/headlong_slack/outbound.py:74, web/src/headlong_web/trajectory.py:318, web/src/headlong_web/push.py:117, tui/headlong/src/main.rs:473, and bin/chat:231 (cmd_history) all skip steps with empty contentchat send-file --to <slack-name> is a silent no-op and Telegram sends vanish from history/dashboards. Please keep a bounded human-readable content (e.g. [file: fig.png]) alongside content_b64.
  2. thinkers/_lib/common.sh:~253 (and check bin/context): the recent-stream filter truncates only .content, so the full base64 payload is interpolated into thinkers/monolith/step route prompts — unbounded token cost and possible context overflow. Elide content_b64 from model-facing renderers and render filename metadata instead.
  3. tests/: add a bash test (SHELLM_ENV=local) that runs chat send-file on a fixture containing NUL bytes and newlines and asserts content_b64 decodes back to the original bytes and filename is the basename. The producer pipeline is currently executed by no test on any platform.
  4. bin/chat (cmd_send_file): reject files above a small cap (well under Telegram's 50MB bot limit). There is no size guard, the log inflates 4/3, and the repo has a documented 590MB-log OOM.
  5. bin/chat / telegram/src/headlong_telegram/filepayload.py: an empty file, or a content_b64 that fails strict decode, yields "File sent" to the agent and nothing delivered or logged. Fail loudly in one of the two places.
  6. design/trajectory_spec.md:391-394: the documented file-step schema still requires content and does not mention content_b64. Update it to match whatever shape (1) settles on.

Thanks for the continued work on this @headlong42 — the Telegram side is in good shape now, and the review confirmed the base64 round-trip and the group-chat guard both hold up.

On (1): please keep a short human-readable content alongside content_b64, e.g. [file: fig.png]. That's a one-line change in bin/chat and every existing reader keeps working. Moving the readers onto content_b64 properly is a bigger change we may do later, but it's out of scope for this PR. Note the Telegram side already prefers content_b64, so no Python change is needed.

For (6), document the content + content_b64 pair as the file-step shape.

headlong42 pushed a commit to headlong42/headlong that referenced this pull request Aug 29, 2026
Nick's remaining laude-institute#84 notes: stamp a short human-readable `content`
(e.g. `[file: fig.png]`) alongside `content_b64` so existing readers
keep working, and document that pair as the file-step shape.
@headlong42

Copy link
Copy Markdown
Author

Pushed 34f8286 for the remaining notes:

  • bin/chat send-file now stamps a short human-readable content (e.g. [file: fig.png]) alongside content_b64.
  • design/trajectory_spec.md documents that pair as the file-step shape.

Telegram side unchanged (already prefers content_b64).

@nickjalbert

Copy link
Copy Markdown
Contributor

Thanks @headlong42, the two fixes you pushed look good but you missed several notes though.

Confirmed the content marker and spec doc — the reader-visibility blocker is resolved.

Still open from the review: items 2, 3, 4, and 5. In detail:

  1. thinkers/_lib/common.sh:~253 (and check bin/context): the recent-stream filter truncates only .content, so the full base64 payload is interpolated into thinkers/monolith/step route prompts — unbounded token cost and possible context overflow. Elide content_b64 from model-facing renderers and render filename metadata instead.
  2. tests/: add a bash test (SHELLM_ENV=local) that runs chat send-file on a fixture containing NUL bytes and newlines and asserts content_b64 decodes back to the original bytes and filename is the basename. The producer pipeline is currently executed by no test on any platform.
  3. bin/chat (cmd_send_file): reject files above a small cap (well under Telegram's 50MB bot limit). There is no size guard, the log inflates 4/3, and the repo has a documented 590MB-log OOM.
  4. bin/chat / telegram/src/headlong_telegram/filepayload.py: an empty file, or a content_b64 that fails strict decode, yields "File sent" to the agent and nothing delivered or logged. Fail loudly in one of the two places.

This will be mergeable after those four fixes.

headlong42 pushed a commit to headlong42/headlong that referenced this pull request Sep 3, 2026
Nick's leftover laude-institute#84 notes: reject empty/oversize files at the producer,
elide content_b64 from model-facing context, treat undecodable
content_b64 as a failed delivery rather than falling through to
sendMessage, and cover the producer with a bash test.
audel added 6 commits September 3, 2026 12:06
Outbound treated every mind-log message as sendMessage, so a PNG/SVG
from chat send-file landed as raw bytes in the chat. Detect file-bearing
steps, add Bot.send_document/send_photo, and keep the source=chat
delivery guard.
Stamp files as content_b64, require an explicit filename (no SVG sniff),
drop non-DM destinations, cap captions at 1024, and restore RecentPosts
coverage.
Address remaining laude-institute#84 review: stamp send-file via jq --rawfile so the
payload never sits on argv, treat JPEG magic like PNG for sendPhoto,
drop the duplicated outbound file-send block, and spell the inbound vs
outbound media split in SECURITY.md.
…fail

Finish remaining laude-institute#84 review: accept only canonical `filename`, run text
file bodies and captions through strip_leaked_command, and fall back to
a short text notice when sendPhoto/sendDocument fails after the cursor
has already advanced.
Nick's remaining laude-institute#84 notes: stamp a short human-readable `content`
(e.g. `[file: fig.png]`) alongside `content_b64` so existing readers
keep working, and document that pair as the file-step shape.
Nick's leftover laude-institute#84 notes: reject empty/oversize files at the producer,
elide content_b64 from model-facing context, treat undecodable
content_b64 as a failed delivery rather than falling through to
sendMessage, and cover the producer with a bash test.
@headlong42
headlong42 force-pushed the audel/telegram-send-file branch from ad7f790 to f68ae29 Compare September 3, 2026 12:08
audel added 2 commits September 3, 2026 12:14
Drop unused `rel` from `traj new` output (SC2034) and fail if cd into
the workdir fails (SC2164). CI shellcheck -S warning was failing on
these two lines in tests/test_chat_send_file.sh.
Rebase conflict resolution had re-included reasoning steps in
_recent_stream, which main and test_recent_stream_filter.sh both
exclude. CI bash tests failed 3 cases because of it.

@nickjalbert nickjalbert left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! From review:

  1. Preserve chat send-file behavior for other transports (bin/chat:238)

chat send-file is a shared Headlong command. Before this change, a text file's contents were stored in content, so Slack and web chat delivered those contents. The new step stores only [file: name] in content, while only Telegram decodes content_b64. Slack and web chat now deliver the marker instead of the file contents.

Preserve the existing text file behavior for other readers, or update every supported transport to consume the new file payload. Add a regression test showing that a text file sent through a non Telegram route still delivers its contents.

  1. Remove file bytes from the dashboard API response (web/src/headlong_web/trajectory.py:239)

The dashboard normalizer copies the complete step into raw, including content_b64, and the mind log endpoint sends that object to the browser. A 1 MiB test file produced a 1,398,488 byte response. A file at the new 10 MiB limit adds about 13.3 MiB to each response and remains in browser memory even though the dashboard displays only the short marker.

Strip content_b64 from the dashboard wire representation during both normal ingestion and raw rehydration. Add a web API test showing that filename and the marker remain visible while content_b64 is absent.

  1. Apply duplicate suppression to file uploads (telegram/src/headlong_telegram/outbound.py:81)

The file branch returns before the existing RecentPosts check at line 121. Two identical file steps therefore produce two Telegram uploads. I reproduced this with two steps containing the same filename and bytes.

Build a bounded file signature, such as the filename plus a hash of the decoded bytes, and pass it through duplicate suppression before uploading. Test that identical files are suppressed while different contents with the same filename are both delivered.

Other comments

  1. Nit: Remove blank lines at end of the new Python tests (telegram/tests/test_filepayload.py:111)

git diff --check reports extra blank lines at the ends of test_filepayload.py and test_outbound.py.

Nick's latest laude-institute#84 review:
- chat send-file keeps a text file's body in content so Slack/web still
  deliver it; binary files keep the short marker plus content_b64
- dashboard ingest and window rehydrate drop content_b64 from the wire form
- Telegram file uploads go through RecentPosts via filename + content hash
- drop extra blank lines at EOF in the new Python tests
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants