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,84 @@
1
+ import type { ChannelKey, ChannelNameResolver, ResolvedChannelNames } from '@/channels/types'
2
+
3
+ const SLACK_API_BASE = 'https://slack.com/api'
4
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000
5
+
6
+ export type SlackChannelResolverOptions = {
7
+ token: string
8
+ now?: () => number
9
+ ttlMs?: number
10
+ }
11
+
12
+ type CacheEntry<T> = { value: T; expiresAt: number }
13
+
14
+ type SlackConversationsInfoResponse = {
15
+ ok: boolean
16
+ channel?: { id?: string; name?: string }
17
+ }
18
+
19
+ type SlackTeamInfoResponse = {
20
+ ok: boolean
21
+ team?: { id?: string; name?: string }
22
+ }
23
+
24
+ export function createSlackChannelResolver(options: SlackChannelResolverOptions): ChannelNameResolver {
25
+ const now = options.now ?? Date.now
26
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS
27
+
28
+ const chatCache = new Map<string, CacheEntry<string>>()
29
+ const teamCache = new Map<string, CacheEntry<string>>()
30
+
31
+ const fetchCached = async <T>(
32
+ cache: Map<string, CacheEntry<T>>,
33
+ key: string,
34
+ fetcher: () => Promise<T | null>,
35
+ ): Promise<T | null> => {
36
+ const cached = cache.get(key)
37
+ if (cached && cached.expiresAt > now()) return cached.value
38
+ const value = await fetcher()
39
+ if (value !== null) cache.set(key, { value, expiresAt: now() + ttlMs })
40
+ return value
41
+ }
42
+
43
+ return async (key: ChannelKey): Promise<ResolvedChannelNames> => {
44
+ if (key.workspace === '@dm') return {}
45
+
46
+ const [chatName, workspaceName] = await Promise.all([
47
+ fetchCached(chatCache, key.chat, () => fetchChannelName(key.chat, options.token)),
48
+ fetchCached(teamCache, key.workspace, () => fetchTeamName(key.workspace, options.token)),
49
+ ])
50
+
51
+ const result: ResolvedChannelNames = {}
52
+ if (chatName !== null) result.chatName = chatName
53
+ if (workspaceName !== null) result.workspaceName = workspaceName
54
+ return result
55
+ }
56
+ }
57
+
58
+ async function fetchChannelName(channelId: string, token: string): Promise<string | null> {
59
+ try {
60
+ const response = await fetch(`${SLACK_API_BASE}/conversations.info?channel=${encodeURIComponent(channelId)}`, {
61
+ headers: { Authorization: `Bearer ${token}` },
62
+ })
63
+ if (!response.ok) return null
64
+ const body = (await response.json()) as SlackConversationsInfoResponse
65
+ if (!body.ok || !body.channel?.name) return null
66
+ return body.channel.name
67
+ } catch {
68
+ return null
69
+ }
70
+ }
71
+
72
+ async function fetchTeamName(teamId: string, token: string): Promise<string | null> {
73
+ try {
74
+ const response = await fetch(`${SLACK_API_BASE}/team.info?team=${encodeURIComponent(teamId)}`, {
75
+ headers: { Authorization: `Bearer ${token}` },
76
+ })
77
+ if (!response.ok) return null
78
+ const body = (await response.json()) as SlackTeamInfoResponse
79
+ if (!body.ok || !body.team?.name) return null
80
+ return body.team.name
81
+ } catch {
82
+ return null
83
+ }
84
+ }
@@ -0,0 +1,213 @@
1
+ import type { SlackFile, SlackSocketModeAppMentionEvent, SlackSocketModeMessageEvent } from 'agent-messenger/slackbot'
2
+
3
+ import { matchesAnyAlias } from '@/channels/engagement'
4
+ import { isAllowed, type ChannelAdapterConfig } from '@/channels/schema'
5
+ import type { InboundMessage } from '@/channels/types'
6
+
7
+ import { slackTsToMillis } from './slack-bot-time'
8
+
9
+ // Upstream's `SlackSocketModeMessageEvent` carries `[key: string]: unknown`
10
+ // for fields it does not type explicitly. Three of those untyped fields are
11
+ // load-bearing for this adapter:
12
+ // - `parent_user_id`: set on every reply within a thread; identifies the
13
+ // author of the message the thread is rooted at. Used to decide whether
14
+ // a reply targets the bot, another human, or an unknown parent.
15
+ // - `client_msg_id`: client-generated UUID on user-authored messages,
16
+ // stable across Slack-side resends of the same gesture. Primary dedupe
17
+ // key for the "one user action surfaces as two events" case.
18
+ // - `files`: attachments delivered inline on the same message event (Slack
19
+ // does not fire a separate file_share for messages we receive).
20
+ // Typing them here (rather than reading them via `as` casts at every call
21
+ // site) keeps the classifier readable and makes it the single source of
22
+ // truth for "what Slack actually sends" — anything else reading these
23
+ // fields imports `SlackInboundMessageEvent` from this module.
24
+ export type SlackInboundMessageEvent = SlackSocketModeMessageEvent & {
25
+ parent_user_id?: string
26
+ client_msg_id?: string
27
+ files?: SlackFile[]
28
+ }
29
+
30
+ // `app_mention` envelopes do not always carry `client_msg_id`, but typing
31
+ // it keeps the promotion to a message-shaped event lossless if Slack
32
+ // starts sending it. Same reasoning as `SlackInboundMessageEvent` above.
33
+ export type SlackInboundAppMentionEvent = SlackSocketModeAppMentionEvent & {
34
+ client_msg_id?: string
35
+ }
36
+
37
+ export type InboundDropReason =
38
+ | 'self_author' // event.user === botUserId; we never route our own messages back to ourselves
39
+ | 'no_user' // event has no `user` field (e.g. system messages: channel_join, message_changed)
40
+ | 'empty_text' // event has neither text nor files — nothing for the agent to act on
41
+ | 'not_in_allow_list' // workspace/channel not admitted by typeclaw.json `channels.slack-bot.allow`
42
+ | 'pre_connect' // bot identity is not known yet, so mention/self/reply classification cannot be trusted
43
+
44
+ export type InboundClassification =
45
+ | { kind: 'drop'; reason: InboundDropReason }
46
+ | { kind: 'route'; payload: InboundMessage }
47
+
48
+ export type SlackInboundContext = {
49
+ teamId: string
50
+ botUserId: string | null
51
+ // Lowered self-aliases (`alias` from typeclaw.json plus the implicit
52
+ // basename(agentDir)). When a top-level message contains one of these
53
+ // but no `<@bot>` mention, the classifier still anchors `thread` on
54
+ // `event.ts` so the bot's reply lands in a thread under the user's
55
+ // message instead of as a fragmented top-level post. Optional for
56
+ // backward compatibility — omitted means "behave like before, no
57
+ // alias-driven thread anchoring". The router's `computeSelfAliases`
58
+ // is the source of truth; the adapter just forwards it.
59
+ selfAliases?: readonly string[]
60
+ }
61
+
62
+ // All decision logic for "should this Socket Mode message event be routed to
63
+ // the agent?" lives here so it can be unit-tested in isolation. The adapter
64
+ // is left as a thin shell that handles SDK lifecycle and translates this
65
+ // verdict into log lines + router calls. Adding a new drop reason MUST extend
66
+ // InboundDropReason — there is no `default` log path, so the type system
67
+ // forces logging to stay exhaustive.
68
+ export function classifyInbound(
69
+ event: SlackInboundMessageEvent,
70
+ config: ChannelAdapterConfig,
71
+ context: SlackInboundContext,
72
+ ): InboundClassification {
73
+ // Self-drop is the hard floor: never route our own messages back to
74
+ // ourselves. The check requires `botUserId` (post-auth.test); before that,
75
+ // fail closed below because mention, reply, and self classification all
76
+ // depend on the bot identity.
77
+ if (context.botUserId !== null && event.user === context.botUserId) {
78
+ return { kind: 'drop', reason: 'self_author' }
79
+ }
80
+ if (event.user === undefined || event.user === '') {
81
+ // System events (channel_join, message_changed, …) have no `user`;
82
+ // they were also the ONLY way previously to flag bot_message subtype
83
+ // events with no user. Now that we accept peer bots, a `bot_message`
84
+ // subtype WITH a user (rare, but happens for legacy integrations) is
85
+ // routed; subtype + no user still drops as `no_user`.
86
+ return { kind: 'drop', reason: 'no_user' }
87
+ }
88
+
89
+ const rawText = event.text ?? ''
90
+ const text = inboundText(event)
91
+ if (text === '') return { kind: 'drop', reason: 'empty_text' }
92
+
93
+ const isDm = event.channel_type === 'im'
94
+ const workspace = isDm ? '@dm' : context.teamId
95
+ if (!isAllowed(config.allow, workspace, event.channel)) {
96
+ return { kind: 'drop', reason: 'not_in_allow_list' }
97
+ }
98
+
99
+ if (context.botUserId === null) {
100
+ return { kind: 'drop', reason: 'pre_connect' }
101
+ }
102
+
103
+ // Mention parsing runs against the raw user-typed text only — the
104
+ // appended `[Slack message with attachment: ...]` summary contains URLs
105
+ // and ids that must not be misread as mentions or group broadcasts.
106
+ // Group mentions (`<!here>`, `<!channel>`, `<!everyone>`) are coerced to
107
+ // direct mentions: the user fired a broadcast that explicitly includes the
108
+ // bot, and from the engagement layer's perspective there is no meaningful
109
+ // difference between "@bot, look at this" and "@channel, look at this" —
110
+ // both are an invitation to participate. Treating them identically also
111
+ // means the existing 'mention' trigger in typeclaw.json catches both
112
+ // without any new config surface.
113
+ const hasGroupMention = GROUP_MENTION_PATTERN.test(rawText)
114
+ const isBotMention = hasGroupMention || rawText.includes(`<@${context.botUserId}>`)
115
+ // Top-level alias addressing (e.g. "윙키야") is engagement-equivalent
116
+ // to a `<@bot>` mention (see engagement.ts: alias is unconditional and
117
+ // ranks alongside explicit triggers). Anchor `thread` on the inbound
118
+ // ts in that case too, so the bot's reply threads under the user's
119
+ // message rather than landing as a sibling top-level post. Mention
120
+ // wins the OR short-circuit; alias matching only runs when no @ was
121
+ // found, keeping the cost negligible in mention-heavy channels.
122
+ const aliasMatched = !isBotMention && matchesAnyAlias(rawText, context.selfAliases ?? [])
123
+ const thread = event.thread_ts ?? (!isDm && (isBotMention || aliasMatched) ? event.ts : null)
124
+
125
+ // A reply is "to the bot" only when the thread parent was authored by the
126
+ // bot. Slack surfaces the parent author via `parent_user_id` on every
127
+ // reply event; without that match we don't know who authored the parent
128
+ // and MUST NOT engage on the `reply` trigger — otherwise every threaded
129
+ // reply between two humans (or two peer bots) wakes us up. The thread
130
+ // root itself shares its ts with thread_ts and carries no parent_user_id.
131
+ const isReply = event.thread_ts !== undefined && event.thread_ts !== event.ts
132
+ const replyToBotMessageId =
133
+ isReply && context.botUserId !== null && event.parent_user_id === context.botUserId ? event.thread_ts! : null
134
+
135
+ const mentionedUserIds = extractMentionedUserIds(rawText)
136
+ const mentionsOthers = mentionedUserIds.length > 0 && !mentionedUserIds.includes(context.botUserId)
137
+
138
+ // Symmetric to `replyToBotMessageId` above: a reply whose parent author
139
+ // is identifiable AND is not the bot is a reply-to-other. The engagement
140
+ // layer uses this to suppress the solo-human fallback so the bot stays
141
+ // quiet when two humans (or a peer bot and a human) hold a thread side-
142
+ // conversation in a busy channel — the exact incident this fix addresses.
143
+ const replyToOtherMessageId =
144
+ isReply &&
145
+ event.parent_user_id !== undefined &&
146
+ event.parent_user_id !== '' &&
147
+ event.parent_user_id !== context.botUserId
148
+ ? event.thread_ts!
149
+ : null
150
+
151
+ // Slack signals "this message was authored by a bot" via either a non-empty
152
+ // bot_id or subtype === 'bot_message'. Either is sufficient.
153
+ const authorIsBot = (event.bot_id !== undefined && event.bot_id !== '') || event.subtype === 'bot_message'
154
+
155
+ return {
156
+ kind: 'route',
157
+ payload: {
158
+ adapter: 'slack-bot',
159
+ workspace,
160
+ chat: event.channel,
161
+ thread,
162
+ text,
163
+ externalMessageId: event.ts,
164
+ authorId: event.user,
165
+ authorName: event.user,
166
+ authorIsBot,
167
+ isBotMention,
168
+ replyToBotMessageId,
169
+ mentionsOthers,
170
+ replyToOtherMessageId,
171
+ isDm,
172
+ ts: slackTsToMillis(event.ts),
173
+ },
174
+ }
175
+ }
176
+
177
+ // Slack encodes user mentions inline as `<@U…>` (or `<@W…>` for some org
178
+ // accounts, and `<@U…|fallback>` when the client supplied a label). Pull
179
+ // every distinct id out of the text — duplicates collapse so the caller
180
+ // can do a clean `includes()` check against the bot's own id.
181
+ const MENTION_PATTERN = /<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g
182
+
183
+ // Slack's group mention markup uses `!` (not `@`) and may carry an optional
184
+ // `|label` suffix, same as user mentions. We deliberately exclude the
185
+ // `<!subteam^ID>` form — engaging on every user-group ping would require
186
+ // knowing which subteams the bot is a member of, which is outside what
187
+ // Socket Mode events surface to us.
188
+ const GROUP_MENTION_PATTERN = /<!(?:here|channel|everyone)(?:\|[^>]*)?>/
189
+
190
+ function extractMentionedUserIds(text: string): string[] {
191
+ const seen = new Set<string>()
192
+ for (const match of text.matchAll(MENTION_PATTERN)) {
193
+ seen.add(match[1]!)
194
+ }
195
+ return Array.from(seen)
196
+ }
197
+
198
+ function inboundText(event: SlackInboundMessageEvent): string {
199
+ const rawText = event.text ?? ''
200
+ const mediaSummary = summarizeSlackMedia(event)
201
+ if (mediaSummary.length === 0) return rawText
202
+ const summary = `[Slack message with ${mediaSummary.join('; ')}]`
203
+ return rawText === '' ? summary : `${rawText}\n${summary}`
204
+ }
205
+
206
+ function summarizeSlackMedia(event: SlackInboundMessageEvent): string[] {
207
+ return (event.files ?? []).map(summarizeSlackFile)
208
+ }
209
+
210
+ function summarizeSlackFile(file: SlackFile): string {
211
+ const parts: string[] = [`attachment: ${file.name}`, `(${file.mimetype})`, `id=${file.id}`]
212
+ return parts.join(' ')
213
+ }
@@ -0,0 +1,51 @@
1
+ import type { SlackInboundMessageEvent } from './slack-bot-classify'
2
+
3
+ export const SLACK_DEDUPE_CAPACITY = 256
4
+
5
+ export type SlackDedupeMatch = 'client_msg_id' | 'channel_ts'
6
+
7
+ export type SlackDedupeKeys = {
8
+ channelTs: string
9
+ clientMsgId: string | null
10
+ }
11
+
12
+ export type SlackDedupe = {
13
+ check: (event: Pick<SlackInboundMessageEvent, 'channel' | 'ts' | 'client_msg_id'>) => SlackDedupeMatch | null
14
+ mark: (event: Pick<SlackInboundMessageEvent, 'channel' | 'ts' | 'client_msg_id'>) => void
15
+ }
16
+
17
+ // Two parallel insertion-ordered Sets. `client_msg_id` is the primary key
18
+ // because it is stable across Slack-side resends of the same user gesture
19
+ // (observed in the wild: a single Slack mention surfaced as two `message`
20
+ // events ~21s apart with different `ts` values, identical text, and — per
21
+ // Slack's API contract — identical `client_msg_id`). `channel:ts` is the
22
+ // fallback because it is the only key available for events Slack does not
23
+ // stamp with `client_msg_id` (bot messages, system messages, and historically
24
+ // `app_mention` envelopes).
25
+ export function createSlackDedupe(capacity: number = SLACK_DEDUPE_CAPACITY): SlackDedupe {
26
+ const tsRing = new Set<string>()
27
+ const clientMsgIdRing = new Set<string>()
28
+
29
+ const remember = (ring: Set<string>, key: string): void => {
30
+ if (ring.has(key)) return
31
+ if (ring.size >= capacity) {
32
+ const oldest = ring.values().next().value
33
+ if (oldest !== undefined) ring.delete(oldest)
34
+ }
35
+ ring.add(key)
36
+ }
37
+
38
+ return {
39
+ check: (event) => {
40
+ const cmid = event.client_msg_id
41
+ if (cmid !== undefined && cmid !== '' && clientMsgIdRing.has(cmid)) return 'client_msg_id'
42
+ if (tsRing.has(`${event.channel}:${event.ts}`)) return 'channel_ts'
43
+ return null
44
+ },
45
+ mark: (event) => {
46
+ remember(tsRing, `${event.channel}:${event.ts}`)
47
+ const cmid = event.client_msg_id
48
+ if (cmid !== undefined && cmid !== '') remember(clientMsgIdRing, cmid)
49
+ },
50
+ }
51
+ }
@@ -0,0 +1,10 @@
1
+ // Slack timestamps are "<seconds>.<microseconds>" strings. Convert to ms
2
+ // so callers can sort/render chronologically without re-parsing. Lives in
3
+ // its own file to break the import cycle between slack-bot.ts (which
4
+ // imports the classifier) and slack-bot-classify.ts (which needs to stamp
5
+ // inbound `ts` at classify time).
6
+ export function slackTsToMillis(ts: string): number {
7
+ const parsed = Number.parseFloat(ts)
8
+ if (!Number.isFinite(parsed)) return 0
9
+ return Math.round(parsed * 1000)
10
+ }