fix: Bug - Users doesn't receive dm messages

Match direct messages against every local identity alias (home id and provisioned signal-server actor ids) so recipients accept traffic addressed to their per-server presence id instead of silently dropping it.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-13 20:59:57 +02:00
parent b2a2d9d770
commit baa350e90a
6 changed files with 258 additions and 24 deletions

View File

@@ -4,6 +4,7 @@ import {
createGroupConversation, createGroupConversation,
directMessageEventIncludesUser, directMessageEventIncludesUser,
directMessageSyncIncludesUser, directMessageSyncIncludesUser,
directMessageConversationIncludesUser,
createDirectCallStartedMessage, createDirectCallStartedMessage,
getDirectConversationId, getDirectConversationId,
isGroupDirectConversation, isGroupDirectConversation,
@@ -137,6 +138,32 @@ describe('DirectMessageService domain flow', () => {
expect(directMessageEventIncludesUser(payload, 'charlie')).toBe(false); expect(directMessageEventIncludesUser(payload, 'charlie')).toBe(false);
}); });
it('recognises direct-message recipients across identity aliases', () => {
const payload = {
message: createMessage('message-1', 'SENT', getDirectConversationId('alice', 'bob-foreign'), ['bob-foreign']),
participants: [alice, { ...bob, userId: 'bob-foreign' }],
sender: alice
};
const bobIds = new Set(['bob', 'bob-foreign']);
expect(directMessageEventIncludesUser(payload, bobIds)).toBe(true);
expect(directMessageEventIncludesUser(payload, 'bob')).toBe(false);
});
it('recognises conversation participants across identity aliases', () => {
const conversation = {
...createDirectConversation(alice, bob, 10),
participants: ['alice', 'bob-foreign'],
participantProfiles: {
alice,
'bob-foreign': { ...bob, userId: 'bob-foreign' }
}
};
expect(directMessageConversationIncludesUser(conversation, new Set(['bob', 'bob-foreign']))).toBe(true);
expect(directMessageConversationIncludesUser(conversation, 'bob')).toBe(false);
});
it('recognises only declared sync participants', () => { it('recognises only declared sync participants', () => {
const payload = { const payload = {
conversationId: 'dm-group-test', conversationId: 'dm-group-test',

View File

@@ -14,6 +14,7 @@ import { OfflineMessageQueueService } from './offline-message-queue.service';
import { PeerDeliveryService } from './peer-delivery.service'; import { PeerDeliveryService } from './peer-delivery.service';
import { AttachmentFacade } from '../../../attachment'; import { AttachmentFacade } from '../../../attachment';
import { CustomEmojiService } from '../../../custom-emoji'; import { CustomEmojiService } from '../../../custom-emoji';
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
import { import {
advanceDirectMessageStatus, advanceDirectMessageStatus,
createDirectConversation, createDirectConversation,
@@ -27,6 +28,7 @@ import {
updateMessageStatusInConversation, updateMessageStatusInConversation,
upsertDirectMessage upsertDirectMessage
} from '../../domain/logic/direct-message.logic'; } from '../../domain/logic/direct-message.logic';
import { collectDirectMessageSelfUserIds, isSelfDirectMessageSender } from '../../domain/logic/direct-message-identity.rules';
import { import {
DirectMessage, DirectMessage,
DirectMessageConversation, DirectMessageConversation,
@@ -67,6 +69,7 @@ export class DirectMessageService {
private readonly delivery = inject(PeerDeliveryService); private readonly delivery = inject(PeerDeliveryService);
private readonly attachments = inject(AttachmentFacade); private readonly attachments = inject(AttachmentFacade);
private readonly customEmoji = inject(CustomEmojiService); private readonly customEmoji = inject(CustomEmojiService);
private readonly credentialStore = inject(SignalServerCredentialStoreService);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly currentUser = this.store.selectSignal(selectCurrentUser); private readonly currentUser = this.store.selectSignal(selectCurrentUser);
@@ -501,8 +504,9 @@ export class DirectMessageService {
private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> { private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser(); const currentUser = this.requireCurrentUser();
const selfUserIds = this.getSelfUserIds();
if (!directMessageEventIncludesUser(payload, ownerId) || payload.sender.userId === ownerId || payload.message.senderId === ownerId) { if (!directMessageEventIncludesUser(payload, selfUserIds) || isSelfDirectMessageSender(payload, selfUserIds)) {
return; return;
} }
@@ -571,8 +575,9 @@ export class DirectMessageService {
private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> { private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.findConversation(ownerId, payload.conversationId); const conversation = await this.findConversation(ownerId, payload.conversationId);
const selfUserIds = this.getSelfUserIds();
if (!conversation || !directMessageConversationIncludesUser(conversation, ownerId)) { if (!conversation || !directMessageConversationIncludesUser(conversation, selfUserIds)) {
return; return;
} }
@@ -580,16 +585,16 @@ export class DirectMessageService {
} }
private handleIncomingTyping(payload: DirectMessageTypingEventPayload): void { private handleIncomingTyping(payload: DirectMessageTypingEventPayload): void {
const currentUserId = this.getCurrentUserId(); const selfUserIds = this.getSelfUserIds();
if (!currentUserId || payload.sender.userId === currentUserId) { if (selfUserIds.size === 0 || selfUserIds.has(payload.sender.userId)) {
return; return;
} }
const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId); const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId);
if (!conversation if (!conversation
|| !directMessageConversationIncludesUser(conversation, currentUserId) || !directMessageConversationIncludesUser(conversation, selfUserIds)
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) { || !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
return; return;
} }
@@ -621,10 +626,11 @@ export class DirectMessageService {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser(); const currentUser = this.requireCurrentUser();
const conversation = await this.findConversation(ownerId, payload.conversationId); const conversation = await this.findConversation(ownerId, payload.conversationId);
const selfUserIds = this.getSelfUserIds();
if (!conversation if (!conversation
|| payload.sender.userId === ownerId || selfUserIds.has(payload.sender.userId)
|| !directMessageConversationIncludesUser(conversation, ownerId) || !directMessageConversationIncludesUser(conversation, selfUserIds)
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) { || !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
return; return;
} }
@@ -647,12 +653,13 @@ export class DirectMessageService {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser(); const currentUser = this.requireCurrentUser();
const currentParticipant = toDirectMessageParticipant(currentUser); const currentParticipant = toDirectMessageParticipant(currentUser);
const selfUserIds = this.getSelfUserIds();
if (payload.sender.userId === ownerId) { if (selfUserIds.has(payload.sender.userId)) {
return; return;
} }
if (!directMessageSyncIncludesUser(payload, ownerId) || !directMessageSyncIncludesUser(payload, payload.sender.userId)) { if (!directMessageSyncIncludesUser(payload, selfUserIds) || !directMessageSyncIncludesUser(payload, payload.sender.userId)) {
return; return;
} }
@@ -929,7 +936,9 @@ export class DirectMessageService {
return []; return [];
} }
return conversation.participants.filter((participantId) => participantId !== currentUserId); const selfUserIds = this.getSelfUserIds();
return conversation.participants.filter((participantId) => !selfUserIds.has(participantId));
} }
private conversationKind(conversation: DirectMessageConversation): 'direct' | 'group' { private conversationKind(conversation: DirectMessageConversation): 'direct' | 'group' {
@@ -991,4 +1000,16 @@ export class DirectMessageService {
return ownerId; return ownerId;
} }
private getSelfUserIds(): ReadonlySet<string> {
const currentUser = this.currentUser();
if (!currentUser) {
return new Set();
}
const actorUserIds = this.credentialStore.listValidCredentials().map((credential) => credential.userId);
return collectDirectMessageSelfUserIds(currentUser, actorUserIds);
}
} }

View File

@@ -9,7 +9,8 @@ import {
} from 'rxjs'; } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectAllUsers } from '../../../../store/users/users.selectors'; import { selectAllUsers } from '../../../../store/users/users.selectors';
import type { ChatEvent, User } from '../../../../shared-kernel'; import { buildUserIdentityLookup, resolveUserByIdentity } from '../../../../store/users/user-identity-lookup.rules';
import type { ChatEvent } from '../../../../shared-kernel';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PeerDeliveryService { export class PeerDeliveryService {
@@ -87,13 +88,13 @@ export class PeerDeliveryService {
return recipientId; return recipientId;
} }
const user = this.users().find((candidate: User) => const lookup = buildUserIdentityLookup(this.users());
candidate.id === recipientId || candidate.oderId === recipientId || candidate.peerId === recipientId const user = resolveUserByIdentity(lookup, recipientId);
);
const candidates = [ const candidates = [
user?.oderId, user?.oderId,
user?.peerId, user?.peerId,
user?.id user?.id,
recipientId
].filter((candidate): candidate is string => !!candidate); ].filter((candidate): candidate is string => !!candidate);
return candidates.find((candidate) => connectedPeerIds.has(candidate)) ?? null; return candidates.find((candidate) => connectedPeerIds.has(candidate)) ?? null;
@@ -135,9 +136,8 @@ export class PeerDeliveryService {
} }
private resolveCandidateIds(recipientId: string): string[] { private resolveCandidateIds(recipientId: string): string[] {
const user = this.users().find((candidate: User) => const lookup = buildUserIdentityLookup(this.users());
candidate.id === recipientId || candidate.oderId === recipientId || candidate.peerId === recipientId const user = resolveUserByIdentity(lookup, recipientId);
);
return [ return [
recipientId, recipientId,

View File

@@ -0,0 +1,114 @@
import {
collectDirectMessageSelfUserIds,
directMessageConversationIncludesAnyUser,
directMessageEventIncludesAnyUser,
isSelfDirectMessageSender
} from './direct-message-identity.rules';
import type { DirectMessageConversation, DirectMessageParticipant } from '../models/direct-message.model';
const aliceHome: DirectMessageParticipant = {
userId: 'alice-home',
username: 'alice',
displayName: 'Alice'
};
const bobHome: DirectMessageParticipant = {
userId: 'bob-home',
username: 'bob',
displayName: 'Bob'
};
describe('direct-message-identity.rules', () => {
it('collects home and provisioned actor ids for the local user', () => {
const ids = collectDirectMessageSelfUserIds(
{ id: 'alice-home', oderId: 'alice-home' },
['alice-foreign']
);
expect(Array.from(ids).sort()).toEqual(['alice-foreign', 'alice-home']);
});
it('accepts incoming direct messages addressed to a provisioned actor id', () => {
const payload = {
message: {
id: 'message-1',
conversationId: 'dm-alice-home--bob-foreign',
senderId: 'alice-home',
recipientId: 'bob-foreign',
recipientIds: ['bob-foreign'],
content: 'hello',
timestamp: 1,
status: 'SENT' as const
},
sender: aliceHome,
participants: [aliceHome, { ...bobHome, userId: 'bob-foreign' }]
};
expect(directMessageEventIncludesAnyUser(payload, collectDirectMessageSelfUserIds(
{ id: 'bob-home', oderId: 'bob-home' },
['bob-foreign']
))).toBe(true);
});
it('rejects incoming direct messages that do not target any local identity', () => {
const payload = {
message: {
id: 'message-1',
conversationId: 'dm-alice-home--charlie',
senderId: 'alice-home',
recipientId: 'charlie',
recipientIds: ['charlie'],
content: 'hello',
timestamp: 1,
status: 'SENT' as const
},
sender: aliceHome,
participants: [aliceHome]
};
expect(directMessageEventIncludesAnyUser(payload, collectDirectMessageSelfUserIds(
{ id: 'bob-home', oderId: 'bob-home' },
['bob-foreign']
))).toBe(false);
});
it('treats any local identity as the sender for echo suppression', () => {
const payload = {
message: {
id: 'message-1',
conversationId: 'dm-alice-home--bob-foreign',
senderId: 'alice-foreign',
recipientId: 'bob-foreign',
recipientIds: ['bob-foreign'],
content: 'hello',
timestamp: 1,
status: 'SENT' as const
},
sender: { ...aliceHome, userId: 'alice-foreign' }
};
const selfIds = collectDirectMessageSelfUserIds(
{ id: 'alice-home', oderId: 'alice-home' },
['alice-foreign']
);
expect(isSelfDirectMessageSender(payload, selfIds)).toBe(true);
});
it('matches conversations stored under a different participant alias', () => {
const conversation: DirectMessageConversation = {
id: 'dm-alice-home--bob-foreign',
participants: ['alice-home', 'bob-foreign'],
participantProfiles: {
'alice-home': aliceHome,
'bob-foreign': { ...bobHome, userId: 'bob-foreign' }
},
messages: [],
lastMessageAt: 1,
unreadCount: 0
};
expect(directMessageConversationIncludesAnyUser(
conversation,
collectDirectMessageSelfUserIds({ id: 'bob-home', oderId: 'bob-home' }, ['bob-foreign'])
)).toBe(true);
});
});

View File

@@ -0,0 +1,49 @@
import type { User } from '../../../../shared-kernel';
import { directMessageConversationIncludesUser, directMessageEventIncludesUser } from './direct-message.logic';
import type { DirectMessageConversation, DirectMessageEventPayload } from '../models/direct-message.model';
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
/** Collect every id that can represent the local user in direct-message traffic. */
export function collectDirectMessageSelfUserIds(
user: UserIdentityFields,
additionalUserIds: readonly string[] = []
): ReadonlySet<string> {
const ids = new Set<string>();
for (const candidate of [
user.id,
user.oderId,
user.peerId,
...additionalUserIds
]) {
const normalized = candidate?.trim();
if (normalized) {
ids.add(normalized);
}
}
return ids;
}
export function directMessageEventIncludesAnyUser(
payload: DirectMessageEventPayload,
userIds: ReadonlySet<string>
): boolean {
return directMessageEventIncludesUser(payload, userIds);
}
export function isSelfDirectMessageSender(
payload: DirectMessageEventPayload,
userIds: ReadonlySet<string>
): boolean {
return userIds.has(payload.sender.userId) || userIds.has(payload.message.senderId);
}
export function directMessageConversationIncludesAnyUser(
conversation: Pick<DirectMessageConversation, 'participantProfiles' | 'participants'>,
userIds: ReadonlySet<string>
): boolean {
return directMessageConversationIncludesUser(conversation, userIds);
}

View File

@@ -100,23 +100,46 @@ export function isGroupDirectConversation(conversation: DirectMessageConversatio
export function directMessageConversationIncludesUser( export function directMessageConversationIncludesUser(
conversation: Pick<DirectMessageConversation, 'participantProfiles' | 'participants'>, conversation: Pick<DirectMessageConversation, 'participantProfiles' | 'participants'>,
userId: string userId: string | ReadonlySet<string>
): boolean { ): boolean {
return conversation.participants.includes(userId) || !!conversation.participantProfiles[userId]; const userIds = typeof userId === 'string' ? new Set([userId]) : userId;
if (conversation.participants.some((participantId) => userIds.has(participantId))) {
return true;
}
if (Object.keys(conversation.participantProfiles).some((participantId) => userIds.has(participantId))) {
return true;
}
return Object.values(conversation.participantProfiles).some((participant) =>
userIds.has(participant.userId)
);
} }
export function directMessageEventIncludesUser( export function directMessageEventIncludesUser(
payload: DirectMessageEventPayload, payload: DirectMessageEventPayload,
userId: string userId: string | ReadonlySet<string>
): boolean { ): boolean {
return collectDirectMessageEventParticipantIds(payload).has(userId); const userIds = typeof userId === 'string' ? new Set([userId]) : userId;
const participantIds = collectDirectMessageEventParticipantIds(payload);
for (const candidate of userIds) {
if (participantIds.has(candidate)) {
return true;
}
}
return false;
} }
export function directMessageSyncIncludesUser( export function directMessageSyncIncludesUser(
payload: DirectMessageSyncEventPayload, payload: DirectMessageSyncEventPayload,
userId: string userId: string | ReadonlySet<string>
): boolean { ): boolean {
return payload.participants.some((participant) => participant.userId === userId); const userIds = typeof userId === 'string' ? new Set([userId]) : userId;
return payload.participants.some((participant) => userIds.has(participant.userId));
} }
export function upsertDirectMessage( export function upsertDirectMessage(