Telegram Import
Import Telegram channel export (zip) with posts, images, files, and videos into Obsidian vault
Global Arguments
| Argument | Type | Description |
|---|---|---|
| zipPath | string | Path to Telegram channel export zip file |
| vault | string | Obsidian vault name |
| folder | string | Target folder in Obsidian vault for imported notes |
| attachmentsFolder | string | Attachments folder name inside the target folder |
| vaultRoot? | string | Absolute 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. |
Resources
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): addedassertSafeSlug, called as the first statement inside the note-createtry— rejects a slug containing a/or a..segment before it can reach the CLIcreate path=argument verbatim (the vaultRoot branch was already confined byresolveVaultPathSafe; 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 inerrors[], and the post resource is still written. Pinned with a non-vacuity test: a normal numeric-id slug still reachescreate 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. Wrapschannel,forwarded_from, and the notetitle— 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.
noteSlugthrowing on a non-stringdate) is recorded inerrors[]asSkipped message (id …): …and the loop moves on to the next message, instead of rejecting the wholeimportcall uncaught.writeResourcefor theresultsummary is still called only after the loop completes — unchanged — but now it is always reached. Pinned:totalMessagesstill 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 beforedata.name/data.messagesare trusted, throwing a clearInvalid Telegram export: …Error on failure.dataitself is left as whateverJSON.parsereturned (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 missingmessagesarray now throws a clear Error instead of an opaqueTypeError; a missingnamenow fails loudly instead of silently rendering the literal string"undefined"into every note'schannelfrontmatter line. - LB-6 (
findhad no timeout, exit code never checked, LOW):findis now run under anAbortControllerwith a 30s timeout (clearTimeoutin afinally, so no timer is ever leaked past the call), andfindOut.success/.codeare checked explicitly before reading stdout — a genuinely FAILING find now throws a distinctfind failed (exit N): <stderr>instead of the genericNo result.json foundan 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 withArray.from(text).slice(0, 500).join("")— truncation is by Unicode CODE POINT, never cutting a surrogate pair in half. Byte-identical to the oldsubstringbehavior 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.jsonsize, LOW): added aDeno.statsize check against a new internalMAX_RESULT_JSON_BYTES(50 MB) constant, run BEFOREDeno.readTextFile/JSON.parseever touch the file — an oversizedresult.jsonnow throws a clear "too large to import" Error instead of being read into memory and parsed regardless of size. - LB-9 (leading-dash
zipPathpositionally ambiguous, LOW): azipPathstarting with-is now normalized to a./-relative form (./${zipPath}) before being placed in unzip's argv — Info-ZIP'sunzipdoes 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
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
vaultRootglobal argument. When set, the vault path resolves to it directly (skipping theobsidian vault ... info=pathCLI call), and the note is written with a confined atomic write instead ofrunObsidian("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) fromobsidian-vault/extensions/models/obsidian_vault.ts(PR #56). The note write now resolves throughresolveVaultPathSafebefore everymkdir/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 betweenextractDir's realpath and the candidate is walked withDeno.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:yamldependency 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-Documentrationale does not apply here. Every hand-built frontmatter string stays byte-for-byte identical to before this change. - Dot-dir/
.trashexclusion is N/A:importwrites 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'screatecommand would have produced on disk — the CLI has never been observed to differ in this repo's tests (it's always stubbed), but a realobsidian createcall could in principle normalize a trailing newline differently than a rawDeno.writeTextFile. Not reproduced or fixed here, just flagged. - Extended all five test suites (contract-fixture, methods, adversarial,
coverage, property-invariant-flow) with
vaultRootcoverage: a golden fs-backend run againstfixtures/basic/result.json, a method-level test proving the CLI is never invoked whenvaultRootis set (Deno.Commandstubbed to throw on"obsidian"), a backend-selection precedence branch matrix, path-confinement adversarial tests (..traversal and symlinked folder segment refused,/var-vs-/private/varreal-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.tsgained two newStubConfigoptions (realMkdir,throwOnObsidian) so these new tests can letDeno.mkdirrun for real against a realDeno.makeTempDirvault while keepingDeno.Command/copyFilestubbed. No new committed fixture files were needed. manifest.yaml/modelversion:2026.08.01.1->2026.08.01.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.
Merge pull request #4 from umag/extensions/magistr-grade-a-workspace
extensions: stage 15 @magistr extensions as Grade A workspace dirs + wire CI
- 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