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,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
|
+
}
|