Skip to content

Commit 95768d4

Browse files
committed
feat(messenger): expose bounded reaction users
1 parent a707b67 commit 95768d4

26 files changed

Lines changed: 1304 additions & 14 deletions

docs/workspace_api.md

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,7 @@ syntax. The URL part is a Workspace URN:
10541054
| `is_own` | boolean | no | yes | Whether `author_uuid` equals the current user. |
10551055
| `mentioned` | boolean | no | yes | Whether the markdown payload mentions the current user; defaults to `false`. |
10561056
| `reactions` | object | no | yes | Aggregated reaction counts keyed by `emoji_name`. |
1057+
| `reaction_users` | object | no | yes | Complete persisted user UUID lists for bounded reaction groups, keyed by `emoji_name`. An empty object or missing key means count-only; lists are never partial. |
10571058
| `source_name` | `native`, `zulip` | no | no | Message source name; the public API defaults it to `native` when omitted. |
10581059
| `source` | object | no | no | Message source payload; defaults to `{"kind": "native"}`. Zulip `message_id` can be `null` until outbound sync succeeds. |
10591060
| `provider` | object or `null` | no | yes | Provider badge inherited from the selected provider-backed stream. |
@@ -1254,10 +1255,10 @@ notification side effects.
12541255

12551256
Message reactions are canonical PostgreSQL resources. Reads are scoped to
12561257
messages visible to the current IAM user.
1257-
Creating, updating, or
1258-
deleting a reaction emits a `message_reaction.*` event for the acting user and
1259-
`message.updated` events for every user that can see the message; the message
1260-
snapshot contains aggregated `reactions`.
1258+
Creating, updating, or deleting a reaction emits a `message_reaction.*` event
1259+
for the acting user and `message.updated` events for every user that can see
1260+
the message; the message snapshot contains aggregated `reactions` and the same
1261+
persisted `reaction_users` projection as REST reads.
12611262

