Daily Bluesky bot for AT Mot. Invites players and congratulates yesterday's solvers.
1/** A Bluesky richtext link facet (UTF-8 byte offsets, per the lexicon). */
2export interface FacetLink {
3 index: { byteStart: number; byteEnd: number };
4 features: Array<{ $type: 'app.bsky.richtext.facet#link'; uri: string }>;
5}
6
7/** A Bluesky richtext hashtag facet. The byte range covers the leading `#`; `tag` omits it. */
8export interface FacetTag {
9 index: { byteStart: number; byteEnd: number };
10 features: Array<{ $type: 'app.bsky.richtext.facet#tag'; tag: string }>;
11}
12
13export type Facet = FacetLink | FacetTag;
14
15const encoder = new TextEncoder();
16
17const byteLen = (s: string): number => encoder.encode(s).length;
18
19/** Facet over the first occurrence of `url` in `text`; [] if it's not there. */
20export function linkFacet(text: string, url: string): FacetLink[] {
21 const charIndex = text.indexOf(url);
22 if (charIndex === -1) return [];
23 const byteStart = byteLen(text.slice(0, charIndex));
24 const byteEnd = byteStart + byteLen(url);
25 return [{ index: { byteStart, byteEnd }, features: [{ $type: 'app.bsky.richtext.facet#link', uri: url }] }];
26}
27
28/**
29 * A hashtag at a word boundary: `#` then a letter then word chars. Requiring a
30 * leading letter means an all-digit token like the puzzle "#3" is not treated as
31 * a tag (matching Bluesky, which rejects all-numeric tags).
32 */
33const TAG_RE = /(^|\s)(#[A-Za-z][A-Za-z0-9_]*)/g;
34
35/** One `#tag` facet per hashtag in `text` (UTF-8 byte offsets, in text order). */
36export function tagFacets(text: string): FacetTag[] {
37 const out: FacetTag[] = [];
38 for (const m of text.matchAll(TAG_RE)) {
39 const lead = m[1] ?? '';
40 const hashtag = m[2]!; // e.g. "#WordGame"
41 const start = m.index! + lead.length; // char index of the '#'
42 const byteStart = byteLen(text.slice(0, start));
43 const byteEnd = byteStart + byteLen(hashtag);
44 out.push({
45 index: { byteStart, byteEnd },
46 features: [{ $type: 'app.bsky.richtext.facet#tag', tag: hashtag.slice(1) }],
47 });
48 }
49 return out;
50}