fix: Bug - User receiving direct call doesn't get notified (identity aliases)

Match incoming direct-call events against every local identity alias - home
id, entity id, peer id, and each provisioned signal-server actor id - instead
of only oderId||id. A caller who met the callee through a room on the
caller's signal server addresses the ring by the callee's provisioned actor
id, so the old admission check silently dropped it: the caller went "In
Voice" while the callee saw no modal, no ring audio, and no rail entry.
Incoming self aliases are normalized onto the canonical local id
(normalizeDirectCallPayloadSelfAliases) so they never appear as a phantom
third participant, and remoteParticipantIds / the DM-header peer lookup skip
all self aliases.

Adds a DM-header call ring e2e including the cross-signal topology (callee
homed on a secondary signal server) that fails on the old code.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 20:18:37 +02:00
co-authored by Cursor
parent 590e487250
commit 3e090933fd
7 changed files with 510 additions and 19 deletions
@@ -17,6 +17,7 @@ import {
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import { ViewportService } from '../../../../core/platform';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
import {
VoiceActivityService,
VoiceConnectionFacade,
@@ -110,6 +111,111 @@ describe('DirectCallService', () => {
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
});
it('notifies when a ring addresses the local user via a provisioned signal-server actor id', async () => {
// Bob's home identity is "bob", but on the caller's signal server he acts
// through the provisioned identity "bob-actor". The ring payload only
// carries the actor id, so admission must match every local alias.
const context = createServiceContext({
currentUser: bob,
allUsers: [alice, bob],
selfActorIds: ['bob-actor']
});
context.directCallEvents.next({
type: 'direct-call',
directCall: {
action: 'ring',
callId: 'dm-alice-bob-actor',
conversationId: 'dm-alice-bob-actor',
createdAt: 10,
sender: toParticipant(alice),
participantIds: ['alice', 'bob-actor'],
participants: [toParticipant(alice), { userId: 'bob-actor', username: 'bob', displayName: 'Bob' }]
}
});
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob-actor'));
await vi.waitFor(() => expect(context.audio.playLoop).toHaveBeenCalledWith(AppSound.Call));
const session = context.service.sessionById('dm-alice-bob-actor');
// The actor alias must collapse onto the local user instead of appearing
// as a third participant (which would convert the call into a group chat).
expect(session?.participantIds.sort()).toEqual(['alice', 'bob']);
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
});
it('ignores rings echoed back to the sender through a provisioned actor alias', async () => {
const context = createServiceContext({
currentUser: bob,
allUsers: [alice, bob],
selfActorIds: ['bob-actor']
});
context.directCallEvents.next({
type: 'direct-call',
directCall: {
action: 'ring',
callId: 'dm-alice-bob',
conversationId: 'dm-alice-bob',
createdAt: 10,
sender: { userId: 'bob-actor', username: 'bob', displayName: 'Bob' },
participantIds: ['alice', 'bob-actor'],
participants: [toParticipant(alice), { userId: 'bob-actor', username: 'bob', displayName: 'Bob' }]
}
});
await Promise.resolve();
expect(context.service.sessionById('dm-alice-bob')).toBeNull();
expect(context.audio.playLoop).not.toHaveBeenCalled();
});
it('excludes provisioned actor aliases from remote participant ids', () => {
const context = createServiceContext({
currentUser: bob,
allUsers: [alice, bob],
selfActorIds: ['bob-actor']
});
expect(context.service.remoteParticipantIds({
...createSession('ringing', false),
participantIds: [
'alice',
'bob',
'bob-actor'
]
})).toEqual(['alice']);
});
it('starts a DM-header call to the peer even when the conversation stores the local user under an actor alias', async () => {
const context = createServiceContext({
currentUser: bob,
allUsers: [alice, bob],
selfActorIds: ['bob-actor']
});
const conversation: DirectMessageConversation = {
id: 'dm-alice-bob-actor',
kind: 'direct',
lastMessageAt: 10,
messages: [],
participantProfiles: {
'alice': toParticipant(alice),
'bob-actor': { userId: 'bob-actor', username: 'bob', displayName: 'Bob' }
},
participants: ['alice', 'bob-actor'],
unreadCount: 0
};
context.service.joinCall = vi.fn(async () => undefined);
await context.service.startConversationCall(conversation);
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('alice', expect.objectContaining({
directCall: expect.objectContaining({ action: 'ring' }),
type: 'direct-call'
}));
});
it('marks a remote join against the session participant alias stored locally', async () => {
const aliceForeign = createUser('alice-foreign', 'Alice');
const bobForeign = createUser('bob-foreign', 'Bob');
@@ -429,6 +535,7 @@ describe('DirectCallService', () => {
interface ServiceContextOptions {
allUsers: User[];
currentUser: User | null;
selfActorIds?: string[];
}
interface ServiceContext {
@@ -536,6 +643,17 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
const voiceSession = {
endSession: vi.fn()
};
const credentialStore = {
listValidCredentials: vi.fn(() => (options.selfActorIds ?? []).map((userId) => ({
serverUrl: `https://signal.example/${userId}`,
userId,
username: userId,
displayName: userId,
token: 'token',
expiresAt: Date.now() + 60_000,
provisioned: true
})))
};
const injector = Injector.create({
providers: [
{
@@ -626,6 +744,10 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
requestVoiceClientTakeover: vi.fn()
}
},
{
provide: SignalServerCredentialStoreService,
useValue: credentialStore
},
...provideAppI18nForTests()
]
});