typeclaw 0.7.0 → 0.9.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 (107) hide show
  1. package/README.md +15 -9
  2. package/package.json +5 -3
  3. package/scripts/dump-system-prompt.ts +12 -1
  4. package/scripts/require-parallel.ts +41 -0
  5. package/src/agent/auth.ts +3 -3
  6. package/src/agent/index.ts +116 -14
  7. package/src/agent/live-sessions.ts +34 -0
  8. package/src/agent/multimodal/read-redirect.ts +43 -0
  9. package/src/agent/plugin-tools.ts +97 -13
  10. package/src/agent/session-meta.ts +21 -2
  11. package/src/agent/session-origin.ts +6 -13
  12. package/src/agent/subagent-completion-reminder.ts +89 -0
  13. package/src/agent/subagents.ts +3 -2
  14. package/src/agent/system-prompt.ts +49 -15
  15. package/src/bundled-plugins/explorer/explorer.ts +2 -2
  16. package/src/bundled-plugins/guard/index.ts +14 -1
  17. package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
  18. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
  19. package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
  20. package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
  21. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
  22. package/src/bundled-plugins/guard/policy.ts +7 -0
  23. package/src/bundled-plugins/memory/README.md +76 -62
  24. package/src/bundled-plugins/memory/append-tool.ts +3 -2
  25. package/src/bundled-plugins/memory/citation-superset.ts +49 -11
  26. package/src/bundled-plugins/memory/citations.ts +19 -8
  27. package/src/bundled-plugins/memory/delete-tool.ts +57 -0
  28. package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
  29. package/src/bundled-plugins/memory/dreaming.ts +364 -146
  30. package/src/bundled-plugins/memory/frontmatter.ts +165 -0
  31. package/src/bundled-plugins/memory/index.ts +236 -16
  32. package/src/bundled-plugins/memory/injection-plan.ts +15 -0
  33. package/src/bundled-plugins/memory/load-memory.ts +102 -103
  34. package/src/bundled-plugins/memory/load-shards.ts +156 -0
  35. package/src/bundled-plugins/memory/memory-logger.ts +16 -15
  36. package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
  37. package/src/bundled-plugins/memory/migration.ts +282 -1
  38. package/src/bundled-plugins/memory/paths.ts +42 -0
  39. package/src/bundled-plugins/memory/search-tool.ts +232 -0
  40. package/src/bundled-plugins/memory/secret-detector.ts +2 -2
  41. package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
  42. package/src/bundled-plugins/memory/slug.ts +59 -0
  43. package/src/bundled-plugins/memory/stream-io.ts +110 -1
  44. package/src/bundled-plugins/memory/strength.ts +3 -3
  45. package/src/bundled-plugins/memory/topics.ts +70 -16
  46. package/src/bundled-plugins/security/index.ts +24 -0
  47. package/src/bundled-plugins/security/permissions.ts +4 -0
  48. package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
  49. package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
  50. package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
  51. package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
  52. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
  53. package/src/channels/adapters/discord-bot-slash-commands.ts +186 -0
  54. package/src/channels/adapters/discord-bot.ts +163 -1
  55. package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
  56. package/src/channels/adapters/kakaotalk.ts +64 -37
  57. package/src/channels/adapters/slack-bot-classify.ts +2 -27
  58. package/src/channels/adapters/slack-bot-slash-commands.ts +82 -0
  59. package/src/channels/adapters/slack-bot.ts +139 -1
  60. package/src/channels/index.ts +5 -0
  61. package/src/channels/router.ts +328 -18
  62. package/src/channels/subagent-completion-bridge.ts +84 -0
  63. package/src/cli/builtins.ts +1 -0
  64. package/src/cli/index.ts +1 -0
  65. package/src/cli/init.ts +122 -14
  66. package/src/cli/inspect.ts +151 -0
  67. package/src/cli/role.ts +7 -2
  68. package/src/cli/tunnel.ts +13 -1
  69. package/src/cli/ui.ts +25 -1
  70. package/src/config/index.ts +1 -0
  71. package/src/config/models-mutation.ts +10 -2
  72. package/src/cron/consumer.ts +1 -1
  73. package/src/init/dockerfile.ts +353 -2
  74. package/src/init/hatching.ts +5 -6
  75. package/src/init/kakaotalk-auth.ts +6 -47
  76. package/src/init/validate-api-key.ts +121 -0
  77. package/src/inspect/index.ts +213 -0
  78. package/src/inspect/label.ts +50 -0
  79. package/src/inspect/live.ts +221 -0
  80. package/src/inspect/render.ts +163 -0
  81. package/src/inspect/replay.ts +265 -0
  82. package/src/inspect/session-list.ts +160 -0
  83. package/src/inspect/types.ts +110 -0
  84. package/src/plugin/hooks.ts +23 -1
  85. package/src/plugin/index.ts +2 -0
  86. package/src/plugin/manager.ts +1 -1
  87. package/src/plugin/registry.ts +1 -1
  88. package/src/plugin/types.ts +10 -0
  89. package/src/run/channel-session-factory.ts +7 -1
  90. package/src/run/index.ts +87 -21
  91. package/src/secrets/kakao-renewal.ts +3 -47
  92. package/src/server/index.ts +241 -60
  93. package/src/shared/index.ts +4 -1
  94. package/src/shared/local-time.ts +17 -0
  95. package/src/shared/protocol.ts +49 -0
  96. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
  97. package/src/skills/typeclaw-claude-code/SKILL.md +83 -40
  98. package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
  99. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
  100. package/src/skills/typeclaw-config/SKILL.md +38 -33
  101. package/src/skills/typeclaw-cron/SKILL.md +1 -1
  102. package/src/skills/typeclaw-git/SKILL.md +2 -2
  103. package/src/skills/typeclaw-memory/SKILL.md +16 -163
  104. package/src/skills/typeclaw-permissions/SKILL.md +2 -2
  105. package/src/skills/typeclaw-plugins/SKILL.md +26 -15
  106. package/src/test-helpers/wait-for.ts +7 -1
  107. package/typeclaw.schema.json +7 -0
