Declarative thread sync for Slack, Discord, Bluesky, and GitHub (PRs/issues).
Two layers:
- CLI — draft threads/posts/PRs as local markdown, one session per doc in a private git repo mirrored to a secret gist, then sync or publish per platform:
thrds slack(a.k.a.slck),thrds github(a.k.a.ghpr),thrds discord,thrds bsky,thrds capture. - Library —
SlackClient/DiscordClient/BskyClienttake a desired thread state (list of message contents), diff against existing messages, and apply minimal edits/posts/deletes to converge.
A TypeScript port (Slack subset) lives on the
tsbranch, published on npm as@rdub/thrds; both sharetests/fixtures/sync.jsonas the cross-language contract for the sync algorithm.
pip install thrds # Core (zero deps); installs `thrds`, `slck`, `ghpr`
pip install thrds[bsky] # + Bluesky (atproto)Slack and Discord clients use only stdlib (urllib) and a curl subprocess respectively.
One session per .md file, living in <git-root-or-cwd>/<platform>/<slug>/ with its own private git repo and (by default) a secret gist mirror. Every state-mutating verb auto-commits and pushes to the gist, so the doc's iteration history is the artifact you keep.
Every platform group shares one sync model, borrowed wholesale from git: a remote-tracking ref per remote under refs/remotes/<name>, fetch to observe it, pull -m rebase|merge|overwrite to reconcile, and a push that refuses a non-fast-forward. See specs/remotes-model.md.
Each session's platform is stamped into thrds.yml at init and guarded on every verb — thrds slack push inside a capture session errors immediately rather than failing halfway.
Draft multi-thread Slack posts locally, sync to a staging private channel, promote each thread to its real prod destination. slck is the thrds slack group (same click group object), so every verb, flag, and help string is shared.
slck init draft.md # scaffold session dir + gist mirror
slck migrate # split the doc into per-thread NN-slug.md files
slck push [-r <remote>] # commit, then sync every thread to staging (refuses if it moved; -f)
slck pull # fetch + reconcile; local commits survive (-m rebase|merge|overwrite)
slck fetch [staging|prod]... # record Slack's state in staging/prod/upstream refs (default: all)
slck diff [<slug>] # local vs. Slack per thread; classified once fetched
slck status # per-thread state + resolved destination
slck promote cw-mpu # post ONE thread to its target (plan + confirm; re-promotes gated)
slck drop cw-summary # mark a thread abandoned without posting
slck reopen cw-mpu # un-finalize a posted/dropped thread
slck reorder # renumber thread files to a gapless 01..NN
slck archive # archive staging (once all threads are terminal)
slck list-sessions #foo # what thrds sessions exist in #foo
slck recover -i <sid> #foo # rebuild a lost session from Slack metadata
slck replay [-n] # rewrite a pre-migrate session's *git history* to per-thread filesOne file per thread. A session directory holds 01-slug.md, 02-slug.md, … — one Slack thread each (+++ separates replies within a thread), so per-file git history reads as "this message went v2→v3" rather than "the doc changed". migrate converts a whole-doc session's working tree; replay converts its git history too (verifying every rewritten commit round-trips before writing, to branch per-thread, never force-pushing). reorder renumbers files gaplessly — thrds.yml is keyed by slug, so targets and posted timestamps follow their thread, and git log --follow survives the rename.
Destination is a property of the thread, not the session. Each thread records its own {channel, thread_ts?} target in thrds.yml (session-level prod_channel is just the default). No thread_ts → post a new top-level message; with one → the thread's messages go in as replies — so "draft a reply to someone else's post" and "batch six messages into one channel" are the same mechanism.
Per-message sender in the doc. A message can post under a custom display name + avatar — the +++ as <name> / op_sender syntax shared with Discord. Name each profile once in frontmatter (sender.<name>.name / .avatar), reference it from the OP via op_sender: and from a reply on its +++ as <name> delimiter; push and promote post each message under its profile (Slack does this natively via chat.postMessage under one token, including the OP; Discord can carry an OP sender too, but only via its webhook transport — the OP is webhook-posted and the bot threads off it). Needs an xoxb- bot token — Slack ignores sender customization on user tokens. This is the versioned counterpart to the programmatic Msg(content, username=, icon_url=) API; a sender-free doc pushes exactly as before.
Staging-only chrome. Staged messages carry one extra line — where the draft is aimed, what it became once posted, and which gist file it is:
→ #oa-amazon-trainium · 01-mfu.md
→ (#marin-alerts) · 02-cw-summary.md # arrow links to the message being replied to
✅ #marin-alerts · 02-cw-summary.md # posted: condenses to our permalink
It's also an input: edit the line in Slack and the next pull acts on it —
| write | effect |
|---|---|
→ #some-channel |
retarget the thread |
→ <paste a channel link> |
same |
→ <paste a message link> |
aim it into that thread |
→ #chan · 04-idea.md |
rename/renumber the thread's file |
You can even start a thread by writing one in the staging channel: a top-level message with a chrome line is adopted on the next pull (file created, target recorded); one without stays a note to self. Chrome is configured in thrds.yml (staging_chrome), appended after md→mrkdwn conversion and stripped before the reverse, and promote refuses to post a body still carrying one — publishing a secret-gist URL to a real channel is worth failing closed over.
Finalizing. Once a thread is posted or dropped, its staged copy re-renders with chrome in a context block — Slack strips the Edit affordance from messages carrying blocks, so the staged copy visibly locks. reopen moves it back to draft (keeping posted_ts, so a later promote syncs the existing message rather than double-posting). finalize_terminal: false disables.
fetch gives diff a merge base. Slack keeps no version of itself, so fetch keeps one for it: refs/remotes/{staging,prod} plus merge base refs/heads/upstream, each pointing at a commit whose tree is what Slack last projected. That turns diff's two-way delta into a classification — changed in Slack (pull applies it), changed locally (push sends it), or CONFLICT (both moved) — distinctions a two-way diff structurally can't make. The refs are plain git: git show upstream, git diff upstream HEAD, git diff staging prod all mean what you'd hope. (pull -n is the raw dump of Slack's current state, no comparison.)
Prod push is per-thread, never whole-doc. promote resolves the destination, prints it with the exact body, asks, then posts only that thread. archive is its own verb, refusing until every thread is posted or dropped (-f overrides).
CRUD verbs for ad-hoc operations — a first-class alternative to hand-rolled chat.* heredocs. These default to raw mrkdwn (send verbatim); -m opts into md→mrkdwn conversion (the opposite of the session verbs' default — see raw-mrkdwn-passthrough):
thrds slack history #foo -n 10 # last 10 messages (ts, sender, text)
thrds slack thread #foo 1783.1 # OP + replies as a table (`-j` = JSON)
thrds slack rm #foo 1783.1 1783.2 # delete msg(s); `-f` = orphans_ok
thrds slack post #foo '*bold*' # raw mrkdwn (`-m` = convert md first)
thrds slack post #foo 'hi' -u 'Bot' -i https://cdn.example/a.png -t 1783.0
thrds slack edit #foo 1783.1 'new' # edit; raw by default
thrds slack permalink #foo 1783.1 # get workspace permalink URLImage blocks. A trailing standalone  line becomes a Block Kit image block after the message body — and because an image block's URL (unlike an attached file) can be edited, a job can converge the same OP with a new card URL and the image refreshes in place. If the bytes change under a stable URL, suffix {bust}: each post/edit appends a fresh ?thrds_bust=<minute> param so Slack's URL-keyed cache refetches, while a no-op converge never touches it. See specs/done/editable-image-blocks.md.
The CLI reads the Slack token from THRDS_SLACK_TOKEN (deprecated alias SLACK_THRDS_USER_TOKEN still works, with a warning).
- User token (
xoxp-…) — needed for the session verbs (init/push/pull/diff/archive/list-sessions/recover/open): staged posts are your Slack user's own, and only a token you own canchat.updatethem. - Bot token (
xoxb-…) — sufficient for the CRUD verbs and for programmaticsync()/sync_linked()when the bot owns the content lifecycle end-to-end.
Add scopes under OAuth & Permissions (User Token Scopes vs. Bot Token Scopes; names are identical):
| Scope | Needed for | Session verbs | CRUD / sync() |
|---|---|---|---|
chat:write |
Post / edit / delete messages | ✓ | ✓ |
groups:write |
Create + archive staging PCs | ✓ (init, push, archive) |
— |
groups:read |
Read + resolve #name for private channels |
✓ | ✓ |
channels:read |
Resolve #name for public channels |
✓ (if pushing/pulling public) | ✓ (public channels) |
users:read |
Resolve foreign-author names on pull |
✓ (pull) |
— |
emoji:read |
Download custom workspace emoji on pull |
✓ (pull) |
— |
chat:write.customize |
Per-message username / icon_* (bot token only — post errors under a user token) |
If used | If used |
reactions:read |
SenderChangePolicy pre-flight (library) |
— | If using aggressive-mode sync |
Metadata visibility is app-scoped, so recover needs no additional scope.
promote posts as you, and Slack marks your own messages read as it posts them — so a prod post is silent by default. Set THRDS_SLACK_BOT_TOKEN to a bot token and promote DMs you a link to the post it just made (from the app, which is what makes it a real notification). Setup: add bot scope chat:write + reinstall, and enable App Home → Show Tabs → Messages tab (no im:write needed — passing your U… id as the channel opens the DM implicitly). Unset → promote says so and carries on; DM failure warns but never fails the post it's reporting on.
"Clone" a GitHub PR or issue — title, description, comments, review threads — to local markdown files, edit them in your editor (full markdown, no textarea, no character limits), and push back, mirrored to a secret gist for history and sharing.
This was ghpr (PyPI: ghpr-py), a standalone tool that converged on the same sync model from the other direction; it now lives here as a platform adapter (thrds/platforms/github/ — its README has the full docs). Installing thrds installs ghpr, which is the thrds github group — same arrangement as slck, so every existing ghpr invocation (and the ghprc/ghprd/ghprp aliases) keeps working.
ghpr clone owner/repo#123 # or a PR/issue URL, or bare inside a repo on a PR branch
ghpr diff # local vs GitHub, with ownership warnings for others' comments
ghpr fetch # snapshot GitHub into `refs/remotes/github`; touches nothing else
ghpr pull [-m rebase|merge|overwrite]
ghpr push # send committed local state to GitHub + gist; refuses a non-FF
ghpr sync # pull, then push
ghpr show / ghpr open # print / browse the PR + gist URLs
ghpr review … # local ops on review threads (synced on next push)
ghpr init / ghpr create # draft a NEW PR/issue in gh/drafts/<slug>/, then file it
ghpr ingest-attachments # rewrite user-attachment URLs to durable gist permalinksClone layout. Clones live in gh/<number>/ (the platform's conventional short form — the dir isn't named for the tool): repo#123.md holds title + description, each comment is its own zNNNNNN-<author>.md, review threads get their own files. Each clone is a git repo with refs/remotes/github as GitHub's tracking ref — reflog and all — so git diff HEAD github is "what changed on GitHub", and fetch/pull/push mean exactly what they mean in git.
Drafts. A new*.md file posts as a new comment on the next push; review-thread reply drafts post only once committed, so you can stage several and publish selectively. ghpr init <slug> scaffolds a whole new PR/issue under gh/drafts/<slug>/ and ghpr create files it (the dir then becomes gh/<number>/).
Two delivery models. Paste (render/preview) posts as you — Discord bans user-token automation, so there's no "post as me" — while bot push (push/thread) posts as a bot. render prints the doc to stdout and auto-runs lint (flags tables and raw @name — constructs Discord's user-message renderer drops; masked [text](url) links do render):
thrds discord init draft.md # scaffold session dir + gist (no channel/bot)
thrds discord lint # just the MD-compat warnings
thrds discord render | pbcopy # MD → clipboard (warnings → stderr)
thrds discord open # browse the gist
thrds discord preview # Discord-faithful live preview + edit loop
thrds discord push -N "Title" # post as a bot: OP → channel, replies → thread
thrds discord thread # dump the pushed thread's messages (id + content)preview serves the doc at localhost:3077, rendered through Discord's real markdown semantics (a vendored, prebuilt bundle of the discord-agent parser/renderer — no node needed at install time; scripts/sync-preview-bundle refreshes it, thrds/preview/BUNDLE_PROVENANCE records the source commit). Edits in the page save back to the .md (mtime-guarded, conflict banner on races); -c commits each save, so UI iterations land in the gist trajectory like any other edit.
push posts the doc as a bot: the OP (before the first +++) goes to the channel and each reply into a thread opened off it (thread dumps that thread back). Re-pushing a session reconciles the live thread to the doc (edit changed messages, add new replies, delete removed ones); the one unsupported transition is growing a lone OP into a thread on re-push (re-init for that). Config comes from THRDS_DISCORD_BOT_TOKEN (never a flag) plus --channel/THRDS_DISCORD_CHANNEL and --guild/THRDS_DISCORD_GUILD; -n previews the plan with no token. A bot posts under one identity — for per-message sender (avatars/names, like the Slack digest's mosaic), give a reply a custom sender in the doc and push routes it through a webhook:
---
sender.alice.name: Alice
sender.alice.avatar: https://example.com/alice.png
---
Weekly digest.
+++ as alice
Alice shipped X.The bot posts the OP + opens the thread; each +++ as <name> reply posts through the webhook (DiscordWebhookClient) with that profile's name/avatar (sender.<name>.avatar takes a URL or a :emoji:). This needs THRDS_DISCORD_WEBHOOK (a secret — never a flag); the OP itself can't carry a custom sender (it anchors the thread as the bot). Programmatic callers build Thread([Msg(...)]) and hand it to DiscordHybridClient directly. See Platform capabilities, specs/doc-sender-syntax.md, and specs/discord-push.md.
Same shape, different lints: bsky's chief drafting pain is the 300-char post limit (per paragraph), so lint flags oversized paragraphs, and warns on masked links (bsky auto-linkifies bare URLs via facets; [text](url) renders literally):
thrds bsky init draft.md # scaffold session dir + gist
thrds bsky lint # length + link warnings
thrds bsky render | pbcopy # MD → clipboard
thrds bsky open # browse the gistCapture-only sessions: same on-disk shape (git repo + gist mirror), no platform target — for capturing the iteration history of a post you'll paste somewhere thrds doesn't (yet) integrate with. The minimal flow is two commands → a secret, one-file, two-revision gist:
dir=$(echo "$claude_draft" | thrds capture init) # secret gist, revision 1; session dir → stdout
echo "$final_msg" | thrds capture update "$dir" # revision 2 (unchanged content = no-op)init takes stdin (slug derived from the first line, or -s) or a DOC_PATH; update replaces the doc from stdin and pushes one revision (SESSION_DIR defaults to the CWD). For hand-editing sessions:
thrds capture init draft.md # scaffold session dir + gist mirror (no channel)
thrds capture push # commit doc changes and push to the gist
thrds capture open # browse the gistCapture gists hold only the doc: thrds.yml is a local, git-excluded file (and, like every fresh thrds.yml, holds only non-default fields — ~4 lines).
Every state-mutating verb auto-commits and pushes to the session's gist. THRDS_NO_PUSH=1 keeps the local commits and skips every push (announced on stderr, never silent) — useful when exercising a real session's code paths without writing to its gist, including from a copy of a session dir, which carries .git and its remotes along and so is not by itself an isolated sandbox.
What each transport can do. The columns are the four sync clients — Slack (SlackClient), Discord under a bot token (DiscordClient) vs. via a channel webhook (DiscordWebhookClient), and Bluesky (BskyClient). GitHub is a different model (see below the table).
| Slack | Discord (bot) | Discord (webhook) | Bluesky | |
|---|---|---|---|---|
| Post a message | ✅ | ✅ | ✅ | ✅ |
| Edit a message | ✅ | ✅ | ✅ | ❌ → delete+repost |
| Delete a message | ✅ | ✅ | ✅ (own) | ✅ |
| Read / list a thread (needed to reconcile) | ✅ | ✅ | ❌ (pair with a bot) | ✅ |
| Post with a custom sender (name + avatar) | ✅ | ❌ | ✅ | ❌ |
| Turn a message into a thread | auto¹ | ✅ (explicit) | ❌ (can't open one) | auto¹ |
| Reply into a thread | ✅ | ✅ | ✅ (?thread_id=) |
✅ |
| Edit a reply | ✅ | ✅ | ✅ | ❌ → delete+repost |
| Post a reply with a custom sender | ✅ | ❌ | ✅ | ❌ |
| Edit an existing message's sender | ❌ | ❌ | ❌ | ❌ |
| Attach an image / file | ✅² | ❌ | ✅² (upload) | ❌ |
| Refresh that image in place (on edit) | ✅² | ❌ | ✅² | ❌ |
| Custom emoji inline in text | ✅³ | ✅³ | ✅³ | ❌ |
| Per-message char limit | 4000 | 2000 | 2000 | 300 / paragraph |
¹ Slack and Bluesky thread implicitly: a reply addresses the OP's own id (Slack's thread_ts, Bluesky's reply refs), so any message is already a thread root — there is no separate "create thread" call. Discord's thread is a distinct channel that must be create_thread'd off the OP (a message id isn't a channel), which is why sync has the open_thread seam that only DiscordClient implements.
² Two different mechanisms, one authoring surface — a Msg(content, images=[Image(url=…, path=…, alt=…, bust=…)]), or a trailing  line in a doc, either way an Image (specs/unified-image-attachments.md). A picture in the message that updates in place, keeping the same message id (no repost). Slack references a hosted URL in a Block Kit image block and refreshes it by editing the block's URL; a {bust} forces a refetch when the bytes change under a stable URL (specs/done/editable-image-blocks.md). The Discord webhook uploads the bytes as a real multipart attachment and refreshes by re-uploading on edit; a url image it fetches first. A hosted URL is the cross-platform currency — it works on both. Local path bytes upload on Discord (webhook multipart) and on Slack too: Slack hosts them via its external-upload flow and references the file in the block by id (slack_file), content-addressed so a re-push uploads iff the bytes change (specs/slack-lazy-image-upload.md). The Discord bot API can attach files too, but only the webhook transport is wired (what the per-sender digests use). Bluesky's image embeds aren't wired.
³ Custom emoji live inline in the message text, a different slot from the sender avatar (which is a hosted image URL, not an emoji). The syntax differs: Slack references workspace emoji by name — :my_emoji:; Discord references them by <:name:id> (static) / <a:name:id> (animated), resolving to a guild emoji (uploaded to a server) or an application emoji (owned by the bot app — the reusable, Slack-workspace-like option). Who can render them differs by transport: the bot always can; a webhook only if it's app-owned (created by the bot via POST /channels/{id}/webhooks, so its application_id is the app's). A user-created webhook (the "Integrations → Webhooks" kind) silently strips <:name:id> to the bare text :name: on ingest — so app-emoji digests need an app-owned webhook, which discord push auto-creates from the bot token when no webhook is configured. discord push also rewrites :name: → <:name:id> for the app's own emoji (where it round-trips), so you author the readable :name: and it renders as the custom emoji (specs/done/discord-app-emoji.md). Bluesky has no custom emoji.
Reading the table:
- Editing a sender is impossible everywhere. The sender (display name + avatar) is fixed when a message is posted. Slack's
chat.updateand Discord's webhook edit both silently ignoreusername/avataron edit; the Discord bot API and Bluesky have no per-message sender at all. So a per-sender mosaic can be built incrementally but never re-attributed. - Custom senders: Slack does it under one bot token (
chat:write.customize). Discord's bot token is a single identity — per-message name/avatar needs the webhook transport. For a threaded per-contributor digest, pair the two: aDiscordClientopens the thread and owns reconcile, and aDiscordWebhookClientfans the per-sender replies into it via?thread_id=(DiscordClient.postraises on a sender override rather than silently dropping it, pointing you here). The OP can carry a sender too —DiscordHybridClientwebhook-posts it to the parent channel and the bot opens the thread off it (a bot cancreate_threadoff any channel message, webhook-authored included); its edits route back to the webhook with no?thread_id. Bluesky posts only as the logged-in account. - Reconcile (declarative edit-in-place) needs read + edit. Slack and Discord converge a live thread to the doc (edit changed messages, add/delete the rest). Bluesky has no edit API, so any content change degrades to delete+repost — losing reactions, permalinks, and position — and it can't reconcile a mixed-author thread.
GitHub (thrds github / ghpr) is deliberately not in the table: it edits issue/PR bodies and comments as the authenticated user — no bots, no per-message senders, no thread creation. Its "thread" is the issue or PR and its comment list, and its verbs are clone → edit locally → push, not a live declarative sync.
from thrds import SlackClient, Thread
slack = SlackClient(token="xoxb-...", channel="C0AQC2VKEJF")
thread = Thread(messages=["OP text", "Reply 1", "Reply 2"])
result = slack.sync(thread) # create new thread
result = slack.sync(thread, thread_ts="1775516040.743629") # converge existing onefrom thrds import DiscordClient, BskyClient
discord = DiscordClient(token="bot-token", channel_id="1489279547689140505")
result = discord.sync(thread, thread_id="1490821926288097503")
bsky = BskyClient(handle="you.bsky.social", password="app-password")
result = bsky.sync(thread) # no edit API: falls back to delete+repost on changePer-message senders on Discord need the webhook transport — a bot token is a single identity, so the webhook fans per-sender replies into a bot-opened thread (DiscordClient.post raises on a sender override rather than silently dropping it). DiscordHybridClient wraps the pair so one declarative sync does the whole threaded per-contributor digest. Each message is routed by whether it needs the webhook: a Msg carrying a sender or an attachment posts (and re-push-edits) through the webhook, bare replies stay on the bot. This applies to the OP too — a plain OP is the bot's identity, but an OP with a sender or a file is webhook-posted to the parent channel and the bot opens the thread off it:
from thrds import DiscordClient, DiscordHybridClient, DiscordWebhookClient, Image, Msg, Thread
bot = DiscordClient(token="bot-token", channel_id="1489279547689140505", guild_id="…")
hook = DiscordWebhookClient("https://discord.com/api/webhooks/…")
digest = DiscordHybridClient(bot, hook)
result = digest.sync(Thread(messages=[
# OP with a custom sender + an attached plot → webhook; bot threads off it.
Msg("GCS usage — August", username="GCS usage", icon_url="https://…/cal.png",
images=[Image(path="plot.png")]),
Msg("8/1 — 3,253 TB", username="8/1 — 3,253 TB", icon_url="https://…/up.png"),
Msg("8/2 — 3,252 TB", username="8/2 — 3,252 TB", icon_url="https://…/down.png"),
]), thread_name="GCS usage — August")
# Re-push reconciles: edits go back to the transport that authored each message
# (the webhook OP edits via the webhook, with no ?thread_id). A `Msg.images` change
# re-uploads the attachment whenever that message's text also changed.
digest.sync(updated_thread, thread_id=result.thread_id)Because sender is fixed at post time everywhere (see the matrix above), a reply that was bot-authored on an earlier push keeps that identity on re-push — only its content edits. The primitives are still usable directly if you want to drive the two transports yourself.
Given desired messages M and existing thread messages N:
- Delete extras from the end (backwards — replies before OP)
- Edit overlapping messages where content changed (skip unchanged)
- Post new messages at the end
Foreign (non-editable) messages — e.g. human replies in a bot thread — are skipped: sync only touches the bot's own messages.
Post summary bullets that link to detail messages in the same thread:
from thrds import LinkedThread, Section
linked = LinkedThread(
summary_prefix="# Daily Digest",
sections=[
Section(title="Topic A", summary="Brief summary", body="Full detail text..."),
Section(title="Topic B", summary="Another summary", body="More details..."),
],
)
result = discord.sync_linked(linked, thread_id="...", guild_id="...") # [**Title**](url)
result = slack.sync_linked(linked, thread_ts="...") # <url|*Title*>Two-phase: posts all messages with placeholder links, then edits summaries with real links once message IDs are known.
result = slack.sync(thread, thread_ts="...", dry_run=True)
print(result.format_preview(color=True, prefix="thread: "))thread: SKIP [0] (unchanged)
thread: EDIT [1]
thread: -old message text
thread: +new message text
thread: POST [2]
thread: +entirely new message
Each Action carries prior_content (for EDIT/DELETE) alongside content, enabling colored unified-diff output via action.format().
- Foreign message preservation: non-bot messages in threads are skipped during sync (no
cant_update_messageerrors) - Rate limit handling: Slack 429 retry with
Retry-After; configurablepace+jitter; Discord 30046 (edit limit on old messages) falls back to delete+repost - Orphan guard: Slack
delete()raisesOrphanedRepliesErrorrather than orphaning thread replies - Unfurl/embed suppression: Slack link previews and Discord embeds suppressed via options
- Editable image blocks (Slack): trailing
→ Block Kitimageblock whose URL swaps onchat.update;{bust}for cache-busting (see above) - Sender-override guard:
username/icon_*customization only works on bot tokens;postfails closed under a user (xoxp-) token instead of letting Slack silently drop the fields - Discord niceties: system messages filtered from
list_messages;Bottoken prefix auto-prepended - Metadata support: Slack message metadata passthrough
@dataclass
class SyncResult:
thread_id: str # thread_ts (Slack), thread channel ID (Discord), AT URI (Bluesky)
message_ids: list[str] # Per-message IDs
actions: list[Action] # What was done: Skip, Edit, Post, Delete
@dataclass
class Action:
type: ActionType # SKIP, EDIT, POST, DELETE
index: int
message_id: str | None
content: str | None # Desired text (POST, EDIT, SKIP)
prior_content: str | None # Previous text (EDIT, DELETE)SyncOptions |
Default | Description |
|---|---|---|
dry_run |
False |
Print actions without executing |
pace |
0.0 |
Seconds between mutating API calls |
jitter |
0.0 |
Random additional delay (0 to jitter) added to pace |
suppress_embeds |
False |
Discord: suppress link previews |
suppress_unfurls |
True |
Slack: suppress link previews |
- hudcostreets/nj-crashes — Slack crash-notification threads (
SlackClient.sync()) - Open-Athena/marin-discord — Discord summary threads (
DiscordClient.sync_linked())