typeclaw 0.22.0 → 0.24.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/index.ts +91 -22
- package/src/agent/plugin-tools.ts +38 -2
- package/src/agent/restart/index.ts +15 -3
- package/src/agent/restart-handoff/index.ts +110 -12
- package/src/agent/session-origin.ts +41 -2
- package/src/agent/subagent-completion-reminder.ts +3 -1
- package/src/agent/subagents.ts +44 -1
- package/src/agent/system-prompt.ts +4 -0
- package/src/agent/todo/continuation-policy.ts +242 -0
- package/src/agent/todo/continuation-state.ts +87 -0
- package/src/agent/todo/continuation-wiring.ts +113 -0
- package/src/agent/todo/continuation.ts +71 -0
- package/src/agent/todo/scope.ts +77 -0
- package/src/agent/todo/store.ts +98 -0
- package/src/agent/tool-not-found-nudge.ts +119 -0
- package/src/agent/tools/channel-reply.ts +51 -0
- package/src/agent/tools/restart.ts +11 -4
- package/src/agent/tools/todo/index.ts +119 -0
- package/src/bundled-plugins/backup/runner.ts +1 -1
- package/src/bundled-plugins/memory/memory-logger.ts +28 -10
- package/src/bundled-plugins/reviewer/reviewer.ts +14 -0
- package/src/channels/adapters/discord-bot-reference.ts +78 -0
- package/src/channels/adapters/discord-bot.ts +31 -3
- package/src/channels/adapters/github/inbound.ts +161 -10
- package/src/channels/adapters/github/index.ts +18 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
- package/src/channels/adapters/kakaotalk-classify.ts +67 -6
- package/src/channels/adapters/slack-bot-classify.ts +9 -1
- package/src/channels/adapters/slack-bot-reference.ts +129 -0
- package/src/channels/adapters/slack-bot.ts +75 -8
- package/src/channels/adapters/telegram-bot.ts +11 -0
- package/src/channels/manager.ts +8 -2
- package/src/channels/router.ts +477 -22
- package/src/channels/schema.ts +20 -4
- package/src/channels/types.ts +95 -0
- package/src/cli/inspect-controller.ts +99 -0
- package/src/cli/inspect.ts +21 -123
- package/src/commands/index.ts +9 -0
- package/src/init/gitignore.ts +5 -2
- package/src/inspect/index.ts +30 -26
- package/src/inspect/live.ts +17 -3
- package/src/inspect/loop.ts +23 -17
- package/src/run/index.ts +60 -5
- package/src/sandbox/build.ts +10 -0
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/policy.ts +10 -0
- package/src/sandbox/writable-zones.ts +78 -0
- package/src/server/index.ts +118 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +34 -7
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/src/skills/typeclaw-git/SKILL.md +1 -1
- package/typeclaw.schema.json +10 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { ReviewThreadResolveRequest, ReviewThreadResolveResult, ReviewThreadResolver } from '@/channels/types'
|
|
2
|
+
|
|
3
|
+
import type { GithubAuthContext } from './auth'
|
|
4
|
+
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
5
|
+
|
|
6
|
+
const GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`
|
|
7
|
+
|
|
8
|
+
// One page of review threads. `first: 100` is the GraphQL max; a busy PR can
|
|
9
|
+
// carry more, so the resolver paginates until it matches the root comment id
|
|
10
|
+
// or exhausts the pages — stopping early on a 404-equivalent (thread absent)
|
|
11
|
+
// rather than fabricating a node id.
|
|
12
|
+
const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{login}}}}}}}}`
|
|
13
|
+
|
|
14
|
+
const RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`
|
|
15
|
+
|
|
16
|
+
type ReviewThreadNode = {
|
|
17
|
+
id: string
|
|
18
|
+
isResolved: boolean
|
|
19
|
+
rootCommentId: number | null
|
|
20
|
+
rootAuthorLogin: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ThreadLookup =
|
|
24
|
+
| { kind: 'found'; thread: ReviewThreadNode }
|
|
25
|
+
| { kind: 'absent' }
|
|
26
|
+
| { kind: 'error'; result: ReviewThreadResolveResult & { ok: false } }
|
|
27
|
+
|
|
28
|
+
export function createGithubReviewThreadResolver(deps: {
|
|
29
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
30
|
+
selfLogin: () => string | null
|
|
31
|
+
fetchImpl?: typeof fetch
|
|
32
|
+
}): ReviewThreadResolver {
|
|
33
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
34
|
+
return async (req): Promise<ReviewThreadResolveResult> => {
|
|
35
|
+
if (req.adapter !== 'github') {
|
|
36
|
+
return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
|
|
37
|
+
}
|
|
38
|
+
const target = parseTarget(req)
|
|
39
|
+
if (target === null) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
error: `unparseable github review-thread target (chat=${req.chat}, root=${req.rootCommentId})`,
|
|
43
|
+
code: 'transient',
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const selfLogin = deps.selfLogin()
|
|
47
|
+
if (selfLogin === null) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
error: 'github self-identity not resolved; cannot verify thread authorship',
|
|
51
|
+
code: 'transient',
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const token = await deps.token({ repoSlug: `${target.owner}/${target.repo}` })
|
|
56
|
+
const lookup = await findThread(fetchImpl, token, target)
|
|
57
|
+
if (lookup.kind === 'error') return lookup.result
|
|
58
|
+
if (lookup.kind === 'absent') {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: `no review thread rooted at comment ${target.rootCommentId} on ${req.chat}`,
|
|
62
|
+
code: 'no-match',
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const thread = lookup.thread
|
|
67
|
+
// The load-bearing guard: only the bot may resolve the bot's own thread.
|
|
68
|
+
// Resolving a human reviewer's thread would erase their open question.
|
|
69
|
+
if (thread.rootAuthorLogin !== selfLogin) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
error: `refusing to resolve thread authored by @${thread.rootAuthorLogin ?? 'unknown'} (not @${selfLogin})`,
|
|
73
|
+
code: 'not-author',
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (thread.isResolved) return { ok: true, alreadyResolved: true }
|
|
77
|
+
|
|
78
|
+
return await runResolveMutation(fetchImpl, token, thread.id)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type ResolveTarget = { owner: string; repo: string; prNumber: number; rootCommentId: number }
|
|
83
|
+
|
|
84
|
+
function parseTarget(req: ReviewThreadResolveRequest): ResolveTarget | null {
|
|
85
|
+
const [owner, repo, ...rest] = req.workspace.split('/')
|
|
86
|
+
if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
|
|
87
|
+
const prMatch = /^pr:(\d+)$/.exec(req.chat)
|
|
88
|
+
if (prMatch === null) return null
|
|
89
|
+
const prNumber = parseDecimalId(prMatch[1])
|
|
90
|
+
const rootCommentId = parseDecimalId(req.rootCommentId)
|
|
91
|
+
if (prNumber === null || rootCommentId === null) return null
|
|
92
|
+
return { owner, repo, prNumber, rootCommentId }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Strict decimal-id parse: `Number()` would coerce '' -> 0, '1e2' -> 100, and
|
|
96
|
+
// silently round ids past 2^53 (GitHub comment ids are large), any of which
|
|
97
|
+
// could match the WRONG thread. Demand a plain run of digits and a safe
|
|
98
|
+
// integer, so a malformed or oversized id fails closed (no resolution) rather
|
|
99
|
+
// than resolving a collided thread.
|
|
100
|
+
function parseDecimalId(value: string | undefined): number | null {
|
|
101
|
+
if (value === undefined || !/^\d+$/.test(value)) return null
|
|
102
|
+
const n = Number(value)
|
|
103
|
+
return Number.isSafeInteger(n) ? n : null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function findThread(fetchImpl: typeof fetch, token: string, target: ResolveTarget): Promise<ThreadLookup> {
|
|
107
|
+
let after: string | null = null
|
|
108
|
+
for (;;) {
|
|
109
|
+
let response: Response
|
|
110
|
+
try {
|
|
111
|
+
response = await fetchImpl(GRAPHQL_ENDPOINT, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: githubJsonHeaders(token),
|
|
114
|
+
body: JSON.stringify({
|
|
115
|
+
query: THREADS_QUERY,
|
|
116
|
+
variables: { owner: target.owner, name: target.repo, number: target.prNumber, after },
|
|
117
|
+
}),
|
|
118
|
+
})
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return {
|
|
121
|
+
kind: 'error',
|
|
122
|
+
result: { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' },
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const parsed = await parseThreadsPage(response)
|
|
126
|
+
if (parsed.kind === 'error') return { kind: 'error', result: parsed.result }
|
|
127
|
+
|
|
128
|
+
for (const node of parsed.nodes) {
|
|
129
|
+
if (node.rootCommentId === target.rootCommentId) return { kind: 'found', thread: node }
|
|
130
|
+
}
|
|
131
|
+
if (!parsed.hasNextPage || parsed.endCursor === null) return { kind: 'absent' }
|
|
132
|
+
after = parsed.endCursor
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type ThreadsPage =
|
|
137
|
+
| { kind: 'ok'; nodes: ReviewThreadNode[]; hasNextPage: boolean; endCursor: string | null }
|
|
138
|
+
| { kind: 'error'; result: ReviewThreadResolveResult & { ok: false } }
|
|
139
|
+
|
|
140
|
+
async function parseThreadsPage(response: Response): Promise<ThreadsPage> {
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
const text = await response.text().catch(() => '')
|
|
143
|
+
return {
|
|
144
|
+
kind: 'error',
|
|
145
|
+
result: {
|
|
146
|
+
ok: false,
|
|
147
|
+
error: `GitHub GraphQL ${response.status}${text !== '' ? `: ${text}` : ''}`,
|
|
148
|
+
code: classifyStatus(response.status),
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const body = (await response.json().catch(() => null)) as GraphqlThreadsResponse | null
|
|
153
|
+
if (body === null)
|
|
154
|
+
return { kind: 'error', result: { ok: false, error: 'GitHub GraphQL returned non-JSON', code: 'transient' } }
|
|
155
|
+
if (body.errors !== undefined && body.errors.length > 0) {
|
|
156
|
+
return {
|
|
157
|
+
kind: 'error',
|
|
158
|
+
result: {
|
|
159
|
+
ok: false,
|
|
160
|
+
error: `GitHub GraphQL error: ${body.errors.map((e) => e.message).join('; ')}`,
|
|
161
|
+
code: 'transient',
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const connection = body.data?.repository?.pullRequest?.reviewThreads
|
|
166
|
+
if (connection === undefined)
|
|
167
|
+
return {
|
|
168
|
+
kind: 'error',
|
|
169
|
+
result: { ok: false, error: 'GitHub GraphQL response missing reviewThreads', code: 'transient' },
|
|
170
|
+
}
|
|
171
|
+
const nodes = connection.nodes.map((n) => {
|
|
172
|
+
const root = n.comments.nodes[0]
|
|
173
|
+
return {
|
|
174
|
+
id: n.id,
|
|
175
|
+
isResolved: n.isResolved,
|
|
176
|
+
rootCommentId: root?.databaseId ?? null,
|
|
177
|
+
rootAuthorLogin: root?.author?.login ?? null,
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
return { kind: 'ok', nodes, hasNextPage: connection.pageInfo.hasNextPage, endCursor: connection.pageInfo.endCursor }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function runResolveMutation(
|
|
184
|
+
fetchImpl: typeof fetch,
|
|
185
|
+
token: string,
|
|
186
|
+
threadId: string,
|
|
187
|
+
): Promise<ReviewThreadResolveResult> {
|
|
188
|
+
let response: Response
|
|
189
|
+
try {
|
|
190
|
+
response = await fetchImpl(GRAPHQL_ENDPOINT, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: githubJsonHeaders(token),
|
|
193
|
+
body: JSON.stringify({ query: RESOLVE_MUTATION, variables: { threadId } }),
|
|
194
|
+
})
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
|
|
197
|
+
}
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
const text = await response.text().catch(() => '')
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
error: `GitHub GraphQL ${response.status}${text !== '' ? `: ${text}` : ''}`,
|
|
203
|
+
code: classifyStatus(response.status),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const body = (await response.json().catch(() => null)) as GraphqlResolveResponse | null
|
|
207
|
+
if (body === null) return { ok: false, error: 'GitHub GraphQL returned non-JSON', code: 'transient' }
|
|
208
|
+
if (body.errors !== undefined && body.errors.length > 0) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
error: `GitHub GraphQL error: ${body.errors.map((e) => e.message).join('; ')}`,
|
|
212
|
+
code: 'transient',
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (body.data?.resolveReviewThread?.thread?.isResolved === true) return { ok: true }
|
|
216
|
+
return { ok: false, error: 'resolveReviewThread mutation did not report isResolved', code: 'transient' }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function classifyStatus(status: number): 'permission-denied' | 'not-found' | 'transient' {
|
|
220
|
+
if (status === 401 || status === 403) return 'permission-denied'
|
|
221
|
+
if (status === 404) return 'not-found'
|
|
222
|
+
return 'transient'
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
type GraphqlThreadsResponse = {
|
|
226
|
+
data?: {
|
|
227
|
+
repository?: {
|
|
228
|
+
pullRequest?: {
|
|
229
|
+
reviewThreads?: {
|
|
230
|
+
pageInfo: { hasNextPage: boolean; endCursor: string | null }
|
|
231
|
+
nodes: Array<{
|
|
232
|
+
id: string
|
|
233
|
+
isResolved: boolean
|
|
234
|
+
comments: { nodes: Array<{ databaseId?: number; author?: { login?: string } }> }
|
|
235
|
+
}>
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
errors?: Array<{ message: string }>
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
type GraphqlResolveResponse = {
|
|
244
|
+
data?: { resolveReviewThread?: { thread?: { id: string; isResolved: boolean } } }
|
|
245
|
+
errors?: Array<{ message: string }>
|
|
246
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { KAKAO_MESSAGE_TYPE, type KakaoTalkPushMessageEvent } from 'agent-messenger/kakaotalk'
|
|
2
2
|
|
|
3
3
|
import { matchesAnyAlias } from '@/channels/engagement'
|
|
4
4
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
5
|
-
import type { InboundAttachment, InboundMessage } from '@/channels/types'
|
|
5
|
+
import type { InboundAttachment, InboundMessage, InboundReferenceContext } from '@/channels/types'
|
|
6
6
|
|
|
7
7
|
export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect' | 'bot_message'
|
|
8
8
|
|
|
@@ -49,7 +49,9 @@ export function classifyInbound(
|
|
|
49
49
|
return { kind: 'drop', reason: 'bot_message' }
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
const
|
|
52
|
+
const rawText = event.message ?? ''
|
|
53
|
+
const replyContext = parseReplyContext(event, context.selfUserId)
|
|
54
|
+
const text = rawText
|
|
53
55
|
if (text === '') return { kind: 'drop', reason: 'empty_text' }
|
|
54
56
|
|
|
55
57
|
const chatInfo = context.lookupChat(event.chat_id)
|
|
@@ -64,7 +66,7 @@ export function classifyInbound(
|
|
|
64
66
|
// mention (see engagement.ts: alias is unconditional and ranks alongside
|
|
65
67
|
// explicit triggers). Without aliases configured, only `reply` and `dm`
|
|
66
68
|
// triggers can fire on KakaoTalk.
|
|
67
|
-
const aliasMatched = matchesAnyAlias(
|
|
69
|
+
const aliasMatched = matchesAnyAlias(rawText, context.selfAliases ?? [])
|
|
68
70
|
|
|
69
71
|
return {
|
|
70
72
|
kind: 'route',
|
|
@@ -74,6 +76,7 @@ export function classifyInbound(
|
|
|
74
76
|
chat: event.chat_id,
|
|
75
77
|
thread: null,
|
|
76
78
|
text,
|
|
79
|
+
...referenceContextPayload(replyContext),
|
|
77
80
|
...(context.attachments !== undefined && context.attachments.length > 0
|
|
78
81
|
? { attachments: context.attachments }
|
|
79
82
|
: {}),
|
|
@@ -82,9 +85,9 @@ export function classifyInbound(
|
|
|
82
85
|
authorName: event.author_name ?? String(event.author_id),
|
|
83
86
|
authorIsBot: false,
|
|
84
87
|
isBotMention: aliasMatched,
|
|
85
|
-
replyToBotMessageId: null,
|
|
88
|
+
replyToBotMessageId: replyContext?.target === 'bot' ? replyContext.logId : null,
|
|
86
89
|
mentionsOthers: false,
|
|
87
|
-
replyToOtherMessageId: null,
|
|
90
|
+
replyToOtherMessageId: replyContext?.target === 'other' ? replyContext.logId : null,
|
|
88
91
|
isDm: chatInfo.isDm,
|
|
89
92
|
// SDK delivers `sent_at` in Unix seconds (LOCO `sendAt`); contract
|
|
90
93
|
// wants ms (see `src/channels/types.ts`). Without `* 1000`, ms-based
|
|
@@ -93,3 +96,61 @@ export function classifyInbound(
|
|
|
93
96
|
},
|
|
94
97
|
}
|
|
95
98
|
}
|
|
99
|
+
|
|
100
|
+
type ParsedReplyContext = {
|
|
101
|
+
logId: string
|
|
102
|
+
authorId: string
|
|
103
|
+
authorName: string
|
|
104
|
+
quotedText: string | null
|
|
105
|
+
target: 'bot' | 'other'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseReplyContext(event: KakaoTalkPushMessageEvent, selfUserId: string): ParsedReplyContext | null {
|
|
109
|
+
if (event.message_type !== KAKAO_MESSAGE_TYPE.REPLY) return null
|
|
110
|
+
if (event.attachment === null) return null
|
|
111
|
+
|
|
112
|
+
const logId = stringField(event.attachment, 'src_logId')
|
|
113
|
+
const sourceUserId = numericField(event.attachment, 'src_userId')
|
|
114
|
+
if (logId === null || sourceUserId === null) return null
|
|
115
|
+
|
|
116
|
+
const sourceAuthorId = String(sourceUserId)
|
|
117
|
+
const quotedText = stringField(event.attachment, 'src_message')
|
|
118
|
+
return {
|
|
119
|
+
logId,
|
|
120
|
+
authorId: sourceAuthorId,
|
|
121
|
+
// classifyInbound is synchronous and has no cheap author resolver access;
|
|
122
|
+
// fall back to Kakao's stable source user id rather than fetching.
|
|
123
|
+
authorName: sourceAuthorId,
|
|
124
|
+
quotedText,
|
|
125
|
+
target: sourceAuthorId === selfUserId ? 'bot' : 'other',
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function referenceContextPayload(
|
|
130
|
+
replyContext: ParsedReplyContext | null,
|
|
131
|
+
): { referenceContext: InboundReferenceContext } | Record<string, never> {
|
|
132
|
+
if (replyContext === null || replyContext.quotedText === null || replyContext.quotedText.trim() === '') return {}
|
|
133
|
+
return {
|
|
134
|
+
referenceContext: {
|
|
135
|
+
kind: 'reply',
|
|
136
|
+
sources: [
|
|
137
|
+
{
|
|
138
|
+
adapter: 'kakaotalk',
|
|
139
|
+
authorId: replyContext.authorId,
|
|
140
|
+
authorName: replyContext.authorName,
|
|
141
|
+
text: replyContext.quotedText,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function stringField(record: Record<string, unknown>, key: string): string | null {
|
|
149
|
+
const value = record[key]
|
|
150
|
+
return typeof value === 'string' && value.length > 0 ? value : null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function numericField(record: Record<string, unknown>, key: string): number | null {
|
|
154
|
+
const value = record[key]
|
|
155
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
156
|
+
}
|
|
@@ -4,6 +4,7 @@ import { matchesAnyAlias } from '@/channels/engagement'
|
|
|
4
4
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
5
5
|
import type { InboundAttachment, InboundMessage } from '@/channels/types'
|
|
6
6
|
|
|
7
|
+
import { hasSlackMessageShareAttachments } from './slack-bot-reference'
|
|
7
8
|
import { slackTsToMillis } from './slack-bot-time'
|
|
8
9
|
|
|
9
10
|
export type SlackInboundMessageEvent = SlackSocketModeMessageEvent
|
|
@@ -62,7 +63,8 @@ export function classifyInbound(
|
|
|
62
63
|
|
|
63
64
|
const rawText = event.text ?? ''
|
|
64
65
|
const { text, attachments } = splitInbound(event)
|
|
65
|
-
|
|
66
|
+
const slackAttachments = Array.isArray(event.attachments) ? event.attachments : undefined
|
|
67
|
+
if (text === '' && !hasSlackMessageShareAttachments(slackAttachments)) return { kind: 'drop', reason: 'empty_text' }
|
|
66
68
|
|
|
67
69
|
const isDm = event.channel_type === 'im'
|
|
68
70
|
const workspace = isDm ? '@dm' : context.teamId
|
|
@@ -92,6 +94,11 @@ export function classifyInbound(
|
|
|
92
94
|
// found, keeping the cost negligible in mention-heavy channels.
|
|
93
95
|
const aliasMatched = !isBotMention && matchesAnyAlias(rawText, context.selfAliases ?? [])
|
|
94
96
|
const thread = event.thread_ts ?? (!isDm && (isBotMention || aliasMatched) ? event.ts : null)
|
|
97
|
+
// DMs stay flat (`thread` null), but Slack's typing status still needs a real
|
|
98
|
+
// message ts; anchor it on the inbound so the indicator renders without
|
|
99
|
+
// threading the reply. `assistant.threads.setStatus` accepts any live channel
|
|
100
|
+
// message ts, confirmed against the API.
|
|
101
|
+
const typingThread = isDm && thread === null ? event.ts : undefined
|
|
95
102
|
|
|
96
103
|
// A reply is "to the bot" only when the thread parent was authored by the
|
|
97
104
|
// bot. Slack surfaces the parent author via `parent_user_id` on every
|
|
@@ -130,6 +137,7 @@ export function classifyInbound(
|
|
|
130
137
|
workspace,
|
|
131
138
|
chat: event.channel,
|
|
132
139
|
thread,
|
|
140
|
+
...(typingThread !== undefined ? { typingThread } : {}),
|
|
133
141
|
text,
|
|
134
142
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
135
143
|
externalMessageId: event.ts,
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { InboundReferenceContext, QuoteAnchorSource } from '@/channels/types'
|
|
2
|
+
|
|
3
|
+
export type SlackResolvedReference = {
|
|
4
|
+
authorId: string
|
|
5
|
+
authorName: string
|
|
6
|
+
text: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type SlackReferenceFetch = (channelId: string, messageTs: string) => Promise<SlackResolvedReference | null>
|
|
10
|
+
|
|
11
|
+
export type SlackMessagePointer = {
|
|
12
|
+
channelId: string
|
|
13
|
+
messageTs: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function enrichSlackReferenceContext(args: {
|
|
17
|
+
text: string
|
|
18
|
+
channelId: string
|
|
19
|
+
threadTs?: string
|
|
20
|
+
messageTs: string
|
|
21
|
+
attachments?: readonly unknown[]
|
|
22
|
+
fetchMessage: SlackReferenceFetch
|
|
23
|
+
linkLimit?: number
|
|
24
|
+
}): Promise<{ text: string; referenceContext?: InboundReferenceContext }> {
|
|
25
|
+
const sources: QuoteAnchorSource[] = []
|
|
26
|
+
let kind: InboundReferenceContext['kind'] = 'link'
|
|
27
|
+
|
|
28
|
+
if (args.threadTs !== undefined && args.threadTs !== args.messageTs) {
|
|
29
|
+
const parent = await fetchSafely(args.fetchMessage, { channelId: args.channelId, messageTs: args.threadTs })
|
|
30
|
+
if (parent !== null) {
|
|
31
|
+
sources.push(toSource(parent))
|
|
32
|
+
kind = 'reply'
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const source of extractSlackShareSources(args.attachments ?? [])) {
|
|
37
|
+
sources.push(source)
|
|
38
|
+
if (kind !== 'reply') kind = 'quote'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const links = extractSlackMessageLinks(args.text).slice(0, args.linkLimit ?? 3)
|
|
42
|
+
const seen = new Set(sources.map((source) => `${source.authorId}\x00${source.text}`))
|
|
43
|
+
for (const link of links) {
|
|
44
|
+
const message = await fetchSafely(args.fetchMessage, link)
|
|
45
|
+
if (message === null) continue
|
|
46
|
+
const source = toSource(message)
|
|
47
|
+
const key = `${source.authorId}\x00${source.text}`
|
|
48
|
+
if (seen.has(key)) continue
|
|
49
|
+
seen.add(key)
|
|
50
|
+
sources.push(source)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (sources.length === 0) return { text: args.text }
|
|
54
|
+
return { text: args.text, referenceContext: { kind, sources } }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function hasSlackMessageShareAttachments(attachments: readonly unknown[] | undefined): boolean {
|
|
58
|
+
return extractSlackShareSources(attachments ?? []).length > 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const SLACK_ARCHIVE_LINK = /https?:\/\/[^\s/]+\.slack\.com\/archives\/([^/\s]+)\/p(\d{10})(\d{6})/g
|
|
62
|
+
|
|
63
|
+
function extractSlackMessageLinks(text: string): SlackMessagePointer[] {
|
|
64
|
+
const seen = new Set<string>()
|
|
65
|
+
const links: SlackMessagePointer[] = []
|
|
66
|
+
for (const match of text.matchAll(SLACK_ARCHIVE_LINK)) {
|
|
67
|
+
const channelId = match[1]
|
|
68
|
+
const seconds = match[2]
|
|
69
|
+
const micros = match[3]
|
|
70
|
+
if (channelId === undefined || seconds === undefined || micros === undefined) continue
|
|
71
|
+
const messageTs = `${seconds}.${micros}`
|
|
72
|
+
const key = `${channelId}:${messageTs}`
|
|
73
|
+
if (seen.has(key)) continue
|
|
74
|
+
seen.add(key)
|
|
75
|
+
links.push({ channelId, messageTs })
|
|
76
|
+
}
|
|
77
|
+
return links
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A stable author id is required because the quote renders as `<@id>`
|
|
81
|
+
// downstream; a name-only source would emit `<@unknown>`, worse than omitting
|
|
82
|
+
// the context. Forwarded message-shares put it under `author_id`, classic
|
|
83
|
+
// shares under `user`/`user_id`. `author_subname` is Slack's display-name
|
|
84
|
+
// fallback on forwarded messages — used for the name only, never the id.
|
|
85
|
+
function extractSlackShareSources(attachments: readonly unknown[]): QuoteAnchorSource[] {
|
|
86
|
+
const sources: QuoteAnchorSource[] = []
|
|
87
|
+
for (const attachment of attachments) {
|
|
88
|
+
const record = recordValue(attachment)
|
|
89
|
+
if (record === null) continue
|
|
90
|
+
const text = stringField(record, 'text') ?? stringField(record, 'fallback')
|
|
91
|
+
if (text === null || text.trim() === '') continue
|
|
92
|
+
const authorId = stringField(record, 'author_id') ?? stringField(record, 'user') ?? stringField(record, 'user_id')
|
|
93
|
+
if (authorId === null) continue
|
|
94
|
+
const authorName = stringField(record, 'author_name') ?? stringField(record, 'author_subname') ?? authorId
|
|
95
|
+
sources.push({
|
|
96
|
+
adapter: 'slack-bot',
|
|
97
|
+
authorId,
|
|
98
|
+
authorName,
|
|
99
|
+
text,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
return sources
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function fetchSafely(
|
|
106
|
+
fetchMessage: SlackReferenceFetch,
|
|
107
|
+
pointer: SlackMessagePointer,
|
|
108
|
+
): Promise<SlackResolvedReference | null> {
|
|
109
|
+
try {
|
|
110
|
+
return await fetchMessage(pointer.channelId, pointer.messageTs)
|
|
111
|
+
} catch {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toSource(message: SlackResolvedReference): QuoteAnchorSource {
|
|
117
|
+
return { adapter: 'slack-bot', authorId: message.authorId, authorName: message.authorName, text: message.text }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function recordValue(value: unknown): Record<string, unknown> | null {
|
|
121
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
122
|
+
? (value as Record<string, unknown>)
|
|
123
|
+
: null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stringField(record: Record<string, unknown>, key: string): string | null {
|
|
127
|
+
const value = record[key]
|
|
128
|
+
return typeof value === 'string' && value.length > 0 ? value : null
|
|
129
|
+
}
|