typeclaw 0.20.0 → 0.22.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 +2 -1
- package/src/agent/index.ts +55 -1
- package/src/agent/loop-guard.ts +180 -53
- package/src/agent/restart/index.ts +101 -0
- package/src/agent/tools/restart.ts +23 -52
- package/src/bundled-plugins/bun-hygiene/README.md +82 -0
- package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
- package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
- package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
- package/src/bundled-plugins/memory/memory-logger.ts +6 -2
- package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
- package/src/channels/adapters/discord-bot-classify.ts +8 -2
- package/src/channels/adapters/discord-bot.ts +29 -2
- package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
- package/src/channels/adapters/github/inbound.ts +92 -1
- package/src/channels/adapters/github/index.ts +12 -1
- package/src/channels/adapters/github/reactions.ts +138 -4
- package/src/channels/adapters/slack-bot-classify.ts +2 -2
- package/src/channels/adapters/slack-bot.ts +129 -7
- package/src/channels/engagement.ts +71 -31
- package/src/channels/manager.ts +8 -0
- package/src/channels/router.ts +180 -25
- package/src/channels/schema.ts +18 -0
- package/src/channels/types.ts +16 -1
- package/src/cli/builtins.ts +1 -0
- package/src/cli/dreams.ts +148 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/inspect.ts +2 -1
- package/src/cli/ui.ts +34 -0
- package/src/commands/index.ts +5 -2
- package/src/config/config.ts +89 -0
- package/src/dreams/git.ts +85 -0
- package/src/dreams/index.ts +134 -0
- package/src/dreams/parse.ts +224 -0
- package/src/dreams/render.ts +155 -0
- package/src/dreams/types.ts +50 -0
- package/src/mcp/catalog.ts +29 -0
- package/src/mcp/client.ts +236 -0
- package/src/mcp/index.ts +25 -0
- package/src/mcp/manager.ts +156 -0
- package/src/mcp/tools.ts +190 -0
- package/src/permissions/builtins.ts +9 -0
- package/src/reload/format.ts +14 -0
- package/src/reload/index.ts +1 -0
- package/src/run/bundled-plugins.ts +7 -0
- package/src/run/channel-session-factory.ts +3 -0
- package/src/run/index.ts +38 -1
- package/src/server/command-runner.ts +5 -0
- package/src/server/index.ts +53 -0
- package/src/shared/protocol.ts +2 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +86 -22
- package/src/tui/index.ts +70 -18
- package/typeclaw.schema.json +82 -0
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
SlackBotClient,
|
|
3
|
+
SlackBotListener,
|
|
4
|
+
type SlackFile,
|
|
5
|
+
type SlackSocketModeSlashCommandArgs,
|
|
6
|
+
} from 'agent-messenger/slackbot'
|
|
2
7
|
|
|
3
8
|
import {
|
|
9
|
+
MEMBERSHIP_CACHE_TRANSIENT_TTL_MS,
|
|
10
|
+
MEMBERSHIP_CACHE_TTL_MS,
|
|
4
11
|
MEMBERSHIP_ENUMERATION_CAP,
|
|
5
12
|
type MembershipResolver,
|
|
6
13
|
type MembershipResolverFailure,
|
|
@@ -28,7 +35,9 @@ import { createSlackAuthorResolver } from './slack-bot-author-resolver'
|
|
|
28
35
|
import { createSlackChannelResolver } from './slack-bot-channel-resolver'
|
|
29
36
|
import {
|
|
30
37
|
classifyInbound,
|
|
38
|
+
describeSlackFile,
|
|
31
39
|
type InboundDropReason,
|
|
40
|
+
renderPlaceholder,
|
|
32
41
|
type SlackInboundAppMentionEvent,
|
|
33
42
|
type SlackInboundMessageEvent,
|
|
34
43
|
} from './slack-bot-classify'
|
|
@@ -51,7 +60,7 @@ import { slackTsToMillis } from './slack-bot-time'
|
|
|
51
60
|
// slash_commands events we route vs drop. The ui.test.ts manifest-drift
|
|
52
61
|
// test asserts equality between this set and SLACK_APP_MANIFEST.features.
|
|
53
62
|
// slash_commands so the two can never silently diverge.
|
|
54
|
-
export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['help', 'stop'])
|
|
63
|
+
export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['help', 'stop', 'reload', 'restart'])
|
|
55
64
|
|
|
56
65
|
// Resolvers fall back to the raw id on failure, so a name equal to the id
|
|
57
66
|
// means resolution failed; we render the bare id rather than `id(id)`. The
|
|
@@ -369,6 +378,7 @@ type SlackRawHistoryMessage = {
|
|
|
369
378
|
text?: string
|
|
370
379
|
thread_ts?: string
|
|
371
380
|
parent_user_id?: string
|
|
381
|
+
files?: SlackFile[]
|
|
372
382
|
}
|
|
373
383
|
|
|
374
384
|
type SlackHistoryResponse = {
|
|
@@ -396,6 +406,16 @@ type SlackUserInfoResponse = {
|
|
|
396
406
|
user?: { is_bot?: boolean; deleted?: boolean }
|
|
397
407
|
}
|
|
398
408
|
|
|
409
|
+
type SlackUsersListResponse = {
|
|
410
|
+
ok: boolean
|
|
411
|
+
error?: string
|
|
412
|
+
members?: Array<{ id?: string; is_bot?: boolean }>
|
|
413
|
+
response_metadata?: { next_cursor?: string }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const USERS_LIST_PAGE_LIMIT = 200
|
|
417
|
+
const USERS_LIST_MAX_PAGES = 50
|
|
418
|
+
|
|
399
419
|
export function createSlackMembershipResolver(deps: {
|
|
400
420
|
token: string
|
|
401
421
|
logger: SlackBotAdapterLogger
|
|
@@ -406,6 +426,43 @@ export function createSlackMembershipResolver(deps: {
|
|
|
406
426
|
const fetchFn = deps.fetchImpl ?? fetch
|
|
407
427
|
const now = deps.now ?? Date.now
|
|
408
428
|
const userBotCache = new Map<string, boolean>()
|
|
429
|
+
|
|
430
|
+
// Keyed by workspace. One resolver instance is bound to a single token/team
|
|
431
|
+
// today, but the router dispatches by adapter (not by adapter+workspace), so
|
|
432
|
+
// scoping the warm set by `key.workspace` keeps a set built for one workspace
|
|
433
|
+
// from ever classifying another's members if a multi-workspace mode is added.
|
|
434
|
+
const botSetCache = new Map<string, { ids: ReadonlySet<string>; fetchedAt: number }>()
|
|
435
|
+
const botSetFailedAt = new Map<string, number>()
|
|
436
|
+
const botSetInFlight = new Map<string, Promise<ReadonlySet<string> | null>>()
|
|
437
|
+
|
|
438
|
+
const warmBotSet = async (workspace: string): Promise<ReadonlySet<string> | null> => {
|
|
439
|
+
const cached = botSetCache.get(workspace)
|
|
440
|
+
if (cached !== undefined && now() - cached.fetchedAt < MEMBERSHIP_CACHE_TTL_MS) return cached.ids
|
|
441
|
+
// Negative-cache a failed warm so a rate-limited workspace doesn't re-run
|
|
442
|
+
// the full paginated `users.list` crawl on every membership read — that
|
|
443
|
+
// would keep the hot path expensive under the exact failure this PR fixes.
|
|
444
|
+
// Members fall back to per-id `users.info` during the cooldown.
|
|
445
|
+
const failedAt = botSetFailedAt.get(workspace)
|
|
446
|
+
if (failedAt !== undefined && now() - failedAt < MEMBERSHIP_CACHE_TRANSIENT_TTL_MS) return null
|
|
447
|
+
const inFlight = botSetInFlight.get(workspace)
|
|
448
|
+
if (inFlight !== undefined) return await inFlight
|
|
449
|
+
const promise = fetchWorkspaceBotIds(fetchFn, deps.token, deps.logger)
|
|
450
|
+
.then((ids) => {
|
|
451
|
+
if (ids !== null) {
|
|
452
|
+
botSetCache.set(workspace, { ids, fetchedAt: now() })
|
|
453
|
+
botSetFailedAt.delete(workspace)
|
|
454
|
+
} else {
|
|
455
|
+
botSetFailedAt.set(workspace, now())
|
|
456
|
+
}
|
|
457
|
+
return ids
|
|
458
|
+
})
|
|
459
|
+
.finally(() => {
|
|
460
|
+
botSetInFlight.delete(workspace)
|
|
461
|
+
})
|
|
462
|
+
botSetInFlight.set(workspace, promise)
|
|
463
|
+
return await promise
|
|
464
|
+
}
|
|
465
|
+
|
|
409
466
|
return async (key): Promise<MembershipResolverResult> => {
|
|
410
467
|
if (key.workspace === '@dm') return { humans: 1, bots: 1, fetchedAt: now(), truncated: false }
|
|
411
468
|
|
|
@@ -458,11 +515,22 @@ export function createSlackMembershipResolver(deps: {
|
|
|
458
515
|
return members.failure
|
|
459
516
|
}
|
|
460
517
|
|
|
518
|
+
// Reached only for channels at or under the cap (larger ones returned
|
|
519
|
+
// `truncated` above). `conversations.members` gives ids with no bot/human
|
|
520
|
+
// flag and Slack has no bulk-classify-ids call, so per-member `users.info`
|
|
521
|
+
// is an N+1 that exceeds the router cold-fetch timeout near the cap; the
|
|
522
|
+
// read then returns null and engagement misreads the busy channel as solo.
|
|
523
|
+
// Classify against a workspace bot-id set from one paginated `users.list`
|
|
524
|
+
// (bots are a small set, shared across channels). `users.info` stays as a
|
|
525
|
+
// per-id fallback for ids minted after the last warm, keeping `bots` and
|
|
526
|
+
// `humanMemberIds` exact for `grant_role`'s "no peer bot present" proof.
|
|
527
|
+
const memberIds = members.value.members ?? []
|
|
528
|
+
const botSet = await warmBotSet(key.workspace)
|
|
461
529
|
let bots = 0
|
|
462
530
|
const humanMemberIds: string[] = []
|
|
463
|
-
for (const userId of
|
|
464
|
-
const
|
|
465
|
-
|
|
531
|
+
for (const userId of memberIds) {
|
|
532
|
+
const isBot =
|
|
533
|
+
botSet?.has(userId) ?? (await resolveSlackUserIsBot(fetchFn, deps.token, userId, deps.logger, userBotCache))
|
|
466
534
|
if (isBot) bots++
|
|
467
535
|
else humanMemberIds.push(userId)
|
|
468
536
|
}
|
|
@@ -504,10 +572,17 @@ async function resolveSlackUserIsBot(
|
|
|
504
572
|
logger: SlackBotAdapterLogger,
|
|
505
573
|
cache: Map<string, boolean>,
|
|
506
574
|
): Promise<boolean> {
|
|
575
|
+
const cached = cache.get(userId)
|
|
576
|
+
if (cached !== undefined) return cached
|
|
507
577
|
const info = await slackApi<SlackUserInfoResponse>(fetchFn, token, 'users.info', { user: userId })
|
|
508
578
|
if (!info.ok) {
|
|
509
579
|
logger.warn(`[slack-bot] membership users.info user=${userId} failed: ${info.reason}`)
|
|
510
|
-
|
|
580
|
+
// Only a definitive answer is cached. A transient failure (429/network)
|
|
581
|
+
// must not be memoized as "human" — that would poison classification until
|
|
582
|
+
// restart and let a peer bot read as human, skewing engagement and
|
|
583
|
+
// `grant_role`'s "no peer bot" proof. Default this read to human (the
|
|
584
|
+
// safe, count-conservative direction) but let the next read retry.
|
|
585
|
+
if (info.failure.kind === 'permanent') cache.set(userId, false)
|
|
511
586
|
return false
|
|
512
587
|
}
|
|
513
588
|
const isBot = info.value.user?.is_bot === true
|
|
@@ -515,6 +590,38 @@ async function resolveSlackUserIsBot(
|
|
|
515
590
|
return isBot
|
|
516
591
|
}
|
|
517
592
|
|
|
593
|
+
// Enumerates the workspace and returns the set of bot user ids. Slack has no
|
|
594
|
+
// server-side `is_bot` filter, so we page the full `users.list` and keep only
|
|
595
|
+
// bots — a complete pass is required so silent lurking bots (never seen in
|
|
596
|
+
// history) are still counted, which `grant_role`'s "no peer bot" proof relies
|
|
597
|
+
// on. Returns null on any failure so the caller can fall back to per-id
|
|
598
|
+
// `users.info` rather than trusting an incomplete set. Page count is bounded so
|
|
599
|
+
// a pathologically large workspace cannot stall the read indefinitely.
|
|
600
|
+
async function fetchWorkspaceBotIds(
|
|
601
|
+
fetchFn: typeof fetch,
|
|
602
|
+
token: string,
|
|
603
|
+
logger: SlackBotAdapterLogger,
|
|
604
|
+
): Promise<ReadonlySet<string> | null> {
|
|
605
|
+
const botIds = new Set<string>()
|
|
606
|
+
let cursor: string | undefined
|
|
607
|
+
for (let page = 0; page < USERS_LIST_MAX_PAGES; page++) {
|
|
608
|
+
const fields: Record<string, string> = { limit: String(USERS_LIST_PAGE_LIMIT) }
|
|
609
|
+
if (cursor !== undefined && cursor !== '') fields.cursor = cursor
|
|
610
|
+
const res = await slackApi<SlackUsersListResponse>(fetchFn, token, 'users.list', fields)
|
|
611
|
+
if (!res.ok) {
|
|
612
|
+
logger.warn(`[slack-bot] users.list failed: ${res.reason}; falling back to per-member classification`)
|
|
613
|
+
return null
|
|
614
|
+
}
|
|
615
|
+
for (const member of res.value.members ?? []) {
|
|
616
|
+
if (member.is_bot === true && typeof member.id === 'string') botIds.add(member.id)
|
|
617
|
+
}
|
|
618
|
+
cursor = res.value.response_metadata?.next_cursor
|
|
619
|
+
if (cursor === undefined || cursor === '') return botIds
|
|
620
|
+
}
|
|
621
|
+
logger.warn(`[slack-bot] users.list exceeded ${USERS_LIST_MAX_PAGES} pages; bot set may be incomplete`)
|
|
622
|
+
return null
|
|
623
|
+
}
|
|
624
|
+
|
|
518
625
|
function slackFailureForError(error: string): MembershipResolverFailure {
|
|
519
626
|
if (['invalid_auth', 'not_authed', 'not_in_channel', 'channel_not_found', 'missing_scope'].includes(error)) {
|
|
520
627
|
return { kind: 'permanent' }
|
|
@@ -601,14 +708,29 @@ function mapSlackMessage(msg: SlackRawHistoryMessage, botUserId: string | null):
|
|
|
601
708
|
msg.parent_user_id === botUserId
|
|
602
709
|
? msg.thread_ts
|
|
603
710
|
: null
|
|
711
|
+
// The history fetch bypasses the inbound classifier, so files on
|
|
712
|
+
// already-posted messages (e.g. an image on a thread root the agent is
|
|
713
|
+
// later @-mentioned under) must be mapped here too — otherwise they are
|
|
714
|
+
// silently dropped and look_at_channel_attachment can never resolve them.
|
|
715
|
+
// Mirror splitInbound: bake placeholders into text and carry the structured
|
|
716
|
+
// attachments so the router can resolve ids.
|
|
717
|
+
const attachments = (msg.files ?? []).map((file, index) => describeSlackFile(file, index + 1))
|
|
718
|
+
const rawText = msg.text ?? ''
|
|
719
|
+
const text =
|
|
720
|
+
attachments.length === 0
|
|
721
|
+
? rawText
|
|
722
|
+
: rawText === ''
|
|
723
|
+
? attachments.map(renderPlaceholder).join('\n')
|
|
724
|
+
: `${rawText}\n${attachments.map(renderPlaceholder).join('\n')}`
|
|
604
725
|
return {
|
|
605
726
|
externalMessageId: msg.ts,
|
|
606
727
|
authorId: msg.user ?? msg.bot_id ?? 'unknown',
|
|
607
728
|
authorName: msg.user ?? msg.bot_id ?? 'unknown',
|
|
608
|
-
text
|
|
729
|
+
text,
|
|
609
730
|
ts: slackTsToMillis(msg.ts),
|
|
610
731
|
isBot,
|
|
611
732
|
replyToBotMessageId,
|
|
733
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
612
734
|
}
|
|
613
735
|
}
|
|
614
736
|
|
|
@@ -89,14 +89,51 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
|
|
|
89
89
|
if (config.trigger.includes('mention') && message.isBotMention) return 'engage'
|
|
90
90
|
if (config.trigger.includes('reply') && message.replyToBotMessageId !== null) return 'engage'
|
|
91
91
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
92
|
+
// Multi-human pre-sticky target check. In a busy group the conversational
|
|
93
|
+
// target shifts every message: the author we're mid-exchange with (and hold
|
|
94
|
+
// a sticky credit for) may, on THIS turn, structurally address a third party
|
|
95
|
+
// — "@bob what do you think?", a reply to another human's message, or a
|
|
96
|
+
// peer bot by name. Sticky below is content-blind by design (it answers "am
|
|
97
|
+
// I mid-conversation with this author?"), so without this guard it would
|
|
98
|
+
// force-engage on a message plainly aimed elsewhere and burn the credit.
|
|
99
|
+
//
|
|
100
|
+
// This stays inside the pinned content-blind philosophy: it adds NO semantic
|
|
101
|
+
// text interpretation. It reuses the SAME structural booleans the post-alias
|
|
102
|
+
// suppressors already trust (`mentionsOthers`, `replyToOtherMessageId`,
|
|
103
|
+
// `textTargetsAnyPeerBot`) — the adapter classifiers decide "addressed to
|
|
104
|
+
// someone else", not this gate. The only refinement is ordering: when those
|
|
105
|
+
// structural signals fire in a multi-human group, observe BEFORE consuming
|
|
106
|
+
// sticky, and PRESERVE the credit so the author's next untargeted follow-up
|
|
107
|
+
// still wakes us within the window. A plain follow-up (no suppressor set)
|
|
108
|
+
// is untouched: it falls through to sticky and engages exactly as before.
|
|
109
|
+
//
|
|
110
|
+
// Solo-human channels are deliberately excluded (`effectiveHumans > 1`):
|
|
111
|
+
// there the sticky-over-mentionsOthers behavior is intentional and tested.
|
|
112
|
+
// The `!matchesAnyAlias` guard preserves the ladder invariant "explicit
|
|
113
|
+
// address to us beats structural targeting of others": a message that names
|
|
114
|
+
// us by alias engages on the alias rule below even when it ALSO tags a third
|
|
115
|
+
// party (the "봉봉아 펭펭아 둘 다 봐" multi-bot case), so we must not pre-empt
|
|
116
|
+
// it here. We only step aside for a credited author whose message is aimed
|
|
117
|
+
// PURELY elsewhere.
|
|
118
|
+
if (
|
|
119
|
+
effectiveHumans > 1 &&
|
|
120
|
+
config.stickiness !== 'off' &&
|
|
121
|
+
!matchesAnyAlias(message.text, selfAliases) &&
|
|
122
|
+
targetsSomeoneElse(message, participants, botInThread)
|
|
123
|
+
) {
|
|
124
|
+
return 'observe'
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Sticky credit force-engages for the full window. This gate is deliberately
|
|
128
|
+
// content-blind: it answers "am I in an active conversation with this
|
|
129
|
+
// author?", not "does THIS message need a reply?" — a boolean over
|
|
130
|
+
// membership cannot tell "where did you send it?" (reply) from "lol ok"
|
|
131
|
+
// (chatter). Selectivity for plain follow-ups is the MODEL's job: engaged
|
|
97
132
|
// group turns get a `composeTurnPrompt` nudge (keyed off `isMultiHumanGroup`)
|
|
98
133
|
// to answer real follow-ups and `NO_REPLY` chatter. Gating sticky off in
|
|
99
|
-
// groups
|
|
134
|
+
// groups wholesale (the prior approach) dropped genuine follow-ups outright;
|
|
135
|
+
// the pre-check above is narrower — it only steps aside when the message is
|
|
136
|
+
// STRUCTURALLY addressed elsewhere, leaving plain follow-ups engaged.
|
|
100
137
|
if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)) {
|
|
101
138
|
return 'engage'
|
|
102
139
|
}
|
|
@@ -155,37 +192,40 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
|
|
|
155
192
|
// has rejected that design repeatedly. The right knob is `trigger`
|
|
156
193
|
// (which already applies symmetrically to humans and bots) plus this
|
|
157
194
|
// fallback fix.
|
|
158
|
-
if (message
|
|
159
|
-
// The replyToOtherMessageId suppressor exists to keep the bot out of
|
|
160
|
-
// human-to-human side conversations in busy channels. But Slack's
|
|
161
|
-
// `parent_user_id` is the THREAD ROOT author, not the immediate parent
|
|
162
|
-
// author — so a thread the human starts by @-mentioning the bot
|
|
163
|
-
// produces `replyToOtherMessageId` on every follow-up (root author is
|
|
164
|
-
// the human, not us), which would silently drop every reply after the
|
|
165
|
-
// first. Once the bot has actually sent into this thread, subsequent
|
|
166
|
-
// replies are part of OUR conversation regardless of who started it,
|
|
167
|
-
// so the suppressor stops applying. The two-humans-in-a-thread case
|
|
168
|
-
// PR #58 fixed is preserved because the bot never sent into that
|
|
169
|
-
// thread in the first place.
|
|
170
|
-
if (message.replyToOtherMessageId !== null && !botInThread) return 'observe'
|
|
171
|
-
|
|
172
|
-
// Plain-text peer-bot addressing as a fallback suppressor. We've reached
|
|
173
|
-
// here because the message lacks a structural mention/reply/dm AND
|
|
174
|
-
// doesn't contain our own alias. If it DOES contain a known peer bot's
|
|
175
|
-
// observed display name, the solo-human fallback would still engage us
|
|
176
|
-
// — same wrong behavior the alias trigger is meant to fix, just for
|
|
177
|
-
// peers instead of self. Each bot only configures its own aliases, so
|
|
178
|
-
// the only source of peer names is `participants[]` (observed
|
|
179
|
-
// authorName once a peer has spoken at least once in this channel).
|
|
180
|
-
// First-time addressing of a never-seen peer slips through; after that
|
|
181
|
-
// peer's first message it's caught forever.
|
|
182
|
-
if (textTargetsAnyPeerBot(message.text, participants)) return 'observe'
|
|
195
|
+
if (targetsSomeoneElse(message, participants, botInThread)) return 'observe'
|
|
183
196
|
|
|
184
197
|
if (effectiveHumans <= 1 && !message.authorIsBot) return 'engage'
|
|
185
198
|
|
|
186
199
|
return 'observe'
|
|
187
200
|
}
|
|
188
201
|
|
|
202
|
+
// Structural "this message is addressed to someone other than us" test. Pure
|
|
203
|
+
// over adapter-classified booleans + observed peer names — no semantic text
|
|
204
|
+
// interpretation, so it is safe for the content-blind engagement gate. Shared
|
|
205
|
+
// by the multi-human pre-sticky check and the post-alias fallback suppressors
|
|
206
|
+
// so the two can never drift apart.
|
|
207
|
+
//
|
|
208
|
+
// - `mentionsOthers` — the message tags at least one other user and none of
|
|
209
|
+
// the mentions resolve to us.
|
|
210
|
+
// - `replyToOtherMessageId !== null && !botInThread` — the message replies to
|
|
211
|
+
// a non-bot message AND we haven't sent into this thread yet. Slack's
|
|
212
|
+
// `parent_user_id` is the THREAD ROOT author, not the immediate parent, so
|
|
213
|
+
// a thread a human opens by @-mentioning us would otherwise drop every
|
|
214
|
+
// follow-up; `botInThread` is the escape hatch once we're participating.
|
|
215
|
+
// - `textTargetsAnyPeerBot` — the text names a known peer bot (observed via
|
|
216
|
+
// `participants[]`). A never-seen peer's first addressing slips through,
|
|
217
|
+
// then is caught forever once that peer has spoken once.
|
|
218
|
+
function targetsSomeoneElse(
|
|
219
|
+
message: InboundMessage,
|
|
220
|
+
participants: readonly ChannelParticipant[],
|
|
221
|
+
botInThread: boolean,
|
|
222
|
+
): boolean {
|
|
223
|
+
if (message.mentionsOthers) return true
|
|
224
|
+
if (message.replyToOtherMessageId !== null && !botInThread) return true
|
|
225
|
+
if (textTargetsAnyPeerBot(message.text, participants)) return true
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
|
|
189
229
|
export function countEffectiveHumans(
|
|
190
230
|
participants: readonly ChannelParticipant[],
|
|
191
231
|
membership: MembershipCount | null,
|
package/src/channels/manager.ts
CHANGED
|
@@ -89,6 +89,12 @@ export type ChannelManagerOptions = {
|
|
|
89
89
|
// per-repo App token minter here on start (App auth only) so plugin hooks
|
|
90
90
|
// can resolve a token for ad-hoc `gh` commands. Tests omit it.
|
|
91
91
|
githubTokenBridge?: GithubTokenBridge
|
|
92
|
+
// Forwarded to the router as the /reload and /restart command handlers.
|
|
93
|
+
// Production wiring (src/run/index.ts) supplies the reload-registry and
|
|
94
|
+
// container-restart bindings; tests omit them so the commands stay
|
|
95
|
+
// unregistered. See CreateChannelRouterOptions.onReload/onRestart.
|
|
96
|
+
onReload?: () => Promise<string>
|
|
97
|
+
onRestart?: () => Promise<string>
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
export type ChannelManager = {
|
|
@@ -125,6 +131,8 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
125
131
|
...(options.permissions ? { permissions: options.permissions } : {}),
|
|
126
132
|
...(options.claimHandler ? { claimHandler: options.claimHandler } : {}),
|
|
127
133
|
...(options.stream ? { stream: options.stream } : {}),
|
|
134
|
+
...(options.onReload ? { onReload: options.onReload } : {}),
|
|
135
|
+
...(options.onRestart ? { onRestart: options.onRestart } : {}),
|
|
128
136
|
})
|
|
129
137
|
const createDiscordAdapter = options.createDiscordAdapter ?? createDiscordBotAdapter
|
|
130
138
|
const createGithub = options.createGithubAdapter ?? createGithubAdapter
|