Skip to main content

Telegram Import

@magistr/telegram-importv2026.08.19.1· 23d agoMODELS
01README

Import Telegram channel export (zip) with posts, images, files, and videos into Obsidian vault

02Models1
@magistr/telegram/importv2026.08.19.1extensions/models/telegram_import.ts

Global Arguments

ArgumentTypeDescription
zipPathstringPath to Telegram channel export zip file
vaultstringObsidian vault name
folderstringTarget folder in Obsidian vault for imported notes
attachmentsFolderstringAttachments folder name inside the target folder
vaultRoot?stringAbsolute path to the Obsidian vault directory. When set, the note is written directly to disk (no Obsidian CLI, no desktop app needed) instead of resolving the vault path and creating the note through the Obsidian CLI.
fn import()
Parse Telegram export zip and import posts with images into Obsidian vault

Resources

result(infinite)— Import summary
post(infinite)— Individual imported post
03Previous Versions5
2026.08.02.1

2026.08.02.1

Real-fix for all eight remaining latent bugs (LB-2..LB-9), all tracked on the LOCAL telegram-import-latent-bugs issue-lifecycle model (never filed to the swamp.club Lab — see CLAUDE.md's anti-bypass rule). model.version and manifest.yaml both bump 2026.08.01.2 -> 2026.08.02.1. LB-0 and LB-1 (both already fixed) are untouched and stay green; every benign/golden/property assertion is byte-identical.

  • LB-2 (note-path traversal via msg.id, MEDIUM): added assertSafeSlug, called as the first statement inside the note-create try — rejects a slug containing a / or a .. segment before it can reach the CLI create path= argument verbatim (the vaultRoot branch was already confined by resolveVaultPathSafe; only the CLI branch had no check of its own). A rejection lands in the existing catch: the note for that message is skipped and recorded in errors[], and the post resource is still written. Pinned with a non-vacuity test: a normal numeric-id slug still reaches create path= unchanged.
  • LB-3 (YAML frontmatter injection, MEDIUM): added yamlDq, escaping backslash/quote/CR/LF/other control characters into a valid single-line YAML double-quoted scalar. Wraps channel, forwarded_from, and the note title — all three are interpolated straight from the Telegram export. An embedded " plus CR/LF can no longer break out of the frontmatter block to inject sibling YAML keys. Byte-identical to the previous bare interpolation for any input with no backslash/quote/control character (the common case; the golden-fixture and property suites, which never generate such input, are the guard).
  • LB-4 (one malformed message aborted the whole import, MEDIUM): the entire per-message loop body is now wrapped in its own try/catch. A failure (e.g. noteSlug throwing on a non-string date) is recorded in errors[] as Skipped message (id …): … and the loop moves on to the next message, instead of rejecting the whole import call uncaught. writeResource for the result summary is still called only after the loop completes — unchanged — but now it is always reached. Pinned: totalMessages still counts all messages (the failure does not zero the import), and the messages before AND after the failing one both get their post resource written.
  • LB-5 (unvalidated top-level export shape, MEDIUM): added ExportSchema (z.object({ name: z.string(), messages: z.array(z.unknown()) })), safeParse'd against the freshly-parsed JSON before data.name/ data.messages are trusted, throwing a clear Invalid Telegram export: … Error on failure. data itself is left as whatever JSON.parse returned (not replaced by the parsed/narrowed value) so every per-message field access downstream keeps its prior permissive typing — this is purely a fail-closed shape check, not a schema migration. A missing messages array now throws a clear Error instead of an opaque TypeError; a missing name now fails loudly instead of silently rendering the literal string "undefined" into every note's channel frontmatter line.
  • LB-6 (find had no timeout, exit code never checked, LOW): find is now run under an AbortController with a 30s timeout (clearTimeout in a finally, so no timer is ever leaked past the call), and findOut.success/ .code are checked explicitly before reading stdout — a genuinely FAILING find now throws a distinct find failed (exit N): <stderr> instead of the generic No result.json found an empty-but-successful find produces (both messages stay pinned, one per outcome).
  • LB-7 (text.substring(0, 500) could split a surrogate pair, LOW): post text is now truncated with Array.from(text).slice(0, 500).join("") — truncation is by Unicode CODE POINT, never cutting a surrogate pair in half. Byte-identical to the old substring behavior for any text with no astral-plane characters at the truncation boundary (the property suite's ASCII-only generator never exercises this, so it stays green unmodified).
  • LB-8 (unbounded result.json size, LOW): added a Deno.stat size check against a new internal MAX_RESULT_JSON_BYTES (50 MB) constant, run BEFORE Deno.readTextFile/JSON.parse ever touch the file — an oversized result.json now throws a clear "too large to import" Error instead of being read into memory and parsed regardless of size.
  • LB-9 (leading-dash zipPath positionally ambiguous, LOW): a zipPath starting with - is now normalized to a ./-relative form (./${zipPath}) before being placed in unzip's argv — Info-ZIP's unzip does not honor a -- end-of-options marker, so a real unzip binary would otherwise positionally misread a leading-dash path as a flag. The already-closed command-injecti
2026.08.01.2

2026.08.01.2

Adds an optional headless vaultRoot filesystem backend to import, so the import can run with the Obsidian desktop app closed (swamp-workspace #57; mirrors the CLI/filesystem backend split done for @magistr/obsidian-vault in PR #56 — see that PR for the path-confinement rationale). The Obsidian CLI (getVaultPath + runObsidian("create", ...)) is kept as the fallback for when vaultRoot is not set. Cross-reference: swamp-workspace#57.

  • Added the vaultRoot global argument. When set, the vault path resolves to it directly (skipping the obsidian vault ... info=path CLI call), and the note is written with a confined atomic write instead of runObsidian("create", ...).
  • Added resolveVaultPath/resolveVaultPathSafe (realpath + symlink refusal
    • .. rejection) and the atomic-write helpers (writeAtomic, ensureParentDir, chmodQuietly), copied VERBATIM (same names/comments, per the approved plan's scope constraint against a shared cross-extension module — swamp bundles each extension independently) from obsidian-vault/extensions/models/obsidian_vault.ts (PR #56). The note write now resolves through resolveVaultPathSafe before every mkdir/write, closing folder-traversal and symlink-escape vectors on the new headless path (the CLI fallback is untouched and keeps whatever behavior it already had).
  • Upgraded isPathContained/safeCopyMedia (LB-1's extractDir confinement) from lexical-only to realpath-aware: after the existing lexical containment check passes, every existing path segment between extractDir's realpath and the candidate is walked with Deno.lstat, refusing to follow a symlink — closing the "symlink created inside extractDir that points outside it" residual the 2026.08.01.1 CHANGELOG entry documented as accepted. A segment that does not exist yet is not an error, same as before.
  • No npm:yaml dependency was added — this model emits brand-new hand-built frontmatter into notes it owns, it never round-trips existing frontmatter, so PR #56's yaml-Document rationale does not apply here. Every hand-built frontmatter string stays byte-for-byte identical to before this change.
  • Dot-dir/.trash exclusion is N/A: import writes into a caller-named folder, it never walks the vault tree (covered by a covered-negative test in the adversarial suite).
  • Real-world behavior note: writing the note directly via Deno.writeTextFile (through the new atomic-write helper) may not be byte-identical to what the real Obsidian CLI's create command would have produced on disk — the CLI has never been observed to differ in this repo's tests (it's always stubbed), but a real obsidian create call could in principle normalize a trailing newline differently than a raw Deno.writeTextFile. Not reproduced or fixed here, just flagged.
  • Extended all five test suites (contract-fixture, methods, adversarial, coverage, property-invariant-flow) with vaultRoot coverage: a golden fs-backend run against fixtures/basic/result.json, a method-level test proving the CLI is never invoked when vaultRoot is set (Deno.Command stubbed to throw on "obsidian"), a backend-selection precedence branch matrix, path-confinement adversarial tests (.. traversal and symlinked folder segment refused, /var-vs-/private/var real-root containment), and a property test asserting exactly one note per message with frontmatter round-trip and no path escaping the vault's real root, for any synthetic set of messages with unique ids. telegram_import_test_helpers.ts gained two new StubConfig options (realMkdir, throwOnObsidian) so these new tests can let Deno.mkdir run for real against a real Deno.makeTempDir vault while keeping Deno.Command/copyFile stubbed. No new committed fixture files were needed.
  • manifest.yaml/model version: 2026.08.01.1 -> 2026.08.01.2.
2026.07.16.2

Release 2026.07.16.2 — align model versions with manifests

Maintenance release across the @magistr extensions. For most packages this carries no functional change: the only edit is the model's version: field, brought back in line with its manifest version so the published model type version and the package version no longer drift.

Functional changes in this release are limited to:

  • anime-cron: normalizeTitle now strips a ": subtitle" suffix and a trailing parenthesized year before comparison, fixing dedup false-misses where the torrent title carries a subtitle or year that the AniList romaji does not.

  • arckit: first publish. Standalone ArcKit port — a 12-phase architecture governance state machine with 65 bundled templates, driven by a bundled skill.

Also tracks three extensions (kaiten, observability-agent, music-library) that previously existed only as untracked working-tree directories, recovered from stashes.

2026.05.25.1

Merge pull request #4 from umag/extensions/magistr-grade-a-workspace

extensions: stage 15 @magistr extensions as Grade A workspace dirs + wire CI

2026.03.28.2
04Stats
A
100 / 100
Downloads
1
Archive size
21.8 KB
  • Has README or module doc2/2earned
  • README has a code example1/1earned
  • README is substantive1/1earned
  • Most symbols documented1/1earned
  • No slow types (deprecated)1/1earned
  • Dependencies pass trust audit2/2earned
  • Has description1/1earned
  • Platform support declared (or universal)2/2earned
  • License declared1/1earned
  • Verified public repository2/2earned
05Platforms
06Labels