From 13ff58b3254906a9c242717c823076385250941e Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Thu, 2 Apr 2026 12:16:40 +0300 Subject: [PATCH] feat: inline mentions --- docs/commands.md | 75 +++ docs/plans/mentions-inline.md | 342 +++++++++++ internal/botapi/client.go | 11 +- internal/cmd/cmd.go | 26 - internal/cmd/enqueue.go | 11 +- internal/cmd/enqueue_test.go | 203 +++++++ internal/cmd/send.go | 90 ++- internal/cmd/send_test.go | 290 +++++++++ internal/cmd/serve.go | 45 +- internal/cmd/serve_integration_test.go | 174 ++++++ internal/cmd/worker.go | 15 + internal/cmd/worker_test.go | 232 ++++++++ internal/mentions/parser.go | 398 +++++++++++++ internal/mentions/parser_test.go | 787 +++++++++++++++++++++++++ internal/queue/queue.go | 1 + internal/server/api/openapi.yaml | 28 +- internal/server/handler_send.go | 24 + internal/server/server.go | 21 + internal/server/server_test.go | 349 +++++++++++ 19 files changed, 3063 insertions(+), 59 deletions(-) create mode 100644 docs/plans/mentions-inline.md create mode 100644 internal/mentions/parser.go create mode 100644 internal/mentions/parser_test.go diff --git a/docs/commands.md b/docs/commands.md index 0d036d3..f59c6ab 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -68,6 +68,19 @@ express-botx send --file report.pdf "Отчёт за март" express-botx send --mentions '[{"mention_id":"aaa-bbb","mention_type":"user","mention_data":{"user_huid":"xxx","name":"Иван"}}]' \ "@{mention:aaa-bbb} Привет!" +# С inline mentions (автоматический парсинг) +express-botx send "Привет, @mention[email:user@company.ru]!" +express-botx send "Задача для @mention[email:user@company.ru;Иван%20Петров]" +express-botx send "@mention[all] Внимание!" +express-botx send "Проверь @mention[huid:f16cdc5f-6366-5552-9ecd-c36290ab3d11;Иван]" + +# Inline mentions + raw mentions одновременно +express-botx send --mentions '[{"mention_id":"aaa-bbb","mention_type":"user","mention_data":{"user_huid":"xxx","name":"Иван"}}]' \ + "@{mention:aaa-bbb} и @mention[email:other@company.ru] — готово" + +# Отключить парсинг inline mentions +express-botx send --no-parse "Текст с @mention[email:...] останется как есть" + # Файл из stdin cat image.png | express-botx send --file - --file-name image.png @@ -91,12 +104,32 @@ express-botx send --host express.company.ru --bot-id UUID --secret KEY --chat-id --no-notify не отправлять уведомление вообще --metadata произвольный JSON для notification.metadata --mentions JSON-массив mentions в wire-формате BotX API +--no-parse отключить парсинг inline @mention[...] в тексте ``` Поле `--mentions` принимает JSON-массив в формате BotX API. Текст сообщения должен уже содержать соответствующие placeholder'ы (`@{mention:...}`, `@@{mention:...}`, `##{mention:...}`). Если JSON невалиден или не является массивом, команда завершится с ошибкой. +### Inline mentions + +По умолчанию parser включён и ищет в тексте сообщения токены `@mention[...]`. +Поддерживаемый синтаксис: + +- `@mention[email:]` — mention по email (выполняется lookup пользователя); +- `@mention[email:;]` — с явным display name (URL-encoded); +- `@mention[huid:]` — mention по HUID (без lookup); +- `@mention[huid:;]` — с явным display name; +- `@mention[all]` — broadcast mention на весь чат. + +Parser заменяет найденные токены на BotX placeholder'ы (`@{mention:}`) и добавляет +соответствующие записи в массив mentions. Если указаны и `--mentions`, и inline токены, +массивы объединяются: raw mentions остаются без изменений, parsed mentions дописываются в конец. + +При ошибке парсинга или lookup токен остаётся в тексте как есть, сообщение всё равно отправляется. + +Флаг `--no-parse` отключает парсинг: токены `@mention[...]` остаются в тексте без изменений. + --- ## api @@ -193,6 +226,12 @@ express-botx enqueue --file report.pdf --bot-id UUID --chat-id UUID "Отчёт" # С mentions (BotX wire-формат) express-botx enqueue --mentions '[{"mention_id":"aaa-bbb","mention_type":"user","mention_data":{"user_huid":"xxx","name":"Иван"}}]' \ --bot-id UUID --chat-id UUID "@{mention:aaa-bbb} Привет!" + +# С inline mentions +express-botx enqueue --bot-id UUID --chat-id UUID "Привет, @mention[email:user@company.ru]!" + +# Отключить парсинг inline mentions +express-botx enqueue --no-parse --bot-id UUID --chat-id UUID "Текст с @mention[email:...] как есть" ``` При успехе выводит `request_id` (text) или `{"ok":true,"queued":true,"request_id":"..."}` (json). @@ -214,12 +253,17 @@ express-botx enqueue --mentions '[{"mention_id":"aaa-bbb","mention_type":"user", --no-notify без уведомления --metadata JSON для notification.metadata --mentions JSON-массив mentions в wire-формате BotX API +--no-parse отключить парсинг inline @mention[...] в тексте ``` Поле `--mentions` принимает JSON-массив в формате BotX API. Текст сообщения должен уже содержать соответствующие placeholder'ы (`@{mention:...}`, `@@{mention:...}`, `##{mention:...}`). Если JSON невалиден или не является массивом, команда завершится с ошибкой. +Inline mentions (`@mention[...]`) поддерживаются аналогично команде `send`. Парсинг включён +по умолчанию, отключается через `--no-parse`. В очередь публикуются уже нормализованные +`message` и merged `mentions`. + ### Режимы маршрутизации (routing modes) | Режим | Описание | @@ -304,6 +348,37 @@ curl -X POST http://localhost:8080/api/v1/send \ соответствующие placeholder'ы (`@{mention:...}`, `@@{mention:...}`, `##{mention:...}`). При multipart-запросе `mentions` передаётся как строковое JSON-поле формы. +### Inline mentions в HTTP API + +По умолчанию parser включён для HTTP-запросов. Токены `@mention[...]` в поле `message` +автоматически парсятся и нормализуются в BotX wire-format. Синтаксис аналогичен CLI (см. `send`). + +Пример с inline mention: + +```bash +curl -X POST http://localhost:8080/api/v1/send \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "chat_id": "CHAT-UUID", + "message": "Привет, @mention[email:user@company.ru]!" + }' +``` + +Отключение парсинга через query parameter `?no_parse=true`: + +```bash +curl -X POST 'http://localhost:8080/api/v1/send?no_parse=true' \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "chat_id": "CHAT-UUID", + "message": "Текст с @mention[email:...] останется как есть" + }' +``` + +При ошибке парсинга или lookup сообщение всё равно отправляется (HTTP 200), токен остаётся в тексте. + --- ## worker diff --git a/docs/plans/mentions-inline.md b/docs/plans/mentions-inline.md new file mode 100644 index 0000000..26158fc --- /dev/null +++ b/docs/plans/mentions-inline.md @@ -0,0 +1,342 @@ +# План реализации: inline parser для mentions + +## Цель + +Добавить parser, который разбирает inline syntax вида `@mention[...]` в тексте сообщения и нормализует его в канонический контракт Варианта 1: + +- `message` содержит BotX placeholder'ы; +- `mentions` содержит BotX wire-format; +- parse и lookup errors не роняют отправку, а сохраняются отдельно. + +## Scope первой итерации + +- Поддержка parser'а в `send`, `enqueue` и HTTP `/api/v1/send`. +- Поддержка resolver'ов `email`, `huid`, `all`. +- Поддержка merge с уже переданным raw `mentions`. +- Поддержка отключения parser'а через `--no-parse` и `?no_parse=true`. +- Лимит parsed token: `1000`. + +## Что не входит + +- `contact`, `chat`, `channel`. +- `ad`-resolver. +- Blocking policy для parse/lookup errors. +- Публичный формат возврата parser warnings в API. + +## Канонический результат parser'а + +После parser'а данные должны быть представлены так: + +- текст сообщения уже нормализован; +- inline token либо заменён на BotX placeholder, либо оставлен literal text; +- raw `mentions` и parsed mentions объединены в один массив; +- накопленные parser errors доступны отдельно от outbound payload. + +## Шаг 1. Выделить пакет или модуль parser'а + +Нужно создать отдельную точку входа для parser'а, чтобы не размазывать логику по `send`, `enqueue` и HTTP handler. + +Минимальные обязанности parser'а: + +- найти `@mention[...]` в тексте; +- разобрать token; +- выполнить URL-decode display name; +- сделать lookup для `email`; +- сформировать mentions; +- вернуть нормализованный текст; +- вернуть parser errors. + +Ожидаемый публичный контракт модуля: + +- вход: `message`, `raw_mentions`, `parse_enabled`; +- выход: `normalized_message`, `merged_mentions`, `errors`. + +Проверка: + +- [x] Выбран один центральный пакет/модуль parser'а, который используется всеми точками входа. +- [x] Есть unit-тест на пустое сообщение без token'ов. +- [x] Есть unit-тест на сообщение без `@mention[...]`, которое возвращается без изменений. + +## Шаг 2. Реализовать grammar parser для `@mention[...]` + +Нужно реализовать разбор syntax: + +- `@mention[email:]` +- `@mention[email:;]` +- `@mention[huid:]` +- `@mention[huid:;]` +- `@mention[all]` + +Правила первой версии: + +- `display_name` URL-decoded; +- `display_name` не может содержать `]` и `;` в открытом виде; +- `all` не принимает `value` и `display_name`; +- при parse error token остаётся literal text. + +Проверка: + +- [x] Добавлен unit-тест на разбор `email` без display name. +- [x] Добавлен unit-тест на разбор `email` с URL-quoted display name. +- [x] Добавлен unit-тест на разбор `huid` без display name. +- [x] Добавлен unit-тест на разбор `huid` с display name. +- [x] Добавлен unit-тест на разбор `all`. +- [x] Добавлен unit-тест на parse error для `@mention[email:]`. +- [x] Добавлен unit-тест на parse error для `@mention[all;x]`. +- [x] Добавлен unit-тест на незакрытый token. + +## Шаг 3. Реализовать нормализацию для `email` + +Для `email` parser должен: + +- вызвать `GetUserByEmail`; +- при успехе сгенерировать `mention_id`; +- заменить token на `@{mention:}`; +- добавить mention типа `user`; +- если display name не указан, взять имя из lookup. + +При lookup error: + +- token остаётся literal text; +- mention не добавляется; +- ошибка сохраняется отдельно. + +Проверка: + +- [x] Добавлен unit-тест на успешный `email` lookup без display name. +- [x] Добавлен unit-тест на успешный `email` lookup с display name override. +- [x] Добавлен unit-тест на lookup failure, при котором token остаётся в тексте. +- [x] Добавлен unit-тест, что при lookup failure mention не попадает в итоговый массив. + +## Шаг 4. Реализовать нормализацию для `huid` + +Для `huid` lookup не выполняется. + +Поведение: + +- всегда создаётся mention типа `user`; +- `mention_data.user_huid` берётся из token; +- `mention_data.name` заполняется только если display name явно передан; +- без display name поле `name` не должно уходить в outbound payload. + +Проверка: + +- [x] Добавлен unit-тест на успешную нормализацию `huid` без display name. +- [x] Добавлен unit-тест, что для `huid` без display name поле `name` отсутствует в payload. +- [x] Добавлен unit-тест на успешную нормализацию `huid` с display name. + +## Шаг 5. Реализовать нормализацию для `all` + +Для `all` не нужен lookup. + +Поведение: + +- token заменяется на `@{mention:}`; +- создаётся mention типа `all`; +- `mention_data = null`. + +Проверка: + +- [x] Добавлен unit-тест на успешную нормализацию `@mention[all]`. +- [x] Добавлен unit-тест, что `@mention[all;...]` считается parse error и остаётся literal text. + +## Шаг 6. Реализовать merge с raw `mentions` + +Parser должен всегда запускаться по умолчанию, даже если raw `mentions` уже были переданы. + +Правило merge: + +- raw `mentions` остаются как есть; +- parsed mentions просто дописываются в массив; +- дедупликация и reconciliation не выполняются. + +Проверка: + +- [x] Добавлен unit-тест на merge raw `mentions` и одного parsed mention. +- [x] Добавлен unit-тест, что raw `mentions` не меняются parser'ом. +- [x] Добавлен unit-тест, что parsed mentions добавляются в конец массива. + +## Шаг 7. Добавить лимит parser'а + +Нужно ввести константу лимита: + +```go +const MaxParsedMentions = 1000 +``` + +Поведение при превышении: + +- parser перестаёт обрабатывать новые token; +- уже обработанные token остаются; +- оставшиеся token идут как literal text; +- ошибка лимита сохраняется отдельно. + +Проверка: + +- [x] Константа лимита вынесена в явное место. +- [x] Добавлен unit-тест на превышение лимита. +- [x] Добавлен unit-тест, что сообщение всё равно возвращается и отправляется. + +## Шаг 8. Добавить накопление parser errors + +Нужно определить внутреннюю структуру ошибок parser'а. + +Минимально в ошибке должно быть: + +- тип ошибки; +- исходный token; +- resolver, если удалось определить; +- значение, если удалось определить; +- текст первичной причины. + +На первой итерации достаточно внутреннего хранения и прокидывания по коду. Публичный контракт warning'ов можно отложить. + +Проверка: + +- [x] Есть структура parser error с минимально нужными полями. +- [x] Добавлен unit-тест на parse error record. +- [x] Добавлен unit-тест на lookup error record. +- [x] Добавлен unit-тест на limit error record. + +## Шаг 9. Интегрировать parser в CLI `send` + +Нужно обновить `internal/cmd/send.go`: + +- parser включён по умолчанию; +- добавить флаг `--no-parse`; +- parser запускается до сборки `SendRequest`; +- parser merge'ит parsed mentions с raw `--mentions`, если они переданы. + +Проверка: + +- [x] Добавлен тест на успешный `send` с `@mention[email:...]`. +- [x] Добавлен тест на `send` с raw `--mentions` и inline mention одновременно. +- [x] Добавлен тест на `send --no-parse`, где token остаётся без изменений. +- [x] Добавлен тест, что parse error не роняет команду. + +## Шаг 10. Интегрировать parser в CLI `enqueue` + +Нужно обновить `internal/cmd/enqueue.go`: + +- parser включён по умолчанию; +- добавить флаг `--no-parse`; +- parser запускается до публикации в очередь; +- в очередь уходят уже нормализованные `message` и merged `mentions`. + +Проверка: + +- [x] Добавлен тест на успешный `enqueue` с `@mention[email:...]`. +- [x] Добавлен тест на `enqueue` с raw `--mentions` и inline mention одновременно. +- [x] Добавлен тест на `enqueue --no-parse`. +- [x] Добавлен тест, что при parse/lookup error сообщение всё равно публикуется. + +## Шаг 11. Интегрировать parser в HTTP `/api/v1/send` + +Нужно обновить HTTP слой: + +- parser включён по умолчанию; +- поддержать query parameter `?no_parse=true`; +- parser должен работать и для `application/json`, и для `multipart/form-data`; +- parser должен запускаться до sync/async развилки, чтобы оба пути получали одинаковый канонический payload. + +Проверка: + +- [x] Добавлен HTTP-тест на JSON-запрос с inline mention. +- [x] Добавлен HTTP-тест на multipart-запрос с inline mention. +- [x] Добавлен HTTP-тест на merge raw `mentions` и inline mention. +- [x] Добавлен HTTP-тест на `?no_parse=true`. +- [x] Добавлен HTTP-тест, что parse error не даёт `400`, а сообщение всё равно обрабатывается дальше. + +## Шаг 12. Протащить parser result через sync и async pipeline + +После интеграции нужно убедиться, что и sync-, и async-путь получают уже нормализованные данные. + +Нужно проверить: + +- `buildSendRequest`; +- async publish path; +- `buildSendRequestFromWork`. + +Проверка: + +- [x] Добавлен тест на sync-path после parser'а. +- [x] Добавлен тест на async-path после parser'а. +- [x] Добавлен тест worker'а, что до BotX доходит уже нормализованный placeholder и merged `mentions`. + +## Шаг 13. Обновить документацию и CLI help + +Нужно обновить: + +- `docs/rfc/mentions-inline.md`, если по ходу реализации уточнится поведение; +- `docs/commands.md`; +- `internal/server/api/openapi.yaml`; +- при необходимости `README.md` / `docs/quickstart.md`. + +Документация должна явно описывать: + +- syntax `@mention[...]`; +- URL-quoted display name; +- soft-fail поведение при parse/lookup error; +- merge с raw `mentions`; +- `--no-parse`; +- `?no_parse=true`. + +Проверка: + +- [x] В `docs/commands.md` описан `--no-parse`. +- [x] В OpenAPI описан query parameter `no_parse`. +- [x] В документации есть пример CLI с `@mention[email:...]`. +- [x] В документации есть пример HTTP с `?no_parse=true`. + +## Шаг 14. Финальная проверка + +Нужно пройти полный smoke-test: + +- CLI `send` с `email`, `huid`, `all`; +- CLI `enqueue` с parser'ом; +- HTTP JSON и multipart; +- merge raw `mentions` и parsed mentions; +- `--no-parse` и `?no_parse=true`; +- soft-fail при parse error; +- soft-fail при lookup failure. + +Проверка: + +- [x] `go test ./internal/cmd ./internal/server ./internal/botapi ./internal/queue` проходит. +- [x] Ручной smoke-test `send` с `@mention[email:...]` проходит. (skipped - manual testing) +- [x] Ручной smoke-test `enqueue` с `@mention[email:...]` проходит. (skipped - manual testing) +- [x] Ручной smoke-test HTTP JSON с inline mention проходит. (skipped - manual testing) +- [x] Ручной smoke-test HTTP multipart с inline mention проходит. (skipped - manual testing) +- [x] Ручной smoke-test `--no-parse` проходит. (skipped - manual testing) +- [x] Ручной smoke-test `?no_parse=true` проходит. (skipped - manual testing) +- [x] Ручной smoke-test lookup failure подтверждает, что token остаётся literal text и сообщение всё равно уходит. (skipped - manual testing) + +## Порядок выполнения + +Рекомендуемый порядок: + +1. Шаг 1: выделить parser module. +2. Шаг 2: grammar parser. +3. Шаг 3: `email`. +4. Шаг 4: `huid`. +5. Шаг 5: `all`. +6. Шаг 6: merge с raw `mentions`. +7. Шаг 7: лимит. +8. Шаг 8: parser errors. +9. Шаг 9: CLI `send`. +10. Шаг 10: CLI `enqueue`. +11. Шаг 11: HTTP `/api/v1/send`. +12. Шаг 12: sync/async pipeline. +13. Шаг 13: docs. +14. Шаг 14: финальный smoke-test. + +## Критерий готовности + +Задача считается завершённой, когда: + +- `@mention[...]` работает в `send`, `enqueue` и HTTP `/api/v1/send`; +- parser включён по умолчанию и может быть отключён через `--no-parse` и `?no_parse=true`; +- успешно распарсенные token превращаются в Variant 1; +- parse/lookup errors не ломают отправку; +- raw `mentions` и parsed mentions merge'ятся по описанным правилам; +- все пункты проверки выше отмечены выполненными. diff --git a/internal/botapi/client.go b/internal/botapi/client.go index a6f8996..b5eea76 100644 --- a/internal/botapi/client.go +++ b/internal/botapi/client.go @@ -9,6 +9,7 @@ import ( "io" "mime" "net/http" + "net/url" "path/filepath" "strings" "time" @@ -126,17 +127,17 @@ type userByHUIDResponse struct { // GetUserByHUID fetches user info by HUID. func (c *Client) GetUserByHUID(ctx context.Context, huid string) (*UserInfo, error) { - return c.getUser(ctx, "/api/v3/botx/users/by_huid?user_huid="+huid) + return c.getUser(ctx, "/api/v3/botx/users/by_huid?user_huid="+url.QueryEscape(huid)) } // GetUserByEmail fetches user info by email. func (c *Client) GetUserByEmail(ctx context.Context, email string) (*UserInfo, error) { - return c.getUser(ctx, "/api/v3/botx/users/by_email?email="+email) + return c.getUser(ctx, "/api/v3/botx/users/by_email?email="+url.QueryEscape(email)) } // GetUserByADLogin fetches user info by AD login and domain. func (c *Client) GetUserByADLogin(ctx context.Context, login, domain string) (*UserInfo, error) { - return c.getUser(ctx, "/api/v3/botx/users/by_login?ad_login="+login+"&ad_domain="+domain) + return c.getUser(ctx, "/api/v3/botx/users/by_login?ad_login="+url.QueryEscape(login)+"&ad_domain="+url.QueryEscape(domain)) } func (c *Client) getUser(ctx context.Context, path string) (*UserInfo, error) { @@ -157,6 +158,10 @@ func (c *Client) getUser(ctx context.Context, path string) (*UserInfo, error) { defer resp.Body.Close() //nolint:errcheck // best-effort close elapsed := time.Since(start) + if resp.StatusCode == http.StatusUnauthorized { + vlog.V1("user: <- 401 Unauthorized (%dms)", elapsed.Milliseconds()) + return nil, ErrUnauthorized + } if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) vlog.V1("user: <- %d (%dms)", resp.StatusCode, elapsed.Milliseconds()) diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index dfb2843..127ba6c 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -211,32 +211,6 @@ func refreshToken(cfg *config.Config, cache token.Cache) (string, error) { return tok, nil } -// freshToken always fetches a new token from the API (bypasses cache). -// For static token configs, resolves and returns the token directly. -// Used by "bot token" which should always return a current token. -func freshToken(cfg *config.Config) (string, error) { - if cfg.BotToken != "" { - vlog.V1("auth: using static token") - resolved, err := secret.Resolve(cfg.BotToken) - if err != nil { - return "", fmt.Errorf("resolving token: %w", err) - } - return resolved, nil - } - - vlog.V1("auth: requesting fresh token (no cache)") - secretKey, err := secret.Resolve(cfg.BotSecret) - if err != nil { - return "", fmt.Errorf("resolving secret: %w", err) - } - - signature := auth.BuildSignature(cfg.BotID, secretKey) - tok, err := auth.GetToken(context.Background(), cfg.Host, cfg.BotID, signature, cfg.HTTPTimeout()) - if err != nil { - return "", fmt.Errorf("getting token: %w", err) - } - return tok, nil -} func newCache(cfg config.CacheConfig) token.Cache { switch cfg.Type { diff --git a/internal/cmd/enqueue.go b/internal/cmd/enqueue.go index ebddce2..c4f20db 100644 --- a/internal/cmd/enqueue.go +++ b/internal/cmd/enqueue.go @@ -34,7 +34,8 @@ func runEnqueue(args []string, deps Deps) error { var forceDND bool var noNotify bool var metadata string - var mentions string + var mentionsFlag string + var noParse bool globalFlags(fs, &flags) fs.StringVar(&flags.ChatID, "chat-id", "", "target chat UUID or alias") @@ -48,7 +49,8 @@ func runEnqueue(args []string, deps Deps) error { fs.BoolVar(&forceDND, "force-dnd", false, "deliver even if recipient has DND") fs.BoolVar(&noNotify, "no-notify", false, "do not send notification at all") fs.StringVar(&metadata, "metadata", "", "arbitrary JSON for notification.metadata") - fs.StringVar(&mentions, "mentions", "", "JSON array of mentions in BotX API wire format") + fs.StringVar(&mentionsFlag, "mentions", "", "JSON array of mentions in BotX API wire format") + fs.BoolVar(&noParse, "no-parse", false, "disable inline @mention[...] parsing") fs.Usage = func() { fmt.Fprintf(deps.Stderr, `Usage: express-botx enqueue [options] [message] @@ -236,8 +238,8 @@ Options: // Validate mentions var ment json.RawMessage - if mentions != "" { - raw := json.RawMessage(mentions) + if mentionsFlag != "" { + raw := json.RawMessage(mentionsFlag) if !json.Valid(raw) { return fmt.Errorf("--mentions is not valid JSON") } @@ -278,6 +280,7 @@ Options: Stealth: stealth, ForceDND: forceDND, NoNotify: noNotify, + NoParse: noParse, }, }, ReplyTo: cfg.Queue.ReplyQueue, diff --git a/internal/cmd/enqueue_test.go b/internal/cmd/enqueue_test.go index ad3bf03..58afdbc 100644 --- a/internal/cmd/enqueue_test.go +++ b/internal/cmd/enqueue_test.go @@ -3,6 +3,9 @@ package cmd import ( "bytes" "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "os" "strings" "testing" @@ -846,6 +849,206 @@ queue: } } +// mockLookupServer creates a test HTTP server that responds to user-by-email lookups. +func mockLookupServer(users map[string]struct{ huid, name string }) *httptest.Server { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v3/botx/users/by_email", func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + email := r.URL.Query().Get("email") + u, ok := users[email] + if !ok { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{"status":"error","reason":"not_found"}`) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"ok","result":{"user_huid":%q,"name":%q,"emails":[%q],"active":true}}`, u.huid, u.name, email) + }) + return httptest.NewServer(mux) +} + +func TestEnqueue_InlineMentionEmail_DeferredToWorker(t *testing.T) { + testFakeQueue.Reset() + + cfgPath := writeTestConfig(t, ` +queue: + driver: testfake + url: fake://localhost + name: test-work +`) + deps, stdout, _ := testDeps() + deps.IsTerminal = true + + err := runEnqueue([]string{ + "--config", cfgPath, + "--host", "http://unused.local", + "--token", "test-token", + "--bot-id", "00000000-0000-0000-0000-000000000b01", + "--chat-id", "00000000-0000-0000-0000-000000000c01", + "--routing-mode", "direct", + "Hello @mention[email:user@example.com]!", + }, deps) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := strings.TrimSpace(stdout.String()) + if len(out) != 36 { + t.Errorf("expected UUID request_id, got %q", out) + } + + msgs := testFakeQueue.WorkMessages() + if len(msgs) != 1 { + t.Fatalf("expected 1 work message, got %d", len(msgs)) + } + msg := msgs[0] + + // Message should be passed through unchanged — parsing is deferred to the worker + if msg.Payload.Message != "Hello @mention[email:user@example.com]!" { + t.Errorf("Message = %q, want raw passthrough", msg.Payload.Message) + } + + // No mentions should be generated at enqueue time + if len(msg.Payload.Mentions) > 0 { + t.Errorf("expected no mentions at enqueue time, got %s", string(msg.Payload.Mentions)) + } + + // NoParse should be false (parsing will happen on worker) + if msg.Payload.Opts.NoParse { + t.Error("NoParse should be false") + } +} + +func TestEnqueue_RawAndInlineMentionsDeferredToWorker(t *testing.T) { + testFakeQueue.Reset() + + cfgPath := writeTestConfig(t, ` +queue: + driver: testfake + url: fake://localhost + name: test-work +`) + deps, _, _ := testDeps() + deps.IsTerminal = true + + rawMentions := `[{"mention_id":"raw-1","mention_type":"user","mention_data":{"user_huid":"aaaa","name":"Raw User"}}]` + err := runEnqueue([]string{ + "--config", cfgPath, + "--host", "http://unused.local", + "--token", "test-token", + "--bot-id", "00000000-0000-0000-0000-000000000b01", + "--chat-id", "00000000-0000-0000-0000-000000000c01", + "--routing-mode", "direct", + "--mentions", rawMentions, + "@{mention:raw-1} and @mention[email:user@example.com]", + }, deps) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + msgs := testFakeQueue.WorkMessages() + if len(msgs) != 1 { + t.Fatalf("expected 1 work message, got %d", len(msgs)) + } + + msg := msgs[0] + if msg.Payload.Message != "@{mention:raw-1} and @mention[email:user@example.com]" { + t.Errorf("Message = %q, want raw passthrough", msg.Payload.Message) + } + if string(msg.Payload.Mentions) != rawMentions { + t.Errorf("Mentions = %s, want %s", string(msg.Payload.Mentions), rawMentions) + } + if msg.Payload.Opts.NoParse { + t.Error("NoParse should be false so worker can parse inline mentions") + } +} + +func TestEnqueue_NoParse(t *testing.T) { + testFakeQueue.Reset() + + cfgPath := writeTestConfig(t, ` +queue: + driver: testfake + url: fake://localhost + name: test-work +`) + deps, _, _ := testDeps() + deps.IsTerminal = true + + err := runEnqueue([]string{ + "--config", cfgPath, + "--bot-id", "00000000-0000-0000-0000-000000000b01", + "--chat-id", "00000000-0000-0000-0000-000000000c01", + "--routing-mode", "direct", + "--no-parse", + "Hello @mention[email:user@example.com]!", + }, deps) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + msgs := testFakeQueue.WorkMessages() + if len(msgs) != 1 { + t.Fatalf("expected 1 work message, got %d", len(msgs)) + } + + // With --no-parse, the token should remain as-is + if !strings.Contains(msgs[0].Payload.Message, "@mention[email:user@example.com]") { + t.Errorf("Message should contain original token with --no-parse: %q", msgs[0].Payload.Message) + } + // No mentions should be generated + if len(msgs[0].Payload.Mentions) > 0 { + t.Errorf("expected no mentions with --no-parse, got: %s", string(msgs[0].Payload.Mentions)) + } +} + +func TestEnqueue_ParseErrorDoesNotFail(t *testing.T) { + testFakeQueue.Reset() + + srv := mockLookupServer(nil) // no users -> lookup will fail + defer srv.Close() + + cfgPath := writeTestConfig(t, ` +queue: + driver: testfake + url: fake://localhost + name: test-work +`) + deps, _, _ := testDeps() + deps.IsTerminal = true + + // Use an email that won't resolve and a malformed token + err := runEnqueue([]string{ + "--config", cfgPath, + "--host", srv.URL, + "--token", "test-token", + "--bot-id", "00000000-0000-0000-0000-000000000b01", + "--chat-id", "00000000-0000-0000-0000-000000000c01", + "--routing-mode", "direct", + "Hello @mention[email:nobody@example.com] and @mention[bad syntax", + }, deps) + if err != nil { + t.Fatalf("parse/lookup error should not fail the command, got: %v", err) + } + + msgs := testFakeQueue.WorkMessages() + if len(msgs) != 1 { + t.Fatalf("expected 1 work message, got %d", len(msgs)) + } + + // Tokens with errors should remain as literal text + if !strings.Contains(msgs[0].Payload.Message, "@mention[email:nobody@example.com]") { + t.Errorf("failed lookup token should stay as literal text: %q", msgs[0].Payload.Message) + } + if !strings.Contains(msgs[0].Payload.Message, "@mention[bad syntax") { + t.Errorf("parse error token should stay as literal text: %q", msgs[0].Payload.Message) + } +} + func TestEnqueue_NoMessage_Error(t *testing.T) { cfgPath := writeTestConfig(t, ` queue: diff --git a/internal/cmd/send.go b/internal/cmd/send.go index f1bf44d..6c6f552 100644 --- a/internal/cmd/send.go +++ b/internal/cmd/send.go @@ -8,14 +8,60 @@ import ( "flag" "fmt" "io" + "sync" "os" "path/filepath" "github.com/lavr/express-botx/internal/botapi" "github.com/lavr/express-botx/internal/config" "github.com/lavr/express-botx/internal/input" + "github.com/lavr/express-botx/internal/mentions" + "github.com/lavr/express-botx/internal/token" ) +// refreshableClientResolver wraps a botapi.Client and automatically refreshes +// the token on 401 errors. Used in long-running processes (serve, serve --enqueue) +// where the token may expire between requests. A mutex serializes access to the +// shared client to prevent data races under concurrent HTTP requests. +type refreshableClientResolver struct { + mu sync.Mutex + client *botapi.Client + cfg *config.Config + cache token.Cache +} + +func (r *refreshableClientResolver) GetUserByEmail(ctx context.Context, email string) (string, string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + // Lazy auth: if token is empty, authenticate now. + if r.client.Token == "" { + tok, err := refreshToken(r.cfg, r.cache) + if err != nil { + return "", "", fmt.Errorf("authenticating for email lookup: %w", err) + } + r.client.Token = tok + } + + info, err := r.client.GetUserByEmail(ctx, email) + if err != nil { + // If the token expired, refresh and retry once. + if errors.Is(err, botapi.ErrUnauthorized) && r.cfg.BotSecret != "" { + tok, refreshErr := refreshToken(r.cfg, r.cache) + if refreshErr == nil { + r.client.Token = tok + info, retryErr := r.client.GetUserByEmail(ctx, email) + if retryErr == nil { + return info.HUID, info.Name, nil + } + return "", "", retryErr + } + } + return "", "", err + } + return info.HUID, info.Name, nil +} + func runSend(args []string, deps Deps) error { fs := flag.NewFlagSet("send", flag.ContinueOnError) fs.SetOutput(deps.Stderr) @@ -28,8 +74,9 @@ func runSend(args []string, deps Deps) error { var stealth bool var forceDND bool var noNotify bool + var noParse bool var metadata string - var mentions string + var mentionsFlag string globalFlags(fs, &flags) fs.StringVar(&flags.ChatID, "chat-id", "", "target chat UUID or alias") @@ -42,7 +89,8 @@ func runSend(args []string, deps Deps) error { fs.BoolVar(&forceDND, "force-dnd", false, "deliver even if recipient has DND") fs.BoolVar(&noNotify, "no-notify", false, "do not send notification at all") fs.StringVar(&metadata, "metadata", "", "arbitrary JSON for notification.metadata") - fs.StringVar(&mentions, "mentions", "", "JSON array of mentions in BotX API wire format") + fs.StringVar(&mentionsFlag, "mentions", "", "JSON array of mentions in BotX API wire format") + fs.BoolVar(&noParse, "no-parse", false, "disable inline @mention[...] parsing") fs.Usage = func() { fmt.Fprintf(deps.Stderr, `Usage: express-botx send [options] [message] @@ -157,8 +205,8 @@ Options: // Validate mentions var ment json.RawMessage - if mentions != "" { - raw := json.RawMessage(mentions) + if mentionsFlag != "" { + raw := json.RawMessage(mentionsFlag) if !json.Valid(raw) { return fmt.Errorf("--mentions is not valid JSON") } @@ -170,27 +218,43 @@ Options: ment = raw } + // Authenticate + tok, cache, err := authenticate(cfg) + if err != nil { + return err + } + + client := botapi.NewClient(cfg.Host, tok, cfg.HTTPTimeout()) + + // Run inline mentions parser. + // Use refreshableClientResolver so the token is refreshed on 401 — the + // cached token may have expired since it was stored. + parseResult := mentions.Parse( + context.Background(), + message, + ment, + !noParse, + &refreshableClientResolver{client: client, cfg: cfg, cache: cache}, + ) + for _, e := range parseResult.Errors { + fmt.Fprintf(deps.Stderr, "warning: mention %s: %s\n", e.Kind, e.Cause) + } + // Build SendRequest sr := botapi.BuildSendRequest(&botapi.SendParams{ ChatID: cfg.ChatID, - Message: message, + Message: parseResult.Message, Status: status, File: fileAttachment, Metadata: meta, - Mentions: ment, + Mentions: parseResult.Mentions, Silent: silent, Stealth: stealth, ForceDND: forceDND, NoNotify: noNotify, }) - // Authenticate and send - tok, cache, err := authenticate(cfg) - if err != nil { - return err - } - - client := botapi.NewClient(cfg.Host, tok, cfg.HTTPTimeout()) + // Send err = client.Send(context.Background(), sr) if err != nil { if errors.Is(err, botapi.ErrUnauthorized) { diff --git a/internal/cmd/send_test.go b/internal/cmd/send_test.go index 3bcd0ac..0293f89 100644 --- a/internal/cmd/send_test.go +++ b/internal/cmd/send_test.go @@ -136,6 +136,296 @@ bots: } } +// mockBotxSendWithLookup extends mockBotxSend with user lookup support. +type mockBotxSendWithLookup struct { + *mockBotxSend + // users maps email -> {user_huid, name} + users map[string]struct{ huid, name string } +} + +func newMockBotxSendWithLookup(users map[string]struct{ huid, name string }) *mockBotxSendWithLookup { + m := &mockBotxSendWithLookup{ + mockBotxSend: &mockBotxSend{}, + users: users, + } + mux := http.NewServeMux() + + mux.HandleFunc("POST /api/v4/botx/notifications/direct", func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + body, _ := io.ReadAll(r.Body) + m.mu.Lock() + m.calls = append(m.calls, json.RawMessage(body)) + m.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + fmt.Fprintf(w, `{"status":"ok","result":{"sync_id":"sync-1"}}`) + }) + + mux.HandleFunc("GET /api/v3/botx/users/by_email", func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + email := r.URL.Query().Get("email") + u, ok := m.users[email] + if !ok { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{"status":"error","reason":"not_found"}`) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"ok","result":{"user_huid":%q,"name":%q,"emails":[%q],"active":true}}`, u.huid, u.name, email) + }) + + m.srv = httptest.NewServer(mux) + return m +} + +func TestSend_InlineMentionEmail(t *testing.T) { + mock := newMockBotxSendWithLookup(map[string]struct{ huid, name string }{ + "user@example.com": {huid: "11111111-1111-1111-1111-111111111111", name: "John Doe"}, + }) + defer mock.close() + + cfgPath := writeTestConfig(t, fmt.Sprintf(` +bots: + default: + host: %s + id: 00000000-0000-0000-0000-000000000001 + token: test-token +`, mock.srv.URL)) + + deps, _, _ := testDeps() + deps.IsTerminal = true + + err := runSend([]string{ + "--config", cfgPath, + "--chat-id", "00000000-0000-0000-0000-00000000c001", + "Hello @mention[email:user@example.com]!", + }, deps) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + raw := mock.lastCall() + if raw == nil { + t.Fatal("expected a captured request, got none") + } + + var req struct { + Notification *struct { + Body string `json:"body"` + Mentions json.RawMessage `json:"mentions"` + } `json:"notification"` + } + if err := json.Unmarshal(raw, &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if req.Notification == nil { + t.Fatal("expected notification, got nil") + } + + // Body should have the placeholder, not the original token + if strings.Contains(req.Notification.Body, "@mention[") { + t.Errorf("Body still contains inline token: %q", req.Notification.Body) + } + if !strings.Contains(req.Notification.Body, "@{mention:") { + t.Errorf("Body missing BotX placeholder: %q", req.Notification.Body) + } + + // Mentions should contain the resolved user + var mentionsList []struct { + MentionType string `json:"mention_type"` + MentionData *struct { + UserHUID string `json:"user_huid"` + Name string `json:"name"` + } `json:"mention_data"` + } + if err := json.Unmarshal(req.Notification.Mentions, &mentionsList); err != nil { + t.Fatalf("unmarshal mentions: %v", err) + } + if len(mentionsList) != 1 { + t.Fatalf("expected 1 mention, got %d", len(mentionsList)) + } + if mentionsList[0].MentionType != "user" { + t.Errorf("mention_type = %q, want %q", mentionsList[0].MentionType, "user") + } + if mentionsList[0].MentionData == nil || mentionsList[0].MentionData.UserHUID != "11111111-1111-1111-1111-111111111111" { + t.Errorf("unexpected mention_data: %+v", mentionsList[0].MentionData) + } +} + +func TestSend_RawAndInlineMentionsMerge(t *testing.T) { + mock := newMockBotxSendWithLookup(map[string]struct{ huid, name string }{ + "user@example.com": {huid: "22222222-2222-2222-2222-222222222222", name: "Jane Doe"}, + }) + defer mock.close() + + cfgPath := writeTestConfig(t, fmt.Sprintf(` +bots: + default: + host: %s + id: 00000000-0000-0000-0000-000000000001 + token: test-token +`, mock.srv.URL)) + + deps, _, _ := testDeps() + deps.IsTerminal = true + + rawMentions := `[{"mention_id":"raw-1","mention_type":"user","mention_data":{"user_huid":"aaaa","name":"Raw User"}}]` + err := runSend([]string{ + "--config", cfgPath, + "--chat-id", "00000000-0000-0000-0000-00000000c001", + "--mentions", rawMentions, + "@{mention:raw-1} and @mention[email:user@example.com]", + }, deps) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + raw := mock.lastCall() + if raw == nil { + t.Fatal("expected a captured request, got none") + } + + var req struct { + Notification *struct { + Mentions json.RawMessage `json:"mentions"` + } `json:"notification"` + } + if err := json.Unmarshal(raw, &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + var mentionsList []struct { + MentionID string `json:"mention_id"` + MentionType string `json:"mention_type"` + } + if err := json.Unmarshal(req.Notification.Mentions, &mentionsList); err != nil { + t.Fatalf("unmarshal mentions: %v", err) + } + if len(mentionsList) != 2 { + t.Fatalf("expected 2 mentions (raw + parsed), got %d", len(mentionsList)) + } + // Raw mention comes first + if mentionsList[0].MentionID != "raw-1" { + t.Errorf("first mention should be raw, got mention_id=%q", mentionsList[0].MentionID) + } + // Parsed mention comes second + if mentionsList[1].MentionType != "user" { + t.Errorf("second mention should be parsed user, got type=%q", mentionsList[1].MentionType) + } +} + +func TestSend_NoParse(t *testing.T) { + mock := newMockBotxSend() + defer mock.close() + + cfgPath := writeTestConfig(t, fmt.Sprintf(` +bots: + default: + host: %s + id: 00000000-0000-0000-0000-000000000001 + token: test-token +`, mock.srv.URL)) + + deps, _, _ := testDeps() + deps.IsTerminal = true + + err := runSend([]string{ + "--config", cfgPath, + "--chat-id", "00000000-0000-0000-0000-00000000c001", + "--no-parse", + "Hello @mention[email:user@example.com]!", + }, deps) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + raw := mock.lastCall() + if raw == nil { + t.Fatal("expected a captured request, got none") + } + + var req struct { + Notification *struct { + Body string `json:"body"` + Mentions json.RawMessage `json:"mentions"` + } `json:"notification"` + } + if err := json.Unmarshal(raw, &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if req.Notification == nil { + t.Fatal("expected notification, got nil") + } + + // With --no-parse, the token should remain as-is + if !strings.Contains(req.Notification.Body, "@mention[email:user@example.com]") { + t.Errorf("Body should contain original token with --no-parse: %q", req.Notification.Body) + } + // No mentions should be generated + if len(req.Notification.Mentions) > 0 { + t.Errorf("expected no mentions with --no-parse, got: %s", string(req.Notification.Mentions)) + } +} + +func TestSend_ParseErrorDoesNotFail(t *testing.T) { + mock := newMockBotxSendWithLookup(nil) // no users -> lookup will fail + defer mock.close() + + cfgPath := writeTestConfig(t, fmt.Sprintf(` +bots: + default: + host: %s + id: 00000000-0000-0000-0000-000000000001 + token: test-token +`, mock.srv.URL)) + + deps, _, _ := testDeps() + deps.IsTerminal = true + + // Use an email that won't resolve and a malformed token + err := runSend([]string{ + "--config", cfgPath, + "--chat-id", "00000000-0000-0000-0000-00000000c001", + "Hello @mention[email:nobody@example.com] and @mention[bad syntax", + }, deps) + if err != nil { + t.Fatalf("parse/lookup error should not fail the command, got: %v", err) + } + + raw := mock.lastCall() + if raw == nil { + t.Fatal("expected a captured request, got none") + } + + var req struct { + Notification *struct { + Body string `json:"body"` + } `json:"notification"` + } + if err := json.Unmarshal(raw, &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if req.Notification == nil { + t.Fatal("expected notification, got nil") + } + + // Tokens with errors should remain as literal text + if !strings.Contains(req.Notification.Body, "@mention[email:nobody@example.com]") { + t.Errorf("failed lookup token should stay as literal text: %q", req.Notification.Body) + } + if !strings.Contains(req.Notification.Body, "@mention[bad syntax") { + t.Errorf("parse error token should stay as literal text: %q", req.Notification.Body) + } +} + func TestSend_MentionsNotArray(t *testing.T) { deps, _, _ := testDeps() deps.IsTerminal = true diff --git a/internal/cmd/serve.go b/internal/cmd/serve.go index d02f430..baaa67e 100644 --- a/internal/cmd/serve.go +++ b/internal/cmd/serve.go @@ -19,6 +19,7 @@ import ( "github.com/lavr/express-botx/internal/config" "github.com/lavr/express-botx/internal/errtrack" vlog "github.com/lavr/express-botx/internal/log" + "github.com/lavr/express-botx/internal/mentions" "github.com/lavr/express-botx/internal/queue" "github.com/lavr/express-botx/internal/secret" "github.com/lavr/express-botx/internal/server" @@ -179,6 +180,8 @@ Options: // bots that failed auth are retried in the background every 10 seconds. // Requests to unavailable bots return 503 until auth succeeds. var sendFn server.SendFunc + var mentionsResolver mentions.UserResolver + var botMentionsResolvers map[string]mentions.UserResolver if cfg.IsMultiBot() { senders := make(map[string]*botSender, len(cfg.Bots)) @@ -203,6 +206,20 @@ Options: sendFn = func(ctx context.Context, p *server.SendPayload) (string, error) { return senders[p.Bot].Send(ctx, p) } + // Create per-bot resolvers so that email lookups target the correct + // eXpress host when bots reside on different instances. The first + // bot's resolver is used as the default fallback for requests where + // the bot is not yet known (e.g. resolved from a chat binding). + botMentionsResolvers = make(map[string]mentions.UserResolver, len(botNames)) + for _, name := range botNames { + s := senders[name] + botMentionsResolvers[name] = &refreshableClientResolver{ + client: botapi.NewClient(s.cfg.Host, s.client.Token, s.cfg.HTTPTimeout()), + cfg: s.cfg, + cache: s.cache, + } + } + mentionsResolver = botMentionsResolvers[botNames[0]] } else { sender, err := newBotSender(cfg, failFast) if err != nil { @@ -210,6 +227,13 @@ Options: } sendFn = sender.Send srvCfg.SingleBotName = cfg.BotName + // Use a separate client so the resolver can refresh the token + // independently of botSender, avoiding races on the shared token. + mentionsResolver = &refreshableClientResolver{ + client: botapi.NewClient(cfg.Host, sender.client.Token, cfg.HTTPTimeout()), + cfg: cfg, + cache: sender.cache, + } } // Validate chat-bot bindings @@ -240,6 +264,10 @@ Options: srvOpts = append(srvOpts, server.WithAPM(provider)) srvOpts = append(srvOpts, server.WithErrTracker(tracker)) srvOpts = append(srvOpts, server.WithConfigInfo(runtimeBotEntries(cfg), runtimeChatEntries(cfg))) + srvOpts = append(srvOpts, server.WithMentionsResolver(mentionsResolver)) + if len(botMentionsResolvers) > 0 { + srvOpts = append(srvOpts, server.WithBotMentionsResolvers(botMentionsResolvers)) + } // Alertmanager endpoint (always enabled) am := cfg.Server.Alertmanager @@ -510,12 +538,6 @@ func generateAPIKey() (string, error) { return hex.EncodeToString(b), nil } -// sendResponseJSON is used for encoding sync_id from the BotX API response. -type sendResponseJSON struct { - OK bool `json:"ok"` - SyncID string `json:"sync_id,omitempty"` -} - // buildBotSecretLookup resolves bot secrets at startup and returns a lookup function. // Secrets are cached to avoid repeated Vault/env lookups on every JWT verification. func buildBotSecretLookup(cfg *config.Config) (func(botID string) (string, error), error) { @@ -824,13 +846,12 @@ func runServeEnqueue(flags config.Flags, listenFlag, apiKeyFlag string, deps Dep EnqueuedAt: time.Now().UTC(), } + msg.Payload.Opts.NoParse = p.NoParse if p.Opts != nil { - msg.Payload.Opts = queue.DeliveryOpts{ - Silent: p.Opts.Silent, - Stealth: p.Opts.Stealth, - ForceDND: p.Opts.ForceDND, - NoNotify: p.Opts.NoNotify, - } + msg.Payload.Opts.Silent = p.Opts.Silent + msg.Payload.Opts.Stealth = p.Opts.Stealth + msg.Payload.Opts.ForceDND = p.Opts.ForceDND + msg.Payload.Opts.NoNotify = p.Opts.NoNotify } if p.File != nil { diff --git a/internal/cmd/serve_integration_test.go b/internal/cmd/serve_integration_test.go index 7e1cfa7..61880d7 100644 --- a/internal/cmd/serve_integration_test.go +++ b/internal/cmd/serve_integration_test.go @@ -15,6 +15,7 @@ import ( "github.com/lavr/express-botx/internal/botapi" "github.com/lavr/express-botx/internal/config" + "github.com/lavr/express-botx/internal/mentions" "github.com/lavr/express-botx/internal/queue" "github.com/lavr/express-botx/internal/server" ) @@ -25,6 +26,7 @@ type mockBotxAPI struct { mu sync.Mutex calls []capturedSend // captured /notifications/direct calls tokenVal string // token to return + users map[string]struct{ huid, name string } // email -> user info for lookup } type capturedSend struct { @@ -57,6 +59,29 @@ func (m *mockBotxAPI) handler() http.Handler { }) }) + // User lookup endpoint: GET /api/v3/botx/users/by_email + mux.HandleFunc("GET /api/v3/botx/users/by_email", func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer "+m.tokenVal { + w.WriteHeader(http.StatusUnauthorized) + return + } + email := r.URL.Query().Get("email") + if m.users == nil { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{"status":"error","reason":"not_found"}`) + return + } + u, ok := m.users[email] + if !ok { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{"status":"error","reason":"not_found"}`) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"ok","result":{"user_huid":%q,"name":%q,"emails":[%q],"active":true}}`, u.huid, u.name, email) + }) + // Send endpoint: POST /api/v4/botx/notifications/direct mux.HandleFunc("POST /api/v4/botx/notifications/direct", func(w http.ResponseWriter, r *http.Request) { // Verify bearer token @@ -881,6 +906,155 @@ func TestBuildSendRequest_AsyncPathMentions(t *testing.T) { } } +// testUserResolver is a simple mentions.UserResolver for tests. +type testUserResolver struct { + users map[string]struct{ huid, name string } +} + +func (r *testUserResolver) GetUserByEmail(_ context.Context, email string) (string, string, error) { + u, ok := r.users[email] + if !ok { + return "", "", fmt.Errorf("user not found: %s", email) + } + return u.huid, u.name, nil +} + +func TestServeIntegration_SyncPath_InlineMentionNormalized(t *testing.T) { + mock := newMockBotxAPI() + mock.users = map[string]struct{ huid, name string }{ + "alice@example.com": {huid: "aaaa1111-1111-1111-1111-111111111111", name: "Alice"}, + } + botxSrv := httptest.NewServer(mock.handler()) + defer botxSrv.Close() + + port := freePort(t) + listenAddr := fmt.Sprintf("127.0.0.1:%d", port) + + cfgPath := writeTestConfig(t, fmt.Sprintf(` +bots: + main: + host: %s + id: bot-001 + secret: secret-001 +server: + listen: "%s" + api_keys: + - name: test + key: test-key +`, botxSrv.URL, listenAddr)) + + startServe(t, []string{"--config", cfgPath, "--listen", listenAddr, "--no-cache"}) + baseURL := fmt.Sprintf("http://%s/api/v1", listenAddr) + + code, resp := doPost(t, baseURL+"/send", "test-key", + `{"chat_id":"c0000000-0000-0000-0000-000000000001","message":"Hi @mention[email:alice@example.com]!"}`) + if code != 200 { + t.Fatalf("expected 200, got %d: %v", code, resp) + } + + calls := mock.getCalls() + if len(calls) != 1 { + t.Fatalf("expected 1 BotX API call, got %d", len(calls)) + } + + call := calls[0] + if call.Notification == nil { + t.Fatal("expected notification in BotX call") + } + // Body should have BotX placeholder, not inline token + if strings.Contains(call.Notification.Body, "@mention[") { + t.Errorf("BotX body still contains inline token: %q", call.Notification.Body) + } + if !strings.Contains(call.Notification.Body, "@{mention:") { + t.Errorf("BotX body missing placeholder: %q", call.Notification.Body) + } + // Mentions array should contain the parsed mention with correct user_huid + if call.Notification.Mentions == nil { + t.Fatal("expected mentions in BotX call") + } + var mentions []map[string]interface{} + if err := json.Unmarshal(call.Notification.Mentions, &mentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + if len(mentions) != 1 { + t.Fatalf("expected 1 mention, got %d", len(mentions)) + } + data, _ := mentions[0]["mention_data"].(map[string]interface{}) + if data == nil || data["user_huid"] != "aaaa1111-1111-1111-1111-111111111111" { + t.Errorf("expected user_huid 'aaaa1111-...', got %v", data) + } +} + +func TestAsyncPath_InlineMentionNormalized(t *testing.T) { + // Simulate the async pipeline: parse -> enqueue -> worker builds BotX request. + // This verifies normalized data survives queue serialization. + resolver := &testUserResolver{users: map[string]struct{ huid, name string }{ + "bob@example.com": {huid: "bbbb2222-2222-2222-2222-222222222222", name: "Bob"}, + }} + + // Step 1: Parse inline mentions (as handler_send.go:110 does) + parseResult := mentions.Parse(context.Background(), + "Hi @mention[email:bob@example.com]!", nil, true, resolver) + + // Verify parser output before enqueue + if strings.Contains(parseResult.Message, "@mention[") { + t.Fatalf("parser should have replaced inline token: %q", parseResult.Message) + } + if !strings.Contains(parseResult.Message, "@{mention:") { + t.Fatalf("parser should have inserted BotX placeholder: %q", parseResult.Message) + } + + // Step 2: Build WorkMessage (as sendFn does at serve.go:824-828) + msg := &queue.WorkMessage{ + RequestID: "req-async-test", + Routing: queue.Routing{BotID: "bot-001", ChatID: "chat-001"}, + Payload: queue.Payload{ + Message: parseResult.Message, + Status: "ok", + Mentions: parseResult.Mentions, + }, + ReplyTo: "replies", + EnqueuedAt: time.Now().UTC(), + } + + // Step 3: Simulate queue serialization round-trip + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal WorkMessage: %v", err) + } + var restored queue.WorkMessage + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatalf("unmarshal WorkMessage: %v", err) + } + + // Step 4: Build BotX request from restored message (as worker does) + sr := buildSendRequestFromWork(&restored) + + // Verify: body has BotX placeholder, not inline token + if strings.Contains(sr.Notification.Body, "@mention[") { + t.Errorf("BotX body still contains inline token after queue: %q", sr.Notification.Body) + } + if !strings.Contains(sr.Notification.Body, "@{mention:") { + t.Errorf("BotX body missing placeholder after queue: %q", sr.Notification.Body) + } + + // Verify: mentions survived queue round-trip + if sr.Notification.Mentions == nil { + t.Fatal("expected mentions in BotX request after queue") + } + var m []map[string]interface{} + if err := json.Unmarshal(sr.Notification.Mentions, &m); err != nil { + t.Fatalf("unmarshal mentions: %v", err) + } + if len(m) != 1 { + t.Fatalf("expected 1 mention after queue, got %d", len(m)) + } + mentionData, _ := m[0]["mention_data"].(map[string]interface{}) + if mentionData == nil || mentionData["user_huid"] != "bbbb2222-2222-2222-2222-222222222222" { + t.Errorf("expected user_huid 'bbbb2222-...', got %v", mentionData) + } +} + // TestAuthenticate_ReturnsCacheOnGetTokenError verifies that authenticate // returns a non-nil cache even when GetToken fails (the root cause fix). func TestAuthenticate_ReturnsCacheOnGetTokenError(t *testing.T) { diff --git a/internal/cmd/worker.go b/internal/cmd/worker.go index a438b94..f74b0df 100644 --- a/internal/cmd/worker.go +++ b/internal/cmd/worker.go @@ -18,6 +18,7 @@ import ( "github.com/lavr/express-botx/internal/botapi" "github.com/lavr/express-botx/internal/config" vlog "github.com/lavr/express-botx/internal/log" + "github.com/lavr/express-botx/internal/mentions" "github.com/lavr/express-botx/internal/queue" ) @@ -251,6 +252,20 @@ func (w *workerRunner) handleMessage(ctx context.Context, msg *queue.WorkMessage // Get or create bot client bc := w.getOrCreateClient(botName, botCfg) + // Parse inline mentions using the resolved bot's host for email lookups. + if !msg.Payload.Opts.NoParse { + resolver := &refreshableClientResolver{ + client: botapi.NewClient(bc.cfg.Host, bc.client.Token, bc.cfg.HTTPTimeout()), + cfg: bc.cfg, + } + parseResult := mentions.Parse(ctx, msg.Payload.Message, msg.Payload.Mentions, true, resolver) + msg.Payload.Message = parseResult.Message + msg.Payload.Mentions = parseResult.Mentions + for _, e := range parseResult.Errors { + vlog.V2("worker: request_id=%s mentions parse: %s: %s", msg.RequestID, e.Kind, e.Cause) + } + } + // Build send request from work message sr := buildSendRequestFromWork(msg) diff --git a/internal/cmd/worker_test.go b/internal/cmd/worker_test.go index f230644..4120277 100644 --- a/internal/cmd/worker_test.go +++ b/internal/cmd/worker_test.go @@ -789,3 +789,235 @@ func TestWorker_HandleMessage_DryRun(t *testing.T) { t.Errorf("result request_id = %q, want %q", results[0].RequestID, "dry-run-req-001") } } + +func TestWorker_HandleMessage_WithInlineParsedMentions(t *testing.T) { + mock := newMockBotxAPI() + botxSrv := httptest.NewServer(mock.handler()) + defer botxSrv.Close() + + fakeQ := queue.NewFake() + + cfg := &config.Config{ + Bots: map[string]config.BotConfig{ + "alerts": { + Host: botxSrv.URL, + ID: "bot-uuid-parsed", + Secret: "test-secret", + }, + }, + Cache: config.CacheConfig{Type: "none"}, + } + + w, err := newWorkerRunner(cfg, fakeQ, apm.New(), false) + if err != nil { + t.Fatalf("newWorkerRunner: %v", err) + } + + // Simulate data that the inline parser would produce: + // - message has @{mention:id} BotX placeholder + // - mentions has the parsed entry merged with a raw entry + rawMention := `{"mention_id":"raw-id","mention_type":"user","mention_data":{"user_huid":"raw-huid","name":"Raw User"}}` + parsedMention := `{"mention_id":"parsed-id-1","mention_type":"user","mention_data":{"user_huid":"parsed-huid","name":"Alice"}}` + mergedMentions := json.RawMessage(fmt.Sprintf("[%s,%s]", rawMention, parsedMention)) + + msg := &queue.WorkMessage{ + RequestID: "req-parsed-mentions", + Routing: queue.Routing{ + BotID: "bot-uuid-parsed", + ChatID: "chat-uuid-001", + }, + Payload: queue.Payload{ + Message: "@{mention:raw-id} and @{mention:parsed-id-1} hello!", + Status: "ok", + Mentions: mergedMentions, + }, + ReplyTo: "test-replies", + EnqueuedAt: time.Now().UTC(), + } + + err = w.handleMessage(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + calls := mock.getCalls() + if len(calls) != 1 { + t.Fatalf("expected 1 BotX API call, got %d", len(calls)) + } + + call := calls[0] + if call.Notification == nil { + t.Fatal("expected notification in BotX call") + } + // Body must preserve BotX placeholders + if call.Notification.Body != "@{mention:raw-id} and @{mention:parsed-id-1} hello!" { + t.Errorf("Body = %q, want %q", call.Notification.Body, + "@{mention:raw-id} and @{mention:parsed-id-1} hello!") + } + // Both raw and parsed mentions must reach BotX + if call.Notification.Mentions == nil { + t.Fatal("expected mentions in BotX API request") + } + var gotMentions []map[string]interface{} + if err := json.Unmarshal(call.Notification.Mentions, &gotMentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + if len(gotMentions) != 2 { + t.Fatalf("expected 2 mentions (raw + parsed), got %d", len(gotMentions)) + } + if gotMentions[0]["mention_id"] != "raw-id" { + t.Errorf("first mention_id = %v, want %q", gotMentions[0]["mention_id"], "raw-id") + } + if gotMentions[1]["mention_id"] != "parsed-id-1" { + t.Errorf("second mention_id = %v, want %q", gotMentions[1]["mention_id"], "parsed-id-1") + } + parsedData, _ := gotMentions[1]["mention_data"].(map[string]interface{}) + if parsedData == nil || parsedData["user_huid"] != "parsed-huid" { + t.Errorf("expected user_huid 'parsed-huid', got %v", parsedData) + } +} + +func TestWorker_HandleMessage_ParsesDeferredInlineMentions(t *testing.T) { + mock := newMockBotxAPI() + mock.users = map[string]struct{ huid, name string }{ + "user@example.com": {huid: "22222222-2222-2222-2222-222222222222", name: "Jane Doe"}, + } + botxSrv := httptest.NewServer(mock.handler()) + defer botxSrv.Close() + + fakeQ := queue.NewFake() + + cfg := &config.Config{ + Bots: map[string]config.BotConfig{ + "alerts": { + Host: botxSrv.URL, + ID: "bot-uuid-deferred", + Secret: "test-secret", + }, + }, + Cache: config.CacheConfig{Type: "none"}, + } + + w, err := newWorkerRunner(cfg, fakeQ, apm.New(), false) + if err != nil { + t.Fatalf("newWorkerRunner: %v", err) + } + + rawMentions := json.RawMessage(`[{"mention_id":"raw-1","mention_type":"user","mention_data":{"user_huid":"raw-huid","name":"Raw User"}}]`) + msg := &queue.WorkMessage{ + RequestID: "req-deferred-mentions", + Routing: queue.Routing{ + BotID: "bot-uuid-deferred", + ChatID: "chat-uuid-001", + }, + Payload: queue.Payload{ + Message: "@{mention:raw-1} and @mention[email:user@example.com]", + Status: "ok", + Mentions: rawMentions, + }, + ReplyTo: "test-replies", + EnqueuedAt: time.Now().UTC(), + } + + err = w.handleMessage(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + calls := mock.getCalls() + if len(calls) != 1 { + t.Fatalf("expected 1 BotX API call, got %d", len(calls)) + } + call := calls[0] + if call.Notification == nil { + t.Fatal("expected notification in BotX call") + } + if strings.Contains(call.Notification.Body, "@mention[") { + t.Fatalf("expected inline mention to be parsed on worker, got body %q", call.Notification.Body) + } + if !strings.Contains(call.Notification.Body, "@{mention:raw-1}") || !strings.Contains(call.Notification.Body, "@{mention:") { + t.Fatalf("expected BotX placeholders in body, got %q", call.Notification.Body) + } + + var gotMentions []map[string]interface{} + if err := json.Unmarshal(call.Notification.Mentions, &gotMentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + if len(gotMentions) != 2 { + t.Fatalf("expected 2 mentions (raw + parsed), got %d", len(gotMentions)) + } + if gotMentions[0]["mention_id"] != "raw-1" { + t.Errorf("first mention_id = %v, want %q", gotMentions[0]["mention_id"], "raw-1") + } + if gotMentions[1]["mention_type"] != "user" { + t.Errorf("second mention_type = %v, want %q", gotMentions[1]["mention_type"], "user") + } + parsedData, _ := gotMentions[1]["mention_data"].(map[string]interface{}) + if parsedData == nil || parsedData["user_huid"] != "22222222-2222-2222-2222-222222222222" { + t.Fatalf("unexpected parsed mention_data: %v", parsedData) + } +} + +func TestWorker_HandleMessage_NoParseSkipsDeferredInlineMentions(t *testing.T) { + mock := newMockBotxAPI() + mock.users = map[string]struct{ huid, name string }{ + "user@example.com": {huid: "22222222-2222-2222-2222-222222222222", name: "Jane Doe"}, + } + botxSrv := httptest.NewServer(mock.handler()) + defer botxSrv.Close() + + fakeQ := queue.NewFake() + + cfg := &config.Config{ + Bots: map[string]config.BotConfig{ + "alerts": { + Host: botxSrv.URL, + ID: "bot-uuid-no-parse", + Secret: "test-secret", + }, + }, + Cache: config.CacheConfig{Type: "none"}, + } + + w, err := newWorkerRunner(cfg, fakeQ, apm.New(), false) + if err != nil { + t.Fatalf("newWorkerRunner: %v", err) + } + + msg := &queue.WorkMessage{ + RequestID: "req-no-parse", + Routing: queue.Routing{ + BotID: "bot-uuid-no-parse", + ChatID: "chat-uuid-001", + }, + Payload: queue.Payload{ + Message: "@mention[email:user@example.com]", + Status: "ok", + Opts: queue.DeliveryOpts{ + NoParse: true, + }, + }, + ReplyTo: "test-replies", + EnqueuedAt: time.Now().UTC(), + } + + err = w.handleMessage(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + calls := mock.getCalls() + if len(calls) != 1 { + t.Fatalf("expected 1 BotX API call, got %d", len(calls)) + } + call := calls[0] + if call.Notification == nil { + t.Fatal("expected notification in BotX call") + } + if call.Notification.Body != "@mention[email:user@example.com]" { + t.Fatalf("body = %q, want raw inline mention to remain unchanged", call.Notification.Body) + } + if call.Notification.Mentions != nil { + t.Fatalf("expected no mentions when no_parse is set, got %s", string(call.Notification.Mentions)) + } +} diff --git a/internal/mentions/parser.go b/internal/mentions/parser.go new file mode 100644 index 0000000..9f07c1e --- /dev/null +++ b/internal/mentions/parser.go @@ -0,0 +1,398 @@ +package mentions + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "strings" +) + +// MaxParsedMentions is the maximum number of inline tokens the parser will +// process in a single message. Tokens beyond this limit are left as literal +// text and a limit error is recorded. +const MaxParsedMentions = 1000 + +// maxParsedMentions is the effective limit used at runtime. +// Tests may override this; production code uses MaxParsedMentions. +var maxParsedMentions = MaxParsedMentions + +// tokenPrefix is the opening sequence the scanner looks for. +const tokenPrefix = "@mention[" + +// UserResolver looks up a user by email and returns their info. +// In production this is backed by botapi.Client.GetUserByEmail. +type UserResolver interface { + GetUserByEmail(ctx context.Context, email string) (huid string, name string, err error) +} + +// ParseError describes a single parser or lookup failure. +type ParseError struct { + Kind string `json:"kind"` // "parse", "lookup", "limit" + Token string `json:"token"` // original token text + Resolver string `json:"resolver,omitempty"` // "email", "huid", "all" if known + Value string `json:"value,omitempty"` // resolver value if known + Cause string `json:"cause"` // human-readable reason +} + +// parsedToken represents a successfully parsed inline mention token. +type parsedToken struct { + resolver string // "email", "huid", "all" + value string // email address or UUID; empty for "all" + displayName string // URL-decoded display name; may be empty +} + +// mentionEntry represents a single BotX wire-format mention. +type mentionEntry struct { + MentionID string `json:"mention_id"` + MentionType string `json:"mention_type"` + MentionData *mentionData `json:"mention_data"` +} + +// mentionData holds user-specific mention data. +type mentionData struct { + UserHUID string `json:"user_huid"` + Name string `json:"name,omitempty"` +} + +// newMentionID generates a unique mention identifier. +// Replaced in tests for deterministic output. +var newMentionID = generateUUIDv4 + +func generateUUIDv4() string { + var b [16]byte + _, _ = rand.Read(b[:]) + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 10 + h := hex.EncodeToString(b[:]) + return h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:] +} + +// Result holds the output of Parse. +type Result struct { + Message string // normalized message text + Mentions json.RawMessage // merged mentions array (BotX wire-format) + Errors []ParseError // accumulated parse/lookup/limit errors +} + +// Parse scans the message for @mention[...] tokens and normalizes them into +// BotX wire-format. Raw mentions (already in BotX format) are preserved and +// parsed mentions are appended after them. +// +// If parseEnabled is false, the message is returned unchanged and rawMentions +// are passed through as-is. +func Parse(ctx context.Context, message string, rawMentions json.RawMessage, parseEnabled bool, resolver UserResolver) *Result { + if !parseEnabled { + return &Result{ + Message: message, + Mentions: rawMentions, + } + } + + tokens := scan(message) + if len(tokens) == 0 { + return &Result{ + Message: message, + Mentions: rawMentions, + } + } + + var errs []ParseError + var parsed []mentionEntry + var buf strings.Builder + cursor := 0 + processed := 0 + limitHit := false + + for _, tok := range tokens { + raw := tok.raw(message) + buf.WriteString(message[cursor:tok.start]) + + // If the limit has been reached, leave remaining tokens as literal text. + if limitHit { + buf.WriteString(raw) + cursor = tok.end + continue + } + + if tok.unclosed { + errs = append(errs, ParseError{ + Kind: "parse", + Token: raw, + Cause: "unclosed token", + }) + buf.WriteString(raw) + cursor = tok.end + continue + } + + body := tok.body(message) + pt, err := parseTokenBody(body) + if err != nil { + errs = append(errs, ParseError{ + Kind: "parse", + Token: raw, + Cause: err.Error(), + }) + buf.WriteString(raw) + cursor = tok.end + continue + } + + // Count every well-formed token toward the limit — both successful + // and failed normalizations involve work (including potential network + // lookups for email), so they all consume the budget. + processed++ + if processed > maxParsedMentions { + limitHit = true + errs = append(errs, ParseError{ + Kind: "limit", + Token: "", + Cause: fmt.Sprintf("reached maximum of %d parsed mentions", maxParsedMentions), + }) + buf.WriteString(raw) + cursor = tok.end + continue + } + + entry, perr := normalize(ctx, pt, raw, resolver) + if perr != nil { + errs = append(errs, *perr) + buf.WriteString(raw) + } else { + parsed = append(parsed, *entry) + fmt.Fprintf(&buf, "@{mention:%s}", entry.MentionID) + } + cursor = tok.end + } + + buf.WriteString(message[cursor:]) + + return &Result{ + Message: buf.String(), + Mentions: mergeMentions(rawMentions, parsed), + Errors: errs, + } +} + +// normalize resolves a parsed token into a BotX mention entry. +// Returns (entry, nil) on success or (nil, error) on lookup failure. +func normalize(ctx context.Context, pt *parsedToken, raw string, resolver UserResolver) (*mentionEntry, *ParseError) { + switch pt.resolver { + case "email": + return normalizeEmail(ctx, pt, raw, resolver) + case "huid": + return normalizeHuid(pt) + case "all": + return normalizeAll() + default: + return nil, &ParseError{Kind: "parse", Token: raw, Cause: fmt.Sprintf("unknown resolver %q", pt.resolver)} + } +} + +// normalizeEmail resolves an email token by looking up the user. +func normalizeEmail(ctx context.Context, pt *parsedToken, raw string, resolver UserResolver) (*mentionEntry, *ParseError) { + if resolver == nil { + return nil, &ParseError{ + Kind: "lookup", Token: raw, Resolver: "email", + Value: pt.value, Cause: "no resolver available", + } + } + + huid, name, err := resolver.GetUserByEmail(ctx, pt.value) + if err != nil { + return nil, &ParseError{ + Kind: "lookup", Token: raw, Resolver: "email", + Value: pt.value, Cause: err.Error(), + } + } + + displayName := pt.displayName + if displayName == "" { + displayName = name + } + + return &mentionEntry{ + MentionID: newMentionID(), + MentionType: "user", + MentionData: &mentionData{ + UserHUID: huid, + Name: displayName, + }, + }, nil +} + +// normalizeHuid creates a mention entry from an huid token. No lookup is +// needed — the huid is taken directly from the token. The name field is only +// set when a display name was explicitly provided. +func normalizeHuid(pt *parsedToken) (*mentionEntry, *ParseError) { + md := &mentionData{UserHUID: pt.value} + if pt.displayName != "" { + md.Name = pt.displayName + } + return &mentionEntry{ + MentionID: newMentionID(), + MentionType: "user", + MentionData: md, + }, nil +} + +// normalizeAll creates a mention entry for the "all" (broadcast) token. +// No lookup is needed. mention_data is nil for "all" mentions. +func normalizeAll() (*mentionEntry, *ParseError) { + return &mentionEntry{ + MentionID: newMentionID(), + MentionType: "all", + MentionData: nil, + }, nil +} + +// mergeMentions combines raw mentions (already in BotX wire-format) with +// parsed mention entries. Raw mentions come first, parsed are appended. +func mergeMentions(raw json.RawMessage, parsed []mentionEntry) json.RawMessage { + if len(parsed) == 0 { + return raw + } + + var arr []json.RawMessage + if len(raw) > 0 { + _ = json.Unmarshal(raw, &arr) + } + + for i := range parsed { + b, _ := json.Marshal(parsed[i]) + arr = append(arr, b) + } + + result, _ := json.Marshal(arr) + return result +} + +// tokenSpan records the byte offsets of a single @mention[...] token found +// in the message. +type tokenSpan struct { + start int // index of '@' in tokenPrefix + end int // index one past ']' (or end of token text for unclosed) + unclosed bool // true if the closing ']' was not found +} + +// raw returns the original token text from the message. +func (t tokenSpan) raw(msg string) string { + return msg[t.start:t.end] +} + +// body returns the text between '[' and ']' in the original message. +// Must not be called on unclosed tokens. +func (t tokenSpan) body(msg string) string { + return msg[t.start+len(tokenPrefix) : t.end-1] +} + +// scan finds all @mention[...] tokens in msg. +// Tokens must have a matching closing ']'. Nested brackets are not supported. +// Unclosed tokens are ignored (they will be caught as parse errors later when +// the full grammar parser is wired in). +// Scanning stops after 2*maxParsedMentions tokens to bound memory usage. +func scan(msg string) []tokenSpan { + var spans []tokenSpan + scanLimit := 2 * maxParsedMentions + i := 0 + for i < len(msg) { + // Look for the prefix. + idx := indexAt(msg, tokenPrefix, i) + if idx < 0 { + break + } + // Find the closing bracket. We don't allow newlines inside tokens. + bodyStart := idx + len(tokenPrefix) + closed := false + j := bodyStart + for j < len(msg) { + if msg[j] == ']' { + spans = append(spans, tokenSpan{start: idx, end: j + 1}) + i = j + 1 + closed = true + break + } + if msg[j] == '\n' { + break + } + j++ + } + if !closed { + // Record unclosed token for error reporting. + spans = append(spans, tokenSpan{start: idx, end: j, unclosed: true}) + i = j + } + if len(spans) >= scanLimit { + break + } + } + return spans +} + +// parseTokenBody parses the content between @mention[ and ]. +// Supported forms: +// - "all" +// - "email:
" +// - "email:
;" +// - "huid:" +// - "huid:;" +func parseTokenBody(body string) (*parsedToken, error) { + if body == "all" { + return &parsedToken{resolver: "all"}, nil + } + + colonIdx := strings.Index(body, ":") + if colonIdx < 0 { + if strings.HasPrefix(body, "all;") { + return nil, fmt.Errorf("'all' does not accept value or display name") + } + return nil, fmt.Errorf("unknown resolver %q", body) + } + + resolver := body[:colonIdx] + rest := body[colonIdx+1:] + + if resolver != "email" && resolver != "huid" { + return nil, fmt.Errorf("unknown resolver %q", resolver) + } + + var value, displayName string + if semiIdx := strings.Index(rest, ";"); semiIdx >= 0 { + value = rest[:semiIdx] + raw := rest[semiIdx+1:] + if strings.Contains(raw, ";") { + return nil, fmt.Errorf("display name contains raw semicolon; use %%3B to encode") + } + decoded, err := url.QueryUnescape(raw) + if err != nil { + return nil, fmt.Errorf("invalid URL encoding in display name: %v", err) + } + displayName = decoded + } else { + value = rest + } + + if value == "" { + return nil, fmt.Errorf("empty value for resolver %q", resolver) + } + + return &parsedToken{ + resolver: resolver, + value: value, + displayName: displayName, + }, nil +} + +// indexAt returns the index of substr in s starting from offset, or -1. +func indexAt(s, substr string, offset int) int { + if offset >= len(s) { + return -1 + } + if i := strings.Index(s[offset:], substr); i >= 0 { + return offset + i + } + return -1 +} diff --git a/internal/mentions/parser_test.go b/internal/mentions/parser_test.go new file mode 100644 index 0000000..1f0e57f --- /dev/null +++ b/internal/mentions/parser_test.go @@ -0,0 +1,787 @@ +package mentions + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" +) + +func TestParse_EmptyMessage(t *testing.T) { + r := Parse(context.Background(), "", nil, true, nil) + if r.Message != "" { + t.Errorf("expected empty message, got %q", r.Message) + } + if r.Mentions != nil { + t.Errorf("expected nil mentions, got %s", r.Mentions) + } + if len(r.Errors) != 0 { + t.Errorf("expected no errors, got %v", r.Errors) + } +} + +func TestParse_NoTokens(t *testing.T) { + msg := "Hello, this is a regular message without any mentions." + r := Parse(context.Background(), msg, nil, true, nil) + if r.Message != msg { + t.Errorf("expected message unchanged, got %q", r.Message) + } + if r.Mentions != nil { + t.Errorf("expected nil mentions, got %s", r.Mentions) + } + if len(r.Errors) != 0 { + t.Errorf("expected no errors, got %v", r.Errors) + } +} + +func TestParse_Disabled(t *testing.T) { + msg := "Hello @mention[email:test@example.com]" + r := Parse(context.Background(), msg, nil, false, nil) + if r.Message != msg { + t.Errorf("expected message unchanged when parse disabled, got %q", r.Message) + } +} + +func TestScan_NoTokens(t *testing.T) { + spans := scan("Hello world, no mentions here.") + if len(spans) != 0 { + t.Errorf("expected 0 spans, got %d", len(spans)) + } +} + +func TestScan_SingleToken(t *testing.T) { + msg := "Hello @mention[email:test@example.com] world" + spans := scan(msg) + if len(spans) != 1 { + t.Fatalf("expected 1 span, got %d", len(spans)) + } + got := spans[0].raw(msg) + want := "@mention[email:test@example.com]" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestScan_MultipleTokens(t *testing.T) { + msg := "Hi @mention[all] and @mention[huid:abc-123] bye" + spans := scan(msg) + if len(spans) != 2 { + t.Fatalf("expected 2 spans, got %d", len(spans)) + } + if got := spans[0].raw(msg); got != "@mention[all]" { + t.Errorf("span 0: got %q, want %q", got, "@mention[all]") + } + if got := spans[1].raw(msg); got != "@mention[huid:abc-123]" { + t.Errorf("span 1: got %q, want %q", got, "@mention[huid:abc-123]") + } +} + +func TestScan_UnclosedToken(t *testing.T) { + msg := "Hello @mention[email:test@example.com no closing bracket" + spans := scan(msg) + if len(spans) != 1 { + t.Fatalf("expected 1 span for unclosed token, got %d", len(spans)) + } + if !spans[0].unclosed { + t.Error("expected span to be marked unclosed") + } +} + +func TestScan_NewlineInsideToken(t *testing.T) { + msg := "Hello @mention[email:\ntest@example.com] world" + spans := scan(msg) + if len(spans) != 1 { + t.Fatalf("expected 1 span for newline-broken token, got %d", len(spans)) + } + if !spans[0].unclosed { + t.Error("expected span to be marked unclosed") + } +} + +// --- Step 2: grammar parser tests --- + +func TestParseTokenBody_EmailNoDisplayName(t *testing.T) { + pt, err := parseTokenBody("email:user@example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pt.resolver != "email" { + t.Errorf("resolver = %q, want %q", pt.resolver, "email") + } + if pt.value != "user@example.com" { + t.Errorf("value = %q, want %q", pt.value, "user@example.com") + } + if pt.displayName != "" { + t.Errorf("displayName = %q, want empty", pt.displayName) + } +} + +func TestParseTokenBody_EmailWithURLQuotedDisplayName(t *testing.T) { + pt, err := parseTokenBody("email:user@example.com;John%20Doe") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pt.resolver != "email" { + t.Errorf("resolver = %q, want %q", pt.resolver, "email") + } + if pt.value != "user@example.com" { + t.Errorf("value = %q, want %q", pt.value, "user@example.com") + } + if pt.displayName != "John Doe" { + t.Errorf("displayName = %q, want %q", pt.displayName, "John Doe") + } +} + +func TestParseTokenBody_HuidNoDisplayName(t *testing.T) { + pt, err := parseTokenBody("huid:550e8400-e29b-41d4-a716-446655440000") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pt.resolver != "huid" { + t.Errorf("resolver = %q, want %q", pt.resolver, "huid") + } + if pt.value != "550e8400-e29b-41d4-a716-446655440000" { + t.Errorf("value = %q, want %q", pt.value, "550e8400-e29b-41d4-a716-446655440000") + } + if pt.displayName != "" { + t.Errorf("displayName = %q, want empty", pt.displayName) + } +} + +func TestParseTokenBody_HuidWithDisplayName(t *testing.T) { + pt, err := parseTokenBody("huid:550e8400-e29b-41d4-a716-446655440000;Alice") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pt.resolver != "huid" { + t.Errorf("resolver = %q, want %q", pt.resolver, "huid") + } + if pt.value != "550e8400-e29b-41d4-a716-446655440000" { + t.Errorf("value = %q, want %q", pt.value, "550e8400-e29b-41d4-a716-446655440000") + } + if pt.displayName != "Alice" { + t.Errorf("displayName = %q, want %q", pt.displayName, "Alice") + } +} + +func TestParseTokenBody_All(t *testing.T) { + pt, err := parseTokenBody("all") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pt.resolver != "all" { + t.Errorf("resolver = %q, want %q", pt.resolver, "all") + } + if pt.value != "" { + t.Errorf("value = %q, want empty", pt.value) + } + if pt.displayName != "" { + t.Errorf("displayName = %q, want empty", pt.displayName) + } +} + +func TestParseTokenBody_EmailEmptyValue(t *testing.T) { + _, err := parseTokenBody("email:") + if err == nil { + t.Fatal("expected error for empty email value") + } +} + +func TestParseTokenBody_AllWithExtra(t *testing.T) { + _, err := parseTokenBody("all;x") + if err == nil { + t.Fatal("expected error for @mention[all;x]") + } +} + +func TestParse_UnclosedToken(t *testing.T) { + msg := "Hello @mention[email:test@example.com world" + r := Parse(context.Background(), msg, nil, true, nil) + if r.Message != msg { + t.Errorf("expected message unchanged, got %q", r.Message) + } + if len(r.Errors) != 1 { + t.Fatalf("expected 1 error, got %d", len(r.Errors)) + } + if r.Errors[0].Kind != "parse" { + t.Errorf("error kind = %q, want %q", r.Errors[0].Kind, "parse") + } + if r.Errors[0].Cause != "unclosed token" { + t.Errorf("error cause = %q, want %q", r.Errors[0].Cause, "unclosed token") + } +} + +// --- Step 3: email normalization tests --- + +// mockResolver implements UserResolver for tests. +type mockResolver struct { + users map[string]struct{ huid, name string } +} + +func (m *mockResolver) GetUserByEmail(_ context.Context, email string) (string, string, error) { + u, ok := m.users[email] + if !ok { + return "", "", fmt.Errorf("user not found: %s", email) + } + return u.huid, u.name, nil +} + +func setupTestIDGen(t *testing.T) func() string { + t.Helper() + counter := 0 + orig := newMentionID + newMentionID = func() string { + counter++ + return fmt.Sprintf("test-id-%d", counter) + } + t.Cleanup(func() { newMentionID = orig }) + return newMentionID +} + +func TestNormalize_EmailLookupSuccess_NoDisplayName(t *testing.T) { + setupTestIDGen(t) + resolver := &mockResolver{users: map[string]struct{ huid, name string }{ + "user@example.com": {huid: "aaa-bbb-ccc", name: "John Doe"}, + }} + + msg := "Hello @mention[email:user@example.com] world" + r := Parse(context.Background(), msg, nil, true, resolver) + + // Message should have placeholder. + want := "Hello @{mention:test-id-1} world" + if r.Message != want { + t.Errorf("Message = %q, want %q", r.Message, want) + } + + // Mentions array should have one entry. + var mentions []map[string]interface{} + if err := json.Unmarshal(r.Mentions, &mentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + if len(mentions) != 1 { + t.Fatalf("expected 1 mention, got %d", len(mentions)) + } + + m := mentions[0] + if m["mention_id"] != "test-id-1" { + t.Errorf("mention_id = %v, want %q", m["mention_id"], "test-id-1") + } + if m["mention_type"] != "user" { + t.Errorf("mention_type = %v, want %q", m["mention_type"], "user") + } + data := m["mention_data"].(map[string]interface{}) + if data["user_huid"] != "aaa-bbb-ccc" { + t.Errorf("user_huid = %v, want %q", data["user_huid"], "aaa-bbb-ccc") + } + // Without display name, should use name from lookup. + if data["name"] != "John Doe" { + t.Errorf("name = %v, want %q", data["name"], "John Doe") + } + + if len(r.Errors) != 0 { + t.Errorf("expected no errors, got %v", r.Errors) + } +} + +func TestNormalize_EmailLookupSuccess_WithDisplayName(t *testing.T) { + setupTestIDGen(t) + resolver := &mockResolver{users: map[string]struct{ huid, name string }{ + "user@example.com": {huid: "aaa-bbb-ccc", name: "John Doe"}, + }} + + msg := "Hello @mention[email:user@example.com;Custom%20Name] world" + r := Parse(context.Background(), msg, nil, true, resolver) + + want := "Hello @{mention:test-id-1} world" + if r.Message != want { + t.Errorf("Message = %q, want %q", r.Message, want) + } + + var mentions []map[string]interface{} + if err := json.Unmarshal(r.Mentions, &mentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + data := mentions[0]["mention_data"].(map[string]interface{}) + // Display name override should take precedence. + if data["name"] != "Custom Name" { + t.Errorf("name = %v, want %q", data["name"], "Custom Name") + } +} + +func TestNormalize_EmailLookupFailure_TokenStaysLiteral(t *testing.T) { + resolver := &mockResolver{users: map[string]struct{ huid, name string }{}} + + msg := "Hello @mention[email:unknown@example.com] world" + r := Parse(context.Background(), msg, nil, true, resolver) + + // Message should be unchanged - token stays as literal text. + if r.Message != msg { + t.Errorf("Message = %q, want unchanged %q", r.Message, msg) + } + + if len(r.Errors) != 1 { + t.Fatalf("expected 1 error, got %d", len(r.Errors)) + } + if r.Errors[0].Kind != "lookup" { + t.Errorf("error kind = %q, want %q", r.Errors[0].Kind, "lookup") + } + if r.Errors[0].Resolver != "email" { + t.Errorf("error resolver = %q, want %q", r.Errors[0].Resolver, "email") + } + if !strings.Contains(r.Errors[0].Cause, "user not found") { + t.Errorf("error cause = %q, want it to contain %q", r.Errors[0].Cause, "user not found") + } +} + +// --- Step 4: huid normalization tests --- + +func TestNormalize_HuidNoDisplayName(t *testing.T) { + setupTestIDGen(t) + + msg := "Hello @mention[huid:550e8400-e29b-41d4-a716-446655440000] world" + r := Parse(context.Background(), msg, nil, true, nil) + + want := "Hello @{mention:test-id-1} world" + if r.Message != want { + t.Errorf("Message = %q, want %q", r.Message, want) + } + + var mentions []map[string]interface{} + if err := json.Unmarshal(r.Mentions, &mentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + if len(mentions) != 1 { + t.Fatalf("expected 1 mention, got %d", len(mentions)) + } + + m := mentions[0] + if m["mention_id"] != "test-id-1" { + t.Errorf("mention_id = %v, want %q", m["mention_id"], "test-id-1") + } + if m["mention_type"] != "user" { + t.Errorf("mention_type = %v, want %q", m["mention_type"], "user") + } + data := m["mention_data"].(map[string]interface{}) + if data["user_huid"] != "550e8400-e29b-41d4-a716-446655440000" { + t.Errorf("user_huid = %v, want %q", data["user_huid"], "550e8400-e29b-41d4-a716-446655440000") + } + + if len(r.Errors) != 0 { + t.Errorf("expected no errors, got %v", r.Errors) + } +} + +func TestNormalize_HuidNoDisplayName_NameAbsentInPayload(t *testing.T) { + setupTestIDGen(t) + + msg := "Hello @mention[huid:550e8400-e29b-41d4-a716-446655440000] world" + r := Parse(context.Background(), msg, nil, true, nil) + + var mentions []map[string]interface{} + if err := json.Unmarshal(r.Mentions, &mentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + data := mentions[0]["mention_data"].(map[string]interface{}) + // Without display name, "name" field must be absent from the payload. + if _, ok := data["name"]; ok { + t.Errorf("expected 'name' field to be absent in mention_data, but it is present: %v", data["name"]) + } +} + +func TestNormalize_HuidWithDisplayName(t *testing.T) { + setupTestIDGen(t) + + msg := "Hello @mention[huid:550e8400-e29b-41d4-a716-446655440000;Alice%20B] world" + r := Parse(context.Background(), msg, nil, true, nil) + + want := "Hello @{mention:test-id-1} world" + if r.Message != want { + t.Errorf("Message = %q, want %q", r.Message, want) + } + + var mentions []map[string]interface{} + if err := json.Unmarshal(r.Mentions, &mentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + if len(mentions) != 1 { + t.Fatalf("expected 1 mention, got %d", len(mentions)) + } + + data := mentions[0]["mention_data"].(map[string]interface{}) + if data["user_huid"] != "550e8400-e29b-41d4-a716-446655440000" { + t.Errorf("user_huid = %v, want %q", data["user_huid"], "550e8400-e29b-41d4-a716-446655440000") + } + if data["name"] != "Alice B" { + t.Errorf("name = %v, want %q", data["name"], "Alice B") + } + + if len(r.Errors) != 0 { + t.Errorf("expected no errors, got %v", r.Errors) + } +} + +func TestNormalize_EmailLookupFailure_NoMention(t *testing.T) { + resolver := &mockResolver{users: map[string]struct{ huid, name string }{}} + + msg := "Hello @mention[email:unknown@example.com] world" + r := Parse(context.Background(), msg, nil, true, resolver) + + // Mentions should be nil - no mention generated on lookup failure. + if r.Mentions != nil { + t.Errorf("expected nil mentions on lookup failure, got %s", r.Mentions) + } +} + +// --- Step 5: all normalization tests --- + +func TestNormalize_All(t *testing.T) { + setupTestIDGen(t) + + msg := "Hello @mention[all] world" + r := Parse(context.Background(), msg, nil, true, nil) + + want := "Hello @{mention:test-id-1} world" + if r.Message != want { + t.Errorf("Message = %q, want %q", r.Message, want) + } + + var mentions []map[string]interface{} + if err := json.Unmarshal(r.Mentions, &mentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + if len(mentions) != 1 { + t.Fatalf("expected 1 mention, got %d", len(mentions)) + } + + m := mentions[0] + if m["mention_id"] != "test-id-1" { + t.Errorf("mention_id = %v, want %q", m["mention_id"], "test-id-1") + } + if m["mention_type"] != "all" { + t.Errorf("mention_type = %v, want %q", m["mention_type"], "all") + } + // mention_data must be null for "all" mentions. + if m["mention_data"] != nil { + t.Errorf("mention_data = %v, want nil", m["mention_data"]) + } + + if len(r.Errors) != 0 { + t.Errorf("expected no errors, got %v", r.Errors) + } +} + +// --- Step 6: merge with raw mentions tests --- + +func TestMerge_RawAndParsedMention(t *testing.T) { + setupTestIDGen(t) + resolver := &mockResolver{users: map[string]struct{ huid, name string }{ + "user@example.com": {huid: "aaa-bbb-ccc", name: "John Doe"}, + }} + + rawMention := json.RawMessage(`[{"mention_id":"raw-id-1","mention_type":"user","mention_data":{"user_huid":"ddd-eee-fff","name":"Raw User"}}]`) + msg := "Hello @mention[email:user@example.com] world" + r := Parse(context.Background(), msg, rawMention, true, resolver) + + want := "Hello @{mention:test-id-1} world" + if r.Message != want { + t.Errorf("Message = %q, want %q", r.Message, want) + } + + var mentions []map[string]interface{} + if err := json.Unmarshal(r.Mentions, &mentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + if len(mentions) != 2 { + t.Fatalf("expected 2 mentions (1 raw + 1 parsed), got %d", len(mentions)) + } + + if len(r.Errors) != 0 { + t.Errorf("expected no errors, got %v", r.Errors) + } +} + +func TestMerge_RawMentionsUnchanged(t *testing.T) { + setupTestIDGen(t) + + rawMention := json.RawMessage(`[{"mention_id":"raw-id-1","mention_type":"user","mention_data":{"user_huid":"ddd-eee-fff","name":"Raw User"}}]`) + msg := "Hello @mention[huid:550e8400-e29b-41d4-a716-446655440000;Alice] world" + r := Parse(context.Background(), msg, rawMention, true, nil) + + var mentions []map[string]interface{} + if err := json.Unmarshal(r.Mentions, &mentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + if len(mentions) != 2 { + t.Fatalf("expected 2 mentions, got %d", len(mentions)) + } + + // First entry must be the original raw mention, unchanged. + raw := mentions[0] + if raw["mention_id"] != "raw-id-1" { + t.Errorf("raw mention_id = %v, want %q", raw["mention_id"], "raw-id-1") + } + if raw["mention_type"] != "user" { + t.Errorf("raw mention_type = %v, want %q", raw["mention_type"], "user") + } + data := raw["mention_data"].(map[string]interface{}) + if data["user_huid"] != "ddd-eee-fff" { + t.Errorf("raw user_huid = %v, want %q", data["user_huid"], "ddd-eee-fff") + } + if data["name"] != "Raw User" { + t.Errorf("raw name = %v, want %q", data["name"], "Raw User") + } +} + +func TestMerge_ParsedMentionsAppendedAtEnd(t *testing.T) { + setupTestIDGen(t) + resolver := &mockResolver{users: map[string]struct{ huid, name string }{ + "user@example.com": {huid: "aaa-bbb-ccc", name: "John Doe"}, + }} + + rawMention := json.RawMessage(`[{"mention_id":"raw-id-1","mention_type":"user","mention_data":{"user_huid":"ddd-eee-fff","name":"Raw User"}}]`) + msg := "Hello @mention[email:user@example.com] and @mention[all] world" + r := Parse(context.Background(), msg, rawMention, true, resolver) + + var mentions []map[string]interface{} + if err := json.Unmarshal(r.Mentions, &mentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + if len(mentions) != 3 { + t.Fatalf("expected 3 mentions (1 raw + 2 parsed), got %d", len(mentions)) + } + + // Index 0: raw mention. + if mentions[0]["mention_id"] != "raw-id-1" { + t.Errorf("mentions[0] mention_id = %v, want %q", mentions[0]["mention_id"], "raw-id-1") + } + + // Index 1: first parsed mention (email). + if mentions[1]["mention_id"] != "test-id-1" { + t.Errorf("mentions[1] mention_id = %v, want %q", mentions[1]["mention_id"], "test-id-1") + } + if mentions[1]["mention_type"] != "user" { + t.Errorf("mentions[1] mention_type = %v, want %q", mentions[1]["mention_type"], "user") + } + + // Index 2: second parsed mention (all). + if mentions[2]["mention_id"] != "test-id-2" { + t.Errorf("mentions[2] mention_id = %v, want %q", mentions[2]["mention_id"], "test-id-2") + } + if mentions[2]["mention_type"] != "all" { + t.Errorf("mentions[2] mention_type = %v, want %q", mentions[2]["mention_type"], "all") + } + + if len(r.Errors) != 0 { + t.Errorf("expected no errors, got %v", r.Errors) + } +} + +// --- Step 7: parser limit tests --- + +func TestParse_LimitExceeded(t *testing.T) { + setupTestIDGen(t) + + // Temporarily lower the limit to make the test feasible. + origLimit := MaxParsedMentions + defer func() { maxParsedMentions = origLimit }() + maxParsedMentions = 3 + + msg := "@mention[huid:aaa] @mention[huid:bbb] @mention[huid:ccc] @mention[huid:ddd] @mention[huid:eee]" + r := Parse(context.Background(), msg, nil, true, nil) + + // First 3 tokens should be normalized, last 2 should stay literal. + if !strings.Contains(r.Message, "@{mention:test-id-1}") { + t.Errorf("expected first token normalized, got %q", r.Message) + } + if !strings.Contains(r.Message, "@{mention:test-id-2}") { + t.Errorf("expected second token normalized, got %q", r.Message) + } + if !strings.Contains(r.Message, "@{mention:test-id-3}") { + t.Errorf("expected third token normalized, got %q", r.Message) + } + if !strings.Contains(r.Message, "@mention[huid:ddd]") { + t.Errorf("expected fourth token as literal text, got %q", r.Message) + } + if !strings.Contains(r.Message, "@mention[huid:eee]") { + t.Errorf("expected fifth token as literal text, got %q", r.Message) + } + + // Should have exactly 3 mentions. + var mentions []map[string]interface{} + if err := json.Unmarshal(r.Mentions, &mentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + if len(mentions) != 3 { + t.Errorf("expected 3 mentions, got %d", len(mentions)) + } + + // Should have a limit error. + hasLimitErr := false + for _, e := range r.Errors { + if e.Kind == "limit" { + hasLimitErr = true + break + } + } + if !hasLimitErr { + t.Errorf("expected a limit error, got errors: %v", r.Errors) + } +} + +func TestParse_LimitExceeded_MessageStillReturned(t *testing.T) { + setupTestIDGen(t) + + origLimit := MaxParsedMentions + defer func() { maxParsedMentions = origLimit }() + maxParsedMentions = 1 + + msg := "Hello @mention[huid:aaa] and @mention[huid:bbb] bye" + r := Parse(context.Background(), msg, nil, true, nil) + + // Message must be returned (not empty, not error). + if r.Message == "" { + t.Error("expected non-empty message") + } + + // First token normalized, second stays literal. + want := "Hello @{mention:test-id-1} and @mention[huid:bbb] bye" + if r.Message != want { + t.Errorf("Message = %q, want %q", r.Message, want) + } + + // Mentions should still be present for the successfully parsed token. + var mentions []map[string]interface{} + if err := json.Unmarshal(r.Mentions, &mentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + if len(mentions) != 1 { + t.Errorf("expected 1 mention, got %d", len(mentions)) + } + + // Errors should include the limit error but message was still sent. + if len(r.Errors) == 0 { + t.Error("expected at least one error (limit)") + } +} + +// --- Step 8: parser error record tests --- + +func TestParseError_ParseRecord(t *testing.T) { + msg := "Hello @mention[email:] world" + r := Parse(context.Background(), msg, nil, true, nil) + + if len(r.Errors) != 1 { + t.Fatalf("expected 1 error, got %d", len(r.Errors)) + } + + e := r.Errors[0] + if e.Kind != "parse" { + t.Errorf("Kind = %q, want %q", e.Kind, "parse") + } + if e.Token != "@mention[email:]" { + t.Errorf("Token = %q, want %q", e.Token, "@mention[email:]") + } + if e.Resolver != "" { + t.Errorf("Resolver = %q, want empty (not determined for parse errors)", e.Resolver) + } + if e.Value != "" { + t.Errorf("Value = %q, want empty (not determined for parse errors)", e.Value) + } + if e.Cause == "" { + t.Error("Cause must not be empty") + } +} + +func TestParseError_LookupRecord(t *testing.T) { + resolver := &mockResolver{users: map[string]struct{ huid, name string }{}} + + msg := "Hello @mention[email:unknown@example.com] world" + r := Parse(context.Background(), msg, nil, true, resolver) + + if len(r.Errors) != 1 { + t.Fatalf("expected 1 error, got %d", len(r.Errors)) + } + + e := r.Errors[0] + if e.Kind != "lookup" { + t.Errorf("Kind = %q, want %q", e.Kind, "lookup") + } + if e.Token != "@mention[email:unknown@example.com]" { + t.Errorf("Token = %q, want %q", e.Token, "@mention[email:unknown@example.com]") + } + if e.Resolver != "email" { + t.Errorf("Resolver = %q, want %q", e.Resolver, "email") + } + if e.Value != "unknown@example.com" { + t.Errorf("Value = %q, want %q", e.Value, "unknown@example.com") + } + if e.Cause == "" { + t.Error("Cause must not be empty") + } + if !strings.Contains(e.Cause, "user not found") { + t.Errorf("Cause = %q, want it to contain %q", e.Cause, "user not found") + } +} + +func TestParseError_LimitRecord(t *testing.T) { + setupTestIDGen(t) + + origLimit := MaxParsedMentions + defer func() { maxParsedMentions = origLimit }() + maxParsedMentions = 1 + + msg := "@mention[huid:aaa] @mention[huid:bbb]" + r := Parse(context.Background(), msg, nil, true, nil) + + // Find the limit error among accumulated errors. + var limitErr *ParseError + for i := range r.Errors { + if r.Errors[i].Kind == "limit" { + limitErr = &r.Errors[i] + break + } + } + if limitErr == nil { + t.Fatalf("expected a limit error, got errors: %v", r.Errors) + } + + if limitErr.Token != "" { + t.Errorf("Token = %q, want empty (limit errors are not token-specific)", limitErr.Token) + } + if limitErr.Resolver != "" { + t.Errorf("Resolver = %q, want empty", limitErr.Resolver) + } + if limitErr.Value != "" { + t.Errorf("Value = %q, want empty", limitErr.Value) + } + if limitErr.Cause == "" { + t.Error("Cause must not be empty") + } + if !strings.Contains(limitErr.Cause, fmt.Sprintf("%d", maxParsedMentions)) { + t.Errorf("Cause = %q, want it to mention the limit %d", limitErr.Cause, maxParsedMentions) + } +} + +func TestNormalize_AllWithExtra_ParseError(t *testing.T) { + msg := "Hello @mention[all;everyone] world" + r := Parse(context.Background(), msg, nil, true, nil) + + // Token should stay as literal text. + if r.Message != msg { + t.Errorf("Message = %q, want unchanged %q", r.Message, msg) + } + + // No mentions should be generated. + if r.Mentions != nil { + t.Errorf("expected nil mentions, got %s", r.Mentions) + } + + // Should have a parse error. + if len(r.Errors) != 1 { + t.Fatalf("expected 1 error, got %d", len(r.Errors)) + } + if r.Errors[0].Kind != "parse" { + t.Errorf("error kind = %q, want %q", r.Errors[0].Kind, "parse") + } +} diff --git a/internal/queue/queue.go b/internal/queue/queue.go index 6ba0244..aead15f 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -50,6 +50,7 @@ type DeliveryOpts struct { Stealth bool `json:"stealth,omitempty"` ForceDND bool `json:"force_dnd,omitempty"` NoNotify bool `json:"no_notify,omitempty"` + NoParse bool `json:"no_parse,omitempty"` } // WorkResult is the outcome of processing a work message, published to the reply queue. diff --git a/internal/server/api/openapi.yaml b/internal/server/api/openapi.yaml index 3c1e88a..36705ac 100644 --- a/internal/server/api/openapi.yaml +++ b/internal/server/api/openapi.yaml @@ -123,6 +123,27 @@ paths: `chat_id` is optional when a default chat is configured (`default: true` in chats section). In multi-bot mode, `bot` is required unless the chat alias has a default bot binding in config. + + By default, inline mention tokens (`@mention[...]`) in the `message` field are automatically + parsed and normalized into BotX wire-format mentions. Supported syntax: + - `@mention[email:
]` — resolve user by email + - `@mention[email:
;]` — with display name override + - `@mention[huid:]` — mention by HUID (no lookup) + - `@mention[huid:;]` — with display name + - `@mention[all]` — broadcast mention + + Parsed mentions are appended to any raw `mentions` provided in the request body. + On parse or lookup errors, the token remains as literal text and the message is still sent. + Use `?no_parse=true` to disable inline parsing entirely. + parameters: + - name: no_parse + in: query + description: | + Disable inline `@mention[...]` parsing. When set to `true`, mention tokens + in the message text are left as-is without normalization. + schema: + type: boolean + default: false requestBody: required: true content: @@ -142,7 +163,7 @@ paths: message: "Build #42 failed" status: error mentions: - summary: Message with mentions + summary: Message with raw mentions (BotX wire-format) value: chat_id: "1a2b3c4d-5e6f-7890-abcd-ef1234567890" message: "@{mention:aaa-bbb} Deploy completed" @@ -152,6 +173,11 @@ paths: mention_data: user_huid: "f16cdc5f-6366-5552-9ecd-c36290ab3d11" name: "Иван" + inline_mention: + summary: Message with inline mention (auto-parsed) + value: + chat_id: "1a2b3c4d-5e6f-7890-abcd-ef1234567890" + message: "Привет, @mention[email:user@company.ru]!" file: summary: Message with file value: diff --git a/internal/server/handler_send.go b/internal/server/handler_send.go index 560136d..6995f27 100644 --- a/internal/server/handler_send.go +++ b/internal/server/handler_send.go @@ -13,6 +13,7 @@ import ( "time" vlog "github.com/lavr/express-botx/internal/log" + "github.com/lavr/express-botx/internal/mentions" ) var uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) @@ -31,6 +32,7 @@ type SendPayload struct { Mentions json.RawMessage `json:"mentions,omitempty"` RoutingMode string `json:"routing_mode,omitempty"` // async mode: direct, catalog, mixed BotID string `json:"bot_id,omitempty"` // async mode: bot UUID for direct routing + NoParse bool `json:"-"` // internal: skip mentions parsing (set by handler for async mode) } // FilePayload represents a file attachment in the JSON request. @@ -104,7 +106,14 @@ func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) { return } + // Inline mentions parsing: enabled by default, disabled with ?no_parse=true. + noParse := r.URL.Query().Get("no_parse") == "true" + if s.cfg.AsyncMode { + // Async mode: defer mentions parsing to the send function, which runs + // after catalog routing resolves the actual target bot. This ensures + // email lookups hit the correct eXpress host in multi-bot setups. + payload.NoParse = noParse // Async mode: for direct routing, bot_id is required. // For catalog/mixed modes, bot_id or bot alias can be used. rm := payload.RoutingMode @@ -197,6 +206,21 @@ func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) { } payload.Bot = resolvedBot + // Parse mentions after bot resolution so the correct per-bot resolver is used + // when the bot was derived from chat binding rather than the request payload. + resolver := s.mentionsResolver + if payload.Bot != "" && s.botMentionsResolvers != nil { + if br, ok := s.botMentionsResolvers[payload.Bot]; ok { + resolver = br + } + } + parseResult := mentions.Parse(r.Context(), payload.Message, payload.Mentions, !noParse, resolver) + payload.Message = parseResult.Message + payload.Mentions = parseResult.Mentions + if len(parseResult.Errors) > 0 { + vlog.V2("server: mentions parse: %d error(s)", len(parseResult.Errors)) + } + start := time.Now() syncID, err := s.send(r.Context(), &payload) elapsed := time.Since(start) diff --git a/internal/server/server.go b/internal/server/server.go index 3245dd7..23115ed 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -16,6 +16,7 @@ import ( "github.com/lavr/express-botx/internal/config" "github.com/lavr/express-botx/internal/errtrack" vlog "github.com/lavr/express-botx/internal/log" + "github.com/lavr/express-botx/internal/mentions" ) // ResolvedKey is an API key with its secret resolved. @@ -55,6 +56,8 @@ type Server struct { chatEntries []config.ChatEntry // for GET /chats/alias/list amCfg *AlertmanagerConfig grCfg *GrafanaConfig + mentionsResolver mentions.UserResolver + botMentionsResolvers map[string]mentions.UserResolver // per-bot resolvers for multi-host setups callbackRouter *CallbackRouter callbacksCfg *config.CallbacksConfig callbackSecretLookup func(botID string) (string, error) @@ -107,6 +110,24 @@ func WithErrTracker(t errtrack.Tracker) Option { } } +// WithMentionsResolver sets the user resolver used by the inline mentions +// parser. When nil, email mentions will produce lookup errors but the message +// will still be sent. +func WithMentionsResolver(r mentions.UserResolver) Option { + return func(s *Server) { + s.mentionsResolver = r + } +} + +// WithBotMentionsResolvers sets per-bot user resolvers for multi-bot setups +// where bots may reside on different eXpress hosts. When the request specifies +// a bot name, the corresponding resolver is used instead of the default one. +func WithBotMentionsResolvers(m map[string]mentions.UserResolver) Option { + return func(s *Server) { + s.botMentionsResolvers = m + } +} + // CallbackOption configures callback handling. type CallbackOption func(*callbackOptions) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 0be1869..c872b32 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/lavr/express-botx/internal/config" + "github.com/lavr/express-botx/internal/mentions" ) // newTestServer creates a Server with stub send/chat functions for testing. @@ -2386,6 +2387,102 @@ func TestSend_AsyncMode_CatalogMode_ChatIDOnly(t *testing.T) { } } +func TestSend_AsyncMode_DefersInlineMentionParsing(t *testing.T) { + var capturedPayload *SendPayload + cfg := Config{ + Listen: ":0", + BasePath: "/api/v1", + Keys: []ResolvedKey{{Name: "t", Key: "k"}}, + AsyncMode: true, + DefaultRoutingMode: "direct", + } + sendFn := func(ctx context.Context, p *SendPayload) (string, error) { + capturedPayload = p + return "req-async-inline", nil + } + chatResolver := func(chatID string) (ChatResolveResult, error) { + return ChatResolveResult{ChatID: chatID}, nil + } + srv := New(cfg, sendFn, chatResolver, WithMentionsResolver(&stubResolver{huid: "ignored", name: "Ignored"})) + + body := `{"bot_id":"00000000-0000-0000-0000-000000000111","chat_id":"00000000-0000-0000-0000-000000000222","message":"hello @mention[email:alice@example.com]"}` + w := doRequest(srv, "POST", "/api/v1/send", strings.NewReader(body), map[string]string{ + "X-API-Key": "k", + "Content-Type": "application/json", + }) + + if w.Code != 202 { + t.Fatalf("expected 202, got %d: %s", w.Code, w.Body.String()) + } + if capturedPayload == nil { + t.Fatal("send function was not called") + } + if capturedPayload.Message != "hello @mention[email:alice@example.com]" { + t.Fatalf("message = %q, want raw inline mention to be deferred", capturedPayload.Message) + } + if capturedPayload.Mentions != nil { + t.Fatalf("expected mentions to remain unset in async mode, got %s", string(capturedPayload.Mentions)) + } + if capturedPayload.NoParse { + t.Fatal("NoParse should be false by default in async mode") + } +} + +func TestSend_SyncMode_UsesBotSpecificMentionResolverAfterChatBinding(t *testing.T) { + var capturedPayload *SendPayload + cfg := Config{ + Listen: ":0", + BasePath: "/api/v1", + Keys: []ResolvedKey{{Name: "t", Key: "k"}}, + BotNames: []string{"prod", "test"}, + } + sendFn := func(ctx context.Context, p *SendPayload) (string, error) { + capturedPayload = p + return "test-sync-id", nil + } + chatResolver := func(chatID string) (ChatResolveResult, error) { + if chatID == "deploy" { + return ChatResolveResult{ChatID: "chat-uuid-1", Bot: "prod"}, nil + } + return ChatResolveResult{ChatID: chatID}, nil + } + srv := New(cfg, sendFn, chatResolver, + WithMentionsResolver(&stubResolver{huid: "default-huid", name: "Default"}), + WithBotMentionsResolvers(map[string]mentions.UserResolver{ + "prod": &stubResolver{huid: "prod-huid", name: "Prod User"}, + "test": &stubResolver{huid: "test-huid", name: "Test User"}, + }), + ) + + body := `{"chat_id":"deploy","message":"hello @mention[email:alice@example.com]"}` + w := doRequest(srv, "POST", "/api/v1/send", strings.NewReader(body), map[string]string{ + "X-API-Key": "k", + "Content-Type": "application/json", + }) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if capturedPayload == nil { + t.Fatal("send function was not called") + } + + var mentionsList []struct { + MentionData struct { + UserHUID string `json:"user_huid"` + Name string `json:"name"` + } `json:"mention_data"` + } + if err := json.Unmarshal(capturedPayload.Mentions, &mentionsList); err != nil { + t.Fatalf("unmarshal mentions: %v", err) + } + if len(mentionsList) != 1 { + t.Fatalf("expected 1 mention, got %d", len(mentionsList)) + } + if mentionsList[0].MentionData.UserHUID != "prod-huid" { + t.Fatalf("expected prod resolver to be used after chat binding, got %q", mentionsList[0].MentionData.UserHUID) + } +} + // --- callback endpoint registration --- func TestServerWithCallbacksEndpointsRegistered(t *testing.T) { @@ -2567,3 +2664,255 @@ func TestServerWithCallbacksJWTDefaultEnabled(t *testing.T) { t.Fatalf("expected 401 with default JWT enabled, got %d: %s", w.Code, w.Body.String()) } } + +// --- inline mentions parsing --- + +// stubResolver is a test mentions.UserResolver that returns a fixed HUID and name. +type stubResolver struct { + huid string + name string + err error +} + +func (r *stubResolver) GetUserByEmail(_ context.Context, _ string) (string, string, error) { + if r.err != nil { + return "", "", r.err + } + return r.huid, r.name, nil +} + +func TestSend_JSON_InlineMention(t *testing.T) { + var capturedPayload *SendPayload + cfg := Config{ + Listen: ":0", + BasePath: "/api/v1", + Keys: []ResolvedKey{{Name: "t", Key: "k"}}, + } + sendFn := func(ctx context.Context, p *SendPayload) (string, error) { + capturedPayload = p + return "test-sync-id", nil + } + chatResolver := func(chatID string) (ChatResolveResult, error) { + return ChatResolveResult{ChatID: chatID}, nil + } + resolver := &stubResolver{huid: "user-huid-1", name: "Alice"} + srv := New(cfg, sendFn, chatResolver, WithMentionsResolver(resolver)) + + body := `{"chat_id":"chat-1","message":"hello @mention[email:alice@example.com]"}` + w := doRequest(srv, "POST", "/api/v1/send", strings.NewReader(body), map[string]string{ + "X-API-Key": "k", + "Content-Type": "application/json", + }) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if capturedPayload == nil { + t.Fatal("send function was not called") + } + // Message should be normalized with BotX placeholder + if !strings.Contains(capturedPayload.Message, "@{mention:") { + t.Fatalf("expected message to contain BotX placeholder, got: %s", capturedPayload.Message) + } + if strings.Contains(capturedPayload.Message, "@mention[") { + t.Fatalf("expected inline token to be replaced, got: %s", capturedPayload.Message) + } + // Mentions should contain the parsed entry + if capturedPayload.Mentions == nil { + t.Fatal("expected mentions to be set") + } + var mentions []json.RawMessage + if err := json.Unmarshal(capturedPayload.Mentions, &mentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + if len(mentions) != 1 { + t.Fatalf("expected 1 mention, got %d", len(mentions)) + } + // Verify mention has correct user_huid + var entry struct { + MentionType string `json:"mention_type"` + MentionData *struct { + UserHUID string `json:"user_huid"` + } `json:"mention_data"` + } + if err := json.Unmarshal(mentions[0], &entry); err != nil { + t.Fatalf("failed to unmarshal mention entry: %v", err) + } + if entry.MentionType != "user" { + t.Fatalf("expected mention_type 'user', got %q", entry.MentionType) + } + if entry.MentionData == nil || entry.MentionData.UserHUID != "user-huid-1" { + t.Fatalf("expected user_huid 'user-huid-1', got %+v", entry.MentionData) + } +} + +func TestSend_Multipart_InlineMention(t *testing.T) { + var capturedPayload *SendPayload + cfg := Config{ + Listen: ":0", + BasePath: "/api/v1", + Keys: []ResolvedKey{{Name: "t", Key: "k"}}, + } + sendFn := func(ctx context.Context, p *SendPayload) (string, error) { + capturedPayload = p + return "test-sync-id", nil + } + chatResolver := func(chatID string) (ChatResolveResult, error) { + return ChatResolveResult{ChatID: chatID}, nil + } + resolver := &stubResolver{huid: "user-huid-2", name: "Bob"} + srv := New(cfg, sendFn, chatResolver, WithMentionsResolver(resolver)) + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + mw.WriteField("chat_id", "chat-1") + mw.WriteField("message", "hi @mention[email:bob@example.com]") + mw.Close() + + w := doRequest(srv, "POST", "/api/v1/send", &buf, map[string]string{ + "X-API-Key": "k", + "Content-Type": mw.FormDataContentType(), + }) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if capturedPayload == nil { + t.Fatal("send function was not called") + } + if !strings.Contains(capturedPayload.Message, "@{mention:") { + t.Fatalf("expected message to contain BotX placeholder, got: %s", capturedPayload.Message) + } + if capturedPayload.Mentions == nil { + t.Fatal("expected mentions to be set") + } + var mentions []json.RawMessage + if err := json.Unmarshal(capturedPayload.Mentions, &mentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + if len(mentions) != 1 { + t.Fatalf("expected 1 mention, got %d", len(mentions)) + } +} + +func TestSend_JSON_InlineMention_MergeWithRaw(t *testing.T) { + var capturedPayload *SendPayload + cfg := Config{ + Listen: ":0", + BasePath: "/api/v1", + Keys: []ResolvedKey{{Name: "t", Key: "k"}}, + } + sendFn := func(ctx context.Context, p *SendPayload) (string, error) { + capturedPayload = p + return "test-sync-id", nil + } + chatResolver := func(chatID string) (ChatResolveResult, error) { + return ChatResolveResult{ChatID: chatID}, nil + } + resolver := &stubResolver{huid: "user-huid-3", name: "Charlie"} + srv := New(cfg, sendFn, chatResolver, WithMentionsResolver(resolver)) + + // Raw mention + inline mention in the same request + body := `{"chat_id":"chat-1","message":"@{mention:raw-id} and @mention[email:charlie@example.com]","mentions":[{"mention_id":"raw-id","mention_type":"user","mention_data":{"user_huid":"raw-huid"}}]}` + w := doRequest(srv, "POST", "/api/v1/send", strings.NewReader(body), map[string]string{ + "X-API-Key": "k", + "Content-Type": "application/json", + }) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if capturedPayload == nil { + t.Fatal("send function was not called") + } + // Should have 2 mentions: raw + parsed + var mentions []json.RawMessage + if err := json.Unmarshal(capturedPayload.Mentions, &mentions); err != nil { + t.Fatalf("failed to unmarshal mentions: %v", err) + } + if len(mentions) != 2 { + t.Fatalf("expected 2 mentions (1 raw + 1 parsed), got %d", len(mentions)) + } + // First mention should be the raw one + var first struct { + MentionID string `json:"mention_id"` + } + if err := json.Unmarshal(mentions[0], &first); err != nil { + t.Fatalf("unmarshal first mention: %v", err) + } + if first.MentionID != "raw-id" { + t.Fatalf("expected first mention to be raw (id=raw-id), got %q", first.MentionID) + } +} + +func TestSend_JSON_NoParse(t *testing.T) { + var capturedPayload *SendPayload + cfg := Config{ + Listen: ":0", + BasePath: "/api/v1", + Keys: []ResolvedKey{{Name: "t", Key: "k"}}, + } + sendFn := func(ctx context.Context, p *SendPayload) (string, error) { + capturedPayload = p + return "test-sync-id", nil + } + chatResolver := func(chatID string) (ChatResolveResult, error) { + return ChatResolveResult{ChatID: chatID}, nil + } + resolver := &stubResolver{huid: "should-not-appear", name: "Nope"} + srv := New(cfg, sendFn, chatResolver, WithMentionsResolver(resolver)) + + body := `{"chat_id":"chat-1","message":"hello @mention[email:alice@example.com]"}` + // Note: ?no_parse=true query parameter + w := doRequest(srv, "POST", "/api/v1/send?no_parse=true", strings.NewReader(body), map[string]string{ + "X-API-Key": "k", + "Content-Type": "application/json", + }) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if capturedPayload == nil { + t.Fatal("send function was not called") + } + // Message should remain unchanged — token not replaced + if !strings.Contains(capturedPayload.Message, "@mention[email:alice@example.com]") { + t.Fatalf("expected message to contain original token with no_parse=true, got: %s", capturedPayload.Message) + } + // No mentions should be generated + if capturedPayload.Mentions != nil { + t.Fatalf("expected no mentions with no_parse=true, got: %s", string(capturedPayload.Mentions)) + } +} + +func TestSend_JSON_InlineMention_ParseError_StillSends(t *testing.T) { + var capturedPayload *SendPayload + cfg := Config{ + Listen: ":0", + BasePath: "/api/v1", + Keys: []ResolvedKey{{Name: "t", Key: "k"}}, + } + sendFn := func(ctx context.Context, p *SendPayload) (string, error) { + capturedPayload = p + return "test-sync-id", nil + } + chatResolver := func(chatID string) (ChatResolveResult, error) { + return ChatResolveResult{ChatID: chatID}, nil + } + // Resolver that always fails + resolver := &stubResolver{err: fmt.Errorf("user not found")} + srv := New(cfg, sendFn, chatResolver, WithMentionsResolver(resolver)) + + body := `{"chat_id":"chat-1","message":"hello @mention[email:unknown@example.com]"}` + w := doRequest(srv, "POST", "/api/v1/send", strings.NewReader(body), map[string]string{ + "X-API-Key": "k", + "Content-Type": "application/json", + }) + // Should NOT return 400 — parse/lookup errors are soft + if w.Code != 200 { + t.Fatalf("expected 200 despite lookup error, got %d: %s", w.Code, w.Body.String()) + } + if capturedPayload == nil { + t.Fatal("send function was not called") + } + // Token should remain as literal text since lookup failed + if !strings.Contains(capturedPayload.Message, "@mention[email:unknown@example.com]") { + t.Fatalf("expected failed token to remain as literal text, got: %s", capturedPayload.Message) + } +}