···11# Changelog
2233+## [0.7.0] - 2025-10-19
44+55+### Added
66+77+**Bluesky-style rich text utilities** (`jacquard`)
88+- Rich text parsing with automatic facet detection (mentions, links, hashtags)
99+- Compatible with Bluesky, with the addition of support for markdown-style links (`[display](url)` syntax)
1010+- Embed candidate detection from URLs and at-URIs
1111+ - Record embeds (posts, lists, starter packs, feeds)
1212+ - External embeds with optional OpenGraph metadata fetching
1313+- Configurable embed domains for at-URI extraction (default: bsky.app, deer.social, blacksky.community, catsky.social)
1414+- Overlap detection and validation for facet byte ranges
1515+1616+**Moderation/labeling client utilities** (`jacquard`)
1717+- Trait-based content moderation with `Labeled` and `Moderateable` traits
1818+- Generic moderation decision making via `moderate()` and `moderate_all()`
1919+- User preference handling (`ModerationPrefs`) with global and per-labeler overrides
2020+- `ModerationIterExt` trait for filtering/mapping moderation over iterators
2121+- `Labeled` implementations for Bluesky types (PostView, ProfileView, ListView, Generator, Notification, etc.)
2222+- `Labeled` implementations for community lexicons (net.anisota, social.grain)
2323+- `fetch_labels()` and `fetch_labeled_record()` helpers for retrieving labels via XRPC
2424+- `fetch_labeler_defs()` and `fetch_labeler_defs_direct()` for fetching labeler definitions
2525+2626+**Subscription control** (`jacquard-common`)
2727+- `SubscriptionControlMessage` trait for dynamic subscription configuration
2828+- `SubscriptionController` for sending control messages to active WebSocket subscriptions
2929+- Enables runtime reconfiguration of subscriptions (e.g., Jetstream filtering)
3030+3131+**Lexicons** (`jacquard-api`)
3232+- teal.fm alpha lexicons for music sharing (fm.teal.alpha.*)
3333+ - Actor profiles with music service status
3434+ - Feed generation from play history
3535+ - Statistics endpoints (top artists, top releases, user stats)
3636+3737+**Examples**
3838+- Updated `create_post.rs` to demonstrate richtext parsing with automatic facet detection
3939+4040+341## [0.6.0] - 2025-10-18
442543### Added
···11-//! Moderation decision making for AT Protocol content
11+//! Moderation
22+//!
33+//! This is an attempt to semi-generalize the Bluesky moderation system. It avoids
44+//! depending on their lexicons as much as reasonably possible. This works via a
55+//! trait, [`Labeled`], which represents things that have labels for moderation
66+//! applied to them. This way the moderation application functions can operate
77+//! primarily via the trait, and are thus generic over lexicon types, and are
88+//! easy to use with your own types.
29//!
33-//! This module provides protocol-agnostic moderation logic for applying label-based
44-//! content filtering. It takes labels from various sources (labeler services, self-labels)
55-//! and user preferences to produce moderation decisions.
1010+//! For more complex types which might have labels applied to components,
1111+//! there is the [`Moderateable`] trait. A mostly complete implementation for
1212+//! `FeedViewPost` is available for reference. The trait method outputs a `Vec`
1313+//! of tuples, where the first element is a string tag and the second is the
1414+//! moderation decision for the tagged element. This lets application developers
1515+//! change behaviour based on what part of the content got a label. The functions
1616+//! mostly match Bluesky behaviour (respecting "!hide", and such) by default.
617//!
77-//! # Core Concepts
1818+//! I've taken the time to go through the generated API bindings and implement
1919+//! the [`Labeled`] trait for a number of types. It's a fairly easy trait to
2020+//! implement, just not really automatable.
821//!
99-//! - **Labels**: Metadata tags applied to content by labelers or authors (see [`Label`](jacquard_api::com_atproto::label::Label))
1010-//! - **Preferences**: User-configured responses to specific label values (hide, warn, ignore)
1111-//! - **Definitions**: Labeler-provided metadata about what labels mean and how they should be displayed
1212-//! - **Decisions**: The output of moderation logic indicating what actions to take
1322//!
1423//! # Example
1524//!
···2736//! ```
28372938mod decision;
3030-#[cfg(feature = "api_bluesky")]
3939+#[cfg(feature = "api")]
3140mod fetch;
3241mod labeled;
3342mod moderatable;
···3746mod tests;
38473948pub use decision::{ModerationIterExt, moderate, moderate_all};
4949+#[cfg(feature = "api")]
5050+pub use fetch::{fetch_labeled_record, fetch_labels};
4051#[cfg(feature = "api_bluesky")]
4152pub use fetch::{fetch_labeler_defs, fetch_labeler_defs_direct};
4242-pub use labeled::Labeled;
5353+pub use labeled::{Labeled, LabeledRecord};
4354pub use moderatable::Moderateable;
4455pub use types::{
4556 Blur, LabelCause, LabelPref, LabelTarget, LabelerDefs, ModerationDecision, ModerationPrefs,
+92-15
crates/jacquard/src/moderation/fetch.rs
···11use super::LabelerDefs;
22-use crate::client::AgentSessionExt;
33-use jacquard_api::app_bsky::labeler::get_services::{GetServices, GetServicesOutput};
44-use jacquard_api::app_bsky::labeler::service::Service;
55-use jacquard_common::IntoStatic;
66-use jacquard_common::error::ClientError;
22+use crate::client::{AgentError, AgentSessionExt, CollectionErr, CollectionOutput};
33+use crate::moderation::labeled::LabeledRecord;
44+55+#[cfg(feature = "api_bluesky")]
66+use jacquard_api::app_bsky::labeler::{
77+ get_services::{GetServices, GetServicesOutput},
88+ service::Service,
99+};
1010+use jacquard_api::com_atproto::label::{Label, query_labels::QueryLabels};
1111+use jacquard_common::cowstr::ToCowStr;
1212+use jacquard_common::error::{ClientError, TransportError};
1313+use jacquard_common::types::collection::Collection;
714use jacquard_common::types::string::Did;
88-use jacquard_common::xrpc::{XrpcClient, XrpcError};
1515+use jacquard_common::types::uri::RecordUri;
1616+use jacquard_common::xrpc::{XrpcClient, XrpcError, XrpcResp};
1717+use jacquard_common::{CowStr, IntoStatic};
1818+use std::convert::From;
9191020/// Fetch labeler definitions from Bluesky's AppView (or a compatible one)
2121+#[cfg(feature = "api_bluesky")]
1122pub async fn fetch_labeler_defs(
1223 client: &(impl XrpcClient + Sync),
1324 dids: Vec<Did<'_>>,
···2031 let response = client.send(request).await?;
2132 let output: GetServicesOutput<'static> = response.into_output().map_err(|e| match e {
2233 XrpcError::Auth(auth) => ClientError::Auth(auth),
2323- XrpcError::Generic(g) => ClientError::Transport(
2424- jacquard_common::error::TransportError::Other(g.to_string().into()),
2525- ),
3434+ XrpcError::Generic(g) => {
3535+ ClientError::Transport(TransportError::Other(g.to_string().into()))
3636+ }
2637 XrpcError::Decode(e) => ClientError::Decode(e),
2727- XrpcError::Xrpc(typed) => ClientError::Transport(
2828- jacquard_common::error::TransportError::Other(format!("{:?}", typed).into()),
2929- ),
3838+ XrpcError::Xrpc(typed) => {
3939+ ClientError::Transport(TransportError::Other(format!("{:?}", typed).into()))
4040+ }
3041 })?;
31423243 let mut defs = LabelerDefs::new();
···6172/// This fetches the `app.bsky.labeler.service` record directly from the PDS where
6273/// the labeler is hosted.
6374///
7575+/// This is much less efficient for the client than querying the AppView, but has
7676+/// the virtue of working without the Bluesky AppView or a compatible one. Other
7777+/// alternatives include querying <https://ufos.microcosm.blue> for definitions
7878+/// created relatively recently, or doing your own scraping and indexing beforehand.
7979+///
8080+#[cfg(feature = "api_bluesky")]
6481pub async fn fetch_labeler_defs_direct(
6582 client: &(impl AgentSessionExt + Sync),
6683 dids: Vec<Did<'_>>,
···7390 for did in dids {
7491 let uri = format!("at://{}/app.bsky.labeler.service/self", did.as_str());
7592 let record_uri = Service::uri(uri).map_err(|e| {
7676- ClientError::Transport(jacquard_common::error::TransportError::Other(
7777- format!("Invalid URI: {}", e).into(),
7878- ))
9393+ ClientError::Transport(TransportError::Other(format!("Invalid URI: {}", e).into()))
7994 })?;
80958196 let output = client.fetch_record(&record_uri).await?;
···8810389104 Ok(defs)
90105}
106106+107107+/// Convenient wrapper for com.atproto.label.queryLabels
108108+///
109109+/// Avoids depending on the Bluesky namespace, though it may call out to the
110110+/// Bluesky AppView (or a compatible one configured via atproto-proxy header).
111111+///
112112+/// Fetches labels directly for a given set of URI patterns.
113113+/// This one defaults to the max number, assuming that you will be fetching
114114+/// in bulk. This is not especially efficient and mostly exists as a demonstration.
115115+///
116116+/// In practice if you are running an app server, you should call [`subscribeLabels`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-api/src/com_atproto/label/subscribe_labels.rs)
117117+/// on labelers to tail their output, and index them alongside the data your app cares about.
118118+pub async fn fetch_labels(
119119+ client: &impl AgentSessionExt,
120120+ uri_patterns: Vec<CowStr<'_>>,
121121+ sources: Vec<Did<'_>>,
122122+ cursor: Option<CowStr<'_>>,
123123+) -> Result<(Vec<Label<'static>>, Option<CowStr<'static>>), AgentError> {
124124+ #[cfg(feature = "tracing")]
125125+ let _span = tracing::debug_span!("fetch_labels", count = sources.len()).entered();
126126+127127+ let request = QueryLabels::new()
128128+ .maybe_cursor(cursor)
129129+ .limit(250)
130130+ .uri_patterns(uri_patterns)
131131+ .sources(sources)
132132+ .build();
133133+ let labels = client
134134+ .send(request)
135135+ .await?
136136+ .into_output()
137137+ .map_err(|e| match e {
138138+ XrpcError::Generic(e) => AgentError::Generic(e),
139139+ _ => unimplemented!(), // We know the error at this point is always GenericXrpcError
140140+ })?;
141141+ Ok((labels.labels, labels.cursor))
142142+}
143143+144144+/// Minimal helper to fetch a URI and any labels.
145145+///
146146+/// This is *extremely* inefficient and should not be used except in experimentation.
147147+/// It primarily exists as a demonstration that you can hydrate labels without
148148+/// using any Bluesky appview methods.
149149+///
150150+/// In practice if you are running an app server, you should call [`subscribeLabels`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-api/src/com_atproto/label/subscribe_labels.rs)
151151+/// on labelers to tail their output, and index them alongside the data your app cares about.
152152+pub async fn fetch_labeled_record<R>(
153153+ client: &impl AgentSessionExt,
154154+ record_uri: &RecordUri<'_, R>,
155155+ sources: Vec<Did<'_>>,
156156+) -> Result<LabeledRecord<'static, R>, AgentError>
157157+where
158158+ R: Collection + From<CollectionOutput<'static, R>>,
159159+ for<'a> CollectionOutput<'a, R>: IntoStatic<Output = CollectionOutput<'static, R>>,
160160+ for<'a> CollectionErr<'a, R>: IntoStatic<Output = CollectionErr<'static, R>>,
161161+{
162162+ let record: R = client.fetch_record(record_uri).await?.into();
163163+ let (labels, _) =
164164+ fetch_labels(client, vec![record_uri.as_uri().to_cowstr()], sources, None).await?;
165165+166166+ Ok(LabeledRecord { record, labels })
167167+}
+16
crates/jacquard/src/moderation/labeled.rs
···1414 }
1515}
16161717+/// Record with applied labels
1818+///
1919+/// Exists as a bare minimum RecordView type primarily for testing/demonstration.
2020+pub struct LabeledRecord<'a, C> {
2121+ /// The record we grabbed labels for
2222+ pub record: C,
2323+ /// The labels applied to the record
2424+ pub labels: Vec<Label<'a>>,
2525+}
2626+2727+impl<'a, C> Labeled<'a> for LabeledRecord<'a, C> {
2828+ fn labels(&self) -> &[Label<'a>] {
2929+ &self.labels
3030+ }
3131+}
3232+1733// Implementations for common Bluesky types
1834#[cfg(feature = "api_bluesky")]
1935mod bluesky_impls {
+47-56
crates/jacquard/src/richtext.rs
···5566#[cfg(feature = "api_bluesky")]
77use crate::api::app_bsky::richtext::facet::Facet;
88+#[cfg(feature = "api_bluesky")]
99+use crate::api::com_atproto::repo::strong_ref::StrongRef;
810use crate::common::CowStr;
1111+#[cfg(feature = "api_bluesky")]
1212+use crate::types::aturi::AtUri;
913use jacquard_common::IntoStatic;
1414+#[cfg(feature = "api_bluesky")]
1515+use jacquard_common::http_client::HttpClient;
1016use jacquard_common::types::did::{DID_REGEX, Did};
1117use jacquard_common::types::handle::HANDLE_REGEX;
1818+use jacquard_common::types::string::AtStrError;
1919+use jacquard_common::types::uri::UriParseError;
2020+use jacquard_identity::resolver::IdentityError;
2121+#[cfg(feature = "api_bluesky")]
2222+use jacquard_identity::resolver::IdentityResolver;
1223use regex::Regex;
1324use std::marker::PhantomData;
1425use std::ops::Range;
···101112 /// Bluesky record (post, list, starterpack, feed)
102113 Record {
103114 /// The at:// URI identifying the record
104104- at_uri: crate::types::aturi::AtUri<'a>,
115115+ at_uri: AtUri<'a>,
105116 /// Strong reference (repo + CID) if resolved
106106- strong_ref: Option<crate::api::com_atproto::repo::strong_ref::StrongRef<'a>>,
117117+ strong_ref: Option<StrongRef<'a>>,
107118 },
108119 /// External link embed
109120 External {
···221232222233/// Entry point for parsing text with automatic facet detection
223234///
224224-/// Uses default embed domains (bsky.app, deer.social) for at-URI extraction.
235235+/// Uses default embed domains (bsky.app, deer.social, blacksky.community, catsky.social) for at-URI extraction.
225236/// For custom domains, use [`parse_with_domains`].
226237pub fn parse(text: impl AsRef<str>) -> RichTextBuilder<Unresolved> {
227238 #[cfg(feature = "api_bluesky")]
···230241 }
231242 #[cfg(not(feature = "api_bluesky"))]
232243 {
233233- parse_with_domains(text, &[])
244244+ parse_with_domains(text)
234245 }
235246}
236247237248/// Parse text with custom embed domains for at-URI extraction
238249///
239239-/// This allows specifying additional domains (beyond bsky.app and deer.social)
250250+/// This allows specifying additional domains (beyond the defaults)
240251/// that use the same URL patterns for records (e.g., /profile/{actor}/post/{rkey}).
241252#[cfg(feature = "api_bluesky")]
242253pub fn parse_with_domains(
···300311301312/// Parse text without embed detection (no api_bluesky feature)
302313#[cfg(not(feature = "api_bluesky"))]
303303-pub fn parse_with_domains(
304304- text: impl AsRef<str>,
305305- _embed_domains: &[&str],
306306-) -> RichTextBuilder<Unresolved> {
314314+pub fn parse_with_domains(text: impl AsRef<str>) -> RichTextBuilder<Unresolved> {
307315 // Step 0: Sanitize text (remove invisible chars, normalize newlines)
308316 let text = sanitize_text(text.as_ref());
309317···378386 }
379387380388 /// Add a mention facet with a resolved DID (requires explicit range)
381381- pub fn mention(mut self, did: &crate::types::did::Did<'_>, range: Range<usize>) -> Self {
389389+ pub fn mention(mut self, did: &Did<'_>, range: Range<usize>) -> Self {
382390 self.facet_candidates.push(FacetCandidate::Mention {
383391 range,
384392 did: Some(did.clone().into_static()),
···424432 /// Add a record embed candidate
425433 pub fn embed_record(
426434 mut self,
427427- at_uri: crate::types::aturi::AtUri<'static>,
428428- strong_ref: Option<crate::api::com_atproto::repo::strong_ref::StrongRef<'static>>,
435435+ at_uri: AtUri<'static>,
436436+ strong_ref: Option<StrongRef<'static>>,
429437 ) -> Self {
430438 self.embed_candidates
431439 .get_or_insert_with(Vec::new)
···607615/// Classifies a URL or at-URI as an embed candidate
608616#[cfg(feature = "api_bluesky")]
609617fn classify_embed(url: &str, embed_domains: &[&str]) -> Option<EmbedCandidate<'static>> {
610610- use crate::types::aturi::AtUri;
611611-612618 // Check if it's an at:// URI
613619 if url.starts_with("at://") {
614620 if let Ok(at_uri) = AtUri::new(url) {
···650656///
651657/// Only works for domains in the provided `embed_domains` list.
652658#[cfg(feature = "api_bluesky")]
653653-fn extract_at_uri_from_url(
654654- url: &str,
655655- embed_domains: &[&str],
656656-) -> Option<crate::types::aturi::AtUri<'static>> {
657657- use crate::types::aturi::AtUri;
658658-659659+fn extract_at_uri_from_url(url: &str, embed_domains: &[&str]) -> Option<AtUri<'static>> {
659660 // Parse URL
660661 let url_parsed = url::Url::parse(url).ok()?;
661662···693694 AtUri::new(&at_uri_str).ok().map(|u| u.into_static())
694695}
695696696696-use jacquard_common::types::string::AtStrError;
697697-use thiserror::Error;
698698-699697/// Errors that can occur during richtext building
700700-#[derive(Debug, Error)]
698698+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
701699pub enum RichTextError {
702700 /// Handle found that needs resolution but no resolver provided
703701 #[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")]
···709707710708 /// Identity resolution failed
711709 #[error("Failed to resolve identity")]
712712- IdentityResolution(#[from] jacquard_identity::resolver::IdentityError),
710710+ IdentityResolution(#[from] IdentityError),
713711714712 /// Invalid byte range
715713 #[error("Invalid byte range {start}..{end} for text of length {text_len}")]
···728726729727 /// Invalid URI
730728 #[error("Invalid URI")]
731731- Uri(#[from] jacquard_common::types::uri::UriParseError),
729729+ Uri(#[from] UriParseError),
732730}
733731734732#[cfg(feature = "api_bluesky")]
···758756 let text_len = self.text.len();
759757760758 for candidate in candidates {
761761- use crate::api::app_bsky::richtext::facet::{ByteSlice, Facet};
759759+ use crate::api::app_bsky::richtext::facet::{
760760+ ByteSlice, FacetFeaturesItem, Link, Mention, Tag,
761761+ };
762762+ use crate::types::uri::Uri;
762763763764 let (range, feature) = match candidate {
764765 FacetCandidate::MarkdownLink { display_range, url } => {
765766 // MarkdownLink stores URL directly, use display_range for index
766767767767- let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Link(
768768- Box::new(crate::api::app_bsky::richtext::facet::Link {
769769- uri: crate::types::uri::Uri::new_owned(&url)?,
770770- extra_data: BTreeMap::new(),
771771- }),
772772- );
768768+ let feature = FacetFeaturesItem::Link(Box::new(Link {
769769+ uri: Uri::new_owned(&url)?,
770770+ extra_data: BTreeMap::new(),
771771+ }));
773772 (display_range, feature)
774773 }
775774 FacetCandidate::Mention { range, did } => {
···784783 RichTextError::HandleNeedsResolution(handle.to_string())
785784 })?;
786785787787- let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Mention(
788788- Box::new(crate::api::app_bsky::richtext::facet::Mention {
789789- did,
790790- extra_data: BTreeMap::new(),
791791- }),
792792- );
786786+ let feature = FacetFeaturesItem::Mention(Box::new(Mention {
787787+ did,
788788+ extra_data: BTreeMap::new(),
789789+ }));
793790 (range, feature)
794791 }
795792 FacetCandidate::Link { range } => {
···809806 url = format!("https://{}", url);
810807 }
811808812812- let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Link(
813813- Box::new(crate::api::app_bsky::richtext::facet::Link {
814814- uri: crate::types::uri::Uri::new_owned(&url)?,
815815- extra_data: BTreeMap::new(),
816816- }),
817817- );
809809+ let feature = FacetFeaturesItem::Link(Box::new(Link {
810810+ uri: Uri::new_owned(&url)?,
811811+ extra_data: BTreeMap::new(),
812812+ }));
818813 (range, feature)
819814 }
820815 FacetCandidate::Tag { range } => {
···835830 .trim_start_matches('#')
836831 .trim_start_matches('#');
837832838838- let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Tag(
839839- Box::new(crate::api::app_bsky::richtext::facet::Tag {
840840- tag: CowStr::from(tag.to_smolstr()),
841841- extra_data: BTreeMap::new(),
842842- }),
843843- );
833833+ let feature = FacetFeaturesItem::Tag(Box::new(Tag {
834834+ tag: CowStr::from(tag.to_smolstr()),
835835+ extra_data: BTreeMap::new(),
836836+ }));
844837 (range, feature)
845838 }
846839 };
···884877 /// Build richtext, resolving handles to DIDs using the provided resolver
885878 pub async fn build_async<R>(self, resolver: &R) -> Result<RichText<'static>, RichTextError>
886879 where
887887- R: jacquard_identity::resolver::IdentityResolver + Sync,
880880+ R: IdentityResolver + Sync,
888881 {
889882 use crate::api::app_bsky::richtext::facet::{
890883 ByteSlice, FacetFeaturesItem, Link, Mention, Tag,
···10401033 client: &C,
10411034 ) -> Result<(RichText<'static>, Option<Vec<EmbedCandidate<'static>>>), RichTextError>
10421035 where
10431043- C: jacquard_common::http_client::HttpClient
10441044- + jacquard_identity::resolver::IdentityResolver
10451045- + Sync,
10361036+ C: HttpClient + IdentityResolver + Sync,
10461037 {
10471038 // Extract embed candidates
10481039 let embed_candidates = self.embed_candidates.take().unwrap_or_default();
···10961087 url: &str,
10971088) -> Result<Option<ExternalMetadata<'static>>, Box<dyn std::error::Error + Send + Sync>>
10981089where
10991099- C: jacquard_common::http_client::HttpClient,
10901090+ C: HttpClient,
11001091{
11011092 // Build HTTP GET request
11021093 let request = http::Request::builder()
+18-5
examples/create_post.rs
···44use jacquard::client::{Agent, AgentSessionExt, FileAuthStore};
55use jacquard::oauth::client::OAuthClient;
66use jacquard::oauth::loopback::LoopbackConfig;
77+use jacquard::richtext::RichText;
78use jacquard::types::string::Datetime;
89910#[derive(Parser, Debug)]
1010-#[command(author, version, about = "Create a simple post")]
1111+#[command(author, version, about = "Create a post with automatic facet detection")]
1112struct Args {
1213 /// Handle (e.g., alice.bsky.social), DID, or PDS URL
1314 input: CowStr<'static>,
14151515- /// Post text
1616+ /// Post text (can include @mentions, #hashtags, URLs, and [markdown](links))
1617 #[arg(short, long)]
1718 text: String,
1819···32333334 let agent: Agent<_> = Agent::from(session);
34353535- // Create a simple text post using the Agent convenience method
3636+ // Parse richtext with automatic facet detection
3737+ // This detects @mentions, #hashtags, URLs, and [markdown](links)
3838+ let richtext = RichText::parse(&args.text).build_async(&agent).await?;
3939+4040+ println!("Detected {} facets:", richtext.facets.as_ref().map(|f| f.len()).unwrap_or(0));
4141+ if let Some(facets) = &richtext.facets {
4242+ for facet in facets {
4343+ let text_slice = &richtext.text[facet.index.byte_start as usize..facet.index.byte_end as usize];
4444+ println!(" - \"{}\" ({:?})", text_slice, facet.features);
4545+ }
4646+ }
4747+4848+ // Create post with parsed facets
3649 let post = Post {
3737- text: CowStr::from(args.text),
5050+ text: richtext.text,
5151+ facets: richtext.facets,
3852 created_at: Datetime::now(),
3953 embed: None,
4054 entities: None,
4141- facets: None,
4255 labels: None,
4356 langs: None,
4457 reply: None,