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,640 @@
1
+ import { DiscordBotClient, DiscordBotListener } from 'agent-messenger/discordbot'
2
+ import { DiscordIntent, type DiscordGatewayMessageCreateEvent } from 'agent-messenger/discordbot'
3
+
4
+ import {
5
+ MEMBERSHIP_ENUMERATION_CAP,
6
+ type MembershipResolver,
7
+ type MembershipResolverFailure,
8
+ type MembershipResolverResult,
9
+ } from '@/channels/membership'
10
+ import { deriveMembershipFromHistory } from '@/channels/membership-from-history'
11
+ import type { ChannelRouter } from '@/channels/router'
12
+ import { isAllowed, type ChannelAdapterConfig } from '@/channels/schema'
13
+ import type {
14
+ ChannelHistoryMessage,
15
+ FetchAttachmentCallback,
16
+ FetchHistoryArgs,
17
+ FetchHistoryResult,
18
+ HistoryCallback,
19
+ OutboundCallback,
20
+ OutboundMessage,
21
+ ResolvedChannelNames,
22
+ SendResult,
23
+ TypingCallback,
24
+ TypingTarget,
25
+ } from '@/channels/types'
26
+
27
+ import { createDiscordChannelResolver } from './discord-bot-channel-resolver'
28
+ import { classifyInbound, type InboundDropReason } from './discord-bot-classify'
29
+
30
+ const DISCORD_API_BASE = 'https://discord.com/api/v10'
31
+
32
+ function formatLabel(name: string | undefined, id: string, prefix = ''): string {
33
+ if (name === undefined || name === '' || name === id) return id
34
+ return `${prefix}${name}(${id})`
35
+ }
36
+
37
+ // agent-messenger's DEFAULT_INTENTS omits MessageContent (privileged), so the
38
+ // bot's gateway IDENTIFY never asks for it and Discord delivers every message
39
+ // with content: ''. We mirror the SDK's defaults here and add MessageContent
40
+ // so inbound messages actually carry text. The portal toggle is necessary but
41
+ // not sufficient — the bitmask must include this bit too.
42
+ export const DISCORD_BOT_INTENTS =
43
+ DiscordIntent.Guilds |
44
+ DiscordIntent.GuildMessages |
45
+ DiscordIntent.GuildMessageReactions |
46
+ DiscordIntent.GuildMessageTyping |
47
+ DiscordIntent.DirectMessages |
48
+ DiscordIntent.DirectMessageReactions |
49
+ DiscordIntent.DirectMessageTyping |
50
+ DiscordIntent.MessageContent
51
+
52
+ export type DiscordBotAdapterLogger = {
53
+ info: (msg: string) => void
54
+ warn: (msg: string) => void
55
+ error: (msg: string) => void
56
+ }
57
+
58
+ const consoleLogger: DiscordBotAdapterLogger = {
59
+ info: (m) => console.log(m),
60
+ warn: (m) => console.warn(m),
61
+ error: (m) => console.error(m),
62
+ }
63
+
64
+ export type DiscordBotAdapterOptions = {
65
+ router: ChannelRouter
66
+ configRef: () => ChannelAdapterConfig
67
+ token: string
68
+ logger?: DiscordBotAdapterLogger
69
+ }
70
+
71
+ export type DiscordBotAdapter = {
72
+ start: () => Promise<void>
73
+ stop: () => Promise<void>
74
+ isConnected: () => boolean
75
+ }
76
+
77
+ // Discord's typing indicator (`POST /channels/{id}/typing`) is fire-and-
78
+ // forget: the indicator expires after ~10s on Discord's side, the router
79
+ // re-fires it every 8s while debouncing or generating, and a missed beat just
80
+ // gaps the indicator by a few seconds. We bypass the SDK because it doesn't
81
+ // expose this endpoint; rate-limit handling is unnecessary here because the
82
+ // router caps cadence per-channel at 8s.
83
+ export function createTypingCallback(deps: {
84
+ token: string
85
+ configRef: () => ChannelAdapterConfig
86
+ logger: DiscordBotAdapterLogger
87
+ formatChannelTag?: (workspace: string, chat: string) => Promise<string>
88
+ }): TypingCallback {
89
+ const { token, configRef, logger, formatChannelTag } = deps
90
+ return async (target: TypingTarget): Promise<void> => {
91
+ if (target.adapter !== 'discord-bot') return
92
+ // Discord's typing indicator auto-expires after ~10s on Discord's side,
93
+ // and there is no API to clear it explicitly. The 'stop' phase exists
94
+ // for platforms (Slack) that need an explicit clear; for Discord it
95
+ // would be extra POSTs that confuse the indicator into reappearing.
96
+ if (target.phase === 'stop') return
97
+ const config = configRef()
98
+ if (!isAllowed(config.allow, target.workspace, target.chat)) return
99
+ // Threads are channels in Discord, so the typing endpoint takes the
100
+ // thread id directly when present.
101
+ const channelId = target.thread ?? target.chat
102
+ const tag = formatChannelTag ? await formatChannelTag(target.workspace, channelId) : `channel=${channelId}`
103
+ try {
104
+ const response = await fetch(`${DISCORD_API_BASE}/channels/${channelId}/typing`, {
105
+ method: 'POST',
106
+ headers: { Authorization: `Bot ${token}`, 'Content-Length': '0' },
107
+ })
108
+ if (!response.ok) {
109
+ logger.warn(`[discord-bot] typing ${tag} status=${response.status}`)
110
+ }
111
+ } catch (err) {
112
+ logger.warn(`[discord-bot] typing ${tag} failed: ${describe(err)}`)
113
+ }
114
+ }
115
+ }
116
+
117
+ export const DISCORD_HISTORY_LIMIT_MAX = 100
118
+
119
+ type DiscordGuildPreview = {
120
+ approximate_member_count?: number
121
+ }
122
+
123
+ type DiscordGuildMember = {
124
+ user?: { id?: string; bot?: boolean }
125
+ }
126
+
127
+ export function createDiscordMembershipResolver(deps: {
128
+ token: string
129
+ logger: DiscordBotAdapterLogger
130
+ historyCallback: HistoryCallback
131
+ fetchImpl?: typeof fetch
132
+ now?: () => number
133
+ }): MembershipResolver {
134
+ const fetchFn = deps.fetchImpl ?? fetch
135
+ const now = deps.now ?? Date.now
136
+ return async (key): Promise<MembershipResolverResult> => {
137
+ if (key.workspace === '@dm') return { humans: 1, bots: 1, fetchedAt: now(), truncated: false }
138
+
139
+ const fallback = (): Promise<MembershipResolverResult> =>
140
+ deriveMembershipFromHistory({
141
+ fetchHistory: (limit) => deps.historyCallback({ chat: key.chat, thread: key.thread, limit }),
142
+ now,
143
+ })
144
+
145
+ const preview = await fetchDiscordJson<DiscordGuildPreview>(
146
+ fetchFn,
147
+ `${DISCORD_API_BASE}/guilds/${key.workspace}/preview`,
148
+ deps.token,
149
+ )
150
+ if (!preview.ok) {
151
+ deps.logger.warn(`[discord-bot] membership preview guild=${key.workspace} failed: ${preview.reason}`)
152
+ return preview.failure
153
+ }
154
+
155
+ const approximate = Math.max(0, Math.floor(preview.value.approximate_member_count ?? 0))
156
+ if (approximate > MEMBERSHIP_ENUMERATION_CAP) {
157
+ // Beyond the enumeration cap, /members truncates anyway, and the
158
+ // recent-speakers count is more useful for engagement than a raw
159
+ // guild-wide approximation that double-counts lurkers.
160
+ return await fallback()
161
+ }
162
+
163
+ const members = await fetchDiscordJson<DiscordGuildMember[]>(
164
+ fetchFn,
165
+ `${DISCORD_API_BASE}/guilds/${key.workspace}/members?limit=100`,
166
+ deps.token,
167
+ )
168
+ if (!members.ok) {
169
+ if (members.status === 403) {
170
+ // 403 here is almost always the GUILD_MEMBERS privileged intent
171
+ // missing on the application (Developer Portal → Bot →
172
+ // Privileged Gateway Intents → SERVER MEMBERS INTENT). Server-side
173
+ // ADMINISTRATOR perms do not unlock this — the gate is at the
174
+ // gateway/API privacy layer.
175
+ deps.logger.warn(
176
+ `[discord-bot] membership members guild=${key.workspace} status=403 (likely missing GUILD_MEMBERS intent); deriving from recent message authors`,
177
+ )
178
+ return await fallback()
179
+ }
180
+ deps.logger.warn(`[discord-bot] membership members guild=${key.workspace} failed: ${members.reason}`)
181
+ return members.failure
182
+ }
183
+
184
+ let bots = 0
185
+ let humans = 0
186
+ for (const member of members.value) {
187
+ if (member.user?.bot === true) bots++
188
+ else humans++
189
+ }
190
+ return { humans, bots, fetchedAt: now(), truncated: false }
191
+ }
192
+ }
193
+
194
+ type DiscordFetchResult<T> =
195
+ | { ok: true; value: T }
196
+ | { ok: false; status: number | null; reason: string; failure: MembershipResolverFailure }
197
+
198
+ async function fetchDiscordJson<T>(fetchFn: typeof fetch, url: string, token: string): Promise<DiscordFetchResult<T>> {
199
+ let response: Response
200
+ try {
201
+ response = await fetchFn(url, { method: 'GET', headers: { Authorization: `Bot ${token}` } })
202
+ } catch (err) {
203
+ return { ok: false, status: null, reason: describe(err), failure: { kind: 'transient' } }
204
+ }
205
+ if (!response.ok) {
206
+ return {
207
+ ok: false,
208
+ status: response.status,
209
+ reason: `http ${response.status}`,
210
+ failure: discordFailureForStatus(response.status),
211
+ }
212
+ }
213
+ try {
214
+ return { ok: true, value: (await response.json()) as T }
215
+ } catch (err) {
216
+ return { ok: false, status: null, reason: `parse failed: ${describe(err)}`, failure: { kind: 'transient' } }
217
+ }
218
+ }
219
+
220
+ function discordFailureForStatus(status: number): MembershipResolverFailure {
221
+ if (status === 401 || status === 403 || status === 404) return { kind: 'permanent' }
222
+ return { kind: 'transient' }
223
+ }
224
+
225
+ type DiscordRawHistoryMessage = {
226
+ id: string
227
+ channel_id: string
228
+ author: { id: string; username?: string; global_name?: string | null; bot?: boolean }
229
+ content: string
230
+ timestamp: string
231
+ message_reference?: { message_id?: string }
232
+ }
233
+
234
+ // Discord treats threads as separate channels with their own snowflake ids,
235
+ // and the gateway puts the thread's id in `event.channel_id`. The inbound
236
+ // classifier therefore stores the thread channel id in `chat` and leaves
237
+ // `thread` null. This callback uses `args.chat` as the channel id directly,
238
+ // which works for both top-level channels and threads. When a future caller
239
+ // passes a non-null `args.thread`, that wins (forward-compatible with a
240
+ // design where `chat` is the parent and `thread` is the thread channel id).
241
+ export function createDiscordHistoryCallback(deps: {
242
+ token: string
243
+ configRef: () => ChannelAdapterConfig
244
+ logger: DiscordBotAdapterLogger
245
+ botUserIdRef: () => string | null
246
+ fetchImpl?: typeof fetch
247
+ }): HistoryCallback {
248
+ const { token, configRef, logger, botUserIdRef } = deps
249
+ const fetchFn = deps.fetchImpl ?? fetch
250
+ return async (args: FetchHistoryArgs): Promise<FetchHistoryResult> => {
251
+ const config = configRef()
252
+ if (!isAllowedAnyGuild(config.allow, args.chat)) {
253
+ return { ok: false, error: 'denied by allow rules' }
254
+ }
255
+
256
+ const channelId = args.thread ?? args.chat
257
+ const limit = clampLimit(args.limit, DISCORD_HISTORY_LIMIT_MAX)
258
+ const params = new URLSearchParams({ limit: String(limit) })
259
+ if (args.cursor !== undefined && args.cursor !== '') params.set('before', args.cursor)
260
+
261
+ let raw: DiscordRawHistoryMessage[]
262
+ let response: Response
263
+ try {
264
+ response = await fetchFn(`${DISCORD_API_BASE}/channels/${channelId}/messages?${params.toString()}`, {
265
+ method: 'GET',
266
+ headers: { Authorization: `Bot ${token}` },
267
+ })
268
+ } catch (err) {
269
+ const message = err instanceof Error ? err.message : String(err)
270
+ logger.warn(`[discord-bot] history fetch failed: ${message}`)
271
+ return { ok: false, error: message }
272
+ }
273
+ if (!response.ok) {
274
+ return { ok: false, error: `http ${response.status}` }
275
+ }
276
+ try {
277
+ raw = (await response.json()) as DiscordRawHistoryMessage[]
278
+ } catch (err) {
279
+ const message = err instanceof Error ? err.message : String(err)
280
+ return { ok: false, error: `parse failed: ${message}` }
281
+ }
282
+
283
+ const botUserId = botUserIdRef()
284
+ // Discord returns newest-first; reverse for oldest-first chronological.
285
+ const mapped = raw.map((m) => mapDiscordMessage(m, botUserId)).reverse()
286
+
287
+ // Cursor for the next (older) page is the oldest message id we just
288
+ // received — Discord's `before=` is exclusive and content-addressed.
289
+ // Only present when this page was fully populated; otherwise the agent
290
+ // has reached the start of the channel.
291
+ const nextCursor = raw.length === limit && raw.length > 0 ? raw[raw.length - 1]!.id : undefined
292
+ if (nextCursor !== undefined) {
293
+ return { ok: true, messages: mapped, nextCursor }
294
+ }
295
+ return { ok: true, messages: mapped }
296
+ }
297
+ }
298
+
299
+ function mapDiscordMessage(msg: DiscordRawHistoryMessage, botUserId: string | null): ChannelHistoryMessage {
300
+ const isBot = msg.author.bot === true || (botUserId !== null && msg.author.id === botUserId)
301
+ const ts = Date.parse(msg.timestamp)
302
+ return {
303
+ externalMessageId: msg.id,
304
+ authorId: msg.author.id,
305
+ authorName: msg.author.global_name ?? msg.author.username ?? msg.author.id,
306
+ text: msg.content,
307
+ ts: Number.isFinite(ts) ? ts : 0,
308
+ isBot,
309
+ replyToBotMessageId: msg.message_reference?.message_id ?? null,
310
+ }
311
+ }
312
+
313
+ function clampLimit(requested: number, max: number): number {
314
+ if (!Number.isFinite(requested) || requested <= 0) return max
315
+ return Math.min(Math.floor(requested), max)
316
+ }
317
+
318
+ // Discord channel ids are globally unique snowflakes, so a `channel:<id>`
319
+ // or `guild:<g>/<id>` rule for any guild admits this chat. We match this
320
+ // way because at fetch time the tool has resolved the chat from session
321
+ // origin but does not always re-supply the guild id (esp. across cursor
322
+ // pagination), so the workspace-aware `isAllowed` is too narrow here.
323
+ function isAllowedAnyGuild(rules: readonly string[], chat: string): boolean {
324
+ for (const rule of rules) {
325
+ if (rule === '*') return true
326
+ if (rule === 'guild:*' || rule === 'team:*') return true
327
+ if (rule === 'dm:*') return true
328
+ if (rule.startsWith('channel:') && rule.slice(8) === chat) return true
329
+ if (rule.startsWith('dm:') && rule.slice(3) === chat) return true
330
+ if (rule.startsWith('guild:')) {
331
+ const body = rule.slice(6)
332
+ const slash = body.indexOf('/')
333
+ if (slash !== -1 && body.slice(slash + 1) === chat) return true
334
+ }
335
+ }
336
+ return false
337
+ }
338
+
339
+ // Discord-side asymmetry: agent-messenger's upstream `uploadFile` posts the
340
+ // file to `POST /channels/{id}/messages` as a multipart-only request. It does
341
+ // not accept a `content` body or a `thread_id`. So when the agent wants to
342
+ // send "text + file together in a thread", we cannot do it in one round-trip
343
+ // the way Slack can. Compromise that preserves observable intent without
344
+ // patching upstream:
345
+ // 1. Upload each attachment individually via uploadFile(chat, path).
346
+ // Files land in channel root even when the session is in a thread —
347
+ // logged as a warning so it shows up in operator triage.
348
+ // 2. After uploads, if `text` was provided, send it via sendMessage with
349
+ // thread_id when applicable. Text DOES get the thread, file does not.
350
+ // Failure semantics: if any upload fails, we abort and return ok:false with
351
+ // the upload error (the file the agent wanted to share is the load-bearing
352
+ // part of the message). The text post is best-effort and only attempted
353
+ // after every upload succeeds.
354
+ export function createOutboundCallback(deps: {
355
+ client: Pick<DiscordBotClient, 'sendMessage' | 'uploadFile'>
356
+ configRef: () => ChannelAdapterConfig
357
+ logger: DiscordBotAdapterLogger
358
+ formatChannelTag: (workspace: string, chat: string) => Promise<string>
359
+ resolvePath?: (path: string) => string
360
+ }): OutboundCallback {
361
+ const { client, configRef, logger, formatChannelTag, resolvePath } = deps
362
+ return async (msg: OutboundMessage): Promise<SendResult> => {
363
+ if (msg.adapter !== 'discord-bot') {
364
+ return { ok: false, error: `unknown adapter: ${msg.adapter}` }
365
+ }
366
+ const config = configRef()
367
+ if (!isAllowed(config.allow, msg.workspace, msg.chat)) {
368
+ logger.warn(`[discord-bot] outbound denied by allow rules: ${msg.workspace}/${msg.chat}`)
369
+ return { ok: false, error: 'denied by allow rules' }
370
+ }
371
+ const text = msg.text ?? ''
372
+ const attachments = msg.attachments ?? []
373
+ if (text === '' && attachments.length === 0) {
374
+ return { ok: false, error: 'message has neither text nor attachments' }
375
+ }
376
+ const tag = await formatChannelTag(msg.workspace, msg.chat)
377
+ logger.info(
378
+ `[discord-bot] outbound ${tag} text_len=${text.length} attachments=${attachments.length}${msg.thread ? ` thread=${msg.thread}` : ''}`,
379
+ )
380
+
381
+ for (const attachment of attachments) {
382
+ const path = resolvePath ? resolvePath(attachment.path) : attachment.path
383
+ try {
384
+ const file = await client.uploadFile(msg.chat, path)
385
+ logger.info(`[discord-bot] uploaded id=${file.id} filename=${file.filename} size=${file.size} ${tag}`)
386
+ if (msg.thread) {
387
+ logger.warn(
388
+ `[discord-bot] uploaded file landed in channel root, not thread ${msg.thread}: ` +
389
+ 'agent-messenger uploadFile does not accept thread_id',
390
+ )
391
+ }
392
+ } catch (err) {
393
+ const message = err instanceof Error ? err.message : String(err)
394
+ logger.error(`[discord-bot] uploadFile failed for ${path}: ${message}`)
395
+ return { ok: false, error: `uploadFile failed: ${message}` }
396
+ }
397
+ }
398
+
399
+ if (text === '') {
400
+ return { ok: true }
401
+ }
402
+
403
+ try {
404
+ const sent = await client.sendMessage(msg.chat, text, msg.thread ? { thread_id: msg.thread } : undefined)
405
+ logger.info(`[discord-bot] sent id=${sent.id} ${tag}`)
406
+ return { ok: true }
407
+ } catch (err) {
408
+ const message = err instanceof Error ? err.message : String(err)
409
+ logger.error(`[discord-bot] sendMessage failed: ${message}`)
410
+ return { ok: false, error: message }
411
+ }
412
+ }
413
+ }
414
+
415
+ // Discord CDN URLs (`cdn.discordapp.com/attachments/...`) are signed and
416
+ // expire (~24h). Sending the bot token alongside makes no difference for
417
+ // public CDN URLs (Discord ignores it), but is required for the rare
418
+ // guild-restricted attachment, so we set it unconditionally — fail-open
419
+ // to ensure-public is the wrong default for a fetch primitive that the
420
+ // agent will lean on. URL validation refuses anything outside Discord's
421
+ // own domains so the agent can't be tricked into using this callback as
422
+ // a generic credentialed fetch.
423
+ const DISCORD_ATTACHMENT_HOSTS = new Set(['cdn.discordapp.com', 'media.discordapp.net'])
424
+
425
+ export function createFetchAttachmentCallback(deps: {
426
+ token: string
427
+ logger: DiscordBotAdapterLogger
428
+ fetchImpl?: typeof fetch
429
+ }): FetchAttachmentCallback {
430
+ const { token, logger } = deps
431
+ const fetchImpl = deps.fetchImpl ?? fetch
432
+ return async ({ ref, filename }) => {
433
+ let url: URL
434
+ try {
435
+ url = new URL(ref)
436
+ } catch {
437
+ return { ok: false, error: `invalid Discord attachment URL: ${ref}` }
438
+ }
439
+ if (!DISCORD_ATTACHMENT_HOSTS.has(url.hostname)) {
440
+ return { ok: false, error: `not a Discord CDN URL: ${url.hostname}` }
441
+ }
442
+ try {
443
+ const res = await fetchImpl(url.toString(), { headers: { Authorization: `Bot ${token}` } })
444
+ if (!res.ok) {
445
+ const body = await res.text().catch(() => '')
446
+ const message = `discord cdn fetch ${res.status} ${res.statusText}${body ? `: ${body.slice(0, 200)}` : ''}`
447
+ logger.error(`[discord-bot] fetchAttachment failed for ${url.toString()}: ${message}`)
448
+ return { ok: false, error: message }
449
+ }
450
+ const arrayBuffer = await res.arrayBuffer()
451
+ const buffer = Buffer.from(arrayBuffer)
452
+ const inferredFilename = filename ?? url.pathname.split('/').pop() ?? 'attachment'
453
+ const contentType = res.headers.get('content-type') ?? undefined
454
+ logger.info(
455
+ `[discord-bot] downloaded url=${url.toString()} name=${inferredFilename} size=${buffer.length}${contentType ? ` type=${contentType}` : ''}`,
456
+ )
457
+ return {
458
+ ok: true,
459
+ buffer,
460
+ filename: inferredFilename,
461
+ ...(contentType !== undefined ? { mimetype: contentType } : {}),
462
+ size: buffer.length,
463
+ }
464
+ } catch (err) {
465
+ const message = err instanceof Error ? err.message : String(err)
466
+ logger.error(`[discord-bot] fetchAttachment failed for ${url.toString()}: ${message}`)
467
+ return { ok: false, error: message }
468
+ }
469
+ }
470
+ }
471
+
472
+ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): DiscordBotAdapter {
473
+ const logger = options.logger ?? consoleLogger
474
+ const client = new DiscordBotClient()
475
+ let listener: DiscordBotListener | null = null
476
+ let botUserId: string | null = null
477
+ let started = false
478
+ let inflightInbounds = 0
479
+ let stopWaiters: Array<() => void> = []
480
+
481
+ const channelResolver = createDiscordChannelResolver({ token: options.token })
482
+
483
+ const formatChannelTag = async (workspace: string, chat: string): Promise<string> => {
484
+ const names = await channelResolver({ adapter: 'discord-bot', workspace, chat, thread: null }).catch(
485
+ () => ({}) as ResolvedChannelNames,
486
+ )
487
+ const workspacePart = workspace === '@dm' ? 'dm' : `guild=${formatLabel(names.workspaceName, workspace)}`
488
+ const chatPart = `channel=${formatLabel(names.chatName, chat)}`
489
+ return `${workspacePart} ${chatPart}`
490
+ }
491
+
492
+ const typingCallback = createTypingCallback({
493
+ token: options.token,
494
+ configRef: options.configRef,
495
+ logger,
496
+ formatChannelTag,
497
+ })
498
+
499
+ const historyCallback = createDiscordHistoryCallback({
500
+ token: options.token,
501
+ configRef: options.configRef,
502
+ logger,
503
+ botUserIdRef: () => botUserId,
504
+ })
505
+
506
+ const membershipResolver = createDiscordMembershipResolver({
507
+ token: options.token,
508
+ logger,
509
+ historyCallback,
510
+ })
511
+
512
+ const outboundCallback = createOutboundCallback({
513
+ client,
514
+ configRef: options.configRef,
515
+ logger,
516
+ formatChannelTag,
517
+ })
518
+
519
+ const fetchAttachmentCallback = createFetchAttachmentCallback({ token: options.token, logger })
520
+
521
+ const handleMessageCreate = async (event: DiscordGatewayMessageCreateEvent): Promise<void> => {
522
+ inflightInbounds++
523
+ try {
524
+ // One log line per gateway event is non-negotiable: it's the only way to
525
+ // tell from logs whether the gateway is delivering at all. content_len=0
526
+ // is the smoking gun for a missing MessageContent privileged intent.
527
+ const inboundWorkspace = event.guild_id ?? '@dm'
528
+ const inboundTag = await formatChannelTag(inboundWorkspace, event.channel_id)
529
+ logger.info(
530
+ `[discord-bot] inbound id=${event.id} author=${formatLabel(event.author.username, event.author.id)} ${inboundTag} content_len=${event.content.length}`,
531
+ )
532
+
533
+ const verdict = classifyInbound(event, options.configRef(), botUserId)
534
+ if (verdict.kind === 'drop') {
535
+ logger.info(`[discord-bot] dropped id=${event.id} reason=${verdict.reason}${dropHint(verdict.reason)}`)
536
+ return
537
+ }
538
+
539
+ const routedTag = await formatChannelTag(verdict.payload.workspace, verdict.payload.chat)
540
+ logger.info(
541
+ `[discord-bot] routed id=${event.id} ${routedTag} mention=${verdict.payload.isBotMention} reply=${verdict.payload.replyToBotMessageId !== null}`,
542
+ )
543
+ await options.router.route(verdict.payload)
544
+ } catch (err) {
545
+ logger.error(`[discord-bot] handleInbound failed: ${describe(err)}`)
546
+ } finally {
547
+ inflightInbounds--
548
+ if (inflightInbounds === 0 && stopWaiters.length > 0) {
549
+ const waiters = stopWaiters
550
+ stopWaiters = []
551
+ for (const w of waiters) w()
552
+ }
553
+ }
554
+ }
555
+
556
+ return {
557
+ async start(): Promise<void> {
558
+ if (started) return
559
+ started = true
560
+ try {
561
+ await client.login({ token: options.token })
562
+ } catch (err) {
563
+ started = false
564
+ logger.error(`[discord-bot] login failed: ${describe(err)}`)
565
+ throw err
566
+ }
567
+
568
+ listener = new DiscordBotListener(client, { intents: DISCORD_BOT_INTENTS })
569
+ listener.on('connected', (info) => {
570
+ botUserId = info.user.id
571
+ logger.info(`[discord-bot] connected as ${info.user.username} (${info.user.id})`)
572
+ })
573
+ listener.on('disconnected', () => {
574
+ logger.warn('[discord-bot] disconnected; SDK will reconnect with backoff')
575
+ })
576
+ listener.on('error', (err) => {
577
+ logger.error(`[discord-bot] gateway error: ${describe(err)}`)
578
+ })
579
+ listener.on('message_create', (event) => {
580
+ void handleMessageCreate(event)
581
+ })
582
+
583
+ options.router.registerOutbound('discord-bot', outboundCallback)
584
+ options.router.registerTyping('discord-bot', typingCallback)
585
+ options.router.registerChannelNameResolver('discord-bot', channelResolver)
586
+ options.router.registerHistory('discord-bot', historyCallback)
587
+ options.router.registerFetchAttachment('discord-bot', fetchAttachmentCallback)
588
+ options.router.registerMembership('discord-bot', membershipResolver)
589
+
590
+ try {
591
+ await listener.start()
592
+ } catch (err) {
593
+ started = false
594
+ logger.error(`[discord-bot] listener start failed: ${describe(err)}`)
595
+ throw err
596
+ }
597
+ },
598
+
599
+ async stop(): Promise<void> {
600
+ if (!started) return
601
+ started = false
602
+ options.router.unregisterOutbound('discord-bot', outboundCallback)
603
+ options.router.unregisterTyping('discord-bot', typingCallback)
604
+ options.router.unregisterChannelNameResolver('discord-bot', channelResolver)
605
+ options.router.unregisterHistory('discord-bot', historyCallback)
606
+ options.router.unregisterFetchAttachment('discord-bot', fetchAttachmentCallback)
607
+ options.router.unregisterMembership('discord-bot', membershipResolver)
608
+ if (inflightInbounds > 0) {
609
+ await new Promise<void>((resolve) => {
610
+ stopWaiters.push(resolve)
611
+ })
612
+ }
613
+ listener?.stop()
614
+ listener = null
615
+ },
616
+
617
+ isConnected(): boolean {
618
+ return botUserId !== null
619
+ },
620
+ }
621
+ }
622
+
623
+ function describe(err: unknown): string {
624
+ return err instanceof Error ? err.message : String(err)
625
+ }
626
+
627
+ // Operator hints appended to drop logs. Kept short — full guidance lives in
628
+ // docs. The empty_content hint is the highest-leverage one because that
629
+ // failure mode is invisible from Discord's side (bot stays green).
630
+ function dropHint(reason: InboundDropReason): string {
631
+ switch (reason) {
632
+ case 'empty_content':
633
+ return ' (enable MESSAGE CONTENT INTENT in Discord Developer Portal and restart)'
634
+ case 'not_in_allow_list':
635
+ return ' (extend channels.discord-bot.allow in typeclaw.json to admit this workspace/channel)'
636
+ case 'pre_connect':
637
+ case 'self_author':
638
+ return ''
639
+ }
640
+ }