12621263
| Field | Type | Required on create | Read-only | Description |
12631264
| --- | --- | --- | --- | --- |
@@ -1295,6 +1296,36 @@ The `reactions` field on message views is an aggregate map:
12951296
}
12961297
```
12971298

1299+
The `reaction_users` field exposes complete UUID lists only for small groups
1300+
selected by server configuration. The default per-group threshold is four
1301+
users (`[messenger_reactions] user_list_limit`). The client does not send or
1302+
infer the limit:
1303+
1304+
```json
1305+
{
1306+
"reactions": {
1307+
"eyes": 12,
1308+
"heart": 3
1309+
},
1310+
"reaction_users": {
1311+
"heart": [
1312+
"11111111-1111-1111-1111-111111111111",
1313+
"22222222-2222-2222-2222-222222222222",
1314+
"33333333-3333-3333-3333-333333333333"
1315+
]
1316+
}
1317+
}
1318+
```
1319+
1320+
Presence of an emoji key guarantees that the list was complete when the
1321+
reaction group was last mutated. If the current count exceeds the configured
1322+
limit, the write removes that key instead of storing a prefix. Historical
1323+
messages are not backfilled and therefore return `reaction_users: {}` until a
1324+
reaction mutation materializes an affected key. Changing the configured limit
1325+
does not rewrite existing snapshots; the next mutation of a group applies the
1326+
new limit. Clients replace the whole map on every REST or realtime message
1327+
snapshot; they must not merge it with a previous value.
1328+
12981329
Reaction realtime payloads include `uuid`, `project_id`, `message_uuid`,
12991330
`user_uuid`, `emoji_name`, `source_name`, and `source`. For
13001331
`message_reaction.updated`, `old_message_uuid`, `old_emoji_name`,

docs/workspace_ui_realtime_integration.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,11 @@ events are minimal:
100100
Reaction changes emit `message_reaction.created`,
101101
`message_reaction.updated`, or `message_reaction.deleted` for the acting user.
102102
The backend also emits `message.updated` snapshots with the updated aggregate
103-
`reactions` map for users who can see the message.
103+
`reactions` map and persisted bounded `reaction_users` map for users who can see
104+
the message. Each present `reaction_users` key is a complete user UUID list
105+
materialized on the reaction write path. The client replaces the entire map on
106+
every full message snapshot; an empty object or missing key means count-only
107+
and must remove any previously cached list.
104108

105109
Batch stream binding creation uses `payload.items`. Read actions emit
106110
`message.read`, `topic.read`, or `stream.read` and continue to emit aggregate

etc/workspace/workspace.conf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ bind_port = 21081
3737
workers = 1
3838

3939

40+
[messenger_reactions]
41+
user_list_limit = 4
42+
43+
4044
[messenger_worker_agent]
4145
event_retention_seconds = 259200
4246
event_prune_interval_seconds = 300

exordos/manifests/workspace.yaml.j2

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,10 @@ resources:
419419
workers = 1
420420

421421

422+
[messenger_reactions]
423+
user_list_limit = 4
424+
425+
422426
[messenger_events]
423427
bind_host = 127.0.0.1
424428
bind_port = 21082
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Copyright 2016 Eugene Frolov <eugene@frolov.net.ru>
2+
#
3+
# All Rights Reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License. You may obtain
7+
# a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations
15+
# under the License.
16+
17+
from restalchemy.storage.sql import migrations
18+
19+
20+
def _user_messages_view(reaction_users):
21+
return f"""
22+
CREATE OR REPLACE VIEW "m_workspace_user_messages_view" AS
23+
SELECT
24+
m.uuid AS uuid,
25+
m.stream_uuid,
26+
m.user_uuid AS author_uuid,
27+
m.topic_uuid,
28+
m.payload,
29+
m.created_at,
30+
m.updated_at,
31+
b.user_uuid AS user_uuid,
32+
m.project_id,
33+
COALESCE(f.read, FALSE) AS read,
34+
COALESCE(f.pinned, FALSE) AS pinned,
35+
COALESCE(f.starred, FALSE) AS starred,
36+
(m.user_uuid = b.user_uuid) AS is_own,
37+
COALESCE(
38+
(
39+
SELECT jsonb_object_agg(
40+
reaction_counts.emoji_name,
41+
reaction_counts.reaction_count
42+
)
43+
FROM (
44+
SELECT
45+
r.emoji_name,
46+
COUNT(*) AS reaction_count
47+
FROM "m_workspace_message_reactions" AS r
48+
WHERE r.project_id = m.project_id
49+
AND r.message_uuid = m.uuid
50+
GROUP BY r.emoji_name
51+
) AS reaction_counts
52+
),
53+
'{{}}'::jsonb
54+
) AS reactions,
55+
m.source_name,
56+
m.source,
57+
POSITION(
58+
'](' || 'urn:user:' || LOWER(b.user_uuid::text) || ')'
59+
IN LOWER(COALESCE(m.payload->>'content', ''))
60+
) > 0 AS mentioned,
61+
{reaction_users} AS reaction_users
62+
FROM "m_workspace_messages" AS m
63+
JOIN "m_workspace_stream_bindings" AS b
64+
ON b.stream_uuid = m.stream_uuid
65+
AND b.project_id = m.project_id
66+
JOIN "m_workspace_streams" AS stream
67+
ON stream.uuid = m.stream_uuid
68+
AND stream.project_id = m.project_id
69+
LEFT JOIN "m_workspace_user_message_flags" AS f
70+
ON f.uuid = m.uuid
71+
AND f.user_uuid = b.user_uuid
72+
AND f.project_id = m.project_id
73+
LEFT JOIN "m_confirmed_external_stream_access" AS access
74+
ON access.project_id = m.project_id
75+
AND access.user_uuid = b.user_uuid
76+
AND access.stream_uuid = m.stream_uuid
77+
WHERE stream.source_name = 'native'
78+
OR access.user_uuid IS NOT NULL;
79+
"""
80+
81+
82+
class MigrationStep(migrations.AbstractMigrationStep):
83+
def __init__(self):
84+
self._depends = ["0126-index-topic-read-boundaries-20ae22.py"]
85+
86+
@property
87+
def migration_id(self):
88+
return "547d747d-c9f1-4583-80d9-b932c1a5df2a"
89+
90+
@property
91+
def is_manual(self):
92+
return False
93+
94+
def upgrade(self, session):
95+
session.execute(
96+
"""
97+
ALTER TABLE "m_workspace_messages"
98+
ADD COLUMN "reaction_users" JSONB NOT NULL DEFAULT '{}'::jsonb;
99+
"""
100+
)
101+
session.execute(_user_messages_view('m."reaction_users"'))
102+
103+
def downgrade(self, session):
104+
# PostgreSQL cannot remove a trailing view column with CREATE OR
105+
# REPLACE. Keep a harmless constant column for old readers while
106+
# severing the dependency before dropping canonical storage.
107+
session.execute(_user_messages_view("'{}'::jsonb"))
108+
session.execute(
109+
"""
110+
ALTER TABLE "m_workspace_messages"
111+
DROP COLUMN "reaction_users";
112+
"""
113+
)
114+
115+
116+
migration_step = MigrationStep()

workspace/cmd/external_bridge_api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from workspace.common import external_bridge_control_opts
2121
from workspace.common import file_storage_opts
2222
from workspace.common import log as infra_log
23+
from workspace.common import messenger_reaction_opts
2324
from workspace.external_bridge_control import files
2425
from workspace.external_bridge_control import file_repository
2526
from workspace.external_bridge_control import pki
@@ -33,6 +34,7 @@
3334
CONF = cfg.CONF
3435
external_bridge_control_opts.register_opts(CONF)
3536
file_storage_opts.register_opts(CONF)
37+
messenger_reaction_opts.register_opts(CONF)
3638
ra_config_opts.register_posgresql_db_opts(CONF)
3739

3840

workspace/cmd/messenger_api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from workspace.common import external_bridge_opts
3030
from workspace.common import file_storage_opts
3131
from workspace.common import log as infra_log
32+
from workspace.common import messenger_reaction_opts
3233
from workspace.messenger_api.api import app
3334
from workspace.messenger_api.api import store as api_store
3435
from workspace.messenger_api.api import store_factory
@@ -60,6 +61,7 @@
6061
iam_opts.register_iam_cli_opts(CONF)
6162
external_bridge_opts.register_opts(CONF)
6263
file_storage_opts.register_opts(CONF)
64+
messenger_reaction_opts.register_opts(CONF)
6365

6466

6567
def main() -> None:

workspace/cmd/workspace_api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from workspace.common import external_bridge_opts
1919
from workspace.common import file_storage_opts
2020
from workspace.common import log as infra_log
21+
from workspace.common import messenger_reaction_opts
2122
from workspace.messenger_api.api import store as api_store
2223
from workspace.messenger_api.api import store_factory
2324
from workspace.workspace_api.api import app
@@ -37,6 +38,7 @@
3738
iam_opts.register_iam_cli_opts(CONF)
3839
external_bridge_opts.register_opts(CONF)
3940
file_storage_opts.register_opts(CONF)
41+
messenger_reaction_opts.register_opts(CONF)
4042

4143

4244
def main() -> None:
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright 2026 Genesis Corporation.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
6+
from oslo_config import cfg
7+
8+
9+
DOMAIN = "messenger_reactions"
10+
DEFAULT_USER_LIST_LIMIT = 4
11+
12+
messenger_reaction_opts = [
13+
cfg.IntOpt(
14+
"user-list-limit",
15+
default=DEFAULT_USER_LIST_LIMIT,
16+
min=0,
17+
help=(
18+
"Persist complete reaction user UUID lists only for emoji groups "
19+
"whose count does not exceed this value; zero disables the lists"
20+
),
21+
),
22+
]
23+
24+
25+
def register_opts(conf: cfg.ConfigOpts = cfg.CONF) -> None:
26+
conf.register_opts(messenger_reaction_opts, DOMAIN)

workspace/external_bridge_control/identity_linking.py

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import typing
99
import uuid as sys_uuid
1010

11+
from workspace.messenger_api import reaction_users
12+
1113

1214
_PROVIDER_IDENTITY_NAMESPACE = sys_uuid.UUID("fda6f96e-c86d-5c94-976d-4e813e3f3655")
1315
_PAYLOAD_REFERENCE_TABLES = (
@@ -455,6 +457,33 @@ def merge_workspace_user_identity(
455457
legacy["provider_external_id"],
456458
),
457459
)
460+
reaction_group_rows = session.execute(
461+
"""
462+
SELECT DISTINCT project_id, message_uuid, emoji_name
463+
FROM m_workspace_message_reactions
464+
WHERE user_uuid = %s
465+
ORDER BY project_id, message_uuid, emoji_name
466+
""",
467+
(legacy_user_uuid,),
468+
).fetchall()
469+
reaction_groups_by_project: dict[
470+
sys_uuid.UUID,
471+
list[tuple[sys_uuid.UUID, str]],
472+
] = {}
473+
for row in reaction_group_rows:
474+
project_id = sys_uuid.UUID(str(row["project_id"]))
475+
reaction_groups_by_project.setdefault(project_id, []).append(
476+
(
477+
sys_uuid.UUID(str(row["message_uuid"])),
478+
str(row["emoji_name"]),
479+
)
480+
)
481+
for project_id, groups in sorted(reaction_groups_by_project.items()):
482+
reaction_users.lock_messages(
483+
project_id,
484+
(message_uuid for message_uuid, _emoji_name in groups),
485+
session=session,
486+
)
458487
session.execute(
459488
"""
460489
INSERT INTO m_workspace_stream_bindings (
@@ -621,13 +650,36 @@ def merge_workspace_user_identity(
621650
for reference in references:
622651
table_name = reference["table_name"].replace('"', '""')
623652
column_name = reference["column_name"].replace('"', '""')
624-
_update_uuid_reference_batch(
625-
session,
626-
table_name=table_name,
627-
column_name=column_name,
628-
legacy_user_uuid=legacy_user_uuid,
629-
canonical_user_uuid=canonical_user_uuid,
653+
is_reaction_user_reference = (
654+
table_name == "m_workspace_message_reactions"
655+
and column_name == "user_uuid"
630656
)
657+
try:
658+
_update_uuid_reference_batch(
659+
session,
660+
table_name=table_name,
661+
column_name=column_name,
662+
legacy_user_uuid=legacy_user_uuid,
663+
canonical_user_uuid=canonical_user_uuid,
664+
)
665+
except IdentityMergePending:
666+
if is_reaction_user_reference:
667+
for project_id, groups in sorted(
668+
reaction_groups_by_project.items()
669+
):
670+
reaction_users.refresh_groups(
671+
project_id,
672+
groups,
673+
session=session,
674+
)
675+
raise
676+
if is_reaction_user_reference:
677+
for project_id, groups in sorted(reaction_groups_by_project.items()):
678+
reaction_users.refresh_groups(
679+
project_id,
680+
groups,
681+
session=session,
682+
)
631683
session.execute(
632684
"""
633685
DELETE FROM m_workspace_event_audience_members_v1 AS legacy

0 commit comments

Comments
 (0)