Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
e63a4e0
docs(spec): add room-worker membership fixes design spec
vjauhari-work May 15, 2026
f805c33
docs(plan): add room-worker membership fixes implementation plan
vjauhari-work May 15, 2026
e044ad3
docs(client-api): align sys-msg row and document Room.uids/accounts
vjauhari-work May 15, 2026
6774d0b
feat(model): add Room.UIDs/Accounts fields and BuildDMParticipants he…
vjauhari-work May 15, 2026
5f02015
feat(room-worker): add UpdateDMParticipants store method
vjauhari-work May 15, 2026
edcf2d7
fix(room-worker): filter individual room docs to actual members on cr…
vjauhari-work May 15, 2026
60c1c51
feat(room-worker): populate sender and content on member system messages
vjauhari-work May 15, 2026
b4160f1
fix(room-worker): validation hardening — UUID request IDs and fail-cl…
vjauhari-work May 15, 2026
22c3628
feat(room-worker): set DM participant fields on DM/botDM rooms
vjauhari-work May 15, 2026
e59fbb7
test(room-worker): adapt tests for room-encryption-keys merge
claude May 17, 2026
453905d
docs(plan,spec) + test(room-worker): align scope statements and exten…
claude May 17, 2026
9e17af4
feat(broadcast-worker): fan out DM/botDM mutation events via room.Acc…
vjauhari-work May 18, 2026
3c6f817
refactor(room-worker): single-write DM/botDM create; dedupe displayName
vjauhari-work May 18, 2026
c789f85
refactor(room-worker): sys-message format follow-ups for PR #185
vjauhari-work May 18, 2026
af22b63
test(room-worker): update integration test assertions for quoted sys-…
vjauhari-work May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions broadcast-worker/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,17 +171,15 @@ func (h *Handler) fanOutMutationEvent(
}
return h.pub.Publish(ctx, subject.RoomEvent(room.ID), payload)

