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.
- package/README.md +15 -9
- package/package.json +5 -3
- package/scripts/dump-system-prompt.ts +12 -1
- package/scripts/require-parallel.ts +41 -0
- package/src/agent/auth.ts +3 -3
- package/src/agent/index.ts +116 -14
- package/src/agent/live-sessions.ts +34 -0
- package/src/agent/multimodal/read-redirect.ts +43 -0
- package/src/agent/plugin-tools.ts +97 -13
- package/src/agent/session-meta.ts +21 -2
- package/src/agent/session-origin.ts +6 -13
- package/src/agent/subagent-completion-reminder.ts +89 -0
- package/src/agent/subagents.ts +3 -2
- package/src/agent/system-prompt.ts +49 -15
- package/src/bundled-plugins/explorer/explorer.ts +2 -2
- package/src/bundled-plugins/guard/index.ts +14 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
- package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
- package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
- package/src/bundled-plugins/guard/policy.ts +7 -0
- package/src/bundled-plugins/memory/README.md +76 -62
- package/src/bundled-plugins/memory/append-tool.ts +3 -2
- package/src/bundled-plugins/memory/citation-superset.ts +49 -11
- package/src/bundled-plugins/memory/citations.ts +19 -8
- package/src/bundled-plugins/memory/delete-tool.ts +57 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
- package/src/bundled-plugins/memory/dreaming.ts +364 -146
- package/src/bundled-plugins/memory/frontmatter.ts +165 -0
- package/src/bundled-plugins/memory/index.ts +236 -16
- package/src/bundled-plugins/memory/injection-plan.ts +15 -0
- package/src/bundled-plugins/memory/load-memory.ts +102 -103
- package/src/bundled-plugins/memory/load-shards.ts +156 -0
- package/src/bundled-plugins/memory/memory-logger.ts +16 -15
- package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
- package/src/bundled-plugins/memory/migration.ts +282 -1
- package/src/bundled-plugins/memory/paths.ts +42 -0
- package/src/bundled-plugins/memory/search-tool.ts +232 -0
- package/src/bundled-plugins/memory/secret-detector.ts +2 -2
- package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
- package/src/bundled-plugins/memory/slug.ts +59 -0
- package/src/bundled-plugins/memory/stream-io.ts +110 -1
- package/src/bundled-plugins/memory/strength.ts +3 -3
- package/src/bundled-plugins/memory/topics.ts +70 -16
- package/src/bundled-plugins/security/index.ts +24 -0
- package/src/bundled-plugins/security/permissions.ts +4 -0
- package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
- package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
- package/src/channels/adapters/discord-bot-slash-commands.ts +186 -0
- package/src/channels/adapters/discord-bot.ts +163 -1
- package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
- package/src/channels/adapters/kakaotalk.ts +64 -37
- package/src/channels/adapters/slack-bot-classify.ts +2 -27
- package/src/channels/adapters/slack-bot-slash-commands.ts +82 -0
- package/src/channels/adapters/slack-bot.ts +139 -1
- package/src/channels/index.ts +5 -0
- package/src/channels/router.ts +328 -18
- package/src/channels/subagent-completion-bridge.ts +84 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/init.ts +122 -14
- package/src/cli/inspect.ts +151 -0
- package/src/cli/role.ts +7 -2
- package/src/cli/tunnel.ts +13 -1
- package/src/cli/ui.ts +25 -1
- package/src/config/index.ts +1 -0
- package/src/config/models-mutation.ts +10 -2
- package/src/cron/consumer.ts +1 -1
- package/src/init/dockerfile.ts +353 -2
- package/src/init/hatching.ts +5 -6
- package/src/init/kakaotalk-auth.ts +6 -47
- package/src/init/validate-api-key.ts +121 -0
- package/src/inspect/index.ts +213 -0
- package/src/inspect/label.ts +50 -0
- package/src/inspect/live.ts +221 -0
- package/src/inspect/render.ts +163 -0
- package/src/inspect/replay.ts +265 -0
- package/src/inspect/session-list.ts +160 -0
- package/src/inspect/types.ts +110 -0
- package/src/plugin/hooks.ts +23 -1
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +1 -1
- package/src/plugin/registry.ts +1 -1
- package/src/plugin/types.ts +10 -0
- package/src/run/channel-session-factory.ts +7 -1
- package/src/run/index.ts +87 -21
- package/src/secrets/kakao-renewal.ts +3 -47
- package/src/server/index.ts +241 -60
- package/src/shared/index.ts +4 -1
- package/src/shared/local-time.ts +17 -0
- package/src/shared/protocol.ts +49 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
- package/src/skills/typeclaw-claude-code/SKILL.md +83 -40
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
- package/src/skills/typeclaw-config/SKILL.md +38 -33
- package/src/skills/typeclaw-cron/SKILL.md +1 -1
- package/src/skills/typeclaw-git/SKILL.md +2 -2
- package/src/skills/typeclaw-memory/SKILL.md +16 -163
- package/src/skills/typeclaw-permissions/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +26 -15
- package/src/test-helpers/wait-for.ts +7 -1
- package/typeclaw.schema.json +7 -0
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { DiscordBotClient, DiscordBotListener } from 'agent-messenger/discordbot'
|
|
2
|
-
import {
|
|
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
|
|
63
|
+
case KAKAO_MESSAGE_TYPE.TEXT:
|
|
74
64
|
return null
|
|
75
|
-
case
|
|
65
|
+
case KAKAO_MESSAGE_TYPE.PHOTO:
|
|
76
66
|
return summarizePhoto(event.attachment)
|
|
77
|
-
case
|
|
67
|
+
case KAKAO_MESSAGE_TYPE.VIDEO:
|
|
78
68
|
return summarizeGeneric('video', event.attachment)
|
|
79
|
-
case
|
|
69
|
+
case KAKAO_MESSAGE_TYPE.AUDIO:
|
|
80
70
|
return summarizeGeneric('audio', event.attachment)
|
|
81
|
-
case
|
|
71
|
+
case KAKAO_MESSAGE_TYPE.FILE:
|
|
82
72
|
return summarizeFile(event.attachment)
|
|
83
|
-
case
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
logger.
|
|
197
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
+
}
|