@@ -1,5 +1,9 @@
1
1
  import { DiscordBotClient, DiscordBotListener } from 'agent-messenger/discordbot'
2
- import { DiscordIntent, type DiscordGatewayMessageCreateEvent } from 'agent-messenger/discordbot'
2
+ import {
3
+ DiscordIntent,
4
+ type DiscordGatewayInteractionEvent,
5
+ type DiscordGatewayMessageCreateEvent,
6
+ } from 'agent-messenger/discordbot'
3
7
 
4
8
  import {
5
9
  MEMBERSHIP_ENUMERATION_CAP,
@@ -26,6 +30,26 @@ import type {
26
30
 
27
31
  import { createDiscordChannelResolver } from './discord-bot-channel-resolver'
28
32
  import { classifyInbound, type InboundDropReason } from './discord-bot-classify'
33
+ import {
34
+ ackInteraction,
35
+ parseInteractionAsCommand,
36
+ registerCommands,
37
+ type DiscordCommandDeclaration,
38
+ } from './discord-bot-slash-commands'
39
+
40
+ // One declared slash command per logical agent gesture. /stop maps to the
41
+ // existing channel-command of the same name in the router. Adding new
42
+ // commands here is the documented extension point: declare the entry here,
43
+ // then add the matching handler in createChannelRouter's command registry.
44
+ const SLASH_COMMANDS: readonly DiscordCommandDeclaration[] = [
45
+ { name: 'stop', description: 'Abort the current turn in this channel' },
46
+ ]
47
+ const SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(SLASH_COMMANDS.map((c) => c.name))
48
+
49
+ const STOP_REPLY_ABORTED = 'Stopped the current turn.'
50
+ const STOP_REPLY_NO_LIVE_SESSION = 'Nothing to stop — no active turn in this channel.'
51
+ const STOP_REPLY_FAILED = 'Could not stop the current turn (internal error).'
52
+ const STOP_REPLY_PERMISSION_DENIED = 'You do not have permission to stop the current turn in this channel.'
29
53
 
30
54
  const DISCORD_API_BASE = 'https://discord.com/api/v10'
31
55
 
@@ -66,6 +90,10 @@ export type DiscordBotAdapterOptions = {
66
90
  configRef: () => ChannelAdapterConfig
67
91
  token: string
68
92
  logger?: DiscordBotAdapterLogger
93
+ // Injectable for tests so adapter integration tests can assert on the
94
+ // exact REST calls without monkey-patching globalThis.fetch. Production
95
+ // callers leave it undefined to use the global fetch.
96
+ fetchImpl?: typeof fetch
69
97
  }
70
98
 
71
99
  export type DiscordBotAdapter = {
@@ -433,9 +461,91 @@ export function createFetchAttachmentCallback(deps: {
433
461
  }
434
462
  }
435
463
 
464
+ export type InteractionHandlerDeps = {
465
+ router: Pick<ChannelRouter, 'executeCommand'>
466
+ knownCommandNames: ReadonlySet<string>
467
+ logger: DiscordBotAdapterLogger
468
+ formatChannelTag: (workspace: string, chat: string) => Promise<string>
469
+ fetchImpl?: typeof fetch
470
+ }
471
+
472
+ export function createInteractionHandler(
473
+ deps: InteractionHandlerDeps,
474
+ ): (event: DiscordGatewayInteractionEvent) => Promise<void> {
475
+ const fetchImpl = deps.fetchImpl ?? fetch
476
+ return async (event) => {
477
+ try {
478
+ const parsed = parseInteractionAsCommand(event, deps.knownCommandNames)
479
+ if (parsed.kind === 'ignore') {
480
+ // 'not-application-command' is the common case (buttons, modals,
481
+ // autocomplete); emit at warn only when we dropped something we
482
+ // ostensibly handle.
483
+ if (parsed.reason !== 'not-application-command') {
484
+ deps.logger.warn(`[discord-bot] interaction id=${event.id} dropped reason=${parsed.reason}`)
485
+ }
486
+ return
487
+ }
488
+ const { command } = parsed
489
+
490
+ // Pre-ACK: emit ONE line with bare ids only (no formatChannelTag).
491
+ // Discord's 3s ack budget covers everything until the callback POST
492
+ // returns 2xx; name resolution involves two Discord REST calls that
493
+ // can blow the budget on a slow API minute. Decorative logging with
494
+ // resolved names happens AFTER the ack.
495
+ deps.logger.info(
496
+ `[discord-bot] interaction /${command.name} id=${event.id} invoker=${command.invokerId} guild=${command.key.workspace} channel=${command.key.chat}`,
497
+ )
498
+
499
+ const result = await deps.router.executeCommand(command.key, command.name, {
500
+ invokerId: command.invokerId,
501
+ })
502
+ const replyContent =
503
+ result.kind === 'handled'
504
+ ? STOP_REPLY_ABORTED
505
+ : result.kind === 'no-live-session'
506
+ ? STOP_REPLY_NO_LIVE_SESSION
507
+ : result.kind === 'permission-denied'
508
+ ? STOP_REPLY_PERMISSION_DENIED
509
+ : STOP_REPLY_FAILED
510
+
511
+ const ack = await ackInteraction({
512
+ interactionId: command.interactionId,
513
+ interactionToken: command.interactionToken,
514
+ content: replyContent,
515
+ fetchImpl,
516
+ })
517
+ if (!ack.ok) {
518
+ // Discord's interaction token is single-use per callback type and
519
+ // ~15min total; once we miss the 3s ack window the user sees
520
+ // "This interaction failed" in the UI. The abort still happened
521
+ // server-side — only the user-visible confirmation is lost.
522
+ deps.logger.warn(`[discord-bot] interaction /${command.name} ack failed: ${ack.error}`)
523
+ }
524
+
525
+ // Decorative post-ack logging: resolve channel/guild names now that
526
+ // the 3s budget is no longer a concern. Best-effort — if name
527
+ // resolution fails we already logged bare ids above.
528
+ try {
529
+ const inboundTag = await deps.formatChannelTag(command.key.workspace, command.key.chat)
530
+ deps.logger.info(`[discord-bot] interaction /${command.name} result=${result.kind} ${inboundTag}`)
531
+ } catch (err) {
532
+ deps.logger.info(
533
+ `[discord-bot] interaction /${command.name} result=${result.kind} (channel-tag resolution failed: ${describe(err)})`,
534
+ )
535
+ }
536
+ } catch (err) {
537
+ deps.logger.error(`[discord-bot] handleInteraction failed: ${describe(err)}`)
538
+ }
539
+ }
540
+ }
541
+
542
+ export const DISCORD_SLASH_COMMANDS = SLASH_COMMANDS
543
+ export const DISCORD_SLASH_COMMAND_NAMES = SLASH_COMMAND_NAMES
544
+
436
545
  export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): DiscordBotAdapter {
437
546
  const logger = options.logger ?? consoleLogger
438
547
  const client = new DiscordBotClient()
548
+ const fetchImpl = options.fetchImpl ?? fetch
439
549
  let listener: DiscordBotListener | null = null
440
550
  let botUserId: string | null = null
441
551
  let started = false
@@ -479,6 +589,28 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
479
589
 
480
590
  const fetchAttachmentCallback = createFetchAttachmentCallback({ token: options.token, logger })
481
591
 
592
+ const interactionHandler = createInteractionHandler({
593
+ router: options.router,
594
+ knownCommandNames: SLASH_COMMAND_NAMES,
595
+ logger,
596
+ formatChannelTag,
597
+ fetchImpl,
598
+ })
599
+
600
+ const handleInteractionCreate = async (event: DiscordGatewayInteractionEvent): Promise<void> => {
601
+ inflightInbounds++
602
+ try {
603
+ await interactionHandler(event)
604
+ } finally {
605
+ inflightInbounds--
606
+ if (inflightInbounds === 0 && stopWaiters.length > 0) {
607
+ const waiters = stopWaiters
608
+ stopWaiters = []
609
+ for (const w of waiters) w()
610
+ }
611
+ }
612
+ }
613
+
482
614
  const handleMessageCreate = async (event: DiscordGatewayMessageCreateEvent): Promise<void> => {
483
615
  inflightInbounds++
484
616
  try {
@@ -530,6 +662,33 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
530
662
  listener.on('connected', (info) => {
531
663
  botUserId = info.user.id
532
664
  logger.info(`[discord-bot] connected as ${info.user.username} (${info.user.id})`)
665
+ // For bots, the gateway's user.id IS the application id — the same
666
+ // value is required for both /me lookups and /applications/{id}/
667
+ // commands. Fire-and-forget registration so a slow Discord API
668
+ // call (or a 403 from missing applications.commands scope) doesn't
669
+ // block the listener from receiving messages. Text-prefix /stop
670
+ // keeps working regardless.
671
+ void registerCommands({
672
+ token: options.token,
673
+ applicationId: info.user.id,
674
+ commands: SLASH_COMMANDS,
675
+ fetchImpl,
676
+ }).then((result) => {
677
+ if (result.ok) {
678
+ logger.info(
679
+ `[discord-bot] slash commands registered (${SLASH_COMMANDS.map((c) => `/${c.name}`).join(' ')})`,
680
+ )
681
+ } else {
682
+ // 403 here is almost always missing applications.commands scope
683
+ // on the OAuth invite URL — operator-fixable, but the listener
684
+ // continues. Adding the hint inline so an operator doesn't have
685
+ // to grep docs to recognize the failure mode.
686
+ logger.warn(
687
+ `[discord-bot] slash command registration failed: ${result.error}` +
688
+ ' (if 403, re-invite the bot with the applications.commands scope)',
689
+ )
690
+ }
691
+ })
533
692
  })
534
693
  listener.on('disconnected', () => {
535
694
  logger.warn('[discord-bot] disconnected; SDK will reconnect with backoff')
@@ -540,6 +699,9 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
540
699
  listener.on('message_create', (event) => {
541
700
  void handleMessageCreate(event)
542
701
  })
702
+ listener.on('interaction_create', (event) => {
703
+ void handleInteractionCreate(event)
704
+ })
543
705
 
544
706
  options.router.registerOutbound('discord-bot', outboundCallback)
545
707
  options.router.registerTyping('discord-bot', typingCallback)
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  KAKAO_EMOTICON_KIND_BY_TYPE,
3
+ KAKAO_MESSAGE_TYPE,
3
4
  type KakaoEmoticonKind,
4
5
  type KakaoMessage,
5
6
  type KakaoTalkPushEmoticonEvent,
@@ -21,17 +22,6 @@ import {
21
22
  // convention used by Slack/Discord/Telegram inbound classifiers, so the
22
23
  // agent sees a consistent placeholder shape across platforms.
23
24
 
24
- // KakaoTalk LOCO message_type values. Only the ones we explicitly format
25
- // are listed; anything else falls into the "generic attachment" branch.
26
- // Reference: src/skills/typeclaw-channel-kakaotalk/SKILL.md and
27
- // agent-messenger docs/cli/kakaotalk.mdx.
28
- const MESSAGE_TYPE_TEXT = 1
29
- const MESSAGE_TYPE_PHOTO = 2
30
- const MESSAGE_TYPE_VIDEO = 3
31
- const MESSAGE_TYPE_AUDIO = 5
32
- const MESSAGE_TYPE_FILE = 18
33
- const MESSAGE_TYPE_MULTIPHOTO = 27
34
-
35
25
  // Non-text inputs that the adapter accepts. We use a thin shared shape
36
26
  // rather than the SDK's union so the same formatter can serve both push
37
27
  // events (no `attachment` on emoticon events — emoticon fields live on
@@ -70,17 +60,17 @@ function summarizeAttachment(event: InboundLike): string | null {
70
60
  // agent isn't woken up by phantom `[KakaoTalk message with type=N]`
71
61
  // placeholders for noise.
72
62
  switch (event.message_type) {
73
- case MESSAGE_TYPE_TEXT:
63
+ case KAKAO_MESSAGE_TYPE.TEXT:
74
64
  return null
75
- case MESSAGE_TYPE_PHOTO:
65
+ case KAKAO_MESSAGE_TYPE.PHOTO:
76
66
  return summarizePhoto(event.attachment)
77
- case MESSAGE_TYPE_VIDEO:
67
+ case KAKAO_MESSAGE_TYPE.VIDEO:
78
68
  return summarizeGeneric('video', event.attachment)
79
- case MESSAGE_TYPE_AUDIO:
69
+ case KAKAO_MESSAGE_TYPE.AUDIO:
80
70
  return summarizeGeneric('audio', event.attachment)
81
- case MESSAGE_TYPE_FILE:
71
+ case KAKAO_MESSAGE_TYPE.FILE:
82
72
  return summarizeFile(event.attachment)
83
- case MESSAGE_TYPE_MULTIPHOTO:
73
+ case KAKAO_MESSAGE_TYPE.MULTIPHOTO:
84
74
  return summarizeGeneric('multiphoto', event.attachment)
85
75
  default:
86
76
  // Emoticon types route through the dedicated emoticon event before
@@ -2,7 +2,9 @@ import {
2
2
  KakaoCredentialManager,
3
3
  KakaoTalkClient as RealKakaoTalkClient,
4
4
  KakaoTalkListener as RealKakaoTalkListener,
5
+ type AttachmentInput,
5
6
  type KakaoChat,
7
+ type KakaoMarkReadResult,
6
8
  type KakaoMember,
7
9
  type KakaoMessage,
8
10
  type KakaoProfile,
@@ -32,16 +34,6 @@ import { createKakaoChannelResolver, type KakaoChannelResolver } from './kakaota
32
34
  import { classifyInbound, type InboundDropReason } from './kakaotalk-classify'
33
35
  import { createFetchAttachmentCallback } from './kakaotalk-fetch-attachment'
34
36
 
35
- // Inlined locally because agent-messenger/kakaotalk's index does not
36
- // re-export KakaoMarkReadResult even though client.markRead returns it
37
- // (agent-messenger 2.14.1). Upstream re-export fix is independent.
38
- export interface KakaoMarkReadResult {
39
- success: boolean
40
- status_code: number
41
- chat_id: string
42
- watermark: string
43
- }
44
-
45
37
  // Structural duck-type of the upstream KakaoTalkClient class. The upstream
46
38
  // type is a class with private fields, and TypeScript treats those
47
39
  // nominally — test fakes that match the public surface get rejected.
@@ -56,6 +48,13 @@ export interface KakaoTalkClient {
56
48
  getChats(options?: { all?: boolean; search?: string }): Promise<KakaoChat[]>
57
49
  getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]>
58
50
  sendMessage(chatId: string, text: string): Promise<KakaoSendResult>
51
+ sendAttachment(
52
+ chatId: string,
53
+ data: Uint8Array | Buffer,
54
+ filename: string,
55
+ mimeType?: string,
56
+ ): Promise<KakaoSendResult>
57
+ sendAttachment(chatId: string, attachments: ReadonlyArray<AttachmentInput>): Promise<KakaoSendResult>
59
58
  markRead(chatId: string, logId: string, opts?: { linkId?: string }): Promise<KakaoMarkReadResult>
60
59
  getProfile(): Promise<KakaoProfile>
61
60
  getMembers(chatId: string): Promise<KakaoMember[]>
@@ -160,49 +159,77 @@ function formatLabel(name: string | undefined, id: string, prefix = ''): string
160
159
  return `${prefix}${name}(${id})`
161
160
  }
162
161
 
162
+ async function readAttachmentBuffer(path: string): Promise<Buffer> {
163
+ const { readFile } = await import('node:fs/promises')
164
+ return await readFile(path)
165
+ }
166
+
163
167
  export function createOutboundCallback(deps: {
164
- client: Pick<KakaoTalkClient, 'sendMessage'>
168
+ client: Pick<KakaoTalkClient, 'sendMessage' | 'sendAttachment'>
165
169
  logger: KakaotalkAdapterLogger
166
170
  formatChannelTag: (workspace: string, chat: string) => Promise<string>
171
+ readFile?: (path: string) => Promise<Buffer>
167
172
  }): OutboundCallback {
168
173
  const { client, logger, formatChannelTag } = deps
174
+ const readFile = deps.readFile ?? readAttachmentBuffer
169
175
  return async (msg: OutboundMessage): Promise<SendResult> => {
170
176
  if (msg.adapter !== 'kakaotalk') {
171
177
  return { ok: false, error: `unknown adapter: ${msg.adapter}` }
172
178
  }
173
179
  const text = msg.text ?? ''
174
180
  const attachments = msg.attachments ?? []
181
+ if (text === '' && attachments.length === 0) {
182
+ return { ok: false, error: 'message has neither text nor attachments' }
183
+ }
184
+ const tag = await formatChannelTag(msg.workspace, msg.chat)
185
+ logger.info(`[kakaotalk] outbound ${tag} text_len=${text.length} attachments=${attachments.length}`)
186
+
187
+ // KakaoTalk has no shared text-with-file send (Slack's initial_comment) — files first, then text.
175
188
  if (attachments.length > 0) {
176
- // Fail loudly rather than partial-send. The agent contract is "ok=true
177
- // means the request as a whole succeeded"; sending text while silently
178
- // dropping the attachments would let the agent confidently report
179
- // "I sent your file" when the file never arrived.
180
- logger.error(
181
- `[kakaotalk] outbound rejected: ${attachments.length} attachment(s) supplied but KakaoTalk is text-only`,
182
- )
183
- return {
184
- ok: false,
185
- error: 'KakaoTalk does not support attachments; send text without files or use a different channel for files',
189
+ let items: AttachmentInput[]
190
+ try {
191
+ items = await Promise.all(
192
+ attachments.map(async (a) => {
193
+ const filename = a.filename ?? a.path.split('/').pop() ?? 'file'
194
+ const data = await readFile(a.path)
195
+ return { data, filename }
196
+ }),
197
+ )
198
+ } catch (err) {
199
+ const message = describe(err)
200
+ logger.error(`[kakaotalk] readFile failed: ${message}`)
201
+ return { ok: false, error: `readFile failed: ${message}` }
202
+ }
203
+ try {
204
+ const result = await client.sendAttachment(msg.chat, items)
205
+ if (!result.success) {
206
+ logger.error(`[kakaotalk] sendAttachment status_code=${result.status_code} ${tag}`)
207
+ return { ok: false, error: `kakaotalk attachment send failed with status ${result.status_code}` }
208
+ }
209
+ logger.info(`[kakaotalk] uploaded log_id=${result.log_id} attachments=${items.length} ${tag}`)
210
+ } catch (err) {
211
+ const message = describe(err)
212
+ logger.error(`[kakaotalk] sendAttachment failed: ${message}`)
213
+ return { ok: false, error: message }
186
214
  }
187
215
  }
188
- if (text === '') {
189
- return { ok: false, error: 'message has no text (KakaoTalk does not support attachment-only messages)' }
190
- }
191
- const tag = await formatChannelTag(msg.workspace, msg.chat)
192
- logger.info(`[kakaotalk] outbound ${tag} text_len=${text.length}`)
193
- try {
194
- const result = await client.sendMessage(msg.chat, text)
195
- if (!result.success) {
196
- logger.error(`[kakaotalk] sendMessage status_code=${result.status_code} ${tag}`)
197
- return { ok: false, error: `kakaotalk send failed with status ${result.status_code}` }
216
+
217
+ if (text !== '') {
218
+ try {
219
+ const result = await client.sendMessage(msg.chat, text)
220
+ if (!result.success) {
221
+ logger.error(`[kakaotalk] sendMessage status_code=${result.status_code} ${tag}`)
222
+ return { ok: false, error: `kakaotalk send failed with status ${result.status_code}` }
223
+ }
224
+ logger.info(`[kakaotalk] sent log_id=${result.log_id} ${tag}`)
225
+ } catch (err) {
226
+ const message = describe(err)
227
+ logger.error(`[kakaotalk] sendMessage failed: ${message}`)
228
+ return { ok: false, error: message }
198
229
  }
199
- logger.info(`[kakaotalk] sent log_id=${result.log_id} ${tag}`)
200
- return { ok: true }
201
- } catch (err) {
202
- const message = describe(err)
203
- logger.error(`[kakaotalk] sendMessage failed: ${message}`)
204
- return { ok: false, error: message }
205
230
  }
231
+
232
+ return { ok: true }
206
233
  }
207
234
  }
208
235
 
@@ -6,33 +6,8 @@ import type { InboundMessage } from '@/channels/types'
6
6
 
7
7
  import { slackTsToMillis } from './slack-bot-time'
8
8
 
9
- // Upstream's `SlackSocketModeMessageEvent` carries `[key: string]: unknown`
10
- // for fields it does not type explicitly. Three of those untyped fields are
11
- // load-bearing for this adapter:
12
- // - `parent_user_id`: set on every reply within a thread; identifies the
13
- // author of the message the thread is rooted at. Used to decide whether
14
- // a reply targets the bot, another human, or an unknown parent.
15
- // - `client_msg_id`: client-generated UUID on user-authored messages,
16
- // stable across Slack-side resends of the same gesture. Primary dedupe
17
- // key for the "one user action surfaces as two events" case.
18
- // - `files`: attachments delivered inline on the same message event (Slack
19
- // does not fire a separate file_share for messages we receive).
20
- // Typing them here (rather than reading them via `as` casts at every call
21
- // site) keeps the classifier readable and makes it the single source of
22
- // truth for "what Slack actually sends" — anything else reading these
23
- // fields imports `SlackInboundMessageEvent` from this module.
24
- export type SlackInboundMessageEvent = SlackSocketModeMessageEvent & {
25
- parent_user_id?: string
26
- client_msg_id?: string
27
- files?: SlackFile[]
28
- }
29
-
30
- // `app_mention` envelopes do not always carry `client_msg_id`, but typing
31
- // it keeps the promotion to a message-shaped event lossless if Slack
32
- // starts sending it. Same reasoning as `SlackInboundMessageEvent` above.
33
- export type SlackInboundAppMentionEvent = SlackSocketModeAppMentionEvent & {
34
- client_msg_id?: string
35
- }
9
+ export type SlackInboundMessageEvent = SlackSocketModeMessageEvent
10
+ export type SlackInboundAppMentionEvent = SlackSocketModeAppMentionEvent
36
11
 
37
12
  export type InboundDropReason =
38
13
  | 'self_author' // event.user === botUserId; we never route our own messages back to ourselves
@@ -0,0 +1,82 @@
1
+ import type { SlackSocketModeSlashCommandArgs } from 'agent-messenger/slackbot'
2
+
3
+ import type { ChannelKey } from '@/channels/types'
4
+
5
+ // Slack channel ids: 'C' = public, 'G' = private/legacy multi-party DM,
6
+ // 'D' = direct message. Slash-command payloads don't carry `channel_type`,
7
+ // so we read the id prefix directly. The slack-bot inbound classifier uses
8
+ // `event.channel_type === 'im'` for the same purpose, but that field isn't
9
+ // in the slash-command body. Group DMs ('G' prefix) are NOT treated as DMs
10
+ // here — they map to `workspace: team_id` like a regular channel, matching
11
+ // how the inbound classifier handles MPIM messages (channel_type 'mpim'
12
+ // is not 'im' and therefore falls through to the team workspace branch).
13
+ const SLACK_DM_CHANNEL_PREFIXES: readonly string[] = ['D']
14
+
15
+ export type ParsedSlackSlashCommand = {
16
+ name: string
17
+ key: ChannelKey
18
+ invokerId: string
19
+ }
20
+
21
+ export type ParseSlashCommandResult =
22
+ | { kind: 'parsed'; command: ParsedSlackSlashCommand }
23
+ | { kind: 'ignore'; reason: 'unknown-command' | 'no-invoker' | 'no-channel' | 'no-team' | 'malformed' }
24
+
25
+ export function parseSlashCommand(
26
+ body: SlackSocketModeSlashCommandArgs['body'],
27
+ knownCommands: ReadonlySet<string>,
28
+ ): ParseSlashCommandResult {
29
+ if (typeof body.command !== 'string' || !body.command.startsWith('/')) {
30
+ return { kind: 'ignore', reason: 'malformed' }
31
+ }
32
+ const name = body.command.slice(1).toLowerCase()
33
+ if (name === '' || !knownCommands.has(name)) {
34
+ return { kind: 'ignore', reason: 'unknown-command' }
35
+ }
36
+ if (typeof body.user_id !== 'string' || body.user_id === '') {
37
+ return { kind: 'ignore', reason: 'no-invoker' }
38
+ }
39
+ if (typeof body.channel_id !== 'string' || body.channel_id === '') {
40
+ return { kind: 'ignore', reason: 'no-channel' }
41
+ }
42
+ // team_id is required for slash commands per Slack's API, but defensively
43
+ // refuse to construct a ChannelKey without it — otherwise the workspace
44
+ // field would collide with a real workspace id named '' downstream.
45
+ if (typeof body.team_id !== 'string' || body.team_id === '') {
46
+ return { kind: 'ignore', reason: 'no-team' }
47
+ }
48
+
49
+ const isDm = SLACK_DM_CHANNEL_PREFIXES.some((prefix) => body.channel_id.startsWith(prefix))
50
+ const workspace = isDm ? '@dm' : body.team_id
51
+
52
+ return {
53
+ kind: 'parsed',
54
+ command: {
55
+ name,
56
+ // thread is null because Slack slash commands cannot be invoked from
57
+ // inside a thread — Slack's compose box always targets the top-level
58
+ // channel. The router's executeCommand falls back to any live session
59
+ // in the same workspace+chat when an exact key match misses, so a
60
+ // thread-keyed live session still gets hit by a thread-less slash.
61
+ key: { adapter: 'slack-bot', workspace, chat: body.channel_id, thread: null },
62
+ invokerId: body.user_id,
63
+ },
64
+ }
65
+ }
66
+
67
+ export const SLACK_SLASH_REPLY_ABORTED = 'Stopped the current turn.'
68
+ export const SLACK_SLASH_REPLY_NO_LIVE_SESSION = 'Nothing to stop — no active turn in this channel.'
69
+ export const SLACK_SLASH_REPLY_FAILED = 'Could not stop the current turn (internal error).'
70
+ export const SLACK_SLASH_REPLY_PERMISSION_DENIED =
71
+ 'You do not have permission to stop the current turn in this channel.'
72
+ export const SLACK_SLASH_REPLY_AMBIGUOUS =
73
+ 'Multiple active turns in this channel. Reply `/stop` from inside the specific thread you want to stop.'
74
+
75
+ // Slack's ack callback accepts an optional response payload that becomes
76
+ // the user-visible reply. `response_type: 'ephemeral'` keeps the reply
77
+ // visible only to the invoker (vs. 'in_channel' which posts to everyone).
78
+ // Control gestures should stay ephemeral — same rationale as Discord's
79
+ // EPHEMERAL flag on interaction callbacks.
80
+ export function buildSlashAckPayload(text: string): { response_type: 'ephemeral'; text: string } {
81
+ return { response_type: 'ephemeral', text }
82
+ }