From 39655d8b5f5324b027a4f61aaca098740560e212 Mon Sep 17 00:00:00 2001
From: Arthur
Date: Tue, 15 Sep 2026 12:19:14 +0200
Subject: [PATCH 1/3] feat(memory): link the diary from the wiki home page
The diary publishes under `/diary/` and nothing on the landing
page pointed at it. It is two folders down, and Quartz sorts it into
the sidebar between unrelated entries, so a family that never ran the
command had no way to learn it exists.
The home page now carries a callout above its first section linking
the diary's front page. The diary module renders the pointer itself,
so its path and its wording stay with the compiler that owns them and
the German set comes along.
The link appears only once compiled diary pages are on disk. The
diary command runs on its own schedule, so a fresh install has a home
page before it has a diary, and a landing page that opens on a 404 is
worse than one that says nothing.
Also drops an em dash from the German diary intro, missed in the
earlier pass over that string table.
---
stacklets/memory/bot/cli/wiki.py | 48 ++++++++++++++++
stacklets/memory/bot/diary.py | 16 +++++-
tests/stacklets/test_memory_diary.py | 34 +++++++++++
tests/stacklets/test_memory_wiki.py | 84 ++++++++++++++++++++++++++++
4 files changed, 181 insertions(+), 1 deletion(-)
diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py
index 85ffb344..9d400e83 100644
--- a/stacklets/memory/bot/cli/wiki.py
+++ b/stacklets/memory/bot/cli/wiki.py
@@ -70,6 +70,13 @@
from stack.ai.client import LLM, LLMUnavailableError # noqa: E402
+# The diary compiler owns the diary's path and its reader-facing words;
+# the home page only places the pointer it renders. Imported by bare
+# name, the way `cli/diary.py` imports it, so the two commands share one
+# module object and therefore one selected language.
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+import diary # noqa: E402
+
HELP = "Regenerate the family wiki's home and member pages"
# Subject prefix the curator's poll loop still filters on. Generation
@@ -249,6 +256,10 @@ async def run(llm: LLM, argv: list[str]) -> int:
shared_bucket = os.environ.get("SHARED_BUCKET", "family")
lang = os.environ.get("LANGUAGE", "en")
+ # The diary renders its own pointer, so it needs the household
+ # language selected here too.
+ diary.configure_language(lang)
+
# One walk feeds every surface. The home page reads the whole index;
# each member page reads its slice. No re-walking per member.
index = _index_vault(vault)
@@ -405,6 +416,10 @@ async def _generate_home(
# the LLM cites reliably, but the citation→document mapping is ours
# to render so links and dates can't be fabricated. Home page lives
# at the vault root, so links are root-relative (page_dir="").
+ # Above the sections, because it is the one link on this page that
+ # leads somewhere a reader browses rather than looks something up.
+ page = _with_diary_link(page, shared_bucket=shared_bucket)
+
page = _with_references(page, index, page_dir="")
# Index pages for the shared bucket's own captures (notes dropped in the
@@ -960,6 +975,39 @@ def _load_facts(vault: Path, slug: str) -> list[tuple[str, str]]:
# ── References ─────────────────────────────────────────────────────────────
+def _with_diary_link(page: str, *, shared_bucket: str) -> str:
+ """Put a pointer to the diary above the home page's first section.
+
+ Nothing else on the landing page leads there: the diary is two
+ folders down and the sidebar files it alphabetically between
+ unrelated entries, so a family that never ran the command has no
+ way to find out it exists.
+
+ Only linked once a compiled diary is on disk. The command that
+ writes those pages runs on its own schedule, and a landing page
+ that opens with a 404 is worse than one that says nothing.
+ """
+ try:
+ published = _brain_dir() / shared_bucket / diary.DIARY_DIR / "about.md"
+ except RuntimeError:
+ return page
+ if not published.exists():
+ return page
+
+ link = diary.home_link(shared_bucket)
+ lines = page.splitlines()
+ for i, line in enumerate(lines):
+ if line.startswith("## "):
+ head = lines[:i]
+ # The address under the H1 is a blockquote, and a callout is
+ # one too: without the blank line between them markdown reads
+ # the pair as a single quote.
+ if head and head[-1].strip():
+ head.append("")
+ return "\n".join(head + [link, ""] + lines[i:])
+ return page.rstrip() + "\n\n" + link
+
+
def _with_references(page: str, entries: list[dict], *, page_dir: str) -> str:
"""Append a `## References` block for the citations the page used."""
section = _build_references_section(page, entries, page_dir=page_dir)
diff --git a/stacklets/memory/bot/diary.py b/stacklets/memory/bot/diary.py
index e6a19635..715a7c20 100644
--- a/stacklets/memory/bot/diary.py
+++ b/stacklets/memory/bot/diary.py
@@ -83,6 +83,7 @@
"recorded_by": "recorded by",
"months_h": "Months", "years_h": "Years",
"diary_title": "Family Diary",
+ "home_teaser": "Recordings and notes, read back month by month.",
"index_intro": (
"Your memories, kept in a chronicle to read back. Voice "
"notes, photos, conversations you recorded. Every entry "
@@ -129,11 +130,13 @@
"recorded_by": "aufgenommen von",
"months_h": "Monate", "years_h": "Jahre",
"diary_title": "Familientagebuch",
+ "home_teaser": ("Aufnahmen und Notizen, Monat f\u00fcr Monat "
+ "zum Nachlesen."),
"index_intro": (
"Eure Erinnerungen, festgehalten in einer Chronik zum "
"Nachlesen. Sprachnotizen, Fotos, Gespr\u00e4che, die ihr "
"aufgenommen habt. Jeder Eintrag f\u00fchrt zur\u00fcck "
- "zur Originalaufnahme \u2014 zum Nachh\u00f6ren, heute "
+ "zur Originalaufnahme, zum Nachh\u00f6ren, heute "
"oder in zwanzig Jahren."),
"nothing_compiled": "Noch nichts zusammengestellt.",
"across": "in",
@@ -1035,6 +1038,17 @@ def render_index(entries) -> str:
return "\n".join(lines).rstrip() + "\n"
+def home_link(bucket: str) -> str:
+ """The diary's pointer, for a page that wants to link to it.
+
+ Both the path and the wording live here, so the linking page needs
+ to know neither. `bucket` is the shared bucket the caller prefixes
+ every diary path with; see `pages_for`.
+ """
+ return (f"> [!tip] [{_L['diary_title']}](/{bucket}/{DIARY_DIR}/about)\n"
+ f"> {_L['home_teaser']}")
+
+
def _and_list(names: list[str]) -> str:
if len(names) == 1:
return names[0]
diff --git a/tests/stacklets/test_memory_diary.py b/tests/stacklets/test_memory_diary.py
index f248209a..b50366a3 100644
--- a/tests/stacklets/test_memory_diary.py
+++ b/tests/stacklets/test_memory_diary.py
@@ -1024,3 +1024,37 @@ def test_a_long_addressed_memo_is_not_distilled(self):
assert "Should never render." not in page
assert "Full transcript" not in page
assert "— for Bart" in page
+
+
+class TestHomeLink:
+ """The pointer the diary hands to pages that link to it.
+
+ The diary owns its own path and its own wording, so the wiki home
+ page renders this string verbatim. Both halves are pinned here.
+ """
+
+ def test_points_at_the_diary_front_page(self):
+ assert "(/family/diary/about)" in diary.home_link("family")
+
+ def test_the_bucket_is_the_callers(self):
+ """Diary paths are relative to the shared bucket, which is named
+ in config. The compiler never reads it."""
+ assert "(/office/diary/about)" in diary.home_link("office")
+
+ def test_is_a_callout_so_it_reads_as_a_signpost(self):
+ assert diary.home_link("family").startswith("> [!tip] ")
+
+ def test_carries_the_diary_title_and_a_line_of_its_own(self):
+ link = diary.home_link("family")
+ assert "Family Diary" in link
+ assert len(link.splitlines()) == 2
+
+ def test_renders_in_the_household_language(self):
+ diary.configure_language("de")
+ try:
+ link = diary.home_link("family")
+ assert "Familientagebuch" in link
+ assert "Recordings" not in link
+ finally:
+ diary.configure_language("en")
+
diff --git a/tests/stacklets/test_memory_wiki.py b/tests/stacklets/test_memory_wiki.py
index 929d4d7f..9034ea47 100644
--- a/tests/stacklets/test_memory_wiki.py
+++ b/tests/stacklets/test_memory_wiki.py
@@ -46,6 +46,7 @@
_topic_entries,
_topic_locations,
_topic_preamble,
+ _with_diary_link,
_yaml_str,
)
@@ -950,3 +951,86 @@ def test_nothing_to_clean_is_ok(self, tmp_path):
(tmp_path / "n.md").write_text("---\ntype: note\n---\n\nx\n", encoding="utf-8")
rc = _clean_generated(brain=tmp_path, dry_run=False, assume_yes=True)
assert rc == 0
+
+
+class TestDiaryLink:
+ """The home page's one pointer into the diary.
+
+ The diary is not reachable from the landing page on its own: it
+ sits two folders down, and Quartz files it into the sidebar
+ alphabetically between unrelated entries. These tests pin where
+ the pointer lands and the case where it must not appear at all.
+ """
+
+ PAGE = (
+ "# The Simpsons\n"
+ "> 742 Evergreen Terrace\n"
+ "\n"
+ "## Members\n"
+ "- **[Homer Simpson](homer/about)** - safety inspector. [1]\n"
+ )
+
+ def _compile_diary(self, brain: Path, bucket: str = "family") -> None:
+ page = brain / bucket / "diary" / "about.md"
+ page.parent.mkdir(parents=True, exist_ok=True)
+ page.write_text("---\ntitle: Family Diary\n---\n", encoding="utf-8")
+
+ def _link_line(self, page: str) -> int:
+ lines = page.splitlines()
+ return next(i for i, ln in enumerate(lines) if "/diary/about" in ln)
+
+ def test_pointer_sits_above_the_first_section(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("BRAIN_REPO_DIR", str(tmp_path))
+ self._compile_diary(tmp_path)
+
+ out = _with_diary_link(self.PAGE, shared_bucket="family")
+
+ assert "/family/diary/about" in out
+ assert self._link_line(out) < out.splitlines().index("## Members")
+
+ def test_pointer_starts_its_own_blockquote(self, tmp_path, monkeypatch):
+ """The address under the H1 is a blockquote and the pointer is a
+ callout, which is one too. Run together with no blank line
+ between them, markdown reads the pair as a single quote."""
+ monkeypatch.setenv("BRAIN_REPO_DIR", str(tmp_path))
+ self._compile_diary(tmp_path)
+
+ out = _with_diary_link(self.PAGE, shared_bucket="family")
+
+ assert out.splitlines()[self._link_line(out) - 1] == ""
+
+ def test_nothing_is_added_until_the_diary_is_compiled(
+ self, tmp_path, monkeypatch,
+ ):
+ """A landing page that opens with a 404 is worse than one that
+ says nothing. The diary command runs on its own schedule, so a
+ fresh install has a home page before it has a diary."""
+ monkeypatch.setenv("BRAIN_REPO_DIR", str(tmp_path))
+
+ assert _with_diary_link(self.PAGE, shared_bucket="family") == self.PAGE
+
+ def test_the_bucket_comes_from_config(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("BRAIN_REPO_DIR", str(tmp_path))
+ self._compile_diary(tmp_path, bucket="office")
+
+ out = _with_diary_link(self.PAGE, shared_bucket="office")
+
+ assert "/office/diary/about" in out
+
+ def test_a_page_with_no_sections_still_gets_the_pointer(
+ self, tmp_path, monkeypatch,
+ ):
+ monkeypatch.setenv("BRAIN_REPO_DIR", str(tmp_path))
+ self._compile_diary(tmp_path)
+
+ out = _with_diary_link("# The Simpsons\n", shared_bucket="family")
+
+ assert "/family/diary/about" in out
+
+ def test_an_unset_brain_dir_is_not_fatal_here(self, monkeypatch):
+ """Generation already fails on a missing BRAIN_REPO_DIR at the
+ point it writes. A navigation line is not where that is
+ reported."""
+ monkeypatch.delenv("BRAIN_REPO_DIR", raising=False)
+
+ assert _with_diary_link(self.PAGE, shared_bucket="family") == self.PAGE
From 95baa9258c2cd5d72b8e2be6f9f6816f2bd397ed Mon Sep 17 00:00:00 2001
From: Arthur
Date: Tue, 15 Sep 2026 12:49:39 +0200
Subject: [PATCH 2/3] feat(memory): explain the diary on a fresh install
Two things made the first diary page unhelpful.
It was never published. A room with nothing in it returned early, so
the page the wiki's diary link points at did not exist, and the one
moment a family needs to be told how to record something produced
nothing to read. The empty run now publishes the index page, and that
page carries a short note: what the Memories room is, that a voice
message is written out in full, that opening with the date files the
entry on that day, and that a reply adds to a memory later. English
and German.
It also was not empty. `stack messages setup` posts a welcome into the
room when it creates it, and the compiler had no sender filter, so on
every fresh install the family's diary opened with the bot's words.
Bot accounts are named by convention (localpart ending in `-bot`,
defined by MicroBot.is_bot_user) and the compiler now drops them.
Both exits now publish through one function, so a full compile and an
empty one produce the same frontmatter and splice contract.
---
stacklets/memory/bot/cli/diary.py | 19 +++++-
stacklets/memory/bot/diary.py | 51 +++++++++++++--
tests/stacklets/test_memory_diary.py | 94 ++++++++++++++++++++++++++++
3 files changed, 157 insertions(+), 7 deletions(-)
diff --git a/stacklets/memory/bot/cli/diary.py b/stacklets/memory/bot/cli/diary.py
index d93fd274..a175201f 100644
--- a/stacklets/memory/bot/cli/diary.py
+++ b/stacklets/memory/bot/cli/diary.py
@@ -851,8 +851,13 @@ async def run(llm, argv: list[str]) -> int:
messages = diary.resolve(events, burst_window_s=window, zone=zone)
if not messages:
- _err(f"nothing in {room_arg} to compile")
- return 0
+ # Still publish. The page is where the wiki's diary link
+ # lands, and with nothing recorded it carries the note that
+ # explains how to record something.
+ _err(f"nothing recorded in {room_arg} yet, publishing an empty diary")
+ await transcriber.aclose()
+ return _publish_pages(diary.pages_for([]),
+ bucket=bucket, dry_run=dry_run)
_err(f"{len(messages)} message(s) in {room_arg}")
# Transcription first and on its own: every later step reads
@@ -930,6 +935,16 @@ async def run(llm, argv: list[str]) -> int:
summaries_cache.save()
pages = diary.pages_for(entries, room_id=room_id, summaries=summaries)
+ return _publish_pages(pages, bucket=bucket, dry_run=dry_run)
+
+
+def _publish_pages(pages, *, bucket: str, dry_run: bool) -> int:
+ """Write the compiled pages into the brain working copy.
+
+ One publisher for both exits: a full compile and the empty diary
+ reach the wiki the same way, so the page a family lands on has the
+ same frontmatter and the same splice contract either way.
+ """
if dry_run:
for path, body, _title in pages:
print(f"\n{'=' * 70}\n{bucket}/{path}\n{'=' * 70}\n{body}")
diff --git a/stacklets/memory/bot/diary.py b/stacklets/memory/bot/diary.py
index 715a7c20..38460101 100644
--- a/stacklets/memory/bot/diary.py
+++ b/stacklets/memory/bot/diary.py
@@ -89,7 +89,19 @@
"notes, photos, conversations you recorded. Every entry "
"leads back to the original recording, there to be "
"listened to, today or in twenty years."),
- "nothing_compiled": "Nothing has been compiled yet.",
+ "getting_started": """## Nothing recorded yet
+
+This diary is written from **Memories**, the room in your family chat.
+Whatever you send there becomes an entry here: a voice message, a photo,
+a video, or a few lines of writing.
+
+- Record a voice message. It is written out here in full, and the
+ recording stays one tap away.
+- Open with the date ("Today is the third of March") and the entry is
+ filed on that day. Without one, the day you sent it counts.
+- Reply to a message to add to that memory later.
+
+There is no wrong way to use it. Press record.""",
"across": "across",
},
"de": {
@@ -138,7 +150,20 @@
"aufgenommen habt. Jeder Eintrag f\u00fchrt zur\u00fcck "
"zur Originalaufnahme, zum Nachh\u00f6ren, heute "
"oder in zwanzig Jahren."),
- "nothing_compiled": "Noch nichts zusammengestellt.",
+ "getting_started": """## Noch nichts aufgenommen
+
+Dieses Tagebuch entsteht aus **Memories**, dem Raum in eurem Familienchat.
+Alles, was ihr dort sendet, wird hier zu einem Eintrag: eine Sprachnachricht,
+ein Foto, ein Video oder ein paar Zeilen Text.
+
+- Nehmt eine Sprachnachricht auf. Sie wird hier vollst\u00e4ndig
+ ausgeschrieben, und die Aufnahme bleibt einen Fingertipp entfernt.
+- Beginnt mit dem Datum ("Heute ist der dritte M\u00e4rz"), dann wird der
+ Eintrag auf diesen Tag datiert. Ohne Datum z\u00e4hlt der Tag, an dem
+ ihr gesendet habt.
+- Antwortet auf eine Nachricht, um sp\u00e4ter etwas zu erg\u00e4nzen.
+
+Es gibt kein falsches Vorgehen. Dr\u00fcckt auf Aufnahme.""",
"across": "in",
},
}
@@ -350,6 +375,15 @@ def _kind_of(msgtype: str) -> str | None:
}.get(msgtype)
+# Bot accounts are named by convention: a localpart ending in `-bot`.
+# The framework owns that definition (`MicroBot.is_bot_user`), which we
+# cannot import here without pulling a Matrix client into a module that
+# is pure on purpose. What a bot posts in the room is instruction, not
+# memory, and a diary that opens with the welcome message opens with
+# someone else's words.
+_BOT_SUFFIX = "-bot"
+
+
def resolve(events, *, burst_window_s: float = DEFAULT_BURST_WINDOW_S,
zone: tzinfo = timezone.utc):
"""Room events to messages: edits applied, replies linked, bursts marked.
@@ -365,6 +399,9 @@ def resolve(events, *, burst_window_s: float = DEFAULT_BURST_WINDOW_S,
for ev in events:
if ev.get("type") != "m.room.message":
continue
+ sender = (ev.get("sender") or "").split(":")[0].lstrip("@")
+ if sender.endswith(_BOT_SUFFIX):
+ continue
content = ev.get("content") or {}
kind = _kind_of(content.get("msgtype", ""))
if kind is None:
@@ -399,7 +436,7 @@ def resolve(events, *, burst_window_s: float = DEFAULT_BURST_WINDOW_S,
plain.append(Message(
event_id=ev.get("event_id", ""),
- sender=(ev.get("sender") or "").split(":")[0].lstrip("@"),
+ sender=sender,
ts=ts,
kind=kind,
body=body,
@@ -1017,8 +1054,12 @@ def render_index(entries) -> str:
"",
]
if not entries:
- lines += [_L["nothing_compiled"], ""]
- return "\n".join(lines)
+ # An empty diary is the one moment a family needs to be told
+ # how to fill one. This page is where the wiki's diary link
+ # lands, so it has to answer "what now" rather than report a
+ # count of zero.
+ lines += [_L["getting_started"], ""]
+ return "\n".join(lines).rstrip() + "\n"
lines += [f"## {_L['years_h']}", ""]
for key, year in sorted(_by_year(entries).items(), reverse=True):
diff --git a/tests/stacklets/test_memory_diary.py b/tests/stacklets/test_memory_diary.py
index b50366a3..d6c72180 100644
--- a/tests/stacklets/test_memory_diary.py
+++ b/tests/stacklets/test_memory_diary.py
@@ -1058,3 +1058,97 @@ def test_renders_in_the_household_language(self):
finally:
diary.configure_language("en")
+
+class TestEmptyDiary:
+ """What the diary page says before anything has been recorded.
+
+ This page is where the wiki's diary link lands, so on a fresh
+ install it is the first thing a family reads about the feature. A
+ count of zero teaches nobody anything; these tests pin that it
+ explains how to record instead.
+ """
+
+ def test_it_says_how_to_record_something(self):
+ page = diary.render_index([])
+
+ assert "Memories" in page # names the room
+ assert "voice message" in page.lower()
+ assert "Press record." in page
+
+ def test_it_keeps_the_diary_title_and_opening(self):
+ """Not a separate error page: the same front door, with the
+ years replaced by the note on how to fill them."""
+ page = diary.render_index([])
+
+ assert page.startswith("# Family Diary")
+ assert "## Years" not in page
+
+ def test_it_explains_the_two_things_that_are_not_obvious(self):
+ """Speaking the date and replying to a message both change what
+ the compiler does with a recording, and neither is guessable
+ from the room."""
+ page = diary.render_index([])
+
+ assert "Today is the third of March" in page
+ assert "Reply to a message" in page
+
+ def test_it_is_written_in_the_household_language(self):
+ diary.configure_language("de")
+ try:
+ page = diary.render_index([])
+ assert "Noch nichts aufgenommen" in page
+ assert "Nothing recorded yet" not in page
+ finally:
+ diary.configure_language("en")
+
+ def test_a_compiled_diary_shows_years_instead(self):
+ """The note is for the empty case only. One entry and the page
+ goes back to being an index."""
+ page = diary.render_index(_compile())
+
+ assert "Press record." not in page
+ assert "## Years" in page
+
+
+class TestBotMessagesAreNotMemories:
+ """A bot writes in the room; none of it belongs in the diary.
+
+ `stack messages setup` posts a welcome into the memories room when
+ it creates it. Without this filter that welcome is the first entry
+ of every fresh install's diary, and the family's own diary opens
+ with someone else's words.
+ """
+
+ def _event(self, sender: str, body: str, ts: int = 1_700_000_000_000):
+ return {
+ "type": "m.room.message",
+ "event_id": f"${sender}-{ts}",
+ "sender": sender,
+ "origin_server_ts": ts,
+ "content": {"msgtype": "m.text", "body": body},
+ }
+
+ def test_a_bot_message_is_dropped(self):
+ messages = diary.resolve([
+ self._event("@stacker-bot:home.local", "This is your family's..."),
+ ])
+
+ assert messages == []
+
+ def test_the_family_still_comes_through(self):
+ messages = diary.resolve([
+ self._event("@stacker-bot:home.local", "welcome", ts=1),
+ self._event("@marge:home.local", "Bart lost a tooth", ts=2),
+ ])
+
+ assert [m.sender for m in messages] == ["marge"]
+
+ def test_a_person_whose_name_ends_in_bot_is_not_a_bot(self):
+ """The convention is a localpart suffix on the whole account, so
+ the check must not fire on a name that merely ends in it."""
+ messages = diary.resolve([
+ self._event("@abbot:home.local", "Bart lost a tooth"),
+ ])
+
+ assert [m.sender for m in messages] == ["abbot"]
+
From 49a1752ee55734d3e1b6c018a7be0839815749b7 Mon Sep 17 00:00:00 2001
From: Arthur
Date: Tue, 15 Sep 2026 12:49:39 +0200
Subject: [PATCH 3/3] fix(messages): post the Memories welcome to a room the
bot has joined
stacker-bot was joined to the Server Room only, then asked to send the
Memories welcome. Synapse rejects a message from a non-member, and the
return value was discarded, so setup reported success and a new family
landed in an empty room with nothing saying what it was for.
The bot now joins Memories before writing to it, and the outcome is
reported as a row in the setup summary like every other step. The test
double returned None where the real client returns (ok, detail); it now
matches, so the same call path is exercised.
---
stacklets/messages/cli/setup.py | 30 +++++++++++++++++++++-----
tests/stacklets/test_messages_setup.py | 5 ++++-
2 files changed, 29 insertions(+), 6 deletions(-)
diff --git a/stacklets/messages/cli/setup.py b/stacklets/messages/cli/setup.py
index 5bda4166..826ca5d2 100644
--- a/stacklets/messages/cli/setup.py
+++ b/stacklets/messages/cli/setup.py
@@ -222,20 +222,32 @@ def _setup(client, users, config, secrets=None):
if bot_created:
results.append({"item": f"@{BOT_NAME}:{server_name}", "action": "ready"})
- # Join bot to Server Room only — Family Room is for humans
+ # Server Room, and Memories so the welcome there can be posted.
+ # Family Room is for humans. Synapse rejects a message from a
+ # non-member, so a room the bot writes to is a room it joins.
if "famstack" in room_ids:
client.join_user(room_ids["famstack"], BOT_NAME)
+ if "memories" in room_ids:
+ client.join_user(room_ids["memories"], BOT_NAME)
# Log in as stacker-bot to post welcome messages
bot_client = MatrixClient(client.base_url, server_name, client.repo_root)
if bot_client.login(BOT_NAME, bot_pass):
- _post_welcome_messages(bot_client, room_ids, server_name, config)
+ _post_welcome_messages(bot_client, room_ids, server_name,
+ config, results)
return {"ok": True, "results": results}
-def _post_welcome_messages(bot, room_ids, server_name, config=None):
- """Post welcome messages from stacker-bot to Server Room."""
+def _post_welcome_messages(bot, room_ids, server_name, config=None,
+ results=None):
+ """Post the welcome messages from stacker-bot.
+
+ `results` collects the same item/action rows the rest of setup
+ reports, so a welcome that does not land is visible in the
+ summary instead of vanishing into a discarded return value.
+ """
+ results = results if results is not None else []
if "famstack" not in room_ids:
return
@@ -316,7 +328,15 @@ def _post_welcome_messages(bot, room_ids, server_name, config=None):
"what the kids want to tell their future selves.
"
"There's no wrong way to use this. Just start recording.
"
)
- bot.send("memories", memories_plain, html=memories_html)
+ ok, detail = bot.send("memories", memories_plain, html=memories_html)
+ # Not fatal: the room and the accounts are already built. But a
+ # silent failure leaves a new family in an empty room with
+ # nothing saying what it is for, which is the one thing this
+ # message exists to prevent.
+ results.append({
+ "item": "#memories welcome",
+ "action": "posted" if ok else f"failed: {detail}",
+ })
def _pretty(result):
diff --git a/tests/stacklets/test_messages_setup.py b/tests/stacklets/test_messages_setup.py
index a50cfa7c..511f0912 100644
--- a/tests/stacklets/test_messages_setup.py
+++ b/tests/stacklets/test_messages_setup.py
@@ -64,7 +64,10 @@ def add_space_child(self, space_id, child_id):
return True
def send(self, room_alias, plain, html=None):
- pass
+ # Same shape as the real client: (ok, detail). Setup reports the
+ # welcome message's outcome, so a fake that returns None here
+ # would pass while the real call path raises.
+ return True, "ok"
def test_stacker_bot_canonical_password(tmp_path):