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