Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0995c55a82 | |||
| 41197c667a | |||
| 7da73aa435 | |||
| 3d83cc61b6 | |||
| 334d52bd2b | |||
| 8ff1de73d4 | |||
| d37fdf8e10 | |||
| 7ff88641c0 | |||
| b8ac5622d6 | |||
| 4df3f8594c | |||
| 56e71293d6 | |||
| 2df214cad1 | |||
| f43b84a7ef | |||
| ce4a0cb7f9 | |||
| 52e9fe2a87 | |||
| a22d67c36c | |||
| 0cb98c2a3e | |||
| e6439238d2 | |||
| 967a306ea8 | |||
| 46d09d4d45 |
+28
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: "strip_bot_mention fails on Element markdown mention pill format"
|
||||
---
|
||||
|
||||
# Bug 461: strip_bot_mention fails on Element markdown mention pill format
|
||||
|
||||
## Description
|
||||
|
||||
When Element sends a message with a mention pill, the plain text body uses Markdown link format: `[@timmy:crashlabs.io](https://matrix.to/#/@timmy:crashlabs.io) status`. The `strip_bot_mention` function in chat/util.rs uses `strip_prefix_ci` which expects the message to start with `@timmy` or the display name. Since the message starts with `[`, all prefix checks fail, the mention is not stripped, and the entire Markdown link becomes the "command name". Deterministic commands like `status`, `help`, etc. are never matched — they fall through to the LLM instead. The `mentions_bot` function works correctly because it uses `contains()` rather than prefix matching, so the bot IS triggered, but the command text extraction is broken.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. In Element, mention the bot using a mention pill: @botname status. 2. Element sends plain body as `[@bot:server](https://matrix.to/#/@bot:server) status`. 3. Observe that the bot routes to LLM instead of the deterministic status command handler.
|
||||
|
||||
## Actual Result
|
||||
|
||||
strip_bot_mention returns the original text unchanged. The command name is parsed as the entire Markdown link. No deterministic command matches. Message falls through to LLM.
|
||||
|
||||
## Expected Result
|
||||
|
||||
strip_bot_mention strips the Markdown mention pill `[...](https://matrix.to/...)` and returns `status`. The deterministic command handler matches and handles it.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] strip_bot_mention in chat/util.rs handles the Markdown mention pill format [display](https://matrix.to/#/@user:server)
|
||||
- [ ] Deterministic commands like 'status', 'help', 'overview' work when sent via Element mention pills
|
||||
- [ ] Existing plain-text mention formats (@bot:server command, @bot command, BotName command) continue to work
|
||||
- [ ] Tests added for Markdown mention pill format in util.rs
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: "strip_bot_mention fails on Element Markdown mention pill format"
|
||||
---
|
||||
|
||||
# Bug 460: strip_bot_mention fails on Element Markdown mention pill format
|
||||
|
||||
## Description
|
||||
|
||||
When Element sends a mention pill, the plain text `body` field contains a Markdown-style link like `[@timmy:crashlabs.io](https://matrix.to/#/@timmy:crashlabs.io) status`. The `strip_bot_mention` function uses prefix matching, so it tries to match `@timmy:crashlabs.io`, `@timmy`, and `Timmy` against text starting with `[` — none match. The entire message falls through to the LLM as a non-command.
|
||||
|
||||
`mentions_bot` works because it uses `body.contains(full_id)` which finds the MXID embedded inside the Markdown link. But `strip_bot_mention` fails because the text starts with `[`, not `@` or the display name.
|
||||
|
||||
This causes all deterministic bot commands (status, help, ambient, etc.) to be routed to the LLM instead of being handled by the bot when the user uses Element's mention pill (@-autocomplete).
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. In Element, type `@timmy` and use the autocomplete pill to mention the bot
|
||||
2. Append a command like `status`
|
||||
3. Send the message
|
||||
|
||||
## Actual Result
|
||||
|
||||
The command falls through to the LLM. The bot logs show no "Handled bot command" entry. The plain body is `[@timmy:crashlabs.io](https://matrix.to/#/@timmy:crashlabs.io) status` which `strip_bot_mention` cannot parse.
|
||||
|
||||
## Expected Result
|
||||
|
||||
The bot should strip the Markdown mention link wrapper, extract the MXID or display name, and match the command deterministically. `@timmy status` via mention pill should produce the same pipeline status output as typing `@timmy status` manually.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] strip_bot_mention handles Markdown link format `[display](https://matrix.to/#/@user:server) command` and extracts the command text
|
||||
- [ ] Deterministic commands (status, help, ambient, etc.) work when invoked via Element mention pill autocomplete
|
||||
- [ ] Unit tests cover the Markdown mention pill body format
|
||||
- [ ] Existing strip_bot_mention tests still pass (plain @mention and display name formats)
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: "store.json created at project root instead of inside .storkit/"
|
||||
---
|
||||
|
||||
# Bug 457: store.json created at project root instead of inside .storkit/
|
||||
|
||||
## Description
|
||||
|
||||
In main.rs, JsonFileStore is initialised with a hardcoded relative path `PathBuf::from("store.json")`, which creates the file in whatever directory the process was started from (typically the project root). It should live inside `.storkit/` alongside other runtime state files. The scaffold .gitignore also lists `store.json` as a root-level pattern rather than `.storkit/store.json`, and the scaffold comment/entries array in scaffold.rs explicitly lists `store.json` as a root-level file to ignore — both need updating.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Run storkit in any project directory. 2. Observe that store.json is created at the project root rather than inside .storkit/.
|
||||
|
||||
## Actual Result
|
||||
|
||||
store.json is created at the working directory root, polluting the project root and not being gitignored by the scaffold-generated .gitignore unless the user happens to have a catch-all pattern.
|
||||
|
||||
## Expected Result
|
||||
|
||||
store.json is created at project_root/.storkit/store.json. The scaffold-generated .gitignore ignores .storkit/store.json. The scaffold comment and entries array in scaffold.rs no longer list store.json as a root-level file.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] main.rs initialises JsonFileStore at project_root.join(".storkit").join("store.json") instead of PathBuf::from("store.json")
|
||||
- [ ] scaffold.rs .gitignore entries updated: store.json root entry removed, .storkit/store.json added
|
||||
- [ ] scaffold.rs comment on line ~333 updated to reflect store.json is no longer at the root
|
||||
- [ ] wizard_tools.rs filter for store.json updated to match the new path if needed
|
||||
- [ ] Existing deployments with a root-level store.json are not broken (storkit migrates or falls back gracefully)
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: "Matrix bot ignores messages addressed to other bots in ambient mode"
|
||||
---
|
||||
|
||||
# Story 458: Matrix bot ignores messages addressed to other bots in ambient mode
|
||||
|
||||
## User Story
|
||||
|
||||
As a user with multiple bots in the same Matrix room, I want each bot to only respond to messages addressed to it in ambient mode, so that bots don't step on each other's responses.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] In ambient mode, the bot ignores messages that begin with another bot's name or mention another bot's display name (e.g. 'sally: do X' or '@sally do X' is ignored by stu)
|
||||
- [ ] In ambient mode, the bot still responds to messages with no explicit addressee
|
||||
- [ ] In ambient mode, the bot still responds to messages explicitly addressed to itself (e.g. 'stu: do X' or '@stu do X')
|
||||
- [ ] Direct @mention of the bot's Matrix user ID always triggers a response regardless of ambient mode
|
||||
- [ ] The bot's own display_name from bot.toml is used to detect when it is being addressed
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: "matrix_history.json and timers.json missing from scaffold .storkit/.gitignore"
|
||||
---
|
||||
|
||||
# Bug 459: matrix_history.json and timers.json missing from scaffold .storkit/.gitignore
|
||||
|
||||
## Description
|
||||
|
||||
The scaffold's write_story_kit_gitignore function in scaffold.rs does not include matrix_history.json or timers.json in the .storkit/.gitignore entries. Both files are runtime state that should not be committed to git. matrix_device_id and matrix_store/ are already covered, but matrix_history.json (conversation history) and timers.json (timer store) are missing.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Run storkit scaffold on a new project. 2. Start the Matrix bot. 3. Observe that matrix_history.json and timers.json are created inside .storkit/ but are not gitignored.
|
||||
|
||||
## Actual Result
|
||||
|
||||
matrix_history.json and timers.json appear as untracked files in git status.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Both files are listed in .storkit/.gitignore and do not appear in git status.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] matrix_history.json added to the entries array in write_story_kit_gitignore in scaffold.rs
|
||||
- [ ] timers.json added to the entries array in write_story_kit_gitignore in scaffold.rs
|
||||
- [ ] scaffold test in scaffold_creates_story_kit_gitignore_with_relative_entries asserts both entries are present
|
||||
Generated
+9
-9
@@ -1779,9 +1779,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
@@ -4077,7 +4077,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "storkit"
|
||||
version = "0.8.7"
|
||||
version = "0.8.8"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -4356,9 +4356,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
version = "1.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -4373,9 +4373,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5630,9 +5630,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "x25519-dalek"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "living-spec-standalone",
|
||||
"version": "0.8.7",
|
||||
"version": "0.8.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "living-spec-standalone",
|
||||
"version": "0.8.7",
|
||||
"version": "0.8.8",
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "living-spec-standalone",
|
||||
"private": true,
|
||||
"version": "0.8.7",
|
||||
"version": "0.8.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "storkit"
|
||||
version = "0.8.7"
|
||||
version = "0.8.8"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@@ -73,6 +73,72 @@ pub(super) async fn is_reply_to_bot(
|
||||
candidate_ids.iter().any(|id| guard.contains(*id))
|
||||
}
|
||||
|
||||
/// Returns `true` when the message body appears to be explicitly addressed to
|
||||
/// someone **other** than this bot.
|
||||
///
|
||||
/// Recognised address patterns at the start of the body:
|
||||
/// - `"name: rest"` — display-name style (e.g. `"sally: do X"`)
|
||||
/// - `"@name rest"` — @ mention style (e.g. `"@sally do X"`)
|
||||
///
|
||||
/// A message is only considered addressed to another party when the name does
|
||||
/// **not** match either the bot's `bot_name` (case-insensitive) or the
|
||||
/// localpart of its `bot_user_id`.
|
||||
///
|
||||
/// Used in ambient mode to suppress responses when a message is clearly
|
||||
/// directed at a different participant (e.g. another bot in the same room).
|
||||
pub fn is_addressed_to_other(body: &str, bot_user_id: &OwnedUserId, bot_name: &str) -> bool {
|
||||
let trimmed = body.trim_start();
|
||||
let lower = trimmed.to_lowercase();
|
||||
let bot_name_lower = bot_name.to_lowercase();
|
||||
let bot_localpart = bot_user_id.localpart().to_lowercase();
|
||||
|
||||
// Pattern A: "@name …" at the start of the message.
|
||||
// Handles both "@localpart" and "@localpart:homeserver" forms.
|
||||
if let Some(rest) = lower.strip_prefix('@') {
|
||||
// Extract everything up to the first whitespace character.
|
||||
let word_end = rest
|
||||
.find(|c: char| c.is_whitespace())
|
||||
.unwrap_or(rest.len());
|
||||
let mention = &rest[..word_end]; // e.g. "sally" or "sally:example.com"
|
||||
|
||||
// Strip the homeserver part to get just the localpart.
|
||||
let localpart = mention.split(':').next().unwrap_or(mention);
|
||||
|
||||
if localpart.is_empty() {
|
||||
return false; // bare "@" — not an address
|
||||
}
|
||||
if localpart == bot_localpart {
|
||||
return false; // addressed to us
|
||||
}
|
||||
return true; // addressed to someone else
|
||||
}
|
||||
|
||||
// Pattern B: "name: rest" — display-name style.
|
||||
// Only the text before the *first* colon is inspected. We require that
|
||||
// the prefix contains no spaces so that ordinary sentences such as
|
||||
// "Here is a question: …" are not misread as bot addresses.
|
||||
if let Some(colon_pos) = lower.find(':') {
|
||||
let prefix = &lower[..colon_pos];
|
||||
|
||||
// Single-word prefix (no spaces).
|
||||
if !prefix.contains(' ') && !prefix.is_empty() {
|
||||
if prefix == bot_name_lower || prefix == bot_localpart {
|
||||
return false; // addressed to us
|
||||
}
|
||||
return true; // addressed to someone else
|
||||
}
|
||||
|
||||
// Multi-word prefix: only treat as an address if it is an exact
|
||||
// case-insensitive match for our display name.
|
||||
if prefix == bot_name_lower {
|
||||
return false; // addressed to us
|
||||
}
|
||||
// Otherwise the colon is part of a regular sentence — not an address.
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -195,4 +261,92 @@ mod tests {
|
||||
|
||||
assert!(is_reply_to_bot(relates_to.as_ref(), &sent).await);
|
||||
}
|
||||
|
||||
// -- is_addressed_to_other ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn addressed_to_other_display_name_colon() {
|
||||
// "sally: do X" — addressed to sally, not our bot (stu)
|
||||
let uid = make_user_id("@stu:homeserver.local");
|
||||
assert!(is_addressed_to_other("sally: do X", &uid, "stu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addressed_to_other_at_mention() {
|
||||
// "@sally do X" — addressed to sally, not our bot (stu)
|
||||
let uid = make_user_id("@stu:homeserver.local");
|
||||
assert!(is_addressed_to_other("@sally do X", &uid, "stu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addressed_to_other_at_mention_full_id() {
|
||||
// "@sally:homeserver.local do X" — localpart is still "sally"
|
||||
let uid = make_user_id("@stu:homeserver.local");
|
||||
assert!(is_addressed_to_other(
|
||||
"@sally:homeserver.local do X",
|
||||
&uid,
|
||||
"stu"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_addressed_to_other_self_display_name() {
|
||||
// "stu: do X" — addressed to us
|
||||
let uid = make_user_id("@stu:homeserver.local");
|
||||
assert!(!is_addressed_to_other("stu: do X", &uid, "stu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_addressed_to_other_self_at_mention() {
|
||||
// "@stu do X" — addressed to us
|
||||
let uid = make_user_id("@stu:homeserver.local");
|
||||
assert!(!is_addressed_to_other("@stu do X", &uid, "stu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_addressed_to_other_self_at_mention_full_id() {
|
||||
// "@stu:homeserver.local do X" — addressed to us
|
||||
let uid = make_user_id("@stu:homeserver.local");
|
||||
assert!(!is_addressed_to_other(
|
||||
"@stu:homeserver.local do X",
|
||||
&uid,
|
||||
"stu"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_addressed_to_other_no_addressee() {
|
||||
// No explicit addressee — ambient message for everyone
|
||||
let uid = make_user_id("@stu:homeserver.local");
|
||||
assert!(!is_addressed_to_other(
|
||||
"what's the status of the pipeline?",
|
||||
&uid,
|
||||
"stu"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_addressed_to_other_sentence_with_colon() {
|
||||
// Regular sentence with colon — not an address
|
||||
let uid = make_user_id("@stu:homeserver.local");
|
||||
assert!(!is_addressed_to_other(
|
||||
"here is the answer: it depends",
|
||||
&uid,
|
||||
"stu"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_addressed_to_other_display_name_case_insensitive() {
|
||||
// "STU: do X" — case-insensitive match against our name "stu"
|
||||
let uid = make_user_id("@stu:homeserver.local");
|
||||
assert!(!is_addressed_to_other("STU: do X", &uid, "stu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addressed_to_other_case_insensitive_other_name() {
|
||||
// "SALLY: do X" — addressed to sally, not us
|
||||
let uid = make_user_id("@stu:homeserver.local");
|
||||
assert!(is_addressed_to_other("SALLY: do X", &uid, "stu"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ use tokio::sync::watch;
|
||||
use super::context::BotContext;
|
||||
use super::format::markdown_to_html;
|
||||
use super::history::{ConversationEntry, ConversationRole, save_history};
|
||||
use super::mentions::{is_reply_to_bot, mentions_bot};
|
||||
use super::mentions::{is_addressed_to_other, is_reply_to_bot, mentions_bot};
|
||||
use super::verification::check_sender_verified;
|
||||
|
||||
/// Build the user-facing prompt for a single turn. In multi-user rooms the
|
||||
@@ -93,6 +93,19 @@ pub(super) async fn on_room_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// In ambient mode, ignore messages that are explicitly addressed to a
|
||||
// different entity (e.g. "sally: do X" or "@sally do X" when we are stu).
|
||||
// We still let through messages addressed to us and the "ambient on" command.
|
||||
if is_ambient && !is_addressed && !is_ambient_on
|
||||
&& is_addressed_to_other(&body, &ctx.bot_user_id, &ctx.bot_name)
|
||||
{
|
||||
slog!(
|
||||
"[matrix-bot] Ignoring ambient message addressed to another bot (sender={})",
|
||||
ev.sender
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject commands from unencrypted rooms — E2EE is mandatory.
|
||||
if !room.encryption_state().is_encrypted() {
|
||||
slog!(
|
||||
|
||||
@@ -103,7 +103,6 @@ pub(super) async fn on_room_verification_request(
|
||||
ev: OriginalSyncRoomMessageEvent,
|
||||
client: Client,
|
||||
) {
|
||||
slog!("[matrix-bot] DEBUG room msg from {} msgtype={:?}", ev.sender, ev.content.msgtype);
|
||||
// Only act on in-room verification request messages.
|
||||
if !matches!(ev.content.msgtype, MessageType::VerificationRequest(_)) {
|
||||
return;
|
||||
|
||||
+88
-8
@@ -49,9 +49,30 @@ pub fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
/// - `DisplayName: rest` → `rest` (Element tab-completion inserts a colon)
|
||||
/// - `DisplayName, rest` → `rest` (Element tab-completion may insert a comma)
|
||||
/// - `DisplayName ⚡️: rest` → `rest` (display name with emoji)
|
||||
/// - `[DisplayName](https://matrix.to/#/@user:server) rest` → `rest` (Element mention pill)
|
||||
pub fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
|
||||
let trimmed = message.trim();
|
||||
|
||||
// Try Element Markdown mention pill format:
|
||||
// "[DisplayName](https://matrix.to/#/@user:server) rest"
|
||||
if trimmed.starts_with('[') {
|
||||
if let Some(after_label) = trimmed.find("](https://matrix.to/#/") {
|
||||
let url_start = after_label + 2; // skip "]("
|
||||
let url_content = &trimmed[url_start..]; // "https://matrix.to/#/@user:server) rest"
|
||||
if let Some(close_paren) = url_content.find(')') {
|
||||
let url = &url_content[..close_paren]; // "https://matrix.to/#/@user:server"
|
||||
let matrix_prefix = "https://matrix.to/#/";
|
||||
if url.starts_with(matrix_prefix) {
|
||||
let mentioned_id = &url[matrix_prefix.len()..];
|
||||
if mentioned_id.eq_ignore_ascii_case(bot_user_id) {
|
||||
let rest = &url_content[close_paren + 1..];
|
||||
return strip_mention_separator(rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try full Matrix user ID (e.g. "@timmy:homeserver.local")
|
||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
|
||||
return strip_mention_separator(rest);
|
||||
@@ -72,16 +93,19 @@ pub fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str
|
||||
trimmed
|
||||
}
|
||||
|
||||
/// Strip an optional Element tab-completion separator (`:` or `,`) and
|
||||
/// surrounding whitespace from the start of text that follows a bot mention.
|
||||
/// Strip decoration between a bot mention and the command text.
|
||||
///
|
||||
/// Element's tab-completion inserts `DisplayName: ` (colon + space) after the
|
||||
/// name. Without this strip the leading `:` would be treated as part of the
|
||||
/// command name and no command would match.
|
||||
/// After the bot name/ID is stripped, what remains may include whitespace,
|
||||
/// emoji from display names (e.g. `Timmy ⚡️`), and Element tab-completion
|
||||
/// separators (`:` or `,`). This function skips all of that and returns a
|
||||
/// slice starting at the first ASCII alphanumeric character (the command).
|
||||
fn strip_mention_separator(rest: &str) -> &str {
|
||||
let rest = rest.trim_start();
|
||||
let rest = rest.strip_prefix([',', ':']).unwrap_or(rest);
|
||||
rest.trim_start()
|
||||
let byte_skip = rest
|
||||
.char_indices()
|
||||
.find(|(_, c)| c.is_ascii_alphanumeric())
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(rest.len());
|
||||
&rest[byte_skip..]
|
||||
}
|
||||
|
||||
/// Returns `true` when `text` ends while inside an open fenced code block.
|
||||
@@ -381,6 +405,62 @@ mod tests {
|
||||
assert_eq!(rest, "help");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_mention_short_name_emoji_suffix_in_body() {
|
||||
// bot_name is "Timmy" (no emoji) but Element mention pill puts
|
||||
// "Timmy ⚡️ status" in the body — the emoji is part of the display
|
||||
// name as set on the Matrix server, not in bot.toml.
|
||||
let rest = strip_bot_mention("Timmy ⚡️ status", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(rest, "status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_mention_element_markdown_pill_format() {
|
||||
// Element sends "[DisplayName](https://matrix.to/#/@user:server) command"
|
||||
// when a user uses the @ autocomplete mention pill.
|
||||
let rest = strip_bot_mention(
|
||||
"[Timmy](https://matrix.to/#/@timmy:homeserver.local) status",
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
);
|
||||
assert_eq!(rest, "status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_mention_element_markdown_pill_with_emoji_display_name() {
|
||||
let rest = strip_bot_mention(
|
||||
"[timmy ⚡️](https://matrix.to/#/@timmy:homeserver.local) ambient on",
|
||||
"timmy ⚡️",
|
||||
"@timmy:homeserver.local",
|
||||
);
|
||||
assert_eq!(rest, "ambient on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_mention_element_markdown_pill_wrong_user_id_no_strip() {
|
||||
// Pill for a different user should not be stripped.
|
||||
let rest = strip_bot_mention(
|
||||
"[Other](https://matrix.to/#/@other:homeserver.local) status",
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
);
|
||||
assert_eq!(
|
||||
rest,
|
||||
"[Other](https://matrix.to/#/@other:homeserver.local) status"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_mention_element_markdown_pill_no_trailing_command() {
|
||||
// Pill with no command after it returns empty string (handled by callers).
|
||||
let rest = strip_bot_mention(
|
||||
"[Timmy](https://matrix.to/#/@timmy:homeserver.local)",
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
);
|
||||
assert_eq!(rest, "");
|
||||
}
|
||||
|
||||
// -- drain_complete_paragraphs ------------------------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -168,7 +168,6 @@ pub(crate) fn is_bare_project(project_root: &Path) -> bool {
|
||||
|| n == "LICENSE"
|
||||
|| n == "README.md"
|
||||
|| n == "script"
|
||||
|| n == "store.json"
|
||||
})
|
||||
})
|
||||
.unwrap_or(true)
|
||||
|
||||
@@ -285,6 +285,8 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
|
||||
"bot.toml",
|
||||
"matrix_store/",
|
||||
"matrix_device_id",
|
||||
"matrix_history.json",
|
||||
"timers.json",
|
||||
"worktrees/",
|
||||
"merge_workspace/",
|
||||
"coverage/",
|
||||
@@ -294,6 +296,7 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
|
||||
"logs/",
|
||||
"token_usage.jsonl",
|
||||
"wizard_state.json",
|
||||
"store.json",
|
||||
];
|
||||
|
||||
let gitignore_path = root.join(".storkit").join(".gitignore");
|
||||
@@ -330,11 +333,13 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Append root-level Story Kit entries to the project `.gitignore`.
|
||||
/// Only `store.json` and `.storkit_port` remain here because they live at
|
||||
/// Only `.storkit_port` and `.mcp.json` remain here because they live at
|
||||
/// the project root and git does not support `../` patterns in `.gitignore`
|
||||
/// files, so they cannot be expressed in `.storkit/.gitignore`.
|
||||
/// `store.json` is excluded via `.storkit/.gitignore` since it now lives
|
||||
/// inside the `.storkit/` directory.
|
||||
fn append_root_gitignore_entries(root: &Path) -> Result<(), String> {
|
||||
let entries = [".storkit_port", "store.json", ".mcp.json"];
|
||||
let entries = [".storkit_port", ".mcp.json"];
|
||||
|
||||
let gitignore_path = root.join(".gitignore");
|
||||
let existing = if gitignore_path.exists() {
|
||||
@@ -699,17 +704,22 @@ mod tests {
|
||||
assert!(sk_content.contains("worktrees/"));
|
||||
assert!(sk_content.contains("merge_workspace/"));
|
||||
assert!(sk_content.contains("coverage/"));
|
||||
assert!(sk_content.contains("matrix_history.json"));
|
||||
assert!(sk_content.contains("timers.json"));
|
||||
// Must NOT contain absolute .storkit/ prefixed paths
|
||||
assert!(!sk_content.contains(".storkit/"));
|
||||
|
||||
// Root .gitignore must contain root-level storkit entries
|
||||
let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
|
||||
assert!(root_content.contains(".storkit_port"));
|
||||
assert!(root_content.contains("store.json"));
|
||||
// store.json now lives inside .storkit/ and must NOT appear in root .gitignore
|
||||
assert!(!root_content.contains("store.json"));
|
||||
// Root .gitignore must NOT contain .storkit/ sub-directory patterns
|
||||
assert!(!root_content.contains(".storkit/worktrees/"));
|
||||
assert!(!root_content.contains(".storkit/merge_workspace/"));
|
||||
assert!(!root_content.contains(".storkit/coverage/"));
|
||||
// store.json must be in .storkit/.gitignore instead
|
||||
assert!(sk_content.contains("store.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+11
-1
@@ -139,8 +139,18 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
|
||||
let app_state = Arc::new(SessionState::default());
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
// Migrate legacy root-level store.json into .storkit/ if the new path does
|
||||
// not yet exist. This keeps existing deployments working after upgrade.
|
||||
let legacy_store_path = cwd.join("store.json");
|
||||
let store_path = cwd.join(".storkit").join("store.json");
|
||||
if legacy_store_path.exists() && !store_path.exists() {
|
||||
if let Some(parent) = store_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = std::fs::rename(&legacy_store_path, &store_path);
|
||||
}
|
||||
let store = Arc::new(
|
||||
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
|
||||
JsonFileStore::from_path(store_path).map_err(std::io::Error::other)?,
|
||||
);
|
||||
|
||||
// Collect CLI args, skipping the binary name (argv[0]).
|
||||
|
||||
Reference in New Issue
Block a user