typeclaw 0.20.0 → 0.21.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 +1 -1
- package/src/agent/restart/index.ts +101 -0
- package/src/agent/tools/restart.ts +23 -52
- package/src/channels/adapters/discord-bot-classify.ts +8 -2
- package/src/channels/adapters/discord-bot.ts +27 -2
- package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
- package/src/channels/adapters/github/inbound.ts +69 -0
- package/src/channels/adapters/github/index.ts +11 -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 +25 -2
- package/src/channels/engagement.ts +71 -31
- package/src/channels/router.ts +112 -10
- package/src/channels/types.ts +16 -1
- package/src/cli/builtins.ts +1 -0
- package/src/cli/dreams.ts +147 -0
- package/src/cli/index.ts +1 -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/server/index.ts +49 -0
- package/src/shared/protocol.ts +2 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +3 -9
- package/src/tui/index.ts +70 -18
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
RemoveReactionCallback,
|
|
3
|
+
ReactionCallback,
|
|
4
|
+
ReactionErrorCode,
|
|
5
|
+
ReactionRef,
|
|
6
|
+
ReactionResult,
|
|
7
|
+
} from '@/channels/types'
|
|
2
8
|
|
|
3
9
|
import type { GithubAuthContext } from './auth'
|
|
4
10
|
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
@@ -21,6 +27,11 @@ export type GithubReactionTarget =
|
|
|
21
27
|
| { kind: 'issue-comment'; owner: string; repo: string; commentId: number }
|
|
22
28
|
| { kind: 'pr-review-comment'; owner: string; repo: string; commentId: number }
|
|
23
29
|
|
|
30
|
+
export type GithubReactionRemovalTarget =
|
|
31
|
+
| { kind: 'issue'; owner: string; repo: string; issueNumber: number; reactionId: number }
|
|
32
|
+
| { kind: 'issue-comment'; owner: string; repo: string; commentId: number; reactionId: number }
|
|
33
|
+
| { kind: 'pr-review-comment'; owner: string; repo: string; commentId: number; reactionId: number }
|
|
34
|
+
|
|
24
35
|
export function encodeGithubReactionRef(target: GithubReactionTarget): ReactionRef {
|
|
25
36
|
return { adapter: 'github', value: JSON.stringify(target) }
|
|
26
37
|
}
|
|
@@ -35,6 +46,7 @@ export function decodeGithubReactionRef(ref: ReactionRef): GithubReactionTarget
|
|
|
35
46
|
}
|
|
36
47
|
if (typeof parsed !== 'object' || parsed === null) return null
|
|
37
48
|
const t = parsed as Record<string, unknown>
|
|
49
|
+
if (t.op !== undefined) return null
|
|
38
50
|
const owner = typeof t.owner === 'string' ? t.owner : null
|
|
39
51
|
const repo = typeof t.repo === 'string' ? t.repo : null
|
|
40
52
|
if (owner === null || repo === null) return null
|
|
@@ -47,6 +59,32 @@ export function decodeGithubReactionRef(ref: ReactionRef): GithubReactionTarget
|
|
|
47
59
|
return null
|
|
48
60
|
}
|
|
49
61
|
|
|
62
|
+
export function encodeGithubRemovalRef(target: GithubReactionRemovalTarget): ReactionRef {
|
|
63
|
+
return { adapter: 'github', value: JSON.stringify({ op: 'remove', ...target }) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function decodeGithubRemovalRef(ref: ReactionRef): GithubReactionRemovalTarget | null {
|
|
67
|
+
if (ref.adapter !== 'github') return null
|
|
68
|
+
let parsed: unknown
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(ref.value)
|
|
71
|
+
} catch {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
75
|
+
const t = parsed as Record<string, unknown>
|
|
76
|
+
const owner = typeof t.owner === 'string' ? t.owner : null
|
|
77
|
+
const repo = typeof t.repo === 'string' ? t.repo : null
|
|
78
|
+
if (t.op !== 'remove' || owner === null || repo === null || typeof t.reactionId !== 'number') return null
|
|
79
|
+
if (t.kind === 'issue' && typeof t.issueNumber === 'number') {
|
|
80
|
+
return { kind: 'issue', owner, repo, issueNumber: t.issueNumber, reactionId: t.reactionId }
|
|
81
|
+
}
|
|
82
|
+
if ((t.kind === 'issue-comment' || t.kind === 'pr-review-comment') && typeof t.commentId === 'number') {
|
|
83
|
+
return { kind: t.kind, owner, repo, commentId: t.commentId, reactionId: t.reactionId }
|
|
84
|
+
}
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
50
88
|
// GitHub's Reactions API takes a fixed vocabulary of content strings. Map the
|
|
51
89
|
// adapter-generic emoji name onto it; anything outside the set is reported as
|
|
52
90
|
// `unsupported` so the model gets a clear signal rather than a silent 422.
|
|
@@ -82,8 +120,35 @@ export function createGithubReactionCallback(deps: {
|
|
|
82
120
|
const endpoint = reactionEndpoint(target)
|
|
83
121
|
const endpointKind: OutboundEndpointKind =
|
|
84
122
|
target.kind === 'pr-review-comment' ? 'pr-review-comment-reaction' : 'issue-reaction'
|
|
85
|
-
return await postReaction(
|
|
86
|
-
|
|
123
|
+
return await postReaction(
|
|
124
|
+
fetchImpl,
|
|
125
|
+
await deps.token({ repoSlug: `${target.owner}/${target.repo}` }),
|
|
126
|
+
endpoint,
|
|
127
|
+
target,
|
|
128
|
+
{
|
|
129
|
+
content,
|
|
130
|
+
authType: deps.authType,
|
|
131
|
+
endpointKind,
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createGithubRemoveReactionCallback(deps: {
|
|
138
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
139
|
+
authType: GithubAuthType
|
|
140
|
+
fetchImpl?: typeof fetch
|
|
141
|
+
}): RemoveReactionCallback {
|
|
142
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
143
|
+
return async (req): Promise<ReactionResult> => {
|
|
144
|
+
if (req.adapter !== 'github') return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
|
|
145
|
+
const target = decodeGithubRemovalRef(req.reactionRef)
|
|
146
|
+
if (target === null) return { ok: false, error: 'unparseable github reaction removal ref', code: 'unsupported' }
|
|
147
|
+
|
|
148
|
+
const endpoint = removeReactionEndpoint(target)
|
|
149
|
+
const endpointKind: OutboundEndpointKind =
|
|
150
|
+
target.kind === 'pr-review-comment' ? 'pr-review-comment-reaction' : 'issue-reaction'
|
|
151
|
+
return await deleteReaction(fetchImpl, await deps.token({ repoSlug: `${target.owner}/${target.repo}` }), endpoint, {
|
|
87
152
|
authType: deps.authType,
|
|
88
153
|
endpointKind,
|
|
89
154
|
})
|
|
@@ -102,10 +167,23 @@ function reactionEndpoint(target: GithubReactionTarget): string {
|
|
|
102
167
|
}
|
|
103
168
|
}
|
|
104
169
|
|
|
170
|
+
function removeReactionEndpoint(target: GithubReactionRemovalTarget): string {
|
|
171
|
+
const base = `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}`
|
|
172
|
+
switch (target.kind) {
|
|
173
|
+
case 'issue':
|
|
174
|
+
return `${base}/issues/${target.issueNumber}/reactions/${target.reactionId}`
|
|
175
|
+
case 'issue-comment':
|
|
176
|
+
return `${base}/issues/comments/${target.commentId}/reactions/${target.reactionId}`
|
|
177
|
+
case 'pr-review-comment':
|
|
178
|
+
return `${base}/pulls/comments/${target.commentId}/reactions/${target.reactionId}`
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
105
182
|
async function postReaction(
|
|
106
183
|
fetchImpl: typeof fetch,
|
|
107
184
|
token: string,
|
|
108
185
|
url: string,
|
|
186
|
+
target: GithubReactionTarget,
|
|
109
187
|
options: { content: string; authType: GithubAuthType; endpointKind: OutboundEndpointKind },
|
|
110
188
|
): Promise<ReactionResult> {
|
|
111
189
|
let response: Response
|
|
@@ -121,7 +199,11 @@ async function postReaction(
|
|
|
121
199
|
// 201 = reaction created, 200 = the actor already left this same reaction.
|
|
122
200
|
// Both are success: an :eyes: that's already there is the desired end state,
|
|
123
201
|
// so a duplicate webhook delivery (or a retried engage) must not surface an error.
|
|
124
|
-
if (response.status === 200 || response.status === 201)
|
|
202
|
+
if (response.status === 200 || response.status === 201) {
|
|
203
|
+
const reactionId = await readReactionId(response)
|
|
204
|
+
if (reactionId === null) return { ok: true }
|
|
205
|
+
return { ok: true, reactionRef: encodeGithubRemovalRef(removalTargetFor(target, reactionId)) }
|
|
206
|
+
}
|
|
125
207
|
const text = await response.text().catch(() => '')
|
|
126
208
|
const baseError = `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}`
|
|
127
209
|
if (isOutboundPermissionDenial(response.status, text)) {
|
|
@@ -134,6 +216,58 @@ async function postReaction(
|
|
|
134
216
|
return { ok: false, error: baseError, code: classifyStatus(response.status) }
|
|
135
217
|
}
|
|
136
218
|
|
|
219
|
+
async function deleteReaction(
|
|
220
|
+
fetchImpl: typeof fetch,
|
|
221
|
+
token: string,
|
|
222
|
+
url: string,
|
|
223
|
+
options: { authType: GithubAuthType; endpointKind: OutboundEndpointKind },
|
|
224
|
+
): Promise<ReactionResult> {
|
|
225
|
+
let response: Response
|
|
226
|
+
try {
|
|
227
|
+
response = await fetchImpl(url, {
|
|
228
|
+
method: 'DELETE',
|
|
229
|
+
headers: githubJsonHeaders(token),
|
|
230
|
+
})
|
|
231
|
+
} catch (err) {
|
|
232
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
|
|
233
|
+
}
|
|
234
|
+
if (response.status === 204) return { ok: true }
|
|
235
|
+
const text = await response.text().catch(() => '')
|
|
236
|
+
const baseError = `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}`
|
|
237
|
+
if (isOutboundPermissionDenial(response.status, text)) {
|
|
238
|
+
return {
|
|
239
|
+
ok: false,
|
|
240
|
+
error: `${baseError}${buildOutboundPermissionGuidance({ authType: options.authType, endpointKind: options.endpointKind })}`,
|
|
241
|
+
code: 'permission-denied',
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { ok: false, error: baseError, code: classifyStatus(response.status) }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function removalTargetFor(target: GithubReactionTarget, reactionId: number): GithubReactionRemovalTarget {
|
|
248
|
+
switch (target.kind) {
|
|
249
|
+
case 'issue':
|
|
250
|
+
return { kind: 'issue', owner: target.owner, repo: target.repo, issueNumber: target.issueNumber, reactionId }
|
|
251
|
+
case 'issue-comment':
|
|
252
|
+
return { kind: 'issue-comment', owner: target.owner, repo: target.repo, commentId: target.commentId, reactionId }
|
|
253
|
+
case 'pr-review-comment':
|
|
254
|
+
return {
|
|
255
|
+
kind: 'pr-review-comment',
|
|
256
|
+
owner: target.owner,
|
|
257
|
+
repo: target.repo,
|
|
258
|
+
commentId: target.commentId,
|
|
259
|
+
reactionId,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function readReactionId(response: Response): Promise<number | null> {
|
|
265
|
+
const body = await response.json().catch(() => null)
|
|
266
|
+
if (typeof body !== 'object' || body === null) return null
|
|
267
|
+
const id = (body as Record<string, unknown>).id
|
|
268
|
+
return typeof id === 'number' ? id : null
|
|
269
|
+
}
|
|
270
|
+
|
|
137
271
|
function classifyStatus(status: number): ReactionErrorCode {
|
|
138
272
|
if (status === 403) return 'permission-denied'
|
|
139
273
|
if (status === 404) return 'not-found'
|
|
@@ -182,7 +182,7 @@ function describeSlackMedia(event: SlackInboundMessageEvent): InboundAttachment[
|
|
|
182
182
|
return (event.files ?? []).map((file, index) => describeSlackFile(file, index + 1))
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
function describeSlackFile(file: SlackFile, id: number): InboundAttachment {
|
|
185
|
+
export function describeSlackFile(file: SlackFile, id: number): InboundAttachment {
|
|
186
186
|
return {
|
|
187
187
|
id,
|
|
188
188
|
kind: 'file',
|
|
@@ -192,7 +192,7 @@ function describeSlackFile(file: SlackFile, id: number): InboundAttachment {
|
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
function renderPlaceholder(attachment: InboundAttachment): string {
|
|
195
|
+
export function renderPlaceholder(attachment: InboundAttachment): string {
|
|
196
196
|
const parts: string[] = [`Slack attachment #${attachment.id}: ${attachment.kind}`]
|
|
197
197
|
if (attachment.mimetype !== undefined) parts.push(attachment.mimetype)
|
|
198
198
|
if (attachment.filename !== undefined) parts.push(`name=${attachment.filename}`)
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
SlackBotClient,
|
|
3
|
+
SlackBotListener,
|
|
4
|
+
type SlackFile,
|
|
5
|
+
type SlackSocketModeSlashCommandArgs,
|
|
6
|
+
} from 'agent-messenger/slackbot'
|
|
2
7
|
|
|
3
8
|
import {
|
|
4
9
|
MEMBERSHIP_ENUMERATION_CAP,
|
|
@@ -28,7 +33,9 @@ import { createSlackAuthorResolver } from './slack-bot-author-resolver'
|
|
|
28
33
|
import { createSlackChannelResolver } from './slack-bot-channel-resolver'
|
|
29
34
|
import {
|
|
30
35
|
classifyInbound,
|
|
36
|
+
describeSlackFile,
|
|
31
37
|
type InboundDropReason,
|
|
38
|
+
renderPlaceholder,
|
|
32
39
|
type SlackInboundAppMentionEvent,
|
|
33
40
|
type SlackInboundMessageEvent,
|
|
34
41
|
} from './slack-bot-classify'
|
|
@@ -369,6 +376,7 @@ type SlackRawHistoryMessage = {
|
|
|
369
376
|
text?: string
|
|
370
377
|
thread_ts?: string
|
|
371
378
|
parent_user_id?: string
|
|
379
|
+
files?: SlackFile[]
|
|
372
380
|
}
|
|
373
381
|
|
|
374
382
|
type SlackHistoryResponse = {
|
|
@@ -601,14 +609,29 @@ function mapSlackMessage(msg: SlackRawHistoryMessage, botUserId: string | null):
|
|
|
601
609
|
msg.parent_user_id === botUserId
|
|
602
610
|
? msg.thread_ts
|
|
603
611
|
: null
|
|
612
|
+
// The history fetch bypasses the inbound classifier, so files on
|
|
613
|
+
// already-posted messages (e.g. an image on a thread root the agent is
|
|
614
|
+
// later @-mentioned under) must be mapped here too — otherwise they are
|
|
615
|
+
// silently dropped and look_at_channel_attachment can never resolve them.
|
|
616
|
+
// Mirror splitInbound: bake placeholders into text and carry the structured
|
|
617
|
+
// attachments so the router can resolve ids.
|
|
618
|
+
const attachments = (msg.files ?? []).map((file, index) => describeSlackFile(file, index + 1))
|
|
619
|
+
const rawText = msg.text ?? ''
|
|
620
|
+
const text =
|
|
621
|
+
attachments.length === 0
|
|
622
|
+
? rawText
|
|
623
|
+
: rawText === ''
|
|
624
|
+
? attachments.map(renderPlaceholder).join('\n')
|
|
625
|
+
: `${rawText}\n${attachments.map(renderPlaceholder).join('\n')}`
|
|
604
626
|
return {
|
|
605
627
|
externalMessageId: msg.ts,
|
|
606
628
|
authorId: msg.user ?? msg.bot_id ?? 'unknown',
|
|
607
629
|
authorName: msg.user ?? msg.bot_id ?? 'unknown',
|
|
608
|
-
text
|
|
630
|
+
text,
|
|
609
631
|
ts: slackTsToMillis(msg.ts),
|
|
610
632
|
isBot,
|
|
611
633
|
replyToBotMessageId,
|
|
634
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
612
635
|
}
|
|
613
636
|
}
|
|
614
637
|
|
|
@@ -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,
|