typeclaw 0.1.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/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- package/typeclaw.schema.json +826 -0
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
import { SlackBotClient, SlackBotListener } from 'agent-messenger/slackbot'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
MEMBERSHIP_ENUMERATION_CAP,
|
|
5
|
+
type MembershipResolver,
|
|
6
|
+
type MembershipResolverFailure,
|
|
7
|
+
type MembershipResolverResult,
|
|
8
|
+
} from '@/channels/membership'
|
|
9
|
+
import { deriveMembershipFromHistory } from '@/channels/membership-from-history'
|
|
10
|
+
import type { ChannelRouter } from '@/channels/router'
|
|
11
|
+
import { isAllowed, type ChannelAdapterConfig } from '@/channels/schema'
|
|
12
|
+
import type {
|
|
13
|
+
ChannelHistoryMessage,
|
|
14
|
+
FetchAttachmentCallback,
|
|
15
|
+
FetchHistoryArgs,
|
|
16
|
+
FetchHistoryResult,
|
|
17
|
+
HistoryCallback,
|
|
18
|
+
OutboundCallback,
|
|
19
|
+
OutboundMessage,
|
|
20
|
+
ResolvedChannelNames,
|
|
21
|
+
SendResult,
|
|
22
|
+
TypingCallback,
|
|
23
|
+
TypingTarget,
|
|
24
|
+
} from '@/channels/types'
|
|
25
|
+
import { chunkMarkdown } from '@/markdown'
|
|
26
|
+
|
|
27
|
+
import { createSlackAuthorResolver } from './slack-bot-author-resolver'
|
|
28
|
+
import { createSlackChannelResolver } from './slack-bot-channel-resolver'
|
|
29
|
+
import {
|
|
30
|
+
classifyInbound,
|
|
31
|
+
type InboundDropReason,
|
|
32
|
+
type SlackInboundAppMentionEvent,
|
|
33
|
+
type SlackInboundMessageEvent,
|
|
34
|
+
} from './slack-bot-classify'
|
|
35
|
+
import { createSlackDedupe } from './slack-bot-dedupe'
|
|
36
|
+
import { slackTsToMillis } from './slack-bot-time'
|
|
37
|
+
|
|
38
|
+
// Resolvers fall back to the raw id on failure, so a name equal to the id
|
|
39
|
+
// means resolution failed; we render the bare id rather than `id(id)`. The
|
|
40
|
+
// prefix is intentionally only applied to the named form so we never log
|
|
41
|
+
// `#C0DEPLOY` when resolution fails.
|
|
42
|
+
function formatLabel(name: string | undefined, id: string, prefix = ''): string {
|
|
43
|
+
if (name === undefined || name === '' || name === id) return id
|
|
44
|
+
return `${prefix}${name}(${id})`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// app_mention payloads omit channel_type and never carry a subtype, so we
|
|
48
|
+
// promote them to a message-shaped event for the shared classifier. The
|
|
49
|
+
// promoted event is classified as a regular channel message; the
|
|
50
|
+
// `<@BOT_USER_ID>` substring inside `text` is what makes the classifier
|
|
51
|
+
// mark it as a mention.
|
|
52
|
+
export function promoteAppMentionToMessage(event: SlackInboundAppMentionEvent): SlackInboundMessageEvent {
|
|
53
|
+
return {
|
|
54
|
+
type: 'message',
|
|
55
|
+
channel: event.channel,
|
|
56
|
+
channel_type: 'channel',
|
|
57
|
+
user: event.user,
|
|
58
|
+
text: event.text,
|
|
59
|
+
ts: event.ts,
|
|
60
|
+
...(event.thread_ts !== undefined ? { thread_ts: event.thread_ts } : {}),
|
|
61
|
+
...(event.event_ts !== undefined ? { event_ts: event.event_ts } : {}),
|
|
62
|
+
...(event.client_msg_id !== undefined ? { client_msg_id: event.client_msg_id } : {}),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type SlackBotAdapterLogger = {
|
|
67
|
+
info: (msg: string) => void
|
|
68
|
+
warn: (msg: string) => void
|
|
69
|
+
error: (msg: string) => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const consoleLogger: SlackBotAdapterLogger = {
|
|
73
|
+
info: (m) => console.log(m),
|
|
74
|
+
warn: (m) => console.warn(m),
|
|
75
|
+
error: (m) => console.error(m),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type SlackBotAdapterOptions = {
|
|
79
|
+
router: ChannelRouter
|
|
80
|
+
configRef: () => ChannelAdapterConfig
|
|
81
|
+
token: string
|
|
82
|
+
appToken: string
|
|
83
|
+
logger?: SlackBotAdapterLogger
|
|
84
|
+
// Read live so an `applied`-class reload of `alias` flows through to
|
|
85
|
+
// thread anchoring without restart. Optional: omitted means the
|
|
86
|
+
// classifier behaves as before (no alias-driven thread anchoring), so
|
|
87
|
+
// tests and ad-hoc adapter constructions stay backwards-compatible.
|
|
88
|
+
selfAliasesRef?: () => readonly string[]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type SlackBotAdapter = {
|
|
92
|
+
start: () => Promise<void>
|
|
93
|
+
stop: () => Promise<void>
|
|
94
|
+
isConnected: () => boolean
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Slack's only bot-accessible typing-style signal is `assistant.threads.
|
|
98
|
+
// setStatus`, which is scoped to AI Assistant threads and requires a
|
|
99
|
+
// `thread_ts`. The classic `user_typing` is RTM-only and rejects bot
|
|
100
|
+
// tokens, so there is nothing to send for top-level (non-threaded) chats —
|
|
101
|
+
// we log and bail in that case. Slack auto-clears the status when the bot
|
|
102
|
+
// posts its reply (per the assistant.threads.setStatus docs), but the
|
|
103
|
+
// router heartbeat (~every 8s) and the outbound postMessage can race: an
|
|
104
|
+
// in-flight setStatus("is typing...") that lands AFTER postMessage will
|
|
105
|
+
// re-set the indicator, and Slack's server-side timeout won't clear it
|
|
106
|
+
// for ~2 minutes. The fix is per-thread serialization (see
|
|
107
|
+
// `createSlackTypingTracker`) plus an explicit empty-string setStatus
|
|
108
|
+
// queued by the outbound callback after every successful send.
|
|
109
|
+
//
|
|
110
|
+
// Slack rejects calls in non-Assistant channels with `channel_not_found` /
|
|
111
|
+
// `not_in_channel`-style errors; we surface those as a single warn line
|
|
112
|
+
// per heartbeat (matching the Discord adapter's non-2xx handling) rather
|
|
113
|
+
// than escalating to error, because the bot may simply be deployed in a
|
|
114
|
+
// regular channel.
|
|
115
|
+
export type SlackTypingTracker = {
|
|
116
|
+
setStatus: (chat: string, threadTs: string, status: string) => Promise<void>
|
|
117
|
+
clearAfterSend: (chat: string, threadTs: string | null | undefined) => Promise<void>
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createSlackTypingTracker(deps: {
|
|
121
|
+
client: Pick<SlackBotClient, 'setAssistantStatus'>
|
|
122
|
+
logger: SlackBotAdapterLogger
|
|
123
|
+
}): SlackTypingTracker {
|
|
124
|
+
const { client, logger } = deps
|
|
125
|
+
const queues = new Map<string, Promise<void>>()
|
|
126
|
+
// Monotonic per-tracker counter so the three lifecycle log lines for one
|
|
127
|
+
// call (queued → sent → ok) can be correlated by id even when many calls
|
|
128
|
+
// for the same (chat, thread) interleave on the wire.
|
|
129
|
+
let nextCallId = 0
|
|
130
|
+
|
|
131
|
+
const enqueue = (chat: string, threadTs: string, status: string): Promise<void> => {
|
|
132
|
+
const key = `${chat}\x00${threadTs}`
|
|
133
|
+
const callId = nextCallId++
|
|
134
|
+
// queue depth BEFORE this call is added — tells us whether the FIFO is
|
|
135
|
+
// back-pressuring (depth>0) or this call gets to fly straight to Slack.
|
|
136
|
+
const queueDepthBefore = queues.has(key) ? 1 : 0
|
|
137
|
+
logger.info(
|
|
138
|
+
`[slack-bot] typing call=${callId} chat=${chat} thread=${threadTs} status="${status}" queued (depth=${queueDepthBefore})`,
|
|
139
|
+
)
|
|
140
|
+
const prev = queues.get(key) ?? Promise.resolve()
|
|
141
|
+
const next = prev
|
|
142
|
+
.catch(() => {})
|
|
143
|
+
.then(() => {
|
|
144
|
+
logger.info(`[slack-bot] typing call=${callId} sending`)
|
|
145
|
+
return client.setAssistantStatus(chat, threadTs, status)
|
|
146
|
+
})
|
|
147
|
+
.then(() => {
|
|
148
|
+
logger.info(`[slack-bot] typing call=${callId} ok`)
|
|
149
|
+
})
|
|
150
|
+
.catch((err: unknown) => {
|
|
151
|
+
logger.warn(
|
|
152
|
+
`[slack-bot] typing call=${callId} chat=${chat} thread=${threadTs} status="${status}" failed: ${describe(err)}`,
|
|
153
|
+
)
|
|
154
|
+
})
|
|
155
|
+
queues.set(key, next)
|
|
156
|
+
void next.finally(() => {
|
|
157
|
+
if (queues.get(key) === next) queues.delete(key)
|
|
158
|
+
})
|
|
159
|
+
return next
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
setStatus: (chat, threadTs, status) => enqueue(chat, threadTs, status),
|
|
164
|
+
clearAfterSend: async (chat, threadTs) => {
|
|
165
|
+
if (threadTs === null || threadTs === undefined || threadTs === '') return
|
|
166
|
+
await enqueue(chat, threadTs, '')
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createTypingCallback(deps: {
|
|
172
|
+
typingTracker: Pick<SlackTypingTracker, 'setStatus' | 'clearAfterSend'>
|
|
173
|
+
configRef: () => ChannelAdapterConfig
|
|
174
|
+
logger: SlackBotAdapterLogger
|
|
175
|
+
formatChannelTag?: (workspace: string, chat: string) => Promise<string>
|
|
176
|
+
}): TypingCallback {
|
|
177
|
+
const { typingTracker, configRef, logger, formatChannelTag } = deps
|
|
178
|
+
return async (target: TypingTarget): Promise<void> => {
|
|
179
|
+
if (target.adapter !== 'slack-bot') return
|
|
180
|
+
const config = configRef()
|
|
181
|
+
if (!isAllowed(config.allow, target.workspace, target.chat)) return
|
|
182
|
+
const tag = formatChannelTag
|
|
183
|
+
? await formatChannelTag(target.workspace, target.thread ?? target.chat)
|
|
184
|
+
: `channel=${target.thread ?? target.chat}`
|
|
185
|
+
if (target.thread === undefined || target.thread === null || target.thread === '') {
|
|
186
|
+
if (target.phase === 'tick') logger.info(`[slack-bot] typing (no-op, top-level chat) ${tag}`)
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
if (target.phase === 'stop') {
|
|
190
|
+
await typingTracker.clearAfterSend(target.chat, target.thread)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
await typingTracker.setStatus(target.chat, target.thread, 'is typing...')
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const SLACK_HISTORY_LIMIT_MAX = 200
|
|
198
|
+
|
|
199
|
+
const SLACK_API_BASE = 'https://slack.com/api'
|
|
200
|
+
|
|
201
|
+
type SlackRawHistoryMessage = {
|
|
202
|
+
ts: string
|
|
203
|
+
type?: string
|
|
204
|
+
subtype?: string
|
|
205
|
+
user?: string
|
|
206
|
+
bot_id?: string
|
|
207
|
+
text?: string
|
|
208
|
+
thread_ts?: string
|
|
209
|
+
parent_user_id?: string
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
type SlackHistoryResponse = {
|
|
213
|
+
ok: boolean
|
|
214
|
+
error?: string
|
|
215
|
+
messages?: SlackRawHistoryMessage[]
|
|
216
|
+
response_metadata?: { next_cursor?: string }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
type SlackConversationInfoResponse = {
|
|
220
|
+
ok: boolean
|
|
221
|
+
error?: string
|
|
222
|
+
channel?: { num_members?: number }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
type SlackConversationMembersResponse = {
|
|
226
|
+
ok: boolean
|
|
227
|
+
error?: string
|
|
228
|
+
members?: string[]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
type SlackUserInfoResponse = {
|
|
232
|
+
ok: boolean
|
|
233
|
+
error?: string
|
|
234
|
+
user?: { is_bot?: boolean; deleted?: boolean }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function createSlackMembershipResolver(deps: {
|
|
238
|
+
token: string
|
|
239
|
+
logger: SlackBotAdapterLogger
|
|
240
|
+
historyCallback: HistoryCallback
|
|
241
|
+
fetchImpl?: typeof fetch
|
|
242
|
+
now?: () => number
|
|
243
|
+
}): MembershipResolver {
|
|
244
|
+
const fetchFn = deps.fetchImpl ?? fetch
|
|
245
|
+
const now = deps.now ?? Date.now
|
|
246
|
+
const userBotCache = new Map<string, boolean>()
|
|
247
|
+
return async (key): Promise<MembershipResolverResult> => {
|
|
248
|
+
if (key.workspace === '@dm') return { humans: 1, bots: 1, fetchedAt: now(), truncated: false }
|
|
249
|
+
|
|
250
|
+
const fallback = (): Promise<MembershipResolverResult> =>
|
|
251
|
+
deriveMembershipFromHistory({
|
|
252
|
+
fetchHistory: (limit) => deps.historyCallback({ chat: key.chat, thread: key.thread, limit }),
|
|
253
|
+
now,
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
const info = await slackApi<SlackConversationInfoResponse>(fetchFn, deps.token, 'conversations.info', {
|
|
257
|
+
channel: key.chat,
|
|
258
|
+
})
|
|
259
|
+
if (!info.ok) {
|
|
260
|
+
// missing_scope / not_in_channel: the bot cannot see the channel's
|
|
261
|
+
// member list at all, but `conversations.history` (or app_mention
|
|
262
|
+
// delivery) usually still works enough to derive recent speakers.
|
|
263
|
+
// Treat any permanent failure here as a signal to fall back rather
|
|
264
|
+
// than propagate "I don't know" upstream — same shape as Discord's
|
|
265
|
+
// 403 path.
|
|
266
|
+
if (info.failure.kind === 'permanent') {
|
|
267
|
+
deps.logger.warn(
|
|
268
|
+
`[slack-bot] membership info channel=${key.chat} failed permanently: ${info.reason}; deriving from recent message authors`,
|
|
269
|
+
)
|
|
270
|
+
return await fallback()
|
|
271
|
+
}
|
|
272
|
+
deps.logger.warn(`[slack-bot] membership info channel=${key.chat} failed: ${info.reason}`)
|
|
273
|
+
return info.failure
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const total = Math.max(0, Math.floor(info.value.channel?.num_members ?? 0))
|
|
277
|
+
if (total > MEMBERSHIP_ENUMERATION_CAP) {
|
|
278
|
+
// Beyond the enumeration cap, the recent-speakers count is more
|
|
279
|
+
// useful for engagement than a raw channel-wide approximation that
|
|
280
|
+
// double-counts lurkers.
|
|
281
|
+
return await fallback()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const members = await slackApi<SlackConversationMembersResponse>(fetchFn, deps.token, 'conversations.members', {
|
|
285
|
+
channel: key.chat,
|
|
286
|
+
limit: String(MEMBERSHIP_ENUMERATION_CAP),
|
|
287
|
+
})
|
|
288
|
+
if (!members.ok) {
|
|
289
|
+
if (members.failure.kind === 'permanent') {
|
|
290
|
+
deps.logger.warn(
|
|
291
|
+
`[slack-bot] membership members channel=${key.chat} failed permanently: ${members.reason}; deriving from recent message authors`,
|
|
292
|
+
)
|
|
293
|
+
return await fallback()
|
|
294
|
+
}
|
|
295
|
+
deps.logger.warn(`[slack-bot] membership members channel=${key.chat} failed: ${members.reason}`)
|
|
296
|
+
return members.failure
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let bots = 0
|
|
300
|
+
let humans = 0
|
|
301
|
+
for (const userId of members.value.members ?? []) {
|
|
302
|
+
const cached = userBotCache.get(userId)
|
|
303
|
+
const isBot = cached ?? (await resolveSlackUserIsBot(fetchFn, deps.token, userId, deps.logger, userBotCache))
|
|
304
|
+
if (isBot) bots++
|
|
305
|
+
else humans++
|
|
306
|
+
}
|
|
307
|
+
return { humans, bots, fetchedAt: now(), truncated: false }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
type SlackApiResult<T> = { ok: true; value: T } | { ok: false; reason: string; failure: MembershipResolverFailure }
|
|
312
|
+
|
|
313
|
+
async function slackApi<T>(
|
|
314
|
+
fetchFn: typeof fetch,
|
|
315
|
+
token: string,
|
|
316
|
+
method: string,
|
|
317
|
+
fields: Record<string, string>,
|
|
318
|
+
): Promise<SlackApiResult<T>> {
|
|
319
|
+
const body = new URLSearchParams(fields)
|
|
320
|
+
let raw: { ok?: boolean; error?: string }
|
|
321
|
+
try {
|
|
322
|
+
const response = await fetchFn(`${SLACK_API_BASE}/${method}`, {
|
|
323
|
+
method: 'POST',
|
|
324
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' },
|
|
325
|
+
body: body.toString(),
|
|
326
|
+
})
|
|
327
|
+
raw = (await response.json()) as { ok?: boolean; error?: string }
|
|
328
|
+
} catch (err) {
|
|
329
|
+
return { ok: false, reason: describe(err), failure: { kind: 'transient' } }
|
|
330
|
+
}
|
|
331
|
+
if (raw.ok !== true) {
|
|
332
|
+
const reason = raw.error ?? 'unknown slack error'
|
|
333
|
+
return { ok: false, reason, failure: slackFailureForError(reason) }
|
|
334
|
+
}
|
|
335
|
+
return { ok: true, value: raw as T }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function resolveSlackUserIsBot(
|
|
339
|
+
fetchFn: typeof fetch,
|
|
340
|
+
token: string,
|
|
341
|
+
userId: string,
|
|
342
|
+
logger: SlackBotAdapterLogger,
|
|
343
|
+
cache: Map<string, boolean>,
|
|
344
|
+
): Promise<boolean> {
|
|
345
|
+
const info = await slackApi<SlackUserInfoResponse>(fetchFn, token, 'users.info', { user: userId })
|
|
346
|
+
if (!info.ok) {
|
|
347
|
+
logger.warn(`[slack-bot] membership users.info user=${userId} failed: ${info.reason}`)
|
|
348
|
+
cache.set(userId, false)
|
|
349
|
+
return false
|
|
350
|
+
}
|
|
351
|
+
const isBot = info.value.user?.is_bot === true
|
|
352
|
+
cache.set(userId, isBot)
|
|
353
|
+
return isBot
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function slackFailureForError(error: string): MembershipResolverFailure {
|
|
357
|
+
if (['invalid_auth', 'not_authed', 'not_in_channel', 'channel_not_found', 'missing_scope'].includes(error)) {
|
|
358
|
+
return { kind: 'permanent' }
|
|
359
|
+
}
|
|
360
|
+
return { kind: 'transient' }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Direct fetch to Slack's Web API. agent-messenger's SlackBotClient
|
|
364
|
+
// covers postMessage / setAssistantStatus / testAuth / uploadFile /
|
|
365
|
+
// downloadFile but not conversations.history or conversations.replies,
|
|
366
|
+
// so history calls go through fetch using the same pattern the Discord
|
|
367
|
+
// adapter uses for /typing. Slack uses application/x-www-form-urlencoded
|
|
368
|
+
// for these endpoints; JSON works too when paired with the right
|
|
369
|
+
// Content-Type but URL-encoded is what every client library defaults to
|
|
370
|
+
// and is the most-tested wire format.
|
|
371
|
+
export function createSlackHistoryCallback(deps: {
|
|
372
|
+
token: string
|
|
373
|
+
configRef: () => ChannelAdapterConfig
|
|
374
|
+
logger: SlackBotAdapterLogger
|
|
375
|
+
botUserIdRef: () => string | null
|
|
376
|
+
fetchImpl?: typeof fetch
|
|
377
|
+
}): HistoryCallback {
|
|
378
|
+
const { token, configRef, logger, botUserIdRef } = deps
|
|
379
|
+
const fetchFn = deps.fetchImpl ?? fetch
|
|
380
|
+
return async (args: FetchHistoryArgs): Promise<FetchHistoryResult> => {
|
|
381
|
+
const config = configRef()
|
|
382
|
+
if (!isAllowed(config.allow, '@dm', args.chat) && !isAllowedAnyTeam(config.allow, args.chat)) {
|
|
383
|
+
// Same defense-in-depth as outbound: refuse to fetch history for a
|
|
384
|
+
// channel the operator hasn't admitted, even if the agent somehow
|
|
385
|
+
// resolved its id. Returning an error rather than empty so the
|
|
386
|
+
// agent doesn't think the channel is genuinely silent.
|
|
387
|
+
return { ok: false, error: 'denied by allow rules' }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const limit = clampLimit(args.limit, SLACK_HISTORY_LIMIT_MAX)
|
|
391
|
+
const endpoint = args.thread === null ? 'conversations.history' : 'conversations.replies'
|
|
392
|
+
const body = new URLSearchParams()
|
|
393
|
+
body.set('channel', args.chat)
|
|
394
|
+
body.set('limit', String(limit))
|
|
395
|
+
if (args.thread !== null) body.set('ts', args.thread)
|
|
396
|
+
if (args.cursor !== undefined && args.cursor !== '') body.set('cursor', args.cursor)
|
|
397
|
+
|
|
398
|
+
let raw: SlackHistoryResponse
|
|
399
|
+
try {
|
|
400
|
+
const response = await fetchFn(`${SLACK_API_BASE}/${endpoint}`, {
|
|
401
|
+
method: 'POST',
|
|
402
|
+
headers: {
|
|
403
|
+
Authorization: `Bearer ${token}`,
|
|
404
|
+
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
|
|
405
|
+
},
|
|
406
|
+
body: body.toString(),
|
|
407
|
+
})
|
|
408
|
+
raw = (await response.json()) as SlackHistoryResponse
|
|
409
|
+
} catch (err) {
|
|
410
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
411
|
+
logger.warn(`[slack-bot] history fetch failed: ${message}`)
|
|
412
|
+
return { ok: false, error: message }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (!raw.ok) {
|
|
416
|
+
return { ok: false, error: raw.error ?? 'unknown slack error' }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const botUserId = botUserIdRef()
|
|
420
|
+
const rawMessages = raw.messages ?? []
|
|
421
|
+
const mapped = rawMessages.map((m) => mapSlackMessage(m, botUserId))
|
|
422
|
+
// Slack's `conversations.history` returns newest-first; `replies`
|
|
423
|
+
// returns oldest-first. Normalize to oldest-first so the agent always
|
|
424
|
+
// reads chronological order regardless of scope.
|
|
425
|
+
if (args.thread === null) mapped.reverse()
|
|
426
|
+
|
|
427
|
+
const nextCursor = raw.response_metadata?.next_cursor
|
|
428
|
+
if (nextCursor !== undefined && nextCursor !== '') {
|
|
429
|
+
return { ok: true, messages: mapped, nextCursor }
|
|
430
|
+
}
|
|
431
|
+
return { ok: true, messages: mapped }
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function mapSlackMessage(msg: SlackRawHistoryMessage, botUserId: string | null): ChannelHistoryMessage {
|
|
436
|
+
const isBot =
|
|
437
|
+
msg.subtype === 'bot_message' ||
|
|
438
|
+
(msg.user !== undefined && botUserId !== null && msg.user === botUserId) ||
|
|
439
|
+
(msg.bot_id !== undefined && (msg.user === undefined || msg.user === ''))
|
|
440
|
+
// Slack's parent_user_id is set on thread replies and points at the
|
|
441
|
+
// author of the parent message. When that parent author is our bot, we
|
|
442
|
+
// expose this as `replyToBotMessageId = thread_ts` so the agent can
|
|
443
|
+
// recognize threads it started — same convention as the inbound
|
|
444
|
+
// classifier uses for live messages.
|
|
445
|
+
const replyToBotMessageId =
|
|
446
|
+
msg.thread_ts !== undefined &&
|
|
447
|
+
msg.parent_user_id !== undefined &&
|
|
448
|
+
botUserId !== null &&
|
|
449
|
+
msg.parent_user_id === botUserId
|
|
450
|
+
? msg.thread_ts
|
|
451
|
+
: null
|
|
452
|
+
return {
|
|
453
|
+
externalMessageId: msg.ts,
|
|
454
|
+
authorId: msg.user ?? msg.bot_id ?? 'unknown',
|
|
455
|
+
authorName: msg.user ?? msg.bot_id ?? 'unknown',
|
|
456
|
+
text: msg.text ?? '',
|
|
457
|
+
ts: slackTsToMillis(msg.ts),
|
|
458
|
+
isBot,
|
|
459
|
+
replyToBotMessageId,
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function clampLimit(requested: number, max: number): number {
|
|
464
|
+
if (!Number.isFinite(requested) || requested <= 0) return max
|
|
465
|
+
return Math.min(Math.floor(requested), max)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Slack channel ids are globally unique on Slack's side, so a `channel:C…`
|
|
469
|
+
// or `team:T/C` rule for any team admits this chat. We use this for the
|
|
470
|
+
// history allow check because at fetch time we only know the channel id,
|
|
471
|
+
// not the workspace (the tool resolves the chat from session origin and
|
|
472
|
+
// the workspace doesn't always round-trip through cursor pagination).
|
|
473
|
+
function isAllowedAnyTeam(rules: readonly string[], chat: string): boolean {
|
|
474
|
+
for (const rule of rules) {
|
|
475
|
+
if (rule === '*') return true
|
|
476
|
+
if (rule === 'team:*' || rule === 'guild:*') return true
|
|
477
|
+
if (rule.startsWith('channel:') && rule.slice(8) === chat) return true
|
|
478
|
+
if (rule.startsWith('team:')) {
|
|
479
|
+
const body = rule.slice(5)
|
|
480
|
+
const slash = body.indexOf('/')
|
|
481
|
+
if (slash !== -1 && body.slice(slash + 1) === chat) return true
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return false
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Slack supports text+file in a single API call via `initial_comment`, and
|
|
488
|
+
// honors `thread_ts` on every upload — both luxuries Discord lacks. So we
|
|
489
|
+
// fold `text` into the FIRST attachment's `initial_comment` rather than
|
|
490
|
+
// posting it separately, which preserves the "single message" appearance
|
|
491
|
+
// in the Slack UI (one notification, one anchored thread reply, one event
|
|
492
|
+
// in the bot's own channel history).
|
|
493
|
+
//
|
|
494
|
+
// Multi-attachment behavior: each attachment is uploaded sequentially. The
|
|
495
|
+
// first carries the comment; the rest are uploaded bare. Sequential not
|
|
496
|
+
// parallel because (a) order matters for users' visual scan and (b) Slack
|
|
497
|
+
// rate-limits aggressive parallel uploads on the bot's behalf.
|
|
498
|
+
//
|
|
499
|
+
// Failure semantics mirror the Discord adapter: any upload failure aborts
|
|
500
|
+
// and returns ok:false. The text-only fallback (no attachments) keeps the
|
|
501
|
+
// original `postMessage` path so message routing and rate limits behave
|
|
502
|
+
// exactly as before for the common case.
|
|
503
|
+
async function readAttachmentBuffer(path: string): Promise<Buffer> {
|
|
504
|
+
const { readFile } = await import('node:fs/promises')
|
|
505
|
+
return await readFile(path)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Slack's `markdown` block (introduced March 2026) accepts standard
|
|
509
|
+
// GitHub-flavored Markdown and renders it correctly — the agent no longer
|
|
510
|
+
// needs to translate `**bold**` → `*bold*`, tables, headings, etc. by hand.
|
|
511
|
+
// We send every text-only message as a `markdown` block, with `text` set
|
|
512
|
+
// to the original GFM as the notification fallback (Slack truncates that
|
|
513
|
+
// for previews; raw GFM artifacts there are acceptable).
|
|
514
|
+
//
|
|
515
|
+
// The cumulative payload limit on `markdown` blocks is 12,000 characters.
|
|
516
|
+
// We allow 11,500 to leave headroom for the block envelope and split with
|
|
517
|
+
// `chunkMarkdown` so structural blocks (tables, code fences) survive the
|
|
518
|
+
// split intact. Multi-chunk messages thread under the first chunk: chunks
|
|
519
|
+
// 2..N reuse the first chunk's `ts` as `thread_ts` so a long reply
|
|
520
|
+
// surfaces as one threaded conversation in the Slack UI.
|
|
521
|
+
export const SLACK_MARKDOWN_BLOCK_LIMIT = 11_500
|
|
522
|
+
|
|
523
|
+
type MarkdownBlock = { type: 'markdown'; text: string }
|
|
524
|
+
|
|
525
|
+
function buildMarkdownBlock(text: string): MarkdownBlock {
|
|
526
|
+
return { type: 'markdown', text }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function createOutboundCallback(deps: {
|
|
530
|
+
client: Pick<SlackBotClient, 'postMessage' | 'uploadFile'>
|
|
531
|
+
configRef: () => ChannelAdapterConfig
|
|
532
|
+
logger: SlackBotAdapterLogger
|
|
533
|
+
formatChannelTag: (workspace: string, chat: string) => Promise<string>
|
|
534
|
+
readFile?: (path: string) => Promise<Buffer>
|
|
535
|
+
typingTracker?: Pick<SlackTypingTracker, 'clearAfterSend'>
|
|
536
|
+
}): OutboundCallback {
|
|
537
|
+
const { client, configRef, logger, formatChannelTag, typingTracker } = deps
|
|
538
|
+
const readFile = deps.readFile ?? readAttachmentBuffer
|
|
539
|
+
return async (msg: OutboundMessage): Promise<SendResult> => {
|
|
540
|
+
if (msg.adapter !== 'slack-bot') {
|
|
541
|
+
return { ok: false, error: `unknown adapter: ${msg.adapter}` }
|
|
542
|
+
}
|
|
543
|
+
const config = configRef()
|
|
544
|
+
if (!isAllowed(config.allow, msg.workspace, msg.chat)) {
|
|
545
|
+
logger.warn(`[slack-bot] outbound denied by allow rules: ${msg.workspace}/${msg.chat}`)
|
|
546
|
+
return { ok: false, error: 'denied by allow rules' }
|
|
547
|
+
}
|
|
548
|
+
const text = msg.text ?? ''
|
|
549
|
+
const attachments = msg.attachments ?? []
|
|
550
|
+
if (text === '' && attachments.length === 0) {
|
|
551
|
+
return { ok: false, error: 'message has neither text nor attachments' }
|
|
552
|
+
}
|
|
553
|
+
const tag = await formatChannelTag(msg.workspace, msg.chat)
|
|
554
|
+
logger.info(
|
|
555
|
+
`[slack-bot] outbound ${tag} text_len=${text.length} attachments=${attachments.length}${msg.thread ? ` thread=${msg.thread}` : ''}`,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
if (attachments.length === 0) {
|
|
559
|
+
const chunks = chunkMarkdown(text, SLACK_MARKDOWN_BLOCK_LIMIT)
|
|
560
|
+
const explicitThread = msg.thread !== undefined && msg.thread !== null ? msg.thread : null
|
|
561
|
+
let threadTs: string | null = explicitThread
|
|
562
|
+
try {
|
|
563
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
564
|
+
const chunk = chunks[i]!
|
|
565
|
+
const options: { thread_ts?: string; blocks?: unknown[] } = { blocks: [buildMarkdownBlock(chunk)] }
|
|
566
|
+
if (threadTs !== null) options.thread_ts = threadTs
|
|
567
|
+
const sent = await client.postMessage(msg.chat, chunk, options)
|
|
568
|
+
logger.info(
|
|
569
|
+
`[slack-bot] sent ts=${sent.ts} ${tag} chunk=${i + 1}/${chunks.length} blocks=markdown len=${chunk.length}`,
|
|
570
|
+
)
|
|
571
|
+
// Anchor follow-up chunks to the first message so a long reply
|
|
572
|
+
// surfaces as one threaded conversation rather than a stream of
|
|
573
|
+
// top-level posts.
|
|
574
|
+
if (threadTs === null && chunks.length > 1) threadTs = sent.ts
|
|
575
|
+
}
|
|
576
|
+
if (typingTracker) await typingTracker.clearAfterSend(msg.chat, msg.thread)
|
|
577
|
+
return { ok: true }
|
|
578
|
+
} catch (err) {
|
|
579
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
580
|
+
logger.error(`[slack-bot] postMessage failed: ${message}`)
|
|
581
|
+
return { ok: false, error: message }
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const threadTs = msg.thread !== undefined && msg.thread !== null ? msg.thread : undefined
|
|
586
|
+
for (const [index, attachment] of attachments.entries()) {
|
|
587
|
+
const filename = attachment.filename ?? attachment.path.split('/').pop() ?? 'file'
|
|
588
|
+
let buffer: Buffer
|
|
589
|
+
try {
|
|
590
|
+
buffer = await readFile(attachment.path)
|
|
591
|
+
} catch (err) {
|
|
592
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
593
|
+
logger.error(`[slack-bot] readFile failed for ${attachment.path}: ${message}`)
|
|
594
|
+
return { ok: false, error: `readFile failed: ${message}` }
|
|
595
|
+
}
|
|
596
|
+
const isFirst = index === 0
|
|
597
|
+
const uploadOptions: { thread_ts?: string; initial_comment?: string } = {}
|
|
598
|
+
if (threadTs !== undefined) uploadOptions.thread_ts = threadTs
|
|
599
|
+
if (isFirst && text !== '') uploadOptions.initial_comment = text
|
|
600
|
+
try {
|
|
601
|
+
const file = await client.uploadFile(msg.chat, buffer, filename, uploadOptions)
|
|
602
|
+
logger.info(`[slack-bot] uploaded id=${file.id} filename=${file.name} size=${file.size} ${tag}`)
|
|
603
|
+
} catch (err) {
|
|
604
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
605
|
+
logger.error(`[slack-bot] uploadFile failed for ${attachment.path}: ${message}`)
|
|
606
|
+
return { ok: false, error: `uploadFile failed: ${message}` }
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (typingTracker) await typingTracker.clearAfterSend(msg.chat, msg.thread)
|
|
610
|
+
return { ok: true }
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Slack file URLs (`url_private`) require Bearer auth and an html-page is
|
|
615
|
+
// returned for unauthenticated GETs, so the agent cannot fetch them via a
|
|
616
|
+
// plain HTTP tool. Routing through the SDK's `downloadFile(fileId)` is
|
|
617
|
+
// the only path that works — it issues `files.info` to fetch metadata
|
|
618
|
+
// (mimetype + name) then GETs `url_private` with the bot token. The
|
|
619
|
+
// classifier emits `id=Fxxxx` in the inbound text exactly so the agent
|
|
620
|
+
// can hand the id back to this callback.
|
|
621
|
+
export function createFetchAttachmentCallback(deps: {
|
|
622
|
+
client: Pick<SlackBotClient, 'downloadFile'>
|
|
623
|
+
logger: SlackBotAdapterLogger
|
|
624
|
+
}): FetchAttachmentCallback {
|
|
625
|
+
const { client, logger } = deps
|
|
626
|
+
return async ({ ref, filename }) => {
|
|
627
|
+
const fileId = ref.trim()
|
|
628
|
+
if (!/^F[A-Z0-9]+$/.test(fileId)) {
|
|
629
|
+
return { ok: false, error: `invalid Slack file id: ${ref}` }
|
|
630
|
+
}
|
|
631
|
+
try {
|
|
632
|
+
const { buffer, file } = await client.downloadFile(fileId)
|
|
633
|
+
logger.info(`[slack-bot] downloaded id=${file.id} name=${file.name} size=${file.size}`)
|
|
634
|
+
return {
|
|
635
|
+
ok: true,
|
|
636
|
+
buffer,
|
|
637
|
+
filename: filename ?? file.name,
|
|
638
|
+
mimetype: file.mimetype,
|
|
639
|
+
size: file.size,
|
|
640
|
+
}
|
|
641
|
+
} catch (err) {
|
|
642
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
643
|
+
logger.error(`[slack-bot] downloadFile failed for ${fileId}: ${message}`)
|
|
644
|
+
return { ok: false, error: message }
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBotAdapter {
|
|
650
|
+
const logger = options.logger ?? consoleLogger
|
|
651
|
+
const client = new SlackBotClient()
|
|
652
|
+
let listener: SlackBotListener | null = null
|
|
653
|
+
let botUserId: string | null = null
|
|
654
|
+
let teamId: string | null = null
|
|
655
|
+
let started = false
|
|
656
|
+
let inflightInbounds = 0
|
|
657
|
+
let stopWaiters: Array<() => void> = []
|
|
658
|
+
|
|
659
|
+
const authorResolver = createSlackAuthorResolver({ token: options.token })
|
|
660
|
+
const channelResolver = createSlackChannelResolver({ token: options.token })
|
|
661
|
+
|
|
662
|
+
const formatChannelTag = async (workspace: string, chat: string): Promise<string> => {
|
|
663
|
+
const names = await channelResolver({ adapter: 'slack-bot', workspace, chat, thread: null }).catch(
|
|
664
|
+
() => ({}) as ResolvedChannelNames,
|
|
665
|
+
)
|
|
666
|
+
const workspacePart = workspace === '@dm' ? 'dm' : `team=${formatLabel(names.workspaceName, workspace)}`
|
|
667
|
+
const chatPart = `channel=${formatLabel(names.chatName, chat, '#')}`
|
|
668
|
+
return `${workspacePart} ${chatPart}`
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const typingTracker = createSlackTypingTracker({ client, logger })
|
|
672
|
+
|
|
673
|
+
const typingCallback = createTypingCallback({
|
|
674
|
+
typingTracker,
|
|
675
|
+
configRef: options.configRef,
|
|
676
|
+
logger,
|
|
677
|
+
formatChannelTag,
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
const historyCallback = createSlackHistoryCallback({
|
|
681
|
+
token: options.token,
|
|
682
|
+
configRef: options.configRef,
|
|
683
|
+
logger,
|
|
684
|
+
botUserIdRef: () => botUserId,
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
const membershipResolver = createSlackMembershipResolver({
|
|
688
|
+
token: options.token,
|
|
689
|
+
logger,
|
|
690
|
+
historyCallback,
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
const outboundCallback = createOutboundCallback({
|
|
694
|
+
client,
|
|
695
|
+
configRef: options.configRef,
|
|
696
|
+
logger,
|
|
697
|
+
formatChannelTag,
|
|
698
|
+
typingTracker,
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
const fetchAttachmentCallback = createFetchAttachmentCallback({ client, logger })
|
|
702
|
+
|
|
703
|
+
const dedupe = createSlackDedupe()
|
|
704
|
+
|
|
705
|
+
const handleMessageEvent = async (
|
|
706
|
+
event: SlackInboundMessageEvent,
|
|
707
|
+
source: 'message' | 'app_mention',
|
|
708
|
+
): Promise<void> => {
|
|
709
|
+
inflightInbounds++
|
|
710
|
+
try {
|
|
711
|
+
const text = event.text ?? ''
|
|
712
|
+
const userId = event.user ?? 'unknown'
|
|
713
|
+
const inboundWorkspace = event.channel_type === 'im' ? '@dm' : (teamId ?? 'unknown')
|
|
714
|
+
const [resolvedUserName, inboundTag] = await Promise.all([
|
|
715
|
+
event.user !== undefined && event.user !== '' ? authorResolver.resolve(event.user) : Promise.resolve(userId),
|
|
716
|
+
formatChannelTag(inboundWorkspace, event.channel),
|
|
717
|
+
])
|
|
718
|
+
logger.info(
|
|
719
|
+
`[slack-bot] inbound source=${source} ts=${event.ts} user=${formatLabel(resolvedUserName, userId)} ${inboundTag} text_len=${text.length}`,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
if (teamId === null) {
|
|
723
|
+
logger.warn(`[slack-bot] dropped ts=${event.ts} reason=pre_connected (team_id unknown)`)
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const dedupeMatch = dedupe.check(event)
|
|
728
|
+
if (dedupeMatch !== null) {
|
|
729
|
+
logger.info(
|
|
730
|
+
`[slack-bot] dropped ts=${event.ts} reason=duplicate_delivery (source=${source}, matched=${dedupeMatch})`,
|
|
731
|
+
)
|
|
732
|
+
return
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const verdict = classifyInbound(event, options.configRef(), {
|
|
736
|
+
teamId,
|
|
737
|
+
botUserId,
|
|
738
|
+
...(options.selfAliasesRef ? { selfAliases: options.selfAliasesRef() } : {}),
|
|
739
|
+
})
|
|
740
|
+
if (verdict.kind === 'drop') {
|
|
741
|
+
logger.info(`[slack-bot] dropped ts=${event.ts} reason=${verdict.reason}${dropHint(verdict.reason)}`)
|
|
742
|
+
return
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
dedupe.mark(event)
|
|
746
|
+
const enriched = { ...verdict.payload, authorName: resolvedUserName }
|
|
747
|
+
const routedTag = await formatChannelTag(enriched.workspace, enriched.chat)
|
|
748
|
+
logger.info(
|
|
749
|
+
`[slack-bot] routed ts=${event.ts} ${routedTag} mention=${enriched.isBotMention} reply=${enriched.replyToBotMessageId !== null}`,
|
|
750
|
+
)
|
|
751
|
+
await options.router.route(enriched)
|
|
752
|
+
} catch (err) {
|
|
753
|
+
logger.error(`[slack-bot] handleInbound failed: ${describe(err)}`)
|
|
754
|
+
} finally {
|
|
755
|
+
inflightInbounds--
|
|
756
|
+
if (inflightInbounds === 0 && stopWaiters.length > 0) {
|
|
757
|
+
const waiters = stopWaiters
|
|
758
|
+
stopWaiters = []
|
|
759
|
+
for (const w of waiters) w()
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return {
|
|
765
|
+
async start(): Promise<void> {
|
|
766
|
+
if (started) return
|
|
767
|
+
started = true
|
|
768
|
+
try {
|
|
769
|
+
await client.login({ token: options.token })
|
|
770
|
+
} catch (err) {
|
|
771
|
+
started = false
|
|
772
|
+
logger.error(`[slack-bot] login failed: ${describe(err)}`)
|
|
773
|
+
throw err
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// auth.test resolves the bot's identity and team. We need both: teamId
|
|
777
|
+
// becomes the `workspace` field on every inbound, and botUserId is how
|
|
778
|
+
// we recognize self-authored messages and mentions. Failure here is
|
|
779
|
+
// fatal — without these we can't classify anything correctly.
|
|
780
|
+
try {
|
|
781
|
+
const auth = await client.testAuth()
|
|
782
|
+
botUserId = auth.user_id
|
|
783
|
+
teamId = auth.team_id
|
|
784
|
+
logger.info(`[slack-bot] authenticated as ${auth.user ?? auth.user_id} in team ${auth.team ?? auth.team_id}`)
|
|
785
|
+
} catch (err) {
|
|
786
|
+
started = false
|
|
787
|
+
logger.error(`[slack-bot] auth.test failed: ${describe(err)}`)
|
|
788
|
+
throw err
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
listener = new SlackBotListener(client, { appToken: options.appToken })
|
|
792
|
+
listener.on('connected', (info) => {
|
|
793
|
+
logger.info(`[slack-bot] connected (app_id=${info.app_id ?? 'unknown'})`)
|
|
794
|
+
})
|
|
795
|
+
listener.on('disconnected', () => {
|
|
796
|
+
logger.warn('[slack-bot] disconnected; SDK will reconnect with backoff')
|
|
797
|
+
})
|
|
798
|
+
listener.on('error', (err) => {
|
|
799
|
+
logger.error(`[slack-bot] socket-mode error: ${describe(err)}`)
|
|
800
|
+
})
|
|
801
|
+
listener.on('message', ({ ack, event }) => {
|
|
802
|
+
// Ack first so Slack stops retrying; failure to ack causes duplicate
|
|
803
|
+
// deliveries within seconds. Then process asynchronously.
|
|
804
|
+
ack()
|
|
805
|
+
// Cast at the SDK boundary: upstream types this event with a
|
|
806
|
+
// `[key: string]: unknown` catchall for fields it does not
|
|
807
|
+
// declare (parent_user_id, client_msg_id, files). The Slack
|
|
808
|
+
// wire format does carry them as typed strings/arrays — see
|
|
809
|
+
// SlackInboundMessageEvent's header comment in slack-bot-classify.
|
|
810
|
+
void handleMessageEvent(event as SlackInboundMessageEvent, 'message')
|
|
811
|
+
})
|
|
812
|
+
// app_mention is required for mentions in channels where the bot is
|
|
813
|
+
// NOT a member: in that case Slack does not fire a `message` event
|
|
814
|
+
// (it requires `*:history` scope + membership), only `app_mention`
|
|
815
|
+
// (which only requires `app_mentions:read`). The dedupe ring buffer
|
|
816
|
+
// collapses the in-channel double-delivery when both events fire.
|
|
817
|
+
listener.on('app_mention', ({ ack, event }) => {
|
|
818
|
+
ack()
|
|
819
|
+
void handleMessageEvent(promoteAppMentionToMessage(event as SlackInboundAppMentionEvent), 'app_mention')
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
options.router.registerOutbound('slack-bot', outboundCallback)
|
|
823
|
+
options.router.registerTyping('slack-bot', typingCallback)
|
|
824
|
+
options.router.registerChannelNameResolver('slack-bot', channelResolver)
|
|
825
|
+
options.router.registerHistory('slack-bot', historyCallback)
|
|
826
|
+
options.router.registerFetchAttachment('slack-bot', fetchAttachmentCallback)
|
|
827
|
+
options.router.registerMembership('slack-bot', membershipResolver)
|
|
828
|
+
|
|
829
|
+
try {
|
|
830
|
+
await listener.start()
|
|
831
|
+
} catch (err) {
|
|
832
|
+
started = false
|
|
833
|
+
logger.error(`[slack-bot] listener start failed: ${describe(err)}`)
|
|
834
|
+
throw err
|
|
835
|
+
}
|
|
836
|
+
},
|
|
837
|
+
|
|
838
|
+
async stop(): Promise<void> {
|
|
839
|
+
if (!started) return
|
|
840
|
+
started = false
|
|
841
|
+
options.router.unregisterOutbound('slack-bot', outboundCallback)
|
|
842
|
+
options.router.unregisterTyping('slack-bot', typingCallback)
|
|
843
|
+
options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
|
|
844
|
+
options.router.unregisterHistory('slack-bot', historyCallback)
|
|
845
|
+
options.router.unregisterFetchAttachment('slack-bot', fetchAttachmentCallback)
|
|
846
|
+
options.router.unregisterMembership('slack-bot', membershipResolver)
|
|
847
|
+
if (inflightInbounds > 0) {
|
|
848
|
+
await new Promise<void>((resolve) => {
|
|
849
|
+
stopWaiters.push(resolve)
|
|
850
|
+
})
|
|
851
|
+
}
|
|
852
|
+
listener?.stop()
|
|
853
|
+
listener = null
|
|
854
|
+
botUserId = null
|
|
855
|
+
teamId = null
|
|
856
|
+
},
|
|
857
|
+
|
|
858
|
+
isConnected(): boolean {
|
|
859
|
+
return botUserId !== null && teamId !== null
|
|
860
|
+
},
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function describe(err: unknown): string {
|
|
865
|
+
return err instanceof Error ? err.message : String(err)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Operator hints appended to drop logs. Kept short — full guidance lives in
|
|
869
|
+
// docs. The not_in_allow_list hint is the highest-leverage one because that
|
|
870
|
+
// failure mode is invisible from Slack's side (bot stays online).
|
|
871
|
+
function dropHint(reason: InboundDropReason): string {
|
|
872
|
+
switch (reason) {
|
|
873
|
+
case 'not_in_allow_list':
|
|
874
|
+
return ' (extend channels.slack-bot.allow in typeclaw.json to admit this team/channel)'
|
|
875
|
+
case 'empty_text':
|
|
876
|
+
case 'no_user':
|
|
877
|
+
case 'pre_connect':
|
|
878
|
+
case 'self_author':
|
|
879
|
+
return ''
|
|
880
|
+
}
|
|
881
|
+
}
|