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.
Files changed (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. 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
+ }