typeclaw 0.23.0 → 0.25.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/README.md +1 -1
- package/package.json +1 -1
- package/src/agent/index.ts +133 -27
- package/src/agent/llm-replay-sanitizer.ts +120 -0
- package/src/agent/loop-guard.ts +34 -0
- package/src/agent/multimodal/look-at.ts +1 -1
- package/src/agent/plugin-tools.ts +122 -8
- package/src/agent/restart/index.ts +15 -3
- package/src/agent/restart-handoff/index.ts +110 -12
- package/src/agent/session-origin.ts +30 -0
- package/src/agent/subagent-completion-reminder.ts +26 -1
- package/src/agent/subagents.ts +75 -3
- package/src/agent/system-prompt.ts +5 -1
- 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 +126 -0
- package/src/agent/tools/channel-reply.ts +51 -0
- package/src/agent/tools/curl-impersonate.ts +2 -2
- package/src/agent/tools/restart.ts +11 -4
- package/src/agent/tools/spawn-subagent.ts +19 -2
- package/src/agent/tools/subagent-access.ts +40 -5
- package/src/agent/tools/subagent-cancel.ts +3 -1
- package/src/agent/tools/subagent-output.ts +6 -2
- package/src/agent/tools/todo/index.ts +119 -0
- package/src/agent/tools/webfetch/fetch.ts +18 -18
- package/src/agent/tools/webfetch/index.ts +1 -1
- package/src/agent/tools/webfetch/tool.ts +13 -13
- package/src/agent/tools/webfetch/types.ts +1 -1
- package/src/agent/tools/websearch.ts +6 -6
- package/src/bundled-plugins/backup/index.ts +40 -37
- package/src/bundled-plugins/backup/runner.ts +23 -2
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
- package/src/bundled-plugins/memory/README.md +11 -11
- package/src/bundled-plugins/memory/dreaming.ts +5 -0
- package/src/bundled-plugins/memory/search-tool.ts +98 -1
- package/src/bundled-plugins/operator/operator.ts +5 -1
- package/src/bundled-plugins/reviewer/reviewer.ts +32 -9
- package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
- package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
- package/src/bundled-plugins/scout/scout.ts +7 -7
- package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
- package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
- package/src/bundled-plugins/tool-result-cap/README.md +1 -1
- package/src/channels/adapters/discord-bot-reference.ts +78 -0
- package/src/channels/adapters/discord-bot.ts +25 -3
- package/src/channels/adapters/github/inbound.ts +172 -10
- package/src/channels/adapters/github/index.ts +10 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
- package/src/channels/adapters/github/webhook-register.ts +32 -27
- 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 +67 -8
- package/src/channels/manager.ts +8 -2
- package/src/channels/router.ts +506 -45
- package/src/channels/schema.ts +21 -4
- package/src/channels/subagent-completion-bridge.ts +18 -18
- package/src/channels/types.ts +69 -1
- package/src/cli/inspect-controller.ts +132 -33
- package/src/cli/inspect.ts +2 -1
- package/src/commands/index.ts +9 -0
- package/src/container/start.ts +7 -1
- package/src/git/mutex.ts +22 -0
- package/src/git/reconcile-ignored.ts +214 -0
- package/src/hostd/daemon.ts +26 -1
- package/src/hostd/portbroker-manager.ts +7 -0
- package/src/init/dockerfile.ts +1 -1
- package/src/init/gitignore.ts +28 -16
- package/src/inspect/index.ts +53 -4
- package/src/inspect/loop.ts +16 -12
- package/src/plugin/define.ts +2 -2
- package/src/plugin/index.ts +2 -2
- package/src/portbroker/hostd-client.ts +36 -13
- package/src/run/index.ts +74 -5
- package/src/sandbox/build.ts +20 -0
- package/src/sandbox/index.ts +10 -0
- package/src/sandbox/policy.ts +22 -0
- package/src/sandbox/session-tmp.ts +43 -0
- package/src/sandbox/writable-zones.ts +178 -0
- package/src/server/command-runner.ts +1 -1
- package/src/server/index.ts +126 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +71 -17
- package/src/skills/typeclaw-memory/SKILL.md +3 -1
- package/src/tui/format.ts +11 -11
- package/typeclaw.schema.json +10 -0
|
@@ -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
|
+
}
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
type SlackInboundMessageEvent,
|
|
44
44
|
} from './slack-bot-classify'
|
|
45
45
|
import { createSlackDedupe } from './slack-bot-dedupe'
|
|
46
|
+
import { enrichSlackReferenceContext } from './slack-bot-reference'
|
|
46
47
|
import {
|
|
47
48
|
buildSlashAckPayload,
|
|
48
49
|
commandResultReply,
|
|
@@ -261,6 +262,7 @@ export type SlackBotAdapterOptions = {
|
|
|
261
262
|
// classifier behaves as before (no alias-driven thread anchoring), so
|
|
262
263
|
// tests and ad-hoc adapter constructions stay backwards-compatible.
|
|
263
264
|
selfAliasesRef?: () => readonly string[]
|
|
265
|
+
fetchImpl?: typeof fetch
|
|
264
266
|
}
|
|
265
267
|
|
|
266
268
|
export type SlackBotAdapter = {
|
|
@@ -351,18 +353,23 @@ export function createTypingCallback(deps: {
|
|
|
351
353
|
const { typingTracker, logger, formatChannelTag } = deps
|
|
352
354
|
return async (target: TypingTarget): Promise<void> => {
|
|
353
355
|
if (target.adapter !== 'slack-bot') return
|
|
356
|
+
// DMs are flat (thread null) but setStatus still needs a real message ts;
|
|
357
|
+
// `typingThread` carries the inbound ts for exactly that case. Real channel
|
|
358
|
+
// threads keep using `thread`. Either way the status is keyed on one ts.
|
|
359
|
+
const statusThread =
|
|
360
|
+
target.typingThread !== undefined && target.typingThread !== '' ? target.typingThread : target.thread
|
|
354
361
|
const tag = formatChannelTag
|
|
355
|
-
? await formatChannelTag(target.workspace,
|
|
356
|
-
: `channel=${
|
|
357
|
-
if (
|
|
362
|
+
? await formatChannelTag(target.workspace, statusThread ?? target.chat)
|
|
363
|
+
: `channel=${statusThread ?? target.chat}`
|
|
364
|
+
if (statusThread === undefined || statusThread === null || statusThread === '') {
|
|
358
365
|
if (target.phase === 'tick') logger.info(`[slack-bot] typing (no-op, top-level chat) ${tag}`)
|
|
359
366
|
return
|
|
360
367
|
}
|
|
361
368
|
if (target.phase === 'stop') {
|
|
362
|
-
await typingTracker.clearAfterSend(target.chat,
|
|
369
|
+
await typingTracker.clearAfterSend(target.chat, statusThread)
|
|
363
370
|
return
|
|
364
371
|
}
|
|
365
|
-
await typingTracker.setStatus(target.chat,
|
|
372
|
+
await typingTracker.setStatus(target.chat, statusThread, 'is typing...')
|
|
366
373
|
}
|
|
367
374
|
}
|
|
368
375
|
|
|
@@ -823,7 +830,7 @@ export function createOutboundCallback(deps: {
|
|
|
823
830
|
// top-level posts.
|
|
824
831
|
if (threadTs === null && chunks.length > 1) threadTs = sent.ts
|
|
825
832
|
}
|
|
826
|
-
if (typingTracker) await typingTracker.clearAfterSend(msg.chat, msg.thread)
|
|
833
|
+
if (typingTracker) await typingTracker.clearAfterSend(msg.chat, msg.typingThread ?? msg.thread)
|
|
827
834
|
return { ok: true }
|
|
828
835
|
} catch (err) {
|
|
829
836
|
const message = err instanceof Error ? err.message : String(err)
|
|
@@ -856,7 +863,7 @@ export function createOutboundCallback(deps: {
|
|
|
856
863
|
return { ok: false, error: `uploadFile failed: ${message}` }
|
|
857
864
|
}
|
|
858
865
|
}
|
|
859
|
-
if (typingTracker) await typingTracker.clearAfterSend(msg.chat, msg.thread)
|
|
866
|
+
if (typingTracker) await typingTracker.clearAfterSend(msg.chat, msg.typingThread ?? msg.thread)
|
|
860
867
|
return { ok: true }
|
|
861
868
|
}
|
|
862
869
|
}
|
|
@@ -897,9 +904,46 @@ export function createFetchAttachmentCallback(deps: {
|
|
|
897
904
|
}
|
|
898
905
|
}
|
|
899
906
|
|
|
907
|
+
function createSlackReferenceFetch(deps: { token: string; fetchImpl: typeof fetch }) {
|
|
908
|
+
return async (channelId: string, messageTs: string) => {
|
|
909
|
+
const url = new URL('https://slack.com/api/conversations.replies')
|
|
910
|
+
url.searchParams.set('channel', channelId)
|
|
911
|
+
url.searchParams.set('ts', messageTs)
|
|
912
|
+
url.searchParams.set('limit', '1')
|
|
913
|
+
const response = await deps.fetchImpl(url, { headers: { Authorization: `Bearer ${deps.token}` } })
|
|
914
|
+
if (!response.ok) return null
|
|
915
|
+
const body = recordValue(await response.json())
|
|
916
|
+
if (body === null || body.ok !== true) return null
|
|
917
|
+
const messages = arrayField(body, 'messages')
|
|
918
|
+
const first = recordValue(messages[0])
|
|
919
|
+
if (first === null) return null
|
|
920
|
+
const authorId = stringField(first, 'user') ?? stringField(first, 'bot_id')
|
|
921
|
+
const text = stringField(first, 'text')
|
|
922
|
+
if (authorId === null || text === null) return null
|
|
923
|
+
return { authorId, authorName: authorId, text }
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function recordValue(value: unknown): Record<string, unknown> | null {
|
|
928
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
929
|
+
? (value as Record<string, unknown>)
|
|
930
|
+
: null
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function arrayField(record: Record<string, unknown>, key: string): readonly unknown[] {
|
|
934
|
+
const value = record[key]
|
|
935
|
+
return Array.isArray(value) ? value : []
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function stringField(record: Record<string, unknown>, key: string): string | null {
|
|
939
|
+
const value = record[key]
|
|
940
|
+
return typeof value === 'string' && value.length > 0 ? value : null
|
|
941
|
+
}
|
|
942
|
+
|
|
900
943
|
export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBotAdapter {
|
|
901
944
|
const logger = options.logger ?? consoleLogger
|
|
902
945
|
const client = new SlackBotClient()
|
|
946
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
903
947
|
let listener: SlackBotListener | null = null
|
|
904
948
|
let botUserId: string | null = null
|
|
905
949
|
let teamId: string | null = null
|
|
@@ -1053,7 +1097,22 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
1053
1097
|
}
|
|
1054
1098
|
|
|
1055
1099
|
dedupe.mark(event)
|
|
1056
|
-
const
|
|
1100
|
+
const slackAttachments = Array.isArray(event.attachments) ? event.attachments : undefined
|
|
1101
|
+
const referenceResult = await enrichSlackReferenceContext({
|
|
1102
|
+
text: verdict.payload.text,
|
|
1103
|
+
channelId: event.channel,
|
|
1104
|
+
...(event.thread_ts !== undefined ? { threadTs: event.thread_ts } : {}),
|
|
1105
|
+
messageTs: event.ts,
|
|
1106
|
+
...(slackAttachments !== undefined ? { attachments: slackAttachments } : {}),
|
|
1107
|
+
fetchMessage: createSlackReferenceFetch({ token: options.token, fetchImpl }),
|
|
1108
|
+
})
|
|
1109
|
+
const enriched = {
|
|
1110
|
+
...verdict.payload,
|
|
1111
|
+
authorName: resolvedUserName,
|
|
1112
|
+
...(referenceResult.referenceContext !== undefined
|
|
1113
|
+
? { referenceContext: referenceResult.referenceContext }
|
|
1114
|
+
: {}),
|
|
1115
|
+
}
|
|
1057
1116
|
const routedTag = await formatChannelTag(enriched.workspace, enriched.chat)
|
|
1058
1117
|
logger.info(
|
|
1059
1118
|
`[slack-bot] routed ts=${event.ts} ${routedTag} mention=${enriched.isBotMention} reply=${enriched.replyToBotMessageId !== null}`,
|
package/src/channels/manager.ts
CHANGED
|
@@ -13,7 +13,13 @@ import { createKakaotalkAdapter, type KakaotalkAdapter } from './adapters/kakaot
|
|
|
13
13
|
import { createSlackBotAdapter, type SlackBotAdapter } from './adapters/slack-bot'
|
|
14
14
|
import { createTelegramBotAdapter, type TelegramBotAdapter } from './adapters/telegram-bot'
|
|
15
15
|
import type { GithubTokenBridge } from './github-token-bridge'
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createChannelRouter,
|
|
18
|
+
type ChannelRouter,
|
|
19
|
+
type ClaimHandler,
|
|
20
|
+
type CreateSessionForChannel,
|
|
21
|
+
type RestartCommandContext,
|
|
22
|
+
} from './router'
|
|
17
23
|
import {
|
|
18
24
|
ADAPTER_IDS,
|
|
19
25
|
type AdapterId,
|
|
@@ -94,7 +100,7 @@ export type ChannelManagerOptions = {
|
|
|
94
100
|
// container-restart bindings; tests omit them so the commands stay
|
|
95
101
|
// unregistered. See CreateChannelRouterOptions.onReload/onRestart.
|
|
96
102
|
onReload?: () => Promise<string>
|
|
97
|
-
onRestart?: () => Promise<string>
|
|
103
|
+
onRestart?: (ctx?: RestartCommandContext) => Promise<string>
|
|
98
104
|
}
|
|
99
105
|
|
|
100
106
|
export type ChannelManager = {
|