Building a social inbox: comments, likes, DMs and updates¶
An app that shows somebody their latest post, the replies underneath it, who liked it, and their direct messages - and lets them answer all three from one screen, without reloading a page to find out what happened while they were away.
New to socialchimp? Read the tutorial first. This page
assumes you know what a connection and an Account are.
As of 0.8.0 this works on Mastodon and Bluesky. Everywhere else,
account.features() says so plainly rather than the calls below quietly
doing nothing - see Hiding a button a network does not
support.
- Showing a post with its comments
- Replying
- Liking, and who liked
- Polling for updates with a stored marker
- Direct messages
- Hiding a button a network does not support
- Handling the new errors
- Testing with
FakePlatform - Per-network notes
Showing a post with its comments¶
account.read_post(post_id) reads one post back in full - not only what
publishing it returned, but the author, the text, any attachments, and the
counts the network keeps:
To show it together with the replies underneath it, ask for the thread
instead. Thread.replies comes back flat, oldest first - build the tree
yourself by matching parent_id to PostDetails.id:
from socialchimp import PostDetails
thread = await account.read_thread(post_id)
by_parent: dict[str | None, list[PostDetails]] = {}
for reply in thread.replies:
by_parent.setdefault(reply.parent_id, []).append(reply)
def render(post_id: str, depth: int = 0) -> None:
for reply in by_parent.get(post_id, []):
print(" " * depth + reply.text)
render(reply.id, depth + 1)
print(thread.post.text)
render(thread.post.id)
thread.complete is False when depth, limit, or the network's own cap
cut the replies short - Mastodon has no pagination here, so a very long
thread is the one case this can happen without you asking for a limit
yourself. A deleted or blocked reply on Bluesky comes back as a placeholder
with unavailable set, rather than vanishing and leaving a gap in the
numbering.
post.html is Mastodon's own HTML for the post, and it is untrusted -
sanitise it yourself before putting it on a page. post.text is already
plain text, safe to show as-is.
Replying¶
This is the call to reach for once a network offers it, rather than
account.post(Post(text=..., reply_to=post_id)) - the older call still
works, but reply() knows things it does not: on Mastodon it keeps a reply
no wider than its parent's own visibility, and it mentions the people
already in the thread the way Mastodon's own web app does.
A reply to a direct or followers-only Mastodon post keeps that
visibility, whatever you ask for. Otherwise nothing is sent at all, so the
account's own default visibility applies - options={"visibility":
"unlisted"} only narrows a reply, it never widens one past its parent:
# The parent is public. Nothing forces "public" here - the account's own
# default applies, same as an ordinary post.
await account.reply(public_post_id, "Good question!")
# The parent is a DM. This still comes out as "direct", not "unlisted".
await account.reply(direct_post_id, "Sent!", options={"visibility": "unlisted"})
Attach media the same way post() does:
from socialchimp import Media
await account.reply(post_id, "Here's a photo", media=(Media.from_file("cat.jpg"),))
Liking, and who liked¶
Both are idempotent - liking something twice, or unliking something not
liked, succeeds and does nothing, so a double-tap or a retried request never
raises. Pass like_id back to unlike() when you have it; on Bluesky that
saves a lookup, and on Mastodon it is simply ignored.
page = await account.read_likes(post_id)
for like in page.items:
print(like.person.display_name, like.liked_at)
Like.liked_at is None on Mastodon - the server does not say when a
favourite happened, only who made it - and filled in on Bluesky. Read the
next page the same way every other paged call here works:
page = await account.read_likes(post_id)
while page.next is not None:
page = await account.read_likes(post_id, after=page.next)
Polling for updates with a stored marker¶
account.fetch_updates_after is the resumable way to ask "what happened
since I last checked" - unlike fetch_updates(since), which takes a moment
in time and can miss or repeat things at the edges, this takes an opaque
marker your app stores and hands back:
marker = await load_marker(connection_id) # None the first time
batch = await account.fetch_updates_after(marker)
for update in batch.updates:
handle(update)
await save_marker(connection_id, batch.marker)
if batch.more:
# There is more waiting right now - ask again without waiting for the
# next tick of your poll loop.
...
marker=None on the first call reads the latest page, which is what sets
the starting point - it does not replay a network's whole history. Once you
have a marker, tell the network you have handled it:
Store the marker exactly as it comes back, and pass it back exactly as
given. A marker your app made up, or one saved against the wrong
connection, raises ConfigError rather than silently starting over -
starting over could skip updates, which this design refuses to risk. Pass
None if you have genuinely lost track; that is the only safe way to
restart.
Update carries more than it used to: actor (who did it), post_id (the
reply, mention or message itself), about_post_id (your own post this
concerns), thread_root_id, and conversation_id for a direct message:
from socialchimp import UpdateKind
for update in batch.updates:
match update.kind:
case UpdateKind.COMMENT_CREATED:
notify(f"{update.actor.display_name} replied: see {update.post_id}")
case UpdateKind.REACTION_ADDED:
notify(f"{update.actor.display_name} liked {update.about_post_id}")
case UpdateKind.REPOST_ADDED:
notify(f"{update.actor.display_name} reposted {update.about_post_id}")
case UpdateKind.MESSAGE_RECEIVED:
notify(f"New message in {update.conversation_id}")
case UpdateKind.FOLLOWED:
notify(f"{update.actor.display_name} followed you")
Direct messages¶
conversations = await account.read_conversations()
for conversation in conversations.items:
who = ", ".join(
person.display_name or person.handle or "?" for person in conversation.people
)
print(who, conversation.unread_count)
Reading and answering one:
messages = await account.read_messages(conversation_id) # newest first
for message in messages.items:
print(message.sender.handle, message.text)
await account.send_message(conversation_id, "On my way!")
await account.mark_read(conversation_id)
Starting a new one, where the network allows it:
from socialchimp import Feature
if Feature.START_CONVERSATIONS in await account.features():
message = await account.start_conversation([other_person_id], "Hi there!")
conversation_id = message.conversation_id
Meta cannot do this - the customer has to write first - so Feature.MESSAGES
can be on while Feature.START_CONVERSATIONS is off. Check the flag rather
than assuming both come together.
Hiding a button a network does not support¶
account.features() is async and looks the connection up lazily, so it is
safe to call before deciding what to show:
from socialchimp import Feature
features = await account.features()
show_like_button = Feature.LIKE in features
show_dm_tab = Feature.MESSAGES in features
Deciding what to show before anyone has connected an account - to grey out a "reply from here" option in a network picker, say - use the client itself, by platform name, with no connection needed:
Calling one of these methods on a network that lacks the feature raises
NotSupportedError naming the network and what it cannot do, rather than
doing nothing or returning an empty result - check the flag first so your UI
never gets there.
Handling the new errors¶
Four error classes arrived with the social inbox - MissingPermissionError
and BlockedError (both a NotAllowedError), PostGoneError (a
NotFoundError), and ReplyWindowClosedError (reserved for Meta's 24-hour
reply window, in a later release). Mastodon and Bluesky raise the first
three today:
from socialchimp import BlockedError, MissingPermissionError, PostGoneError
try:
await account.reply(post_id, "Still here?")
except PostGoneError:
show("That post isn't there any more.")
except BlockedError:
show("You can't reply to that account.")
except MissingPermissionError as error:
show(f"Reconnect this account: {error.needs} is missing.")
MissingPermissionError.suggestion is set where there is something more
useful to say than "reconnect" - Bluesky's DM gap says a new app password
has to be made, because the permission cannot be added to an existing one:
try:
await account.read_conversations()
except MissingPermissionError as error:
print(error.needs, error.suggestion)
# "direct messages" "A new app password is needed ..."
An except NotAllowedError or except NotFoundError written before 0.8.0
already catches all four - nothing breaks, you only get to be more specific
if you want to be.
A fifth thing to catch is not a new class: an unrecognised
fetch_updates_after or mark_seen marker raises the existing
ConfigError - see Polling for updates with a stored
marker above.
Testing with FakePlatform¶
Every call above works against FakePlatform with nothing installed beyond
socialchimp itself - no pytest, no network, no credentials:
from socialchimp import Feature, InMemoryStorage, SocialChimp
from socialchimp.testing import FakePlatform
fake = FakePlatform(name="mastodon")
storage = InMemoryStorage()
sc = SocialChimp(storage=storage, platforms={"mastodon": fake})
connection = fake.connection()
await storage.save_connection(connection)
account = sc.account(connection.id)
Seed a post and a reply, then read the thread back exactly the way
read_thread would hand it to a real app:
root = fake.add_post(text="Cabinets are back in stock.")
fake.add_reply(root.id, text="Grabbing one today!")
thread = await account.read_thread(root.id)
assert len(thread.replies) == 1
Seed a like, an update, and a conversation the same way:
from socialchimp import Person, UpdateKind
fake.add_like(
root.id,
Person(
id="p1",
handle="rita@example.social",
display_name="Rita",
avatar_url=None,
url=None,
),
)
fake.add_update(UpdateKind.COMMENT_CREATED, about_post_id=root.id)
batch = await account.fetch_updates_after(None)
buyer = Person(
id="p2",
handle="buyer@example.social",
display_name="Buyer",
avatar_url=None,
url=None,
)
conversation = fake.add_conversation([buyer])
fake.add_message(conversation.id, buyer, "Is this still available?")
page = await account.read_messages(conversation.id)
assert page.items[0].text == "Is this still available?"
fetch_updates_after(None) on a fake behaves the same as a real network's
first call: it reads the latest page (page_size, or whatever limit you
pass), not the whole history you have ever seeded. And the same marker rule
applies:
from socialchimp import ConfigError
with pytest.raises(ConfigError):
await account.mark_seen("not-a-real-marker")
So a bug in your own marker storage shows up in a test rather than in production.
Give FakePlatform fixed features to test what your app does when one is
missing:
no_dms = FakePlatform(name="mastodon", features=Feature.POST_TEXT | Feature.READ_POST)
assert Feature.MESSAGES not in no_dms.features
Per-network notes¶
- Bluesky direct messages need an app password with "Allow access to your
direct messages" ticked. An app password made without it answers every
DM call with
MissingPermissionError(needs="direct messages"). The box cannot be turned on for an existing app password - a new one has to be made with it checked from the start. - Mastodon only has part of a conversation's history. Mastodon has no
"every message in this conversation" call, so
read_messagesreads the thread around the conversation's last message and keeps what belongs to it -Conversation.full_historyisFalseto say so, and there is no further page to ask for. - Mastodon has no
liked_at.Like.liked_atis alwaysNonethere; Bluesky fills it in. - A Bluesky quote's
about_post_idis the post it quotes, and it arrives asUpdateKind.MENTION- quoting is not replying, so it shares the "somebody is talking about you" shape a mention already has. - Push is not here yet. Both networks are polled with
fetch_updates_afterin 0.8.0; Mastodon Web Push and Meta's webhooks are planned together for a later release.
Elsewhere¶
- Networks - Mastodon and Bluesky's own pages, with the social inbox notes alongside everything else about them.
- Capability matrix - the same facts as a generated table.
- Errors - every error socialchimp raises, in full.
- Testing helpers - the full
FakePlatformreference.