Daily Bluesky bot for AT Mot. Invites players and congratulates yesterday's solvers.
0

Configure Feed

Select the types of activity you want to include in your feed.

fix: emit hashtag facets so #tags are clickable

Posts carried only a #link facet for the homepage URL, so the
#WordGame / #JeuDeMots / #atproto hashtags rendered as inert text
rather than real Bluesky tags.

Add tagFacets() to produce app.bsky.richtext.facet#tag facets (one per
hashtag; byte range over "#tag", tag value without the "#") and merge
them with the link facet, sorted by byte offset. The leading-letter
rule keeps the puzzle number "#N" from being mistaken for a tag.

Regression tests cover tag byte offsets (including multibyte) and that
"#3" is not tagged.

+106 -9
+8 -3
src/compose.ts
··· 1 1 import { ORIGIN, type Lang, type YesterdayCounts } from './config.js'; 2 - import { linkFacet, type FacetLink } from './facets.js'; 2 + import { linkFacet, tagFacets, type Facet } from './facets.js'; 3 3 4 4 export interface ComposeInput { 5 5 lang: Lang; ··· 10 10 11 11 export interface ComposedPost { 12 12 text: string; 13 - facets: FacetLink[]; 13 + facets: Facet[]; 14 14 langs: [Lang]; 15 15 } 16 16 ··· 74 74 const tags = lang === 'fr' ? '#JeuDeMots #atproto' : '#WordGame #atproto'; 75 75 76 76 const text = [inviteLine(lang, todayN), congrats, playLine, tags].filter(Boolean).join('\n'); 77 - return { text, facets: linkFacet(text, ORIGIN), langs: [lang] }; 77 + // Homepage link + a #tag facet per hashtag, sorted by position so the byte 78 + // ranges appear in document order. 79 + const facets = [...linkFacet(text, ORIGIN), ...tagFacets(text)].sort( 80 + (a, b) => a.index.byteStart - b.index.byteStart, 81 + ); 82 + return { text, facets, langs: [lang] }; 78 83 }
+36 -2
src/facets.ts
··· 4 4 features: Array<{ $type: 'app.bsky.richtext.facet#link'; uri: string }>; 5 5 } 6 6 7 + /** A Bluesky richtext hashtag facet. The byte range covers the leading `#`; `tag` omits it. */ 8 + export interface FacetTag { 9 + index: { byteStart: number; byteEnd: number }; 10 + features: Array<{ $type: 'app.bsky.richtext.facet#tag'; tag: string }>; 11 + } 12 + 13 + export type Facet = FacetLink | FacetTag; 14 + 7 15 const encoder = new TextEncoder(); 16 + 17 + const byteLen = (s: string): number => encoder.encode(s).length; 8 18 9 19 /** Facet over the first occurrence of `url` in `text`; [] if it's not there. */ 10 20 export function linkFacet(text: string, url: string): FacetLink[] { 11 21 const charIndex = text.indexOf(url); 12 22 if (charIndex === -1) return []; 13 - const byteStart = encoder.encode(text.slice(0, charIndex)).length; 14 - const byteEnd = byteStart + encoder.encode(url).length; 23 + const byteStart = byteLen(text.slice(0, charIndex)); 24 + const byteEnd = byteStart + byteLen(url); 15 25 return [{ index: { byteStart, byteEnd }, features: [{ $type: 'app.bsky.richtext.facet#link', uri: url }] }]; 16 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 + */ 33 + const 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). */ 36 + export 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 + }
+24 -3
test/compose.test.ts
··· 8 8 sampled, 9 9 }); 10 10 11 + /** Collect the tag names from a post's #tag facets, in order. */ 12 + function tagNames(facets: ReturnType<typeof composePost>['facets']): string[] { 13 + const names: string[] = []; 14 + for (const f of facets) { 15 + for (const feat of f.features) { 16 + if (feat.$type === 'app.bsky.richtext.facet#tag') names.push(feat.tag); 17 + } 18 + } 19 + return names; 20 + } 21 + 11 22 describe('postMarker', () => { 12 23 it('is language-specific and carries the puzzle number', () => { 13 24 expect(postMarker('en', 5)).toBe('AT Mot #5'); ··· 25 36 `#WordGame #atproto`, 26 37 ); 27 38 expect(langs).toEqual(['en']); 28 - expect(facets).toHaveLength(1); 29 - expect(facets[0]!.features[0]!.uri).toBe(ORIGIN); 39 + // One homepage link facet, plus a #tag facet per hashtag. 40 + const links = facets.filter((f) => f.features[0]!.$type === 'app.bsky.richtext.facet#link'); 41 + expect(links).toHaveLength(1); 42 + expect(links[0]!.features[0]).toEqual({ $type: 'app.bsky.richtext.facet#link', uri: ORIGIN }); 43 + expect(tagNames(facets)).toEqual(['WordGame', 'atproto']); 44 + }); 45 + 46 + it('hashtags become #tag facets; the puzzle "#N" does not', () => { 47 + const { facets } = composePost({ lang: 'en', todayN: 3, yesterday: y(2, 2) }); 48 + expect(tagNames(facets)).toEqual(['WordGame', 'atproto']); 49 + expect(tagNames(facets)).not.toContain('3'); 30 50 }); 31 51 32 52 it('singular solver, no non-solvers', () => { ··· 80 100 81 101 describe('composePost — FR', () => { 82 102 it('normal: solvers + non-solvers', () => { 83 - const { text, langs } = composePost({ lang: 'fr', todayN: 5, yesterday: y(23, 18) }); 103 + const { text, langs, facets } = composePost({ lang: 'fr', todayN: 5, yesterday: y(23, 18) }); 84 104 expect(text).toBe( 85 105 `🟩 AT Mot n°5 est en ligne ! Six essais pour deviner le mot de cinq lettres du jour.\n` + 86 106 `Bravo aux 18 qui ont trouvé le mot d'hier. Et aux 5 qui ont séché, meilleure chance aujourd'hui !\n` + ··· 88 108 `#JeuDeMots #atproto`, 89 109 ); 90 110 expect(langs).toEqual(['fr']); 111 + expect(tagNames(facets)).toEqual(['JeuDeMots', 'atproto']); 91 112 }); 92 113 93 114 it('singular solver uses "au seul joueur" + "a trouvé"', () => {
+38 -1
test/facets.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 - import { linkFacet } from '../src/facets.js'; 2 + import { linkFacet, tagFacets } from '../src/facets.js'; 3 3 4 4 const URL = 'https://atmot.herve.bzh'; // 23 bytes (ASCII) 5 5 ··· 28 28 expect(linkFacet('no link here', URL)).toEqual([]); 29 29 }); 30 30 }); 31 + 32 + describe('tagFacets', () => { 33 + it('emits a #tag facet per hashtag (range covers the #, tag has no #)', () => { 34 + expect(tagFacets('#WordGame #atproto')).toEqual([ 35 + { 36 + index: { byteStart: 0, byteEnd: 9 }, 37 + features: [{ $type: 'app.bsky.richtext.facet#tag', tag: 'WordGame' }], 38 + }, 39 + { 40 + index: { byteStart: 10, byteEnd: 18 }, 41 + features: [{ $type: 'app.bsky.richtext.facet#tag', tag: 'atproto' }], 42 + }, 43 + ]); 44 + }); 45 + 46 + it('counts multi-byte characters before a hashtag', () => { 47 + // "🟩 " is 5 bytes, so "#atproto" starts at byte 5. 48 + expect(tagFacets('🟩 #atproto')).toEqual([ 49 + { 50 + index: { byteStart: 5, byteEnd: 13 }, 51 + features: [{ $type: 'app.bsky.richtext.facet#tag', tag: 'atproto' }], 52 + }, 53 + ]); 54 + }); 55 + 56 + it('does NOT treat an all-digit token like a puzzle number as a tag', () => { 57 + expect(tagFacets('AT Mot #3 is live')).toEqual([]); 58 + }); 59 + 60 + it('only matches a hashtag at a word boundary, not mid-word', () => { 61 + expect(tagFacets('foo#bar')).toEqual([]); 62 + }); 63 + 64 + it('returns [] when there are no hashtags', () => { 65 + expect(tagFacets('no tags here')).toEqual([]); 66 + }); 67 + });