typeclaw 0.17.0 → 0.19.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/auth.schema.json +0 -5
- package/package.json +2 -2
- package/secrets.schema.json +0 -5
- package/src/agent/index.ts +2 -1
- package/src/agent/model-overrides.ts +77 -0
- package/src/agent/plugin-tools.ts +53 -4
- package/src/agent/tools/grant-role.ts +102 -8
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
- package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
- package/src/channels/adapters/discord-bot-classify.ts +23 -0
- package/src/channels/adapters/discord-bot.ts +22 -4
- package/src/channels/adapters/github/auth-app.ts +49 -26
- package/src/channels/adapters/github/auth-pat.ts +3 -3
- package/src/channels/adapters/github/auth.ts +19 -5
- package/src/channels/adapters/github/channel-resolver.ts +3 -2
- package/src/channels/adapters/github/history.ts +3 -2
- package/src/channels/adapters/github/inbound.ts +30 -55
- package/src/channels/adapters/github/index.ts +147 -43
- package/src/channels/adapters/github/membership.ts +7 -2
- package/src/channels/adapters/github/outbound.ts +6 -2
- package/src/channels/adapters/github/team-membership.ts +4 -2
- package/src/channels/adapters/github/webhook-register.ts +19 -16
- package/src/channels/adapters/slack-bot-slash-commands.ts +78 -1
- package/src/channels/adapters/slack-bot.ts +119 -18
- package/src/channels/commands.ts +10 -0
- package/src/channels/engagement.ts +34 -3
- package/src/channels/github-token-bridge.ts +42 -0
- package/src/channels/index.ts +6 -0
- package/src/channels/manager.ts +6 -0
- package/src/channels/membership.ts +9 -0
- package/src/channels/router.ts +155 -37
- package/src/cli/channel.ts +0 -12
- package/src/cli/init.ts +0 -9
- package/src/cli/ui.ts +6 -0
- package/src/commands/index.ts +54 -4
- package/src/init/dockerfile.ts +60 -0
- package/src/init/github-webhook-install.ts +1 -2
- package/src/init/index.ts +4 -10
- package/src/init/validate-api-key.ts +15 -1
- package/src/plugin/context.ts +8 -0
- package/src/plugin/manager.ts +3 -0
- package/src/plugin/types.ts +6 -0
- package/src/run/bundled-plugins.ts +9 -0
- package/src/run/index.ts +6 -0
- package/src/secrets/schema.ts +0 -1
- package/src/server/command-runner.ts +14 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +70 -43
|
@@ -35,12 +35,11 @@ import {
|
|
|
35
35
|
import { createSlackDedupe } from './slack-bot-dedupe'
|
|
36
36
|
import {
|
|
37
37
|
buildSlashAckPayload,
|
|
38
|
+
commandResultReply,
|
|
38
39
|
parseSlashCommand,
|
|
39
|
-
|
|
40
|
-
SLACK_SLASH_REPLY_AMBIGUOUS,
|
|
40
|
+
parseThreadCommand,
|
|
41
41
|
SLACK_SLASH_REPLY_FAILED,
|
|
42
|
-
|
|
43
|
-
SLACK_SLASH_REPLY_PERMISSION_DENIED,
|
|
42
|
+
type ThreadCommandInput,
|
|
44
43
|
} from './slack-bot-slash-commands'
|
|
45
44
|
import { slackTsToMillis } from './slack-bot-time'
|
|
46
45
|
|
|
@@ -52,7 +51,7 @@ import { slackTsToMillis } from './slack-bot-time'
|
|
|
52
51
|
// slash_commands events we route vs drop. The ui.test.ts manifest-drift
|
|
53
52
|
// test asserts equality between this set and SLACK_APP_MANIFEST.features.
|
|
54
53
|
// slash_commands so the two can never silently diverge.
|
|
55
|
-
export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['stop'])
|
|
54
|
+
export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['help', 'stop'])
|
|
56
55
|
|
|
57
56
|
// Resolvers fall back to the raw id on failure, so a name equal to the id
|
|
58
57
|
// means resolution failed; we render the bare id rather than `id(id)`. The
|
|
@@ -124,16 +123,7 @@ export function createSlashCommandHandler(
|
|
|
124
123
|
return
|
|
125
124
|
}
|
|
126
125
|
|
|
127
|
-
const replyContent =
|
|
128
|
-
result.kind === 'handled'
|
|
129
|
-
? SLACK_SLASH_REPLY_ABORTED
|
|
130
|
-
: result.kind === 'no-live-session'
|
|
131
|
-
? SLACK_SLASH_REPLY_NO_LIVE_SESSION
|
|
132
|
-
: result.kind === 'permission-denied'
|
|
133
|
-
? SLACK_SLASH_REPLY_PERMISSION_DENIED
|
|
134
|
-
: result.kind === 'ambiguous'
|
|
135
|
-
? SLACK_SLASH_REPLY_AMBIGUOUS
|
|
136
|
-
: SLACK_SLASH_REPLY_FAILED
|
|
126
|
+
const replyContent = commandResultReply(result)
|
|
137
127
|
|
|
138
128
|
// Final ack on the happy path: own try/catch so a thrown ack here does
|
|
139
129
|
// NOT cascade into the error-path ack above (which would violate the
|
|
@@ -158,6 +148,67 @@ export function createSlashCommandHandler(
|
|
|
158
148
|
}
|
|
159
149
|
}
|
|
160
150
|
|
|
151
|
+
export type ThreadCommandReplyPoster = (args: { chat: string; thread: string | null; text: string }) => Promise<void>
|
|
152
|
+
|
|
153
|
+
export type ThreadCommandHandlerDeps = {
|
|
154
|
+
router: Pick<ChannelRouter, 'executeCommand'>
|
|
155
|
+
knownCommandNames: ReadonlySet<string>
|
|
156
|
+
postReply: ThreadCommandReplyPoster
|
|
157
|
+
logger: SlackBotAdapterLoggerLike
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type ThreadCommandOutcome = { kind: 'not-a-command' } | { kind: 'duplicate' } | { kind: 'executed' }
|
|
161
|
+
|
|
162
|
+
// Synchronous reservation: the adapter marks the dedupe ring inside this hook,
|
|
163
|
+
// which the handler calls before its first `await`. Two duplicate Slack
|
|
164
|
+
// deliveries can both clear `dedupe.check()` on the same JS tick; whichever
|
|
165
|
+
// reserves first wins and returns `true`, the loser returns `false` and aborts
|
|
166
|
+
// — so a control command never runs twice across the check→execute window.
|
|
167
|
+
export type ThreadCommandReserve = () => boolean
|
|
168
|
+
|
|
169
|
+
// Routes a `!cmd` thread message through the SAME router.executeCommand path as
|
|
170
|
+
// native slashes, then posts the outcome back into the thread. Returns
|
|
171
|
+
// 'not-a-command' (caller proceeds with normal classify/route), 'duplicate'
|
|
172
|
+
// (a racing delivery already reserved this event — caller stops silently), or
|
|
173
|
+
// 'executed' (command handled — caller stops; it is not agent input).
|
|
174
|
+
export function createThreadCommandHandler(
|
|
175
|
+
deps: ThreadCommandHandlerDeps,
|
|
176
|
+
): (input: ThreadCommandInput, reserve: ThreadCommandReserve) => Promise<ThreadCommandOutcome> {
|
|
177
|
+
return async (input, reserve) => {
|
|
178
|
+
const parsed = parseThreadCommand(input, deps.knownCommandNames)
|
|
179
|
+
if (parsed.kind === 'ignore') {
|
|
180
|
+
return { kind: 'not-a-command' }
|
|
181
|
+
}
|
|
182
|
+
// Reserve synchronously, before any await, to close the check→execute race.
|
|
183
|
+
if (!reserve()) {
|
|
184
|
+
return { kind: 'duplicate' }
|
|
185
|
+
}
|
|
186
|
+
const { command } = parsed
|
|
187
|
+
deps.logger.info(
|
|
188
|
+
`[slack-bot] thread-command !${command.name} invoker=${command.invokerId} team=${command.key.workspace} channel=${command.key.chat} thread=${command.key.thread ?? '(none)'}`,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
let reply: string
|
|
192
|
+
try {
|
|
193
|
+
const result = await deps.router.executeCommand(command.key, command.name, {
|
|
194
|
+
invokerId: command.invokerId,
|
|
195
|
+
})
|
|
196
|
+
reply = commandResultReply(result)
|
|
197
|
+
deps.logger.info(`[slack-bot] thread-command !${command.name} result=${result.kind}`)
|
|
198
|
+
} catch (err) {
|
|
199
|
+
deps.logger.error(`[slack-bot] thread-command !${command.name} failed: ${describe(err)}`)
|
|
200
|
+
reply = SLACK_SLASH_REPLY_FAILED
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
await deps.postReply({ chat: input.channel, thread: input.threadTs, text: reply })
|
|
205
|
+
} catch (err) {
|
|
206
|
+
deps.logger.warn(`[slack-bot] thread-command reply post failed: ${describe(err)}`)
|
|
207
|
+
}
|
|
208
|
+
return { kind: 'executed' }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
161
212
|
// app_mention payloads omit channel_type and never carry a subtype, so we
|
|
162
213
|
// promote them to a message-shaped event for the shared classifier. The
|
|
163
214
|
// promoted event is classified as a regular channel message; the
|
|
@@ -408,14 +459,14 @@ export function createSlackMembershipResolver(deps: {
|
|
|
408
459
|
}
|
|
409
460
|
|
|
410
461
|
let bots = 0
|
|
411
|
-
|
|
462
|
+
const humanMemberIds: string[] = []
|
|
412
463
|
for (const userId of members.value.members ?? []) {
|
|
413
464
|
const cached = userBotCache.get(userId)
|
|
414
465
|
const isBot = cached ?? (await resolveSlackUserIsBot(fetchFn, deps.token, userId, deps.logger, userBotCache))
|
|
415
466
|
if (isBot) bots++
|
|
416
|
-
else
|
|
467
|
+
else humanMemberIds.push(userId)
|
|
417
468
|
}
|
|
418
|
-
return { humans, bots, fetchedAt: now(), truncated: false }
|
|
469
|
+
return { humans: humanMemberIds.length, bots, fetchedAt: now(), truncated: false, humanMemberIds }
|
|
419
470
|
}
|
|
420
471
|
}
|
|
421
472
|
|
|
@@ -783,6 +834,24 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
783
834
|
formatChannelTag,
|
|
784
835
|
})
|
|
785
836
|
|
|
837
|
+
const handleThreadCommand = createThreadCommandHandler({
|
|
838
|
+
router: options.router,
|
|
839
|
+
knownCommandNames: SLACK_SLASH_COMMAND_NAMES,
|
|
840
|
+
logger,
|
|
841
|
+
postReply: async ({ chat, thread, text }) => {
|
|
842
|
+
const result = await outboundCallback({
|
|
843
|
+
adapter: 'slack-bot',
|
|
844
|
+
workspace: teamId ?? 'unknown',
|
|
845
|
+
chat,
|
|
846
|
+
...(thread !== null ? { thread } : {}),
|
|
847
|
+
text,
|
|
848
|
+
})
|
|
849
|
+
if (!result.ok) {
|
|
850
|
+
throw new Error(result.error)
|
|
851
|
+
}
|
|
852
|
+
},
|
|
853
|
+
})
|
|
854
|
+
|
|
786
855
|
const handleMessageEvent = async (
|
|
787
856
|
event: SlackInboundMessageEvent,
|
|
788
857
|
source: 'message' | 'app_mention',
|
|
@@ -813,6 +882,38 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
813
882
|
return
|
|
814
883
|
}
|
|
815
884
|
|
|
885
|
+
// Intercept `!cmd` thread-message commands BEFORE classifyInbound. A
|
|
886
|
+
// command is control traffic — neither dropped nor routed to the agent —
|
|
887
|
+
// so it must short-circuit here. Bypassing classifyInbound also bypasses
|
|
888
|
+
// its self_author / no_user drops, so we replicate those guards: never
|
|
889
|
+
// execute a command from our own message (echo loop) or a userless
|
|
890
|
+
// system event. The `reserve` closure marks dedupe synchronously the
|
|
891
|
+
// instant the command is recognised (before the router await), closing
|
|
892
|
+
// the check→execute race for duplicate deliveries.
|
|
893
|
+
if (event.user !== undefined && event.user !== '' && (botUserId === null || event.user !== botUserId)) {
|
|
894
|
+
const reserve = (): boolean => {
|
|
895
|
+
if (dedupe.check(event) !== null) return false
|
|
896
|
+
dedupe.mark(event)
|
|
897
|
+
return true
|
|
898
|
+
}
|
|
899
|
+
const outcome = await handleThreadCommand(
|
|
900
|
+
{
|
|
901
|
+
text: event.text ?? '',
|
|
902
|
+
channel: event.channel,
|
|
903
|
+
threadTs: event.thread_ts ?? null,
|
|
904
|
+
isDm: event.channel_type === 'im',
|
|
905
|
+
teamId,
|
|
906
|
+
invokerId: event.user,
|
|
907
|
+
},
|
|
908
|
+
reserve,
|
|
909
|
+
)
|
|
910
|
+
if (outcome.kind === 'executed') return
|
|
911
|
+
if (outcome.kind === 'duplicate') {
|
|
912
|
+
logger.info(`[slack-bot] dropped ts=${event.ts} reason=duplicate_delivery (thread-command race)`)
|
|
913
|
+
return
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
816
917
|
const verdict = classifyInbound(event, options.configRef(), {
|
|
817
918
|
teamId,
|
|
818
919
|
botUserId,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CommandInfo } from '@/commands'
|
|
2
|
+
|
|
3
|
+
// Generated from registry metadata so the listing can never drift from the
|
|
4
|
+
// actual command set. The `/` prefix is canonical across every surface; Slack
|
|
5
|
+
// threads accept the `!` alias for the same names.
|
|
6
|
+
export function formatChannelCommandHelp(commands: readonly CommandInfo[]): string {
|
|
7
|
+
if (commands.length === 0) return 'No commands are available.'
|
|
8
|
+
const lines = commands.map((command) => `/${command.name} — ${command.description}`)
|
|
9
|
+
return ['Available commands:', ...lines].join('\n')
|
|
10
|
+
}
|
|
@@ -81,11 +81,25 @@ export type EngagementInput = {
|
|
|
81
81
|
export function decideEngagement(input: EngagementInput): EngagementDecision {
|
|
82
82
|
const { message, config, key, ledger, now, participants, selfAliases, botInThread } = input
|
|
83
83
|
|
|
84
|
+
// The human count drives both the sticky-credit gate (below) and the
|
|
85
|
+
// solo-human fallback (bottom). Compute it once, up front. Peer bots are
|
|
86
|
+
// excluded — a 1-human-N-bot room is still "solo" for engagement purposes.
|
|
87
|
+
const effectiveHumans = countEffectiveHumans(participants, input.membership, now)
|
|
88
|
+
const multiHumanGroup = isMultiHumanGroup(message.isDm, effectiveHumans)
|
|
89
|
+
|
|
84
90
|
if (config.trigger.includes('dm') && message.isDm) return 'engage'
|
|
85
91
|
if (config.trigger.includes('mention') && message.isBotMention) return 'engage'
|
|
86
92
|
if (config.trigger.includes('reply') && message.replyToBotMessageId !== null) return 'engage'
|
|
87
93
|
|
|
88
|
-
|
|
94
|
+
// Sticky credit. ALWAYS consume when present (the credit stays one-shot,
|
|
95
|
+
// so a later membership change can't resurrect stale conversational
|
|
96
|
+
// credit), but only let it FORCE engagement outside a multi-human group.
|
|
97
|
+
// In a group, sticky alone no longer wakes the bot on every follow-up —
|
|
98
|
+
// the author must re-address us (mention/reply/alias) to re-engage. This
|
|
99
|
+
// narrows an existing permissive rule in the exact context where it's
|
|
100
|
+
// harmful; it is NOT a new bot-specific gate (peer bots and humans are
|
|
101
|
+
// treated identically, via `multiHumanGroup`). See engagement.mdx.
|
|
102
|
+
if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now) && !multiHumanGroup) {
|
|
89
103
|
return 'engage'
|
|
90
104
|
}
|
|
91
105
|
|
|
@@ -169,13 +183,30 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
|
|
|
169
183
|
// peer's first message it's caught forever.
|
|
170
184
|
if (textTargetsAnyPeerBot(message.text, participants)) return 'observe'
|
|
171
185
|
|
|
172
|
-
const persistedHumans = participants.filter((p) => p.isBot !== true).length
|
|
173
|
-
const effectiveHumans = resolveEffectiveHumans(persistedHumans, input.membership, now)
|
|
174
186
|
if (effectiveHumans <= 1 && !message.authorIsBot) return 'engage'
|
|
175
187
|
|
|
176
188
|
return 'observe'
|
|
177
189
|
}
|
|
178
190
|
|
|
191
|
+
export function countEffectiveHumans(
|
|
192
|
+
participants: readonly ChannelParticipant[],
|
|
193
|
+
membership: MembershipCount | null,
|
|
194
|
+
now: number,
|
|
195
|
+
): number {
|
|
196
|
+
const persistedHumans = participants.filter((p) => p.isBot !== true).length
|
|
197
|
+
return resolveEffectiveHumans(persistedHumans, membership, now)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A multi-human group is the one place where the chatty "reply to every
|
|
201
|
+
// follow-up" behavior (sticky credit, and the prompt's default eagerness) is
|
|
202
|
+
// wrong. DMs — 1:1, or platform group-DMs reached via the `dm` trigger — and
|
|
203
|
+
// solo-human channels keep the back-and-forth. The router reuses this to
|
|
204
|
+
// decide both sticky suppression and the group-chat prompt nudge, so the two
|
|
205
|
+
// stay in lockstep off one definition.
|
|
206
|
+
export function isMultiHumanGroup(isDm: boolean, effectiveHumans: number): boolean {
|
|
207
|
+
return !isDm && effectiveHumans > 1
|
|
208
|
+
}
|
|
209
|
+
|
|
179
210
|
function textTargetsAnyPeerBot(text: string, participants: readonly ChannelParticipant[]): boolean {
|
|
180
211
|
const haystack = text.toLocaleLowerCase()
|
|
181
212
|
for (const p of participants) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Decoupled from ChannelRouter on purpose: minting a token for an arbitrary
|
|
2
|
+
// bash `gh` command is adjacent to channels but is not routing, and a global
|
|
3
|
+
// singleton would leak resolver state across tests. One instance is created in
|
|
4
|
+
// run/index.ts and threaded to both the plugin loader and the channel manager.
|
|
5
|
+
|
|
6
|
+
export type GithubTokenResolveResult = { kind: 'token'; token: string } | { kind: 'unavailable'; reason: string }
|
|
7
|
+
|
|
8
|
+
export type ResolveGithubTokenForRepo = (repoSlug: string) => Promise<GithubTokenResolveResult>
|
|
9
|
+
|
|
10
|
+
export type GithubTokenBridge = {
|
|
11
|
+
resolveTokenForRepo: ResolveGithubTokenForRepo
|
|
12
|
+
registerResolver: (resolver: (repoSlug: string) => Promise<string>) => () => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const NO_RESOLVER_REASON =
|
|
16
|
+
'GitHub App token unavailable; the GitHub channel adapter is not running or failed to start. ' +
|
|
17
|
+
'Check `typeclaw logs` and `secrets.json#channels.github`.'
|
|
18
|
+
|
|
19
|
+
export function createGithubTokenBridge(): GithubTokenBridge {
|
|
20
|
+
let current: ((repoSlug: string) => Promise<string>) | null = null
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
resolveTokenForRepo: async (repoSlug) => {
|
|
24
|
+
const resolver = current
|
|
25
|
+
if (resolver === null) return { kind: 'unavailable', reason: NO_RESOLVER_REASON }
|
|
26
|
+
try {
|
|
27
|
+
const token = await resolver(repoSlug)
|
|
28
|
+
return { kind: 'token', token }
|
|
29
|
+
} catch (err) {
|
|
30
|
+
return { kind: 'unavailable', reason: err instanceof Error ? err.message : String(err) }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
registerResolver: (resolver) => {
|
|
34
|
+
current = resolver
|
|
35
|
+
return () => {
|
|
36
|
+
// Only clear if still the active resolver: a stop() racing a newer
|
|
37
|
+
// start() must not wipe the newer registration.
|
|
38
|
+
if (current === resolver) current = null
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/channels/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export { createChannelManager, type ChannelManager, type ChannelManagerOptions } from './manager'
|
|
2
|
+
export {
|
|
3
|
+
createGithubTokenBridge,
|
|
4
|
+
type GithubTokenBridge,
|
|
5
|
+
type GithubTokenResolveResult,
|
|
6
|
+
type ResolveGithubTokenForRepo,
|
|
7
|
+
} from './github-token-bridge'
|
|
2
8
|
export {
|
|
3
9
|
createChannelRouter,
|
|
4
10
|
type ChannelRouter,
|
package/src/channels/manager.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { createGithubAdapter, type GithubAdapter } from './adapters/github'
|
|
|
12
12
|
import { createKakaotalkAdapter, type KakaotalkAdapter } from './adapters/kakaotalk'
|
|
13
13
|
import { createSlackBotAdapter, type SlackBotAdapter } from './adapters/slack-bot'
|
|
14
14
|
import { createTelegramBotAdapter, type TelegramBotAdapter } from './adapters/telegram-bot'
|
|
15
|
+
import type { GithubTokenBridge } from './github-token-bridge'
|
|
15
16
|
import { createChannelRouter, type ChannelRouter, type ClaimHandler, type CreateSessionForChannel } from './router'
|
|
16
17
|
import {
|
|
17
18
|
ADAPTER_IDS,
|
|
@@ -84,6 +85,10 @@ export type ChannelManagerOptions = {
|
|
|
84
85
|
// Production wiring (`src/run/index.ts`) always passes the agent's
|
|
85
86
|
// Stream; tests typically omit it.
|
|
86
87
|
stream?: Stream
|
|
88
|
+
// Write-side of the GithubTokenBridge. The github adapter publishes its
|
|
89
|
+
// per-repo App token minter here on start (App auth only) so plugin hooks
|
|
90
|
+
// can resolve a token for ad-hoc `gh` commands. Tests omit it.
|
|
91
|
+
githubTokenBridge?: GithubTokenBridge
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
export type ChannelManager = {
|
|
@@ -199,6 +204,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
199
204
|
logger,
|
|
200
205
|
tunnelUrl: () => options.tunnelUrlForChannel?.('github') ?? null,
|
|
201
206
|
tunnelConfiguredForChannel: () => options.tunnelConfiguredForChannel?.('github') ?? false,
|
|
207
|
+
...(options.githubTokenBridge !== undefined ? { githubTokenBridge: options.githubTokenBridge } : {}),
|
|
202
208
|
})
|
|
203
209
|
}
|
|
204
210
|
if (name === 'telegram-bot') {
|
|
@@ -21,6 +21,15 @@ export type MembershipCount = {
|
|
|
21
21
|
bots: number
|
|
22
22
|
fetchedAt: number
|
|
23
23
|
truncated: boolean
|
|
24
|
+
// Identities of the human members, present ONLY when the adapter enumerated
|
|
25
|
+
// the COMPLETE current membership and classified every listed member in the
|
|
26
|
+
// same pass that produced `humans`. When set, `humanMemberIds.length` equals
|
|
27
|
+
// `humans` by construction, so a consumer can prove "every human in the room
|
|
28
|
+
// is X" by resolving each id — something the bare `humans` count cannot do.
|
|
29
|
+
// Left undefined by approximate/truncated/history-derived reads and by
|
|
30
|
+
// adapters that cannot enumerate members (Telegram, KakaoTalk); consumers
|
|
31
|
+
// that need a completeness proof must fail closed when it is absent.
|
|
32
|
+
humanMemberIds?: readonly string[]
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
export type MembershipResolverFailure = { kind: 'transient' } | { kind: 'permanent' }
|