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,622 @@
|
|
|
1
|
+
import {
|
|
2
|
+
KakaoCredentialManager,
|
|
3
|
+
KakaoTalkClient as RealKakaoTalkClient,
|
|
4
|
+
KakaoTalkListener as RealKakaoTalkListener,
|
|
5
|
+
type KakaoChat,
|
|
6
|
+
type KakaoMember,
|
|
7
|
+
type KakaoMessage,
|
|
8
|
+
type KakaoProfile,
|
|
9
|
+
type KakaoSendResult,
|
|
10
|
+
type KakaoTalkListenerEventMap,
|
|
11
|
+
type KakaoTalkPushMessageEvent,
|
|
12
|
+
} from 'agent-messenger/kakaotalk'
|
|
13
|
+
|
|
14
|
+
import type { ChannelRouter } from '@/channels/router'
|
|
15
|
+
import { isAllowed, type ChannelAdapterConfig, type KakaotalkAdapterConfig } from '@/channels/schema'
|
|
16
|
+
import type {
|
|
17
|
+
ChannelHistoryMessage,
|
|
18
|
+
FetchHistoryArgs,
|
|
19
|
+
FetchHistoryResult,
|
|
20
|
+
HistoryCallback,
|
|
21
|
+
OutboundCallback,
|
|
22
|
+
OutboundMessage,
|
|
23
|
+
ResolvedChannelNames,
|
|
24
|
+
SendResult,
|
|
25
|
+
} from '@/channels/types'
|
|
26
|
+
|
|
27
|
+
import { createKakaoAuthorResolver, type KakaoAuthorResolver } from './kakaotalk-author-resolver'
|
|
28
|
+
import { createKakaoChannelResolver, type KakaoChannelResolver } from './kakaotalk-channel-resolver'
|
|
29
|
+
import { classifyInbound, type InboundDropReason } from './kakaotalk-classify'
|
|
30
|
+
|
|
31
|
+
// Inlined locally because agent-messenger/kakaotalk's index does not
|
|
32
|
+
// re-export KakaoMarkReadResult even though client.markRead returns it
|
|
33
|
+
// (agent-messenger 2.14.1). Upstream re-export fix is independent.
|
|
34
|
+
export interface KakaoMarkReadResult {
|
|
35
|
+
success: boolean
|
|
36
|
+
status_code: number
|
|
37
|
+
chat_id: string
|
|
38
|
+
watermark: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Structural duck-type of the upstream KakaoTalkClient class. The upstream
|
|
42
|
+
// type is a class with private fields, and TypeScript treats those
|
|
43
|
+
// nominally — test fakes that match the public surface get rejected.
|
|
44
|
+
// Declaring this as an interface lets fakes satisfy it without inheriting
|
|
45
|
+
// private state. The cast on the const below bridges the runtime class
|
|
46
|
+
// onto this interface; the real upstream class satisfies every method.
|
|
47
|
+
export interface KakaoTalkClient {
|
|
48
|
+
login(
|
|
49
|
+
credentials?: { oauthToken: string; userId: string; deviceUuid?: string; deviceType?: 'pc' | 'tablet' },
|
|
50
|
+
accountId?: string,
|
|
51
|
+
): Promise<this>
|
|
52
|
+
getChats(options?: { all?: boolean; search?: string }): Promise<KakaoChat[]>
|
|
53
|
+
getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]>
|
|
54
|
+
sendMessage(chatId: string, text: string): Promise<KakaoSendResult>
|
|
55
|
+
markRead(chatId: string, logId: string, opts?: { linkId?: string }): Promise<KakaoMarkReadResult>
|
|
56
|
+
getProfile(): Promise<KakaoProfile>
|
|
57
|
+
getMembers(chatId: string): Promise<KakaoMember[]>
|
|
58
|
+
lookupAuthorName(chatId: string, authorId: number): string | null
|
|
59
|
+
close(): void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface KakaoTalkListener {
|
|
63
|
+
start(): Promise<void>
|
|
64
|
+
stop(): void
|
|
65
|
+
on<K extends keyof KakaoTalkListenerEventMap>(
|
|
66
|
+
event: K,
|
|
67
|
+
listener: (...args: KakaoTalkListenerEventMap[K]) => void,
|
|
68
|
+
): this
|
|
69
|
+
off<K extends keyof KakaoTalkListenerEventMap>(
|
|
70
|
+
event: K,
|
|
71
|
+
listener: (...args: KakaoTalkListenerEventMap[K]) => void,
|
|
72
|
+
): this
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const KakaoTalkClient = RealKakaoTalkClient as unknown as new () => KakaoTalkClient
|
|
76
|
+
const KakaoTalkListener = RealKakaoTalkListener as unknown as new (client: KakaoTalkClient) => KakaoTalkListener
|
|
77
|
+
|
|
78
|
+
export type KakaotalkAdapterLogger = {
|
|
79
|
+
info: (msg: string) => void
|
|
80
|
+
warn: (msg: string) => void
|
|
81
|
+
error: (msg: string) => void
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const consoleLogger: KakaotalkAdapterLogger = {
|
|
85
|
+
info: (m) => console.log(m),
|
|
86
|
+
warn: (m) => console.warn(m),
|
|
87
|
+
error: (m) => console.error(m),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type KakaotalkAdapterOptions = {
|
|
91
|
+
router: ChannelRouter
|
|
92
|
+
configRef: () => KakaotalkAdapterConfig
|
|
93
|
+
logger?: KakaotalkAdapterLogger
|
|
94
|
+
selfAliasesRef?: () => readonly string[]
|
|
95
|
+
// When set, the adapter loads KakaoTalk credentials from this directory
|
|
96
|
+
// (via KakaoCredentialManager(credentialsDir)) instead of relying on
|
|
97
|
+
// the SDK's AGENT_MESSENGER_CONFIG_DIR env-var fallback. Production
|
|
98
|
+
// wiring in src/channels/manager.ts passes the agent-folder workspace
|
|
99
|
+
// path here so the adapter's credential resolution does NOT depend on
|
|
100
|
+
// process.env state — easier to test, and removes a hidden coupling
|
|
101
|
+
// with whatever set the env var (Dockerfile, CLI shell, etc.).
|
|
102
|
+
credentialsDir?: string
|
|
103
|
+
client?: KakaoTalkClient
|
|
104
|
+
listenerFactory?: (client: KakaoTalkClient) => KakaoTalkListener
|
|
105
|
+
// Test seam for KICKOUT auto-recovery. Production uses Date.now and
|
|
106
|
+
// setTimeout. Tests inject deterministic clocks/schedulers so they can
|
|
107
|
+
// assert the recovery semantics without real-time waits.
|
|
108
|
+
now?: () => number
|
|
109
|
+
scheduleRecovery?: (fn: () => void, delayMs: number) => void
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// LOCO emits KICKOUT when the same device_uuid logs in elsewhere. Three
|
|
113
|
+
// shapes converge on this one signal:
|
|
114
|
+
// 1. Init handoff — `typeclaw init` left a brief session that the
|
|
115
|
+
// container's re-login kicks. One delayed reconnect resolves it.
|
|
116
|
+
// 2. Ghost session — a previous run's LOCO connection is still
|
|
117
|
+
// half-alive server-side and ping-pongs with our reconnect for
|
|
118
|
+
// ~1-2 minutes until it times out. Old one-shot recovery
|
|
119
|
+
// reconnected once, got kicked again, and died — the bug this
|
|
120
|
+
// state machine exists to fix.
|
|
121
|
+
// 3. Real conflict — another device or process holds the same
|
|
122
|
+
// device_uuid. Our retries can't win this fight; we should give up
|
|
123
|
+
// cleanly so the user notices and intervenes.
|
|
124
|
+
// One signal, three shapes: we always try to recover, but with a
|
|
125
|
+
// strictly bounded budget. Within an episode we allow KICKOUT_RECOVERY_
|
|
126
|
+
// _DELAYS_MS.length retries spaced by the listed delays; an episode is
|
|
127
|
+
// declared successful only after the reconnect stays connected for
|
|
128
|
+
// SUCCESS_MS (bare `connected` is too weak — ghost ping-pong reconnects
|
|
129
|
+
// for seconds before getting kicked again). Past the budget or the
|
|
130
|
+
// MAX_ELAPSED cap we let the session die. After a successful episode
|
|
131
|
+
// the state resets, so a fresh KICKOUT hours later gets a fresh
|
|
132
|
+
// episode rather than being permanently locked out.
|
|
133
|
+
const KICKOUT_RECOVERY_SUCCESS_MS = 60_000
|
|
134
|
+
const KICKOUT_RECOVERY_MAX_ELAPSED_MS = 5 * 60_000
|
|
135
|
+
const KICKOUT_RECOVERY_DELAYS_MS: readonly number[] = [2_000, 10_000, 60_000]
|
|
136
|
+
|
|
137
|
+
export type KakaotalkAdapter = {
|
|
138
|
+
start: () => Promise<void>
|
|
139
|
+
stop: () => Promise<void>
|
|
140
|
+
isConnected: () => boolean
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const KAKAO_HISTORY_LIMIT_MAX = 200
|
|
144
|
+
|
|
145
|
+
function formatLabel(name: string | undefined, id: string, prefix = ''): string {
|
|
146
|
+
if (name === undefined || name === '' || name === id) return id
|
|
147
|
+
return `${prefix}${name}(${id})`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createOutboundCallback(deps: {
|
|
151
|
+
client: Pick<KakaoTalkClient, 'sendMessage'>
|
|
152
|
+
configRef: () => ChannelAdapterConfig
|
|
153
|
+
logger: KakaotalkAdapterLogger
|
|
154
|
+
formatChannelTag: (workspace: string, chat: string) => Promise<string>
|
|
155
|
+
}): OutboundCallback {
|
|
156
|
+
const { client, configRef, logger, formatChannelTag } = deps
|
|
157
|
+
return async (msg: OutboundMessage): Promise<SendResult> => {
|
|
158
|
+
if (msg.adapter !== 'kakaotalk') {
|
|
159
|
+
return { ok: false, error: `unknown adapter: ${msg.adapter}` }
|
|
160
|
+
}
|
|
161
|
+
const config = configRef()
|
|
162
|
+
if (!isAllowed(config.allow, msg.workspace, msg.chat)) {
|
|
163
|
+
logger.warn(`[kakaotalk] outbound denied by allow rules: ${msg.workspace}/${msg.chat}`)
|
|
164
|
+
return { ok: false, error: 'denied by allow rules' }
|
|
165
|
+
}
|
|
166
|
+
const text = msg.text ?? ''
|
|
167
|
+
const attachments = msg.attachments ?? []
|
|
168
|
+
if (attachments.length > 0) {
|
|
169
|
+
// Fail loudly rather than partial-send. The agent contract is "ok=true
|
|
170
|
+
// means the request as a whole succeeded"; sending text while silently
|
|
171
|
+
// dropping the attachments would let the agent confidently report
|
|
172
|
+
// "I sent your file" when the file never arrived.
|
|
173
|
+
logger.error(
|
|
174
|
+
`[kakaotalk] outbound rejected: ${attachments.length} attachment(s) supplied but KakaoTalk is text-only`,
|
|
175
|
+
)
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
error: 'KakaoTalk does not support attachments; send text without files or use a different channel for files',
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (text === '') {
|
|
182
|
+
return { ok: false, error: 'message has no text (KakaoTalk does not support attachment-only messages)' }
|
|
183
|
+
}
|
|
184
|
+
const tag = await formatChannelTag(msg.workspace, msg.chat)
|
|
185
|
+
logger.info(`[kakaotalk] outbound ${tag} text_len=${text.length}`)
|
|
186
|
+
try {
|
|
187
|
+
const result = await client.sendMessage(msg.chat, text)
|
|
188
|
+
if (!result.success) {
|
|
189
|
+
logger.error(`[kakaotalk] sendMessage status_code=${result.status_code} ${tag}`)
|
|
190
|
+
return { ok: false, error: `kakaotalk send failed with status ${result.status_code}` }
|
|
191
|
+
}
|
|
192
|
+
logger.info(`[kakaotalk] sent log_id=${result.log_id} ${tag}`)
|
|
193
|
+
return { ok: true }
|
|
194
|
+
} catch (err) {
|
|
195
|
+
const message = describe(err)
|
|
196
|
+
logger.error(`[kakaotalk] sendMessage failed: ${message}`)
|
|
197
|
+
return { ok: false, error: message }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function createKakaoHistoryCallback(deps: {
|
|
203
|
+
client: Pick<KakaoTalkClient, 'getMessages'>
|
|
204
|
+
configRef: () => ChannelAdapterConfig
|
|
205
|
+
logger: KakaotalkAdapterLogger
|
|
206
|
+
channelResolver: Pick<KakaoChannelResolver, 'lookupChat' | 'refresh'>
|
|
207
|
+
authorResolver: Pick<KakaoAuthorResolver, 'resolve'>
|
|
208
|
+
selfUserIdRef: () => string | null
|
|
209
|
+
}): HistoryCallback {
|
|
210
|
+
const { client, configRef, logger, channelResolver, authorResolver, selfUserIdRef } = deps
|
|
211
|
+
return async (args: FetchHistoryArgs): Promise<FetchHistoryResult> => {
|
|
212
|
+
const config = configRef()
|
|
213
|
+
let lookup = channelResolver.lookupChat(args.chat)
|
|
214
|
+
if (lookup === null) {
|
|
215
|
+
await channelResolver.refresh()
|
|
216
|
+
lookup = channelResolver.lookupChat(args.chat)
|
|
217
|
+
}
|
|
218
|
+
// Fallback to the most restrictive bucket (group) when the resolver
|
|
219
|
+
// can't classify after refresh — keeps allow-rule enforcement strict
|
|
220
|
+
// rather than defaulting to a permissive bucket.
|
|
221
|
+
const workspace = lookup?.workspace ?? '@kakao-group'
|
|
222
|
+
if (!isAllowed(config.allow, workspace, args.chat)) {
|
|
223
|
+
return { ok: false, error: 'denied by allow rules' }
|
|
224
|
+
}
|
|
225
|
+
const limit = clampLimit(args.limit, KAKAO_HISTORY_LIMIT_MAX)
|
|
226
|
+
try {
|
|
227
|
+
const messages = await client.getMessages(args.chat, {
|
|
228
|
+
count: limit,
|
|
229
|
+
...(args.cursor !== undefined && args.cursor !== '' ? { from: args.cursor } : {}),
|
|
230
|
+
})
|
|
231
|
+
const selfId = selfUserIdRef()
|
|
232
|
+
const mapped: ChannelHistoryMessage[] = await Promise.all(
|
|
233
|
+
messages.map(async (m) => {
|
|
234
|
+
const authorId = String(m.author_id)
|
|
235
|
+
const authorName = m.author_name ?? (await authorResolver.resolve(authorId, args.chat)) ?? authorId
|
|
236
|
+
return {
|
|
237
|
+
externalMessageId: m.log_id,
|
|
238
|
+
authorId,
|
|
239
|
+
authorName,
|
|
240
|
+
text: m.message,
|
|
241
|
+
ts: m.sent_at,
|
|
242
|
+
isBot: selfId !== null && authorId === selfId,
|
|
243
|
+
replyToBotMessageId: null,
|
|
244
|
+
}
|
|
245
|
+
}),
|
|
246
|
+
)
|
|
247
|
+
return { ok: true, messages: mapped }
|
|
248
|
+
} catch (err) {
|
|
249
|
+
const message = describe(err)
|
|
250
|
+
logger.warn(`[kakaotalk] history fetch failed: ${message}`)
|
|
251
|
+
return { ok: false, error: message }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function clampLimit(requested: number, max: number): number {
|
|
257
|
+
if (!Number.isFinite(requested) || requested <= 0) return max
|
|
258
|
+
return Math.min(Math.floor(requested), max)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): KakaotalkAdapter {
|
|
262
|
+
const logger = options.logger ?? consoleLogger
|
|
263
|
+
const client = options.client ?? new KakaoTalkClient()
|
|
264
|
+
const now = options.now ?? Date.now
|
|
265
|
+
const scheduleRecovery =
|
|
266
|
+
options.scheduleRecovery ??
|
|
267
|
+
((fn: () => void, delayMs: number): void => {
|
|
268
|
+
setTimeout(fn, delayMs)
|
|
269
|
+
})
|
|
270
|
+
let listener: KakaoTalkListener | null = null
|
|
271
|
+
let selfUserId: string | null = null
|
|
272
|
+
let connected = false
|
|
273
|
+
let started = false
|
|
274
|
+
let lastConnectedAt: number | null = null
|
|
275
|
+
let inflightInbounds = 0
|
|
276
|
+
let stopWaiters: Array<() => void> = []
|
|
277
|
+
|
|
278
|
+
type RecoveryEpisode = {
|
|
279
|
+
startedAt: number
|
|
280
|
+
attemptCount: number
|
|
281
|
+
pendingStabilityCheck: boolean
|
|
282
|
+
}
|
|
283
|
+
let recoveryEpisode: RecoveryEpisode | null = null
|
|
284
|
+
|
|
285
|
+
const resetRecoveryEpisode = (): void => {
|
|
286
|
+
recoveryEpisode = null
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const channelResolver = createKakaoChannelResolver({ client, logger })
|
|
290
|
+
const authorResolver = createKakaoAuthorResolver({ client, logger })
|
|
291
|
+
|
|
292
|
+
const formatChannelTag = async (workspace: string, chat: string): Promise<string> => {
|
|
293
|
+
const names = await channelResolver
|
|
294
|
+
.resolve({ adapter: 'kakaotalk', workspace, chat, thread: null })
|
|
295
|
+
.catch(() => ({}) as ResolvedChannelNames)
|
|
296
|
+
return `bucket=${workspace} chat=${formatLabel(names.chatName, chat, '#')}`
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const historyCallback = createKakaoHistoryCallback({
|
|
300
|
+
client,
|
|
301
|
+
configRef: options.configRef,
|
|
302
|
+
logger,
|
|
303
|
+
channelResolver,
|
|
304
|
+
authorResolver,
|
|
305
|
+
selfUserIdRef: () => selfUserId,
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
const outboundCallback = createOutboundCallback({
|
|
309
|
+
client,
|
|
310
|
+
configRef: options.configRef,
|
|
311
|
+
logger,
|
|
312
|
+
formatChannelTag,
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
const handleMessageEvent = async (event: KakaoTalkPushMessageEvent): Promise<void> => {
|
|
316
|
+
inflightInbounds++
|
|
317
|
+
try {
|
|
318
|
+
if (channelResolver.lookupChat(event.chat_id) === null) {
|
|
319
|
+
await channelResolver.refresh()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const inboundTag = await formatChannelTag(
|
|
323
|
+
channelResolver.lookupChat(event.chat_id)?.workspace ?? '@kakao-group',
|
|
324
|
+
event.chat_id,
|
|
325
|
+
)
|
|
326
|
+
logger.info(
|
|
327
|
+
`[kakaotalk] inbound log_id=${event.log_id} author=${event.author_id} ${inboundTag} text_len=${event.message.length}`,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
// Ack the message BEFORE classify/route so the sender's unread "1"
|
|
331
|
+
// (노란숫자) clears even when we drop the message (self-author,
|
|
332
|
+
// not-in-allow, empty text, etc.). The receiver of a kakao adapter is
|
|
333
|
+
// expected to behave like a "read it as soon as it arrives" client —
|
|
334
|
+
// the agent has observed the bytes, so the user should see the read
|
|
335
|
+
// acknowledgement regardless of what we decide to do with the message
|
|
336
|
+
// downstream. Open-chat skip is enforced inside markReadIfSupported.
|
|
337
|
+
markReadIfSupported({ client, event, channelResolver, logger })
|
|
338
|
+
|
|
339
|
+
const verdict = classifyInbound(event, options.configRef(), {
|
|
340
|
+
selfUserId,
|
|
341
|
+
lookupChat: (id) => channelResolver.lookupChat(id),
|
|
342
|
+
...(options.selfAliasesRef ? { selfAliases: options.selfAliasesRef() } : {}),
|
|
343
|
+
})
|
|
344
|
+
if (verdict.kind === 'drop') {
|
|
345
|
+
const bucket = channelResolver.lookupChat(event.chat_id)?.workspace ?? null
|
|
346
|
+
logger.info(
|
|
347
|
+
`[kakaotalk] dropped log_id=${event.log_id} reason=${verdict.reason}${dropHint(verdict.reason, bucket, event.chat_id)}`,
|
|
348
|
+
)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const inlineName = event.author_name
|
|
353
|
+
const resolvedName = inlineName ?? (await authorResolver.resolve(verdict.payload.authorId, verdict.payload.chat))
|
|
354
|
+
const enriched = {
|
|
355
|
+
...verdict.payload,
|
|
356
|
+
authorName: resolvedName ?? verdict.payload.authorId,
|
|
357
|
+
}
|
|
358
|
+
logger.info(
|
|
359
|
+
`[kakaotalk] routed log_id=${event.log_id} ${inboundTag} mention=${enriched.isBotMention} dm=${enriched.isDm}`,
|
|
360
|
+
)
|
|
361
|
+
await options.router.route(enriched)
|
|
362
|
+
} catch (err) {
|
|
363
|
+
logger.error(`[kakaotalk] handleInbound failed: ${describe(err)}`)
|
|
364
|
+
} finally {
|
|
365
|
+
inflightInbounds--
|
|
366
|
+
if (inflightInbounds === 0 && stopWaiters.length > 0) {
|
|
367
|
+
const waiters = stopWaiters
|
|
368
|
+
stopWaiters = []
|
|
369
|
+
for (const w of waiters) w()
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
async start(): Promise<void> {
|
|
376
|
+
if (started) return
|
|
377
|
+
started = true
|
|
378
|
+
lastConnectedAt = null
|
|
379
|
+
resetRecoveryEpisode()
|
|
380
|
+
try {
|
|
381
|
+
if (options.credentialsDir !== undefined) {
|
|
382
|
+
// Explicit credential path: read the file ourselves and pass the
|
|
383
|
+
// tokens directly to client.login(). This bypasses the SDK's
|
|
384
|
+
// ensureKakaoAuth() (which reads AGENT_MESSENGER_CONFIG_DIR or
|
|
385
|
+
// ~/.config/agent-messenger), making the adapter independent of
|
|
386
|
+
// process.env state.
|
|
387
|
+
const credManager = new KakaoCredentialManager(options.credentialsDir)
|
|
388
|
+
const account = await credManager.getAccount()
|
|
389
|
+
if (account === null) {
|
|
390
|
+
throw new Error(
|
|
391
|
+
`no KakaoTalk account in ${options.credentialsDir}/kakaotalk-credentials.json (run typeclaw init to authenticate)`,
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
await client.login({
|
|
395
|
+
oauthToken: account.oauth_token,
|
|
396
|
+
userId: account.user_id,
|
|
397
|
+
deviceUuid: account.device_uuid,
|
|
398
|
+
deviceType: account.device_type,
|
|
399
|
+
})
|
|
400
|
+
} else {
|
|
401
|
+
// Fall back to the SDK's env-var-driven path. Honors
|
|
402
|
+
// AGENT_MESSENGER_CONFIG_DIR set by the Dockerfile, otherwise
|
|
403
|
+
// ~/.config/agent-messenger.
|
|
404
|
+
await client.login()
|
|
405
|
+
}
|
|
406
|
+
} catch (err) {
|
|
407
|
+
started = false
|
|
408
|
+
logger.error(`[kakaotalk] login failed: ${describe(err)}`)
|
|
409
|
+
throw err
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const profile = await client.getProfile()
|
|
414
|
+
selfUserId = profile.user_id
|
|
415
|
+
logger.info(`[kakaotalk] authenticated as ${profile.nickname || profile.user_id} (${profile.user_id})`)
|
|
416
|
+
} catch (err) {
|
|
417
|
+
started = false
|
|
418
|
+
logger.error(`[kakaotalk] getProfile failed: ${describe(err)}`)
|
|
419
|
+
throw err
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
await channelResolver.refresh()
|
|
424
|
+
} catch (err) {
|
|
425
|
+
logger.warn(`[kakaotalk] initial chat list fetch failed: ${describe(err)}`)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
listener = options.listenerFactory ? options.listenerFactory(client) : new KakaoTalkListener(client)
|
|
429
|
+
const activeListener = listener
|
|
430
|
+
const scheduleStabilityCheck = (): void => {
|
|
431
|
+
if (recoveryEpisode === null) return
|
|
432
|
+
if (recoveryEpisode.pendingStabilityCheck) return
|
|
433
|
+
recoveryEpisode.pendingStabilityCheck = true
|
|
434
|
+
const expectedConnectedAt = lastConnectedAt
|
|
435
|
+
scheduleRecovery(() => {
|
|
436
|
+
if (recoveryEpisode === null) return
|
|
437
|
+
recoveryEpisode.pendingStabilityCheck = false
|
|
438
|
+
if (!started || listener !== activeListener) return
|
|
439
|
+
if (!connected || lastConnectedAt !== expectedConnectedAt) return
|
|
440
|
+
logger.info(
|
|
441
|
+
`[kakaotalk] KICKOUT recovery episode succeeded after ${recoveryEpisode.attemptCount} attempt(s); session is stable.`,
|
|
442
|
+
)
|
|
443
|
+
resetRecoveryEpisode()
|
|
444
|
+
}, KICKOUT_RECOVERY_SUCCESS_MS)
|
|
445
|
+
}
|
|
446
|
+
listener.on('connected', (info) => {
|
|
447
|
+
connected = true
|
|
448
|
+
lastConnectedAt = now()
|
|
449
|
+
logger.info(`[kakaotalk] connected (user_id=${info.userId})`)
|
|
450
|
+
if (recoveryEpisode !== null) scheduleStabilityCheck()
|
|
451
|
+
})
|
|
452
|
+
listener.on('disconnected', () => {
|
|
453
|
+
connected = false
|
|
454
|
+
logger.warn('[kakaotalk] disconnected; SDK will reconnect with backoff')
|
|
455
|
+
})
|
|
456
|
+
listener.on('error', (err) => {
|
|
457
|
+
logger.error(`[kakaotalk] listener error: ${describe(err)}`)
|
|
458
|
+
if (!isKickoutError(err)) return
|
|
459
|
+
// KICKOUT closes the SDK session and skips scheduleReconnect, so
|
|
460
|
+
// without intervention the adapter goes silent. We must either
|
|
461
|
+
// start/continue a recovery episode or surface the dead state.
|
|
462
|
+
connected = false
|
|
463
|
+
if (!started) return
|
|
464
|
+
const tNow = now()
|
|
465
|
+
if (recoveryEpisode === null) {
|
|
466
|
+
recoveryEpisode = { startedAt: tNow, attemptCount: 0, pendingStabilityCheck: false }
|
|
467
|
+
}
|
|
468
|
+
const episode = recoveryEpisode
|
|
469
|
+
const elapsedInEpisode = tNow - episode.startedAt
|
|
470
|
+
const nextAttemptIndex = episode.attemptCount
|
|
471
|
+
const delayMs = KICKOUT_RECOVERY_DELAYS_MS[nextAttemptIndex]
|
|
472
|
+
if (delayMs === undefined || elapsedInEpisode + delayMs > KICKOUT_RECOVERY_MAX_ELAPSED_MS) {
|
|
473
|
+
const reason =
|
|
474
|
+
delayMs === undefined
|
|
475
|
+
? `${KICKOUT_RECOVERY_DELAYS_MS.length} attempt(s) exhausted`
|
|
476
|
+
: `${Math.round(KICKOUT_RECOVERY_MAX_ELAPSED_MS / 1000)}s recovery budget exhausted`
|
|
477
|
+
logger.error(
|
|
478
|
+
`[kakaotalk] session is DEAD after KICKOUT — ${reason}. ` +
|
|
479
|
+
'Likely a real cross-device login is fighting our session. ' +
|
|
480
|
+
'Stop the other client, then run `typeclaw restart`. ' +
|
|
481
|
+
'If the conflict persists, re-run `typeclaw init` to mint a new device_uuid.',
|
|
482
|
+
)
|
|
483
|
+
resetRecoveryEpisode()
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
episode.attemptCount = nextAttemptIndex + 1
|
|
487
|
+
logger.warn(
|
|
488
|
+
`[kakaotalk] KICKOUT during recovery episode (attempt ${episode.attemptCount}/${KICKOUT_RECOVERY_DELAYS_MS.length}, episode_elapsed=${Math.round(elapsedInEpisode)}ms); reconnecting in ${delayMs}ms.`,
|
|
489
|
+
)
|
|
490
|
+
scheduleRecovery(() => {
|
|
491
|
+
if (!started || listener !== activeListener) return
|
|
492
|
+
if (recoveryEpisode !== episode) return
|
|
493
|
+
activeListener.start().catch((retryErr) => {
|
|
494
|
+
logger.error(
|
|
495
|
+
`[kakaotalk] KICKOUT auto-recovery failed: ${describe(retryErr)}. Run \`typeclaw restart\` to retry.`,
|
|
496
|
+
)
|
|
497
|
+
})
|
|
498
|
+
}, delayMs)
|
|
499
|
+
})
|
|
500
|
+
listener.on('message', (event) => {
|
|
501
|
+
void handleMessageEvent(event)
|
|
502
|
+
})
|
|
503
|
+
listener.on('member_joined', () => {
|
|
504
|
+
void channelResolver.refresh()
|
|
505
|
+
})
|
|
506
|
+
listener.on('member_left', () => {
|
|
507
|
+
void channelResolver.refresh()
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
await listener.start()
|
|
512
|
+
} catch (err) {
|
|
513
|
+
started = false
|
|
514
|
+
logger.error(`[kakaotalk] listener start failed: ${describe(err)}`)
|
|
515
|
+
throw err
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Registration intentionally happens AFTER listener.start() resolves
|
|
519
|
+
// so a start failure cannot leave the router pointing at callbacks
|
|
520
|
+
// belonging to a half-initialized adapter (the listener is closed,
|
|
521
|
+
// but outboundCallback would still send via a dead client). Stop()
|
|
522
|
+
// unregisters in the inverse order.
|
|
523
|
+
options.router.registerOutbound('kakaotalk', outboundCallback)
|
|
524
|
+
options.router.registerChannelNameResolver('kakaotalk', channelResolver.resolve)
|
|
525
|
+
options.router.registerHistory('kakaotalk', historyCallback)
|
|
526
|
+
},
|
|
527
|
+
|
|
528
|
+
async stop(): Promise<void> {
|
|
529
|
+
if (!started) return
|
|
530
|
+
started = false
|
|
531
|
+
options.router.unregisterOutbound('kakaotalk', outboundCallback)
|
|
532
|
+
options.router.unregisterChannelNameResolver('kakaotalk', channelResolver.resolve)
|
|
533
|
+
options.router.unregisterHistory('kakaotalk', historyCallback)
|
|
534
|
+
if (inflightInbounds > 0) {
|
|
535
|
+
await new Promise<void>((resolve) => {
|
|
536
|
+
stopWaiters.push(resolve)
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
listener?.stop()
|
|
540
|
+
listener = null
|
|
541
|
+
try {
|
|
542
|
+
client.close()
|
|
543
|
+
} catch {
|
|
544
|
+
// close() throwing on a half-initialized client is benign; the
|
|
545
|
+
// session is gone either way and there's nothing to recover.
|
|
546
|
+
}
|
|
547
|
+
selfUserId = null
|
|
548
|
+
connected = false
|
|
549
|
+
lastConnectedAt = null
|
|
550
|
+
resetRecoveryEpisode()
|
|
551
|
+
},
|
|
552
|
+
|
|
553
|
+
isConnected(): boolean {
|
|
554
|
+
return connected && selfUserId !== null
|
|
555
|
+
},
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function describe(err: unknown): string {
|
|
560
|
+
return err instanceof Error ? err.message : String(err)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function markReadIfSupported(deps: {
|
|
564
|
+
client: Pick<KakaoTalkClient, 'markRead'>
|
|
565
|
+
event: KakaoTalkPushMessageEvent
|
|
566
|
+
channelResolver: Pick<KakaoChannelResolver, 'lookupChat'>
|
|
567
|
+
logger: KakaotalkAdapterLogger
|
|
568
|
+
}): void {
|
|
569
|
+
const { client, event, channelResolver, logger } = deps
|
|
570
|
+
const bucket = channelResolver.lookupChat(event.chat_id)?.workspace
|
|
571
|
+
if (bucket === '@kakao-open') {
|
|
572
|
+
// Open chats require the LOCO `li` (linkId) field on NOTIREAD; without
|
|
573
|
+
// it the server returns a non-success status. The resolver does not
|
|
574
|
+
// surface linkId today, so rather than send a doomed ack we skip and
|
|
575
|
+
// log once. Wiring linkId through the resolver is a follow-up.
|
|
576
|
+
logger.info(
|
|
577
|
+
`[kakaotalk] mark-read skipped chat=${event.chat_id} log=${event.log_id} reason=open_chat_link_id_unsupported`,
|
|
578
|
+
)
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
client.markRead(event.chat_id, event.log_id).then(
|
|
582
|
+
(result) => {
|
|
583
|
+
if (!result.success) {
|
|
584
|
+
logger.warn(
|
|
585
|
+
`[kakaotalk] mark-read non-success status_code=${result.status_code} chat=${event.chat_id} log=${event.log_id}`,
|
|
586
|
+
)
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
(err) => {
|
|
590
|
+
logger.warn(`[kakaotalk] mark-read failed: ${describe(err)} chat=${event.chat_id} log=${event.log_id}`)
|
|
591
|
+
},
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function dropHint(
|
|
596
|
+
reason: InboundDropReason,
|
|
597
|
+
bucket: '@kakao-dm' | '@kakao-group' | '@kakao-open' | null,
|
|
598
|
+
chatId: string,
|
|
599
|
+
): string {
|
|
600
|
+
switch (reason) {
|
|
601
|
+
case 'not_in_allow_list':
|
|
602
|
+
return ` (add ${suggestedAllowPattern(bucket, chatId)} to channels.kakaotalk.allow to admit this chat)`
|
|
603
|
+
case 'unknown_chat':
|
|
604
|
+
return ' (chat not in cache; resolver refresh may be lagging)'
|
|
605
|
+
case 'empty_text':
|
|
606
|
+
case 'pre_connect':
|
|
607
|
+
case 'self_author':
|
|
608
|
+
return ''
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function suggestedAllowPattern(bucket: '@kakao-dm' | '@kakao-group' | '@kakao-open' | null, chatId: string): string {
|
|
613
|
+
if (bucket === '@kakao-dm') return `"kakao:dm/*" or "kakao:${chatId}"`
|
|
614
|
+
if (bucket === '@kakao-group') return `"kakao:group/*" or "kakao:${chatId}"`
|
|
615
|
+
if (bucket === '@kakao-open') return `"kakao:open/*" or "kakao:${chatId}"`
|
|
616
|
+
return `"kakao:${chatId}"`
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function isKickoutError(err: unknown): boolean {
|
|
620
|
+
if (!(err instanceof Error)) return false
|
|
621
|
+
return err.message.includes('kicked') || err.message.includes('KICKOUT')
|
|
622
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const SLACK_API_BASE = 'https://slack.com/api'
|
|
2
|
+
const DEFAULT_TTL_MS = 60 * 60 * 1000
|
|
3
|
+
|
|
4
|
+
export type SlackAuthorResolverOptions = {
|
|
5
|
+
token: string
|
|
6
|
+
now?: () => number
|
|
7
|
+
ttlMs?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type SlackAuthorResolver = {
|
|
11
|
+
resolve: (userId: string) => Promise<string>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type CacheEntry = { name: string; expiresAt: number }
|
|
15
|
+
|
|
16
|
+
type SlackUsersInfoResponse = {
|
|
17
|
+
ok: boolean
|
|
18
|
+
user?: {
|
|
19
|
+
id?: string
|
|
20
|
+
name?: string
|
|
21
|
+
real_name?: string
|
|
22
|
+
profile?: {
|
|
23
|
+
display_name?: string
|
|
24
|
+
real_name?: string
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createSlackAuthorResolver(options: SlackAuthorResolverOptions): SlackAuthorResolver {
|
|
30
|
+
const now = options.now ?? Date.now
|
|
31
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS
|
|
32
|
+
const cache = new Map<string, CacheEntry>()
|
|
33
|
+
const inflight = new Map<string, Promise<string>>()
|
|
34
|
+
|
|
35
|
+
const resolve = async (userId: string): Promise<string> => {
|
|
36
|
+
const cached = cache.get(userId)
|
|
37
|
+
if (cached && cached.expiresAt > now()) return cached.name
|
|
38
|
+
|
|
39
|
+
const existing = inflight.get(userId)
|
|
40
|
+
if (existing) return existing
|
|
41
|
+
|
|
42
|
+
const promise = fetchUserName(userId, options.token)
|
|
43
|
+
.then((name) => {
|
|
44
|
+
if (name !== userId) {
|
|
45
|
+
cache.set(userId, { name, expiresAt: now() + ttlMs })
|
|
46
|
+
}
|
|
47
|
+
return name
|
|
48
|
+
})
|
|
49
|
+
.finally(() => {
|
|
50
|
+
inflight.delete(userId)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
inflight.set(userId, promise)
|
|
54
|
+
return promise
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { resolve }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function fetchUserName(userId: string, token: string): Promise<string> {
|
|
61
|
+
try {
|
|
62
|
+
const response = await fetch(`${SLACK_API_BASE}/users.info?user=${encodeURIComponent(userId)}`, {
|
|
63
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
64
|
+
})
|
|
65
|
+
if (!response.ok) return userId
|
|
66
|
+
const body = (await response.json()) as SlackUsersInfoResponse
|
|
67
|
+
if (!body.ok || !body.user) return userId
|
|
68
|
+
return pickDisplayName(body.user) ?? userId
|
|
69
|
+
} catch {
|
|
70
|
+
return userId
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pickDisplayName(user: NonNullable<SlackUsersInfoResponse['user']>): string | null {
|
|
75
|
+
const candidates = [user.profile?.display_name, user.profile?.real_name, user.real_name, user.name]
|
|
76
|
+
for (const c of candidates) {
|
|
77
|
+
if (typeof c === 'string' && c !== '') return c
|
|
78
|
+
}
|
|
79
|
+
return null
|
|
80
|
+
}
|