Skip to content

Commit c92bed6

Browse files
committed
Refuse a post that mentions a deactivated account
Zulip still resolves a deactivated account's name in @**Name** markup, but renders the mention silent, so it notifies nobody. The write-path check resolved names against every member of /users, deactivated ones included, so a post naming a released ephemeral seat went out silently instead of failing with UnresolvedMention. The check now counts only active members, in both the @**Name** and @**Name|id** forms. The read path is unchanged: it already reads the rendered span, which Zulip marks silent.
1 parent 8116d3d commit c92bed6

2 files changed

Lines changed: 59 additions & 3 deletions

File tree

‎packages/zulip/adapter.test.ts‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
type UnknownIdentity,
3232
UnresolvedMention,
3333
} from '@commy/core/ports'
34-
import { effectTest } from '@commy/testing/effect-test'
34+
import { effectTest, runTestEffect } from '@commy/testing/effect-test'
3535
import {
3636
makeStubHttpClient,
3737
type StubBody,
@@ -1237,6 +1237,45 @@ effectTest('publisher.post fails with a tagged UnresolvedMention on a dead menti
12371237
}),
12381238
)
12391239

1240+
// Zulip still resolves a deactivated account's name, but renders the mention
1241+
// silent, so it notifies nobody. The directory lists deactivated members too,
1242+
// which must not count as resolving the mention — in either markup form.
1243+
test.each([
1244+
['@**cc-homelab-e99f08af**', 'cc-homelab-e99f08af'],
1245+
['@**cc-homelab-e99f08af|4977**', 'cc-homelab-e99f08af|4977'],
1246+
])(
1247+
'publisher.post fails with UnresolvedMention when %s names a deactivated seat',
1248+
(markup, token) =>
1249+
runTestEffect(() =>
1250+
Effect.gen(function* () {
1251+
const stub = yield* makeStubHttpClient
1252+
yield* seedSendMessage(stub, 203)
1253+
const adapter = yield* buildAdapter(stub)
1254+
yield* seedUsers(stub, [
1255+
HERMES,
1256+
{
1257+
user_id: 4977,
1258+
email: 'cc-homelab-e99f08af-bot@example.com',
1259+
full_name: 'cc-homelab-e99f08af',
1260+
is_bot: true,
1261+
is_active: false,
1262+
role: 400,
1263+
},
1264+
])
1265+
const error = yield* Effect.flip(
1266+
adapter.publisher.post(
1267+
generalChannel.name,
1268+
decodeMessageBodySync(`${markup} your answer`),
1269+
),
1270+
)
1271+
expect(error).toBeInstanceOf(UnresolvedMention)
1272+
if (error instanceof UnresolvedMention) {
1273+
expect(error.tokens).toEqual([token])
1274+
}
1275+
}),
1276+
),
1277+
)
1278+
12401279
// A dead form written as an example inside a code span is literal text Zulip
12411280
// never delivers as a mention — validating it would block agents from
12421281
// discussing mention discipline. It must post verbatim, not fail.

‎packages/zulip/adapter.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,7 @@ export const zulipAdapter = (
693693
readonly byId: ReadonlyMap<ZulipUserRef, Identity>
694694
readonly byName: ReadonlyMap<string, Identity>
695695
readonly byIdentityId: ReadonlyMap<IdentityId, ZulipUserRef>
696+
readonly active: ReadonlySet<IdentityId>
696697
}
697698

698699
const buildDirectoryLookup = (): Effect.Effect<
@@ -705,13 +706,15 @@ export const zulipAdapter = (
705706
const byId = new Map<ZulipUserRef, Identity>()
706707
const byName = new Map<string, Identity>()
707708
const byIdentityId = new Map<IdentityId, ZulipUserRef>()
709+
const active = new Set<IdentityId>()
708710
for (const u of members) {
709711
const ident = yield* toIdentity(u)
710712
byId.set(ZulipUserRef(u.user_id), ident)
711713
byName.set(u.full_name, ident)
712714
byIdentityId.set(ident.id, ZulipUserRef(u.user_id))
715+
if (u.is_active) active.add(ident.id)
713716
}
714-
return { byId, byName, byIdentityId }
717+
return { byId, byName, byIdentityId, active }
715718
}),
716719
),
717720
)
@@ -738,6 +741,20 @@ export const zulipAdapter = (
738741
byUserId: (userId) => directory.byId.get(ZulipUserRef(userId)),
739742
})
740743

744+
// Zulip resolves a deactivated member's name but renders the mention
745+
// silent, so only active members can be notified by one.
746+
const notifiableMentionDirectory = (directory: DirectoryLookup): MentionDirectory => {
747+
const isActive = (identity: Identity | undefined): identity is Identity =>
748+
identity !== undefined && directory.active.has(identity.id)
749+
return {
750+
byName: new Map([...directory.byName].filter(([, identity]) => isActive(identity))),
751+
byUserId: (userId) => {
752+
const identity = directory.byId.get(ZulipUserRef(userId))
753+
return isActive(identity) ? identity : undefined
754+
},
755+
}
756+
}
757+
741758
// Write-path mention pre-flight: a `@**Name**` token in an outbound body
742759
// that resolves to no known identity would be posted verbatim and notify
743760
// nobody — Zulip accepts it silently. Reject the write instead. The token
@@ -754,7 +771,7 @@ export const zulipAdapter = (
754771
? Effect.void
755772
: buildDirectoryLookup().pipe(
756773
Effect.flatMap((directory) => {
757-
const dead = unresolvedMentions(body, mentionDirectory(directory))
774+
const dead = unresolvedMentions(body, notifiableMentionDirectory(directory))
758775
return dead.length === 0
759776
? Effect.void
760777
: Effect.fail(

0 commit comments

Comments
 (0)