typeclaw 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -49,6 +49,7 @@ import {
49
49
  } from './tool-result-budget'
50
50
  import { createChannelFetchAttachmentTool } from './tools/channel-fetch-attachment'
51
51
  import { createChannelHistoryTool } from './tools/channel-history'
52
+ import { createChannelReactTool } from './tools/channel-react'
52
53
  import { createChannelReplyTool } from './tools/channel-reply'
53
54
  import { createChannelSendTool } from './tools/channel-send'
54
55
  import { createGrantRoleTool } from './tools/grant-role'
@@ -532,6 +533,12 @@ export function buildChannelTools(
532
533
  tools.push(createChannelReplyTool({ router: channelRouter, origin: channelOrigin }))
533
534
  tools.push(createChannelHistoryTool({ router: channelRouter, origin: channelOrigin }))
534
535
  tools.push(createChannelSendTool({ router: channelRouter, origin: channelOrigin }))
536
+ tools.push(
537
+ createChannelReactTool({
538
+ router: channelRouter,
539
+ origin: { ...channelOrigin, ...(origin.reactionRef !== undefined ? { reactionRef: origin.reactionRef } : {}) },
540
+ }),
541
+ )
535
542
  tools.push(
536
543
  createChannelFetchAttachmentTool({
537
544
  router: channelRouter,
@@ -19,6 +19,10 @@ export type LiveSubagent = {
19
19
  sessionId: string
20
20
  subagentName: string
21
21
  parentSessionId?: string
22
+ // Role that resolved at spawn time, captured for the provenance cap on
23
+ // subagent_output/subagent_cancel. Absent when no permission service was
24
+ // active at spawn, in which case the cap fails closed.
25
+ spawnedByRole?: string
22
26
  startedAt: number
23
27
  status: SubagentStatus
24
28
  completion?: SubagentCompletion
@@ -1,5 +1,6 @@
1
1
  import { MEMBERSHIP_FRESHNESS_MS, type MembershipCount } from '@/channels/membership'
2
2
  import type { AdapterId } from '@/channels/schema'
3
+ import type { ReactionRef } from '@/channels/types'
3
4
 
4
5
  export type ChannelParticipant = {
5
6
  authorId: string
@@ -38,6 +39,7 @@ export type SessionOrigin =
38
39
  chatName?: string
39
40
  thread: string | null
40
41
  lastInboundAuthorId?: string
42
+ reactionRef?: ReactionRef
41
43
  participants?: readonly ChannelParticipant[]
42
44
  membership?: MembershipCount
43
45
  }
@@ -301,6 +303,13 @@ function renderChannelOrigin(
301
303
  'audience: post the answer (or review summary) itself, never a status',
302
304
  'line about having posted it elsewhere. A narrated "Posted review result',
303
305
  'for PR #N: …" inside the PR is exactly the failure to avoid.',
306
+ '',
307
+ '**Do not post an "On it" acknowledgment comment.** The runtime already',
308
+ 'adds an :eyes: reaction to the triggering item the moment it engages, so a',
309
+ 'separate "looking into this" comment is redundant noise on the PR. If you',
310
+ 'want to signal acknowledgment explicitly, use `channel_react({ emoji })`',
311
+ '(it reacts, it does not comment) — never a text ack. Reserve `channel_reply`',
312
+ 'for the actual substantive answer.',
304
313
  )
305
314
  }
306
315
 
@@ -339,12 +348,21 @@ function renderChannelOrigin(
339
348
  '`channel_reply` call, not narration. This includes acks.',
340
349
  '',
341
350
  '**One substantive reply per inbound.** If the answer needs more than one',
342
- 'tool call, send a one-line ack first via `channel_reply({ text: "On it.",',
343
- 'continue: true })`, keep working, then send the answer with a final',
344
- '`channel_reply`. The ack is not your reply; the answer is. Once the answer',
345
- 'lands, end your turn. The `continue: true` is not optional on that ack:',
346
- 'without it the turn ends the instant the ack lands and the rest of your',
347
- 'work the fetch, the subagent, the actual answer is silently dropped.',
351
+ ...(origin.adapter === 'github'
352
+ ? [
353
+ 'tool call, keep working and post the answer with a single final',
354
+ '`channel_reply`. Do not post an "On it" ack comment first the runtime',
355
+ 'already added an :eyes: reaction on engage; use `channel_react` if you',
356
+ 'want to acknowledge explicitly. The answer is your reply.',
357
+ ]
358
+ : [
359
+ 'tool call, send a one-line ack first via `channel_reply({ text: "On it.",',
360
+ 'continue: true })`, keep working, then send the answer with a final',
361
+ '`channel_reply`. The ack is not your reply; the answer is. Once the answer',
362
+ 'lands, end your turn. The `continue: true` is not optional on that ack:',
363
+ 'without it the turn ends the instant the ack lands and the rest of your',
364
+ 'work — the fetch, the subagent, the actual answer — is silently dropped.',
365
+ ]),
348
366
  '',
349
367
  '**Backgrounded work does not end the obligation.** If you spawn a',
350
368
  'subagent with `run_in_background: true` to answer the current inbound,',
@@ -400,15 +418,19 @@ function renderMembershipSummary(
400
418
  if (membership === undefined) return null
401
419
 
402
420
  const total = membership.humans + membership.bots
421
+ // Exact Discord counts are channel-scoped (filtered by who can VIEW_CHANNEL),
422
+ // so the count is the channel's room, not the guild. The truncated branch is
423
+ // history-derived recent speakers, which is not a channel-membership claim,
424
+ // so the caveat would mislead there.
425
+ const isExact = !membership.truncated && now - membership.fetchedAt < MEMBERSHIP_FRESHNESS_MS
403
426
  const caveat =
404
- origin.adapter === 'discord-bot' && origin.workspace !== '@dm'
405
- ? ' (Note: this is the count of guild members; private channels with permission overwrites may have fewer actual viewers.)'
427
+ isExact && origin.adapter === 'discord-bot' && origin.workspace !== '@dm'
428
+ ? ' (This counts only members who can view this channel, not the whole guild.)'
406
429
  : ''
407
- const isExact = !membership.truncated && now - membership.fetchedAt < MEMBERSHIP_FRESHNESS_MS
408
430
  if (isExact) {
409
431
  return `This channel has ${total} members: ${membership.humans} humans, ${membership.bots} bots.${caveat} The 10 most recent speakers are listed below.`
410
432
  }
411
- return `This channel has approximately ${total} members (about ${membership.humans} humans, ${membership.bots} bots — the bot count is approximate, the full member list was not enumerated because it exceeds the 50-member cap).${caveat} The 10 most recent speakers are listed below.`
433
+ return `This channel has approximately ${total} members (about ${membership.humans} humans, ${membership.bots} bots — the bot count is approximate, the full member list was not enumerated because it exceeds the 50-member cap). The 10 most recent speakers are listed below.`
412
434
  }
413
435
 
414
436
  function renderMentionGuidance(
@@ -0,0 +1,79 @@
1
+ import { Type } from '@mariozechner/pi-ai'
2
+ import { defineTool } from '@mariozechner/pi-coding-agent'
3
+
4
+ import type { ChannelRouter } from '@/channels/router'
5
+ import type { AdapterId } from '@/channels/schema'
6
+ import type { ReactionRef } from '@/channels/types'
7
+
8
+ import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
9
+ import { TOOL_RESULT_PREFIX } from './channel-reply'
10
+
11
+ export type ChannelReactOrigin = {
12
+ adapter: AdapterId
13
+ workspace: string
14
+ chat: string
15
+ thread: string | null
16
+ reactionRef?: ReactionRef
17
+ }
18
+
19
+ export type CreateChannelReactToolOptions = {
20
+ router: ChannelRouter
21
+ origin: ChannelReactOrigin
22
+ logger?: ChannelToolLogger
23
+ }
24
+
25
+ export function createChannelReactTool({
26
+ router,
27
+ origin,
28
+ logger = consoleChannelLogger,
29
+ }: CreateChannelReactToolOptions) {
30
+ return defineTool({
31
+ name: 'channel_react',
32
+ label: 'Channel React',
33
+ description:
34
+ 'Add an emoji reaction to the message that triggered this turn — a lightweight acknowledgment that does not post a comment. ' +
35
+ 'On GitHub this reacts to the triggering issue/PR/comment (e.g. :eyes: to signal "I am looking at this"). ' +
36
+ 'Use this instead of a textual "on it" reply when a reaction is enough. Pass the bare emoji name, no colons.',
37
+ parameters: Type.Object({
38
+ emoji: Type.String({
39
+ description: 'Bare emoji name, no surrounding colons. e.g. "eyes", "+1", "rocket", "heart".',
40
+ minLength: 1,
41
+ }),
42
+ }),
43
+
44
+ async execute(_toolCallId, params) {
45
+ const deny = (error: string) => {
46
+ logger.warn(formatChannelToolFailure('channel_react', error))
47
+ const details: { ok: boolean; error?: string } = { ok: false, error }
48
+ return {
49
+ content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}channel_react denied: ${error}` }],
50
+ details,
51
+ }
52
+ }
53
+
54
+ if (origin.reactionRef === undefined) return deny('this conversation has no message to react to')
55
+
56
+ const result = await router.react({
57
+ adapter: origin.adapter,
58
+ workspace: origin.workspace,
59
+ chat: origin.chat,
60
+ thread: origin.thread,
61
+ reactionRef: origin.reactionRef,
62
+ emoji: params.emoji,
63
+ })
64
+
65
+ if (!result.ok) return deny(`${origin.adapter}:${origin.workspace}/${origin.chat}: ${result.error}`)
66
+
67
+ const details: { ok: boolean; error?: string } = { ok: true }
68
+ return {
69
+ content: [
70
+ {
71
+ type: 'text' as const,
72
+ text: `${TOOL_RESULT_PREFIX}reacted with :${params.emoji}: on ${origin.adapter}:${origin.workspace}/${origin.chat}`,
73
+ },
74
+ ],
75
+ details,
76
+ }
77
+ },
78
+ })
79
+ }
@@ -129,6 +129,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
129
129
  sessionId: resolvedHandle.sessionId ?? '<pending>',
130
130
  subagentName,
131
131
  parentSessionId,
132
+ ...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
132
133
  startedAt,
133
134
  status: 'running' as const,
134
135
  abort: resolvedHandle.abort,
@@ -0,0 +1,67 @@
1
+ import type { PermissionService } from '@/permissions'
2
+
3
+ import type { LiveSubagent, LiveSubagentRegistry } from '../live-subagents'
4
+ import type { SessionOrigin } from '../session-origin'
5
+
6
+ export type SubagentAccessPermission = 'subagent.output' | 'subagent.cancel'
7
+
8
+ export type SubagentAccessResult = { ok: true; live: LiveSubagent } | { ok: false; message: string }
9
+
10
+ export type AuthorizeLiveSubagentAccessArgs = {
11
+ permissions: PermissionService | undefined
12
+ origin: SessionOrigin | undefined
13
+ liveRegistry: LiveSubagentRegistry
14
+ taskId: string
15
+ permission: SubagentAccessPermission
16
+ }
17
+
18
+ // Authorizes a single subagent_output/subagent_cancel call and resolves the
19
+ // live entry in one place so the two tools cannot drift. Caps access to the
20
+ // requester's role: the caller must hold the permission AND resolve to a role
21
+ // at least as high as the role that spawned the subagent.
22
+ //
23
+ // The ordering closes an existence oracle: the task-independent base-permission
24
+ // check runs BEFORE any registry lookup, and for non-owner callers an absent
25
+ // task, a capped task, and a task with missing provenance all collapse to one
26
+ // identical denial — so a lower-role caller cannot probe which task IDs are
27
+ // live. Only `owner` (the trust root, which outranks every spawner) learns the
28
+ // truthful `Unknown task_id` for a genuine miss. The cap fails closed.
29
+ export function authorizeLiveSubagentAccess(args: AuthorizeLiveSubagentAccessArgs): SubagentAccessResult {
30
+ const { permissions, origin, liveRegistry, taskId, permission } = args
31
+
32
+ if (permissions === undefined) {
33
+ const live = liveRegistry.get(taskId)
34
+ if (live === undefined) {
35
+ return { ok: false, message: `Unknown task_id: ${taskId}.` }
36
+ }
37
+ return { ok: true, live }
38
+ }
39
+
40
+ if (!permissions.has(origin, permission)) {
41
+ return { ok: false, message: `${permission} denied: insufficient permissions` }
42
+ }
43
+
44
+ const requesterRole = permissions.resolveRole(origin)
45
+ const accessAll = requesterRole === 'owner'
46
+ const opaqueDenial = `${permission} denied: unknown task_id or insufficient role`
47
+
48
+ const live = liveRegistry.get(taskId)
49
+ if (live === undefined) {
50
+ return { ok: false, message: accessAll ? `Unknown task_id: ${taskId}.` : opaqueDenial }
51
+ }
52
+ if (accessAll) {
53
+ return { ok: true, live }
54
+ }
55
+
56
+ const spawnedByRole = live.spawnedByRole
57
+ if (spawnedByRole === undefined) {
58
+ return { ok: false, message: opaqueDenial }
59
+ }
60
+
61
+ const cmp = permissions.compareRoleSeverity(requesterRole, spawnedByRole)
62
+ if (cmp === undefined || cmp < 0) {
63
+ return { ok: false, message: opaqueDenial }
64
+ }
65
+
66
+ return { ok: true, live }
67
+ }
@@ -5,6 +5,7 @@ import type { PermissionService } from '@/permissions'
5
5
 
6
6
  import type { LiveSubagentRegistry } from '../live-subagents'
7
7
  import type { SessionOrigin } from '../session-origin'
8
+ import { authorizeLiveSubagentAccess } from './subagent-access'
8
9
 
9
10
  export type SubagentCancelToolDetails =
10
11
  | { ok: true; taskId: string; subagent: string; alreadyDone: boolean }
@@ -33,13 +34,17 @@ export function createSubagentCancelTool(options: CreateSubagentCancelToolOption
33
34
  }),
34
35
 
35
36
  async execute(_toolCallId, params): Promise<ToolReturn> {
36
- if (permissions !== undefined && !permissions.has(getOrigin(), 'subagent.cancel')) {
37
- return errorResult('subagent.cancel denied: insufficient permissions')
38
- }
39
- const live = liveRegistry.get(params.task_id)
40
- if (live === undefined) {
41
- return errorResult(`Unknown task_id: ${params.task_id}.`)
37
+ const access = authorizeLiveSubagentAccess({
38
+ permissions,
39
+ origin: getOrigin(),
40
+ liveRegistry,
41
+ taskId: params.task_id,
42
+ permission: 'subagent.cancel',
43
+ })
44
+ if (!access.ok) {
45
+ return errorResult(access.message)
42
46
  }
47
+ const live = access.live
43
48
  if (live.status !== 'running') {
44
49
  const details: SubagentCancelToolDetails = {
45
50
  ok: true,
@@ -5,6 +5,7 @@ import type { PermissionService } from '@/permissions'
5
5
 
6
6
  import type { LiveSubagentRegistry, StatusSnapshot, SubagentProgressEvent } from '../live-subagents'
7
7
  import type { SessionOrigin } from '../session-origin'
8
+ import { authorizeLiveSubagentAccess } from './subagent-access'
8
9
 
9
10
  export type SubagentOutputToolDetails =
10
11
  | {
@@ -64,8 +65,15 @@ export function createSubagentOutputTool(options: CreateSubagentOutputToolOption
64
65
  }),
65
66
 
66
67
  async execute(_toolCallId, params) {
67
- if (permissions !== undefined && !permissions.has(getOrigin(), 'subagent.output')) {
68
- return errorResult('subagent.output denied: insufficient permissions')
68
+ const access = authorizeLiveSubagentAccess({
69
+ permissions,
70
+ origin: getOrigin(),
71
+ liveRegistry,
72
+ taskId: params.task_id,
73
+ permission: 'subagent.output',
74
+ })
75
+ if (!access.ok) {
76
+ return errorResult(access.message)
69
77
  }
70
78
  const snap = liveRegistry.snapshot(params.task_id, now())
71
79
  if (snap === undefined) {
@@ -148,6 +148,116 @@ type DiscordGuildPreview = {
148
148
 
149
149
  type DiscordGuildMember = {
150
150
  user?: { id?: string; bot?: boolean }
151
+ roles?: string[]
152
+ }
153
+
154
+ type DiscordPermissionOverwrite = {
155
+ id?: string
156
+ type?: number
157
+ allow?: string
158
+ deny?: string
159
+ }
160
+
161
+ // Discord channel `type` values that are threads. A thread's own
162
+ // permission_overwrites are empty and its `parent_id` points at its parent
163
+ // channel (not a category), so the normal "compute from this channel's
164
+ // overwrites" path would treat a thread as fully public. We refuse to count
165
+ // threads channel-scoped and fall back instead — fail-closed.
166
+ const DISCORD_THREAD_CHANNEL_TYPES: ReadonlySet<number> = new Set([10, 11, 12])
167
+
168
+ type DiscordChannelObject = {
169
+ type?: number
170
+ permission_overwrites?: DiscordPermissionOverwrite[]
171
+ }
172
+
173
+ type DiscordRole = {
174
+ id?: string
175
+ permissions?: string
176
+ }
177
+
178
+ type DiscordGuildObject = {
179
+ owner_id?: string
180
+ roles?: DiscordRole[]
181
+ }
182
+
183
+ // Discord permission bits. Discord serialises permission bitsets as decimal
184
+ // STRINGS that can exceed Number.MAX_SAFE_INTEGER, so all bit math is done in
185
+ // BigInt. Only the two bits this resolver needs are named.
186
+ const DISCORD_PERMISSION_VIEW_CHANNEL = 0x400n
187
+ const DISCORD_PERMISSION_ADMINISTRATOR = 0x8n
188
+
189
+ function parsePermissionBits(raw: string | undefined): bigint {
190
+ if (raw === undefined || raw === '') return 0n
191
+ try {
192
+ return BigInt(raw)
193
+ } catch {
194
+ return 0n
195
+ }
196
+ }
197
+
198
+ // Computes whether a single member can VIEW_CHANNEL on a normal guild channel,
199
+ // following Discord's documented resolution order:
200
+ // base = @everyone role perms OR all of the member's role perms
201
+ // → owner or ADMINISTRATOR short-circuits to visible
202
+ // → @everyone channel overwrite (clear deny bits, then set allow bits)
203
+ // → all matching role overwrites combined (OR every deny, OR every allow;
204
+ // apply denies then allows)
205
+ // → member-specific overwrite (clear deny, set allow)
206
+ // Returns null when the data is insufficient to decide (a member references a
207
+ // role absent from the guild role map), so the caller can fail closed rather
208
+ // than guess.
209
+ function memberCanViewChannel(args: {
210
+ guildId: string
211
+ ownerId: string | undefined
212
+ rolePermissions: ReadonlyMap<string, bigint>
213
+ overwrites: ReadonlyMap<string, { allow: bigint; deny: bigint }>
214
+ memberId: string | undefined
215
+ memberRoleIds: readonly string[]
216
+ }): boolean | null {
217
+ const { guildId, ownerId, rolePermissions, overwrites, memberId, memberRoleIds } = args
218
+
219
+ if (memberId !== undefined && ownerId !== undefined && memberId === ownerId) return true
220
+
221
+ // Discord's `@everyone` role id always equals the guild id, so the guild id
222
+ // keys both the base @everyone permissions and the @everyone overwrite.
223
+ let base = rolePermissions.get(guildId) ?? 0n
224
+ for (const roleId of memberRoleIds) {
225
+ const perms = rolePermissions.get(roleId)
226
+ if (perms === undefined) return null
227
+ base |= perms
228
+ }
229
+
230
+ if ((base & DISCORD_PERMISSION_ADMINISTRATOR) !== 0n) return true
231
+
232
+ let perms = base
233
+
234
+ const everyoneOverwrite = overwrites.get(guildId)
235
+ if (everyoneOverwrite !== undefined) {
236
+ perms &= ~everyoneOverwrite.deny
237
+ perms |= everyoneOverwrite.allow
238
+ }
239
+
240
+ let roleDeny = 0n
241
+ let roleAllow = 0n
242
+ for (const roleId of memberRoleIds) {
243
+ const overwrite = overwrites.get(roleId)
244
+ if (overwrite !== undefined) {
245
+ roleDeny |= overwrite.deny
246
+ roleAllow |= overwrite.allow
247
+ }
248
+ }
249
+ perms &= ~roleDeny
250
+ perms |= roleAllow
251
+
252
+ if (memberId !== undefined) {
253
+ const memberOverwrite = overwrites.get(memberId)
254
+ if (memberOverwrite !== undefined) {
255
+ perms &= ~memberOverwrite.deny
256
+ perms |= memberOverwrite.allow
257
+ }
258
+ }
259
+
260
+ return (perms & DISCORD_PERMISSION_VIEW_CHANNEL) !== 0n
151
261
  }
152
262
 
153
263
  export function createDiscordMembershipResolver(deps: {
@@ -207,28 +317,136 @@ export function createDiscordMembershipResolver(deps: {
207
317
  return members.failure
208
318
  }
209
319
 
210
- let bots = 0
211
- let humans = 0
212
- const humanMemberIds: string[] = []
213
- let everyHumanIdentified = true
214
- for (const member of members.value) {
215
- if (member.user?.bot === true) {
216
- bots++
217
- continue
218
- }
219
- humans++
220
- const userId = member.user?.id
221
- if (userId === undefined) everyHumanIdentified = false
222
- else humanMemberIds.push(userId)
320
+ // Guild `/members` returns the whole guild, not the channel. A bot or
321
+ // human that cannot VIEW_CHANNEL the target channel is not in the room and
322
+ // not a prompt-injection surface, so it must not be counted — otherwise a
323
+ // private channel of "operator + agent bot" inside a multi-bot guild reads
324
+ // as bots>1 and the grant_role relaxation (provesOnlyAgentBotPresent)
325
+ // false-refuses. Resolve channel visibility for each member; on ANY
326
+ // inability to decide, fall back rather than over- or under-count.
327
+ // `key.thread ?? key.chat` mirrors the history callback's channel-id
328
+ // resolution: today Discord stores a thread's own snowflake in `chat` with
329
+ // `thread` null, but a future caller that passes `chat=parent,
330
+ // thread=threadId` must scope visibility to the thread, not the parent.
331
+ const scoped = await scopeMembersToChannel({
332
+ fetchFn,
333
+ token: deps.token,
334
+ logger: deps.logger,
335
+ guildId: key.workspace,
336
+ channelId: key.thread ?? key.chat,
337
+ members: members.value,
338
+ })
339
+ if (scoped === 'fallback') return await fallback()
340
+
341
+ return scoped.everyHumanIdentified
342
+ ? {
343
+ humans: scoped.humans,
344
+ bots: scoped.bots,
345
+ fetchedAt: now(),
346
+ truncated: false,
347
+ humanMemberIds: scoped.humanMemberIds,
348
+ }
349
+ : { humans: scoped.humans, bots: scoped.bots, fetchedAt: now(), truncated: false }
350
+ }
351
+ }
352
+
353
+ type ScopedMembership = {
354
+ humans: number
355
+ bots: number
356
+ humanMemberIds: string[]
357
+ everyHumanIdentified: boolean
358
+ }
359
+
360
+ // Filters a guild member list down to those who can VIEW_CHANNEL the target
361
+ // channel, then counts humans/bots over that visible set. Returns 'fallback'
362
+ // when channel visibility cannot be computed completely (channel/guild fetch
363
+ // failure, a member referencing an unknown role, or a thread channel whose
364
+ // visibility this resolver does not model) — the caller then derives from
365
+ // history (truncated:true), keeping every security consumer fail-closed.
366
+ async function scopeMembersToChannel(args: {
367
+ fetchFn: typeof fetch
368
+ token: string
369
+ logger: DiscordBotAdapterLogger
370
+ guildId: string
371
+ channelId: string
372
+ members: readonly DiscordGuildMember[]
373
+ }): Promise<ScopedMembership | 'fallback'> {
374
+ const { fetchFn, token, logger, guildId, channelId, members } = args
375
+
376
+ const [channel, guild] = await Promise.all([
377
+ fetchDiscordJson<DiscordChannelObject>(fetchFn, `${DISCORD_API_BASE}/channels/${channelId}`, token),
378
+ fetchDiscordJson<DiscordGuildObject>(fetchFn, `${DISCORD_API_BASE}/guilds/${guildId}`, token),
379
+ ])
380
+ if (!channel.ok) {
381
+ logger.warn(
382
+ `[discord-bot] membership channel=${channelId} fetch failed: ${channel.reason}; deriving from recent message authors`,
383
+ )
384
+ return 'fallback'
385
+ }
386
+ if (!guild.ok) {
387
+ logger.warn(
388
+ `[discord-bot] membership guild=${guildId} fetch failed: ${guild.reason}; deriving from recent message authors`,
389
+ )
390
+ return 'fallback'
391
+ }
392
+
393
+ if (channel.value.type !== undefined && DISCORD_THREAD_CHANNEL_TYPES.has(channel.value.type)) {
394
+ // A thread inherits visibility from its parent channel and (for private
395
+ // threads) an explicit member list; we do not model either here. Fall
396
+ // back rather than treat the thread as world-visible.
397
+ logger.warn(
398
+ `[discord-bot] membership channel=${channelId} is a thread; deriving from recent message authors instead of channel-scoped enumeration`,
399
+ )
400
+ return 'fallback'
401
+ }
402
+
403
+ const rolePermissions = new Map<string, bigint>()
404
+ for (const role of guild.value.roles ?? []) {
405
+ if (role.id !== undefined) rolePermissions.set(role.id, parsePermissionBits(role.permissions))
406
+ }
407
+
408
+ const overwrites = new Map<string, { allow: bigint; deny: bigint }>()
409
+ for (const overwrite of channel.value.permission_overwrites ?? []) {
410
+ if (overwrite.id === undefined) continue
411
+ overwrites.set(overwrite.id, {
412
+ allow: parsePermissionBits(overwrite.allow),
413
+ deny: parsePermissionBits(overwrite.deny),
414
+ })
415
+ }
416
+
417
+ let humans = 0
418
+ let bots = 0
419
+ const humanMemberIds: string[] = []
420
+ let everyHumanIdentified = true
421
+
422
+ for (const member of members) {
423
+ const visible = memberCanViewChannel({
424
+ guildId,
425
+ ownerId: guild.value.owner_id,
426
+ rolePermissions,
427
+ overwrites,
428
+ memberId: member.user?.id,
429
+ memberRoleIds: member.roles ?? [],
430
+ })
431
+ if (visible === null) {
432
+ logger.warn(
433
+ `[discord-bot] membership channel=${channelId} member references unknown role; deriving from recent message authors`,
434
+ )
435
+ return 'fallback'
223
436
  }
224
- // Only attach identities when every human was identifiable; an
225
- // unidentifiable human must not be silently dropped, or a consumer proving
226
- // "all humans trusted" would skip an unaccounted member. Falling back to
227
- // counts-only keeps that consumer fail-closed.
228
- return everyHumanIdentified
229
- ? { humans, bots, fetchedAt: now(), truncated: false, humanMemberIds }
230
- : { humans, bots, fetchedAt: now(), truncated: false }
437
+ if (!visible) continue
438
+
439
+ if (member.user?.bot === true) {
440
+ bots++
441
+ continue
442
+ }
443
+ humans++
444
+ const userId = member.user?.id
445
+ if (userId === undefined) everyHumanIdentified = false
446
+ else humanMemberIds.push(userId)
231
447
  }
448
+
449
+ return { humans, bots, humanMemberIds, everyHumanIdentified }
232
450
  }
233
451
 
234
452
  type DiscordFetchResult<T> =