Resolve invitees via /state and accept 'invite' membership
For chats where we sent an opening message but the recipient never
accepted, /sync drops the room with no heroes and the recipient is
in state as m.room.member with membership:'invite' — not 'join'.
chat.reddit.com's web UI labels these rooms with the invitee anyway,
because it reads the full room state.
- roomToActiveChat now accepts 'invite' membership in addition to
'join' when scanning member-join state events.
- enrichUsernames pass 2 hits /_matrix/client/v3/rooms/{id}/state
first (cheap, returns all member events including invites) and
only falls back to /messages history if /state yields nothing.
- Added MatrixClient.getRoomFullState to back that lookup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
cf7fc41761
commit
71548ec2ad
2 files changed
+64
-25
No files matched your search
@@ -276,6 +276,19 @@ export class MatrixClient {
|
||||
return res.joined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the room's full state — every state event, including
|
||||
* m.room.member entries with `membership: 'invite'` for chats the
|
||||
* recipient hasn't accepted yet. Used to recover usernames for chats
|
||||
* where /sync omitted heroes and state.
|
||||
*/
|
||||
async getRoomFullState(roomId: string): Promise<MatrixTimelineEvent[]> {
|
||||
return this.request<MatrixTimelineEvent[]>(
|
||||
'GET',
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the room's m.room.name content if set, else null. */
|
||||
async getRoomName(roomId: string): Promise<string | null> {
|
||||
try {
|
||||
|
||||
@@ -830,7 +830,14 @@ export class ChatService {
|
||||
if (ev.type !== 'm.room.member') continue;
|
||||
const stateKey = (ev as { state_key?: string }).state_key;
|
||||
const membership = (ev.content as { membership?: string }).membership;
|
||||
if (membership === 'join' && stateKey && stateKey !== this.matrix.userId) {
|
||||
// Include 'invite' too — for outgoing chats the recipient never
|
||||
// accepted, their MXID is only in state as an invite, not a join.
|
||||
// chat.reddit.com's web UI reads the same to display the invitee.
|
||||
if (
|
||||
(membership === 'join' || membership === 'invite') &&
|
||||
stateKey &&
|
||||
stateKey !== this.matrix.userId
|
||||
) {
|
||||
memberJoins.push(stateKey);
|
||||
}
|
||||
}
|
||||
@@ -1028,9 +1035,10 @@ export class ChatService {
|
||||
}
|
||||
|
||||
// Pass 2 — chats still stuck on 'unknown' (or a bare MXID) didn't get a
|
||||
// t2 ID from /sync heroes/state/timeline. Try to mine one from /messages:
|
||||
// a single 'b' page is enough to surface the recipient's MXID on any
|
||||
// room that has ever exchanged a message.
|
||||
// t2 ID from /sync heroes/state/timeline. Try /state first — it returns
|
||||
// every m.room.member event including 'invite'-membership ones, which is
|
||||
// how chat.reddit.com's web UI names rooms the recipient never accepted.
|
||||
// Fall back to mining a /messages page if /state somehow yields nothing.
|
||||
for (const chat of this.cache.get()) {
|
||||
if (!chat.roomId) continue;
|
||||
if (this.unresolvableRooms.has(chat.roomId)) continue;
|
||||
@@ -1039,30 +1047,48 @@ export class ChatService {
|
||||
chat.username === 'unknown' ||
|
||||
chat.username.startsWith('@');
|
||||
if (!needsLookup) continue;
|
||||
let t2: string | undefined;
|
||||
try {
|
||||
const res = await this.withAuth(() =>
|
||||
this.matrix.getRoomMessages(chat.roomId!, { limit: 20, dir: 'b' }),
|
||||
);
|
||||
const otherSender = res.chunk?.find(
|
||||
(e) => e.sender && e.sender !== this.matrix.userId,
|
||||
)?.sender;
|
||||
const t2 = otherSender?.match(/^@(t2_[^:]+):/)?.[1];
|
||||
if (t2) {
|
||||
const bare = t2.replace(/^t2_/, '');
|
||||
const known = this.t2ToUsername.get(bare);
|
||||
chat.username = known ?? t2;
|
||||
if (!chat.roomName || chat.roomName === 'unknown' || chat.roomName.startsWith('@')) {
|
||||
chat.roomName = chat.username;
|
||||
}
|
||||
dirty = true;
|
||||
} else {
|
||||
// No other party has ever sent a message here (we sent an opener
|
||||
// that was never accepted, etc). Don't retry every sync.
|
||||
this.unresolvableRooms.add(chat.roomId);
|
||||
}
|
||||
const state = await this.withAuth(() => this.matrix.getRoomFullState(chat.roomId!));
|
||||
const otherMember = state.find((e) => {
|
||||
if (e.type !== 'm.room.member') return false;
|
||||
const sk = (e as { state_key?: string }).state_key;
|
||||
const membership = (e.content as { membership?: string }).membership;
|
||||
return (
|
||||
sk &&
|
||||
sk !== this.matrix.userId &&
|
||||
(membership === 'join' || membership === 'invite')
|
||||
);
|
||||
}) as { state_key?: string } | undefined;
|
||||
t2 = otherMember?.state_key?.match(/^@(t2_[^:]+):/)?.[1];
|
||||
} catch (err) {
|
||||
this.logger.debug(`[chat] /state lookup failed for ${chat.roomId}:`, err);
|
||||
}
|
||||
if (!t2) {
|
||||
try {
|
||||
const res = await this.withAuth(() =>
|
||||
this.matrix.getRoomMessages(chat.roomId!, { limit: 20, dir: 'b' }),
|
||||
);
|
||||
const otherSender = res.chunk?.find(
|
||||
(e) => e.sender && e.sender !== this.matrix.userId,
|
||||
)?.sender;
|
||||
t2 = otherSender?.match(/^@(t2_[^:]+):/)?.[1];
|
||||
} catch (err) {
|
||||
this.logger.debug(`[chat] /messages lookup failed for ${chat.roomId}:`, err);
|
||||
}
|
||||
}
|
||||
if (t2) {
|
||||
const bare = t2.replace(/^t2_/, '');
|
||||
const known = this.t2ToUsername.get(bare);
|
||||
chat.username = known ?? `t2_${bare}`;
|
||||
if (!chat.roomName || chat.roomName === 'unknown' || chat.roomName.startsWith('@')) {
|
||||
chat.roomName = chat.username;
|
||||
}
|
||||
dirty = true;
|
||||
} else {
|
||||
// Neither /state nor /messages surfaced a t2 — give up on this room
|
||||
// so we don't keep hammering the homeserver every sync.
|
||||
this.unresolvableRooms.add(chat.roomId);
|
||||
this.logger.debug(`[chat] history lookup failed for ${chat.roomId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user