case model.RoomTypeDM:
subs, err := h.store.ListSubscriptions(ctx, room.ID)
if err != nil {
return fmt.Errorf("list subscriptions for DM room %s: %w", room.ID, err)
}
case model.RoomTypeDM, model.RoomTypeBotDM:
payload, err := json.Marshal(&roomEvt)
if err != nil {
return fmt.Errorf("marshal %s DM event: %w", roomEvtType, err)
}
for i := range subs {
account := subs[i].User.Account
for _, account := range room.Accounts {
if isBot(account) {
continue
}
if err := h.pub.Publish(ctx, subject.UserRoomEvent(account), payload); err != nil {
slog.Error("publish DM mutation event failed",
"error", err,
Expand Down
62 changes: 52 additions & 10 deletions broadcast-worker/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -888,13 +888,13 @@ func TestHandleUpdated_DMRoom_FansOutToBothMembers(t *testing.T) {
keyStore := NewMockRoomKeyProvider(ctrl)

roomID := "dm-alice-bob"
room := &model.Room{ID: roomID, Type: model.RoomTypeDM, SiteID: "site-a"}
subs := []model.Subscription{
{User: model.SubscriptionUser{ID: "u-alice", Account: "alice"}, RoomID: roomID},
{User: model.SubscriptionUser{ID: "u-bob", Account: "bob"}, RoomID: roomID},
room := &model.Room{
ID: roomID,
Type: model.RoomTypeDM,
SiteID: "site-a",
Accounts: []string{"alice", "bob"},
}
store.EXPECT().GetRoom(gomock.Any(), roomID).Return(room, nil)
store.EXPECT().ListSubscriptions(gomock.Any(), roomID).Return(subs, nil)

edited := time.Date(2026, 5, 14, 12, 5, 0, 0, time.UTC)
evt := model.MessageEvent{
Expand Down Expand Up @@ -946,13 +946,13 @@ func TestHandleDeleted_DMRoom_FansOutToBothMembers(t *testing.T) {
keyStore := NewMockRoomKeyProvider(ctrl)

roomID := "dm-alice-bob"
room := &model.Room{ID: roomID, Type: model.RoomTypeDM, SiteID: "site-a"}
subs := []model.Subscription{
{User: model.SubscriptionUser{ID: "u-alice", Account: "alice"}, RoomID: roomID},
{User: model.SubscriptionUser{ID: "u-bob", Account: "bob"}, RoomID: roomID},
room := &model.Room{
ID: roomID,
Type: model.RoomTypeDM,
SiteID: "site-a",
Accounts: []string{"alice", "bob"},
}
store.EXPECT().GetRoom(gomock.Any(), roomID).Return(room, nil)
store.EXPECT().ListSubscriptions(gomock.Any(), roomID).Return(subs, nil)

deletedAt := time.Date(2026, 5, 14, 12, 10, 0, 0, time.UTC)
evt := model.MessageEvent{
Expand Down Expand Up @@ -993,6 +993,48 @@ func TestHandleDeleted_DMRoom_FansOutToBothMembers(t *testing.T) {
assert.True(t, subjects[subject.UserRoomEvent("bob")])
}

func TestHandleUpdated_BotDMRoom_SkipsBotAccount(t *testing.T) {
ctrl := gomock.NewController(t)
store := NewMockStore(ctrl)
us := NewMockUserStore(ctrl)
pub := &mockPublisher{}
keyStore := NewMockRoomKeyProvider(ctrl)

roomID := "botdm-alice-helper.bot"
room := &model.Room{
ID: roomID,
Type: model.RoomTypeBotDM,
SiteID: "site-a",
Accounts: []string{"alice", "helper.bot"},
}
store.EXPECT().GetRoom(gomock.Any(), roomID).Return(room, nil)

edited := time.Date(2026, 5, 14, 12, 5, 0, 0, time.UTC)
evt := model.MessageEvent{
Event: model.EventUpdated,
SiteID: "site-a",
Timestamp: edited.UnixMilli(),
Message: model.Message{
ID: "msg-1",
RoomID: roomID,
UserID: "u-alice",
UserAccount: "alice",
Content: "updated content",
CreatedAt: time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC),
EditedAt: &edited,
UpdatedAt: &edited,
},
}
data, err := json.Marshal(&evt)
require.NoError(t, err)

h := NewHandler(store, us, pub, keyStore, true)
require.NoError(t, h.HandleMessage(context.Background(), data))

require.Len(t, pub.records, 1, "botDM: only the human recipient gets the live event")
assert.Equal(t, subject.UserRoomEvent("alice"), pub.records[0].subject)
}

func TestHandler_HandleMessage_ChannelEncryptionDisabled(t *testing.T) {
msgTime := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
senderUser := model.User{ID: "u-sender", Account: "sender", EngName: "Sender Lin", ChineseName: "寄件者", SiteID: "site-a"}
Expand Down
12 changes: 12 additions & 0 deletions broadcast-worker/helper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package main

import "strings"

// isBot returns true if account follows the bot naming convention used across
// the codebase (suffix `.bot` or prefix `p_`). Mirrors the predicate in
// message-gatekeeper/helper.go and room-service/helper.go — promoting to a
// shared pkg/botid is a future cleanup; keep these copies in sync if the
// convention changes.
func isBot(account string) bool {
return strings.HasSuffix(account, ".bot") || strings.HasPrefix(account, "p_")
}
4 changes: 3 additions & 1 deletion docs/client-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ The created `Room` object.
| `createdAt` | string | RFC 3339 timestamp. |
| `updatedAt` | string | RFC 3339 timestamp. |
| `restricted` | boolean | Optional. |
| `uids` | string[] | Optional. `dm`/`botDM` only. Sorted ascending; paired by index with `accounts` so `uids[i]` and `accounts[i]` describe the same user. Absent on channels and on legacy DMs created before this field was introduced. |
| `accounts` | string[] | Optional. `dm`/`botDM` only. Permuted to mirror `uids` order. Absent on channels and legacy DMs. |

```json
{
Expand Down Expand Up @@ -948,7 +950,7 @@ Used by every history-service method that returns messages. Mirrors the Cassandr
| `visibleTo` | string | Optional. Visibility scope. |
| `reactions` | object | Optional. Map of `emoji → Participant[]`. |
| `deleted` | boolean | Optional. `true` for tombstoned messages. |
| `type` | string | Optional. System-message type when set (e.g. `"member_added"`); regular messages omit it. |
| `type` | string | Optional. System-message type when set; regular messages omit it. Known values: `"room_created"`, `"members_added"`, `"member_removed"`, `"member_left"`. For all four, `msg` is populated with a server-rendered human-readable body and `sender.account` is the responsible actor (the requester for adds/removes-by-other and room-creates, the leaving user for self-leave). |
| `sysMsgData` | string | Optional. Base64-encoded raw JSON payload for system messages. |
| `siteId` | string | Optional. The site that owns the message. |
| `editedAt` | string | Optional. RFC 3339. Set after an edit. |
Expand Down
Loading
Loading