typeclaw 0.12.0 → 0.13.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/package.json +1 -1
- package/scripts/dump-system-prompt.ts +12 -11
- package/src/agent/index.ts +15 -22
- package/src/agent/loop-guard.ts +170 -0
- package/src/agent/model-fallback.ts +2 -1
- package/src/agent/multimodal/index.ts +1 -1
- package/src/agent/multimodal/look-at.ts +118 -55
- package/src/agent/plugin-tools.ts +57 -0
- package/src/agent/subagents.ts +2 -1
- package/src/agent/system-prompt.ts +28 -25
- package/src/agent/tools/channel-fetch-attachment.ts +45 -16
- package/src/agent/tools/normalize-ref.ts +11 -0
- package/src/bundled-plugins/reviewer/index.ts +11 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
- package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
- package/src/channels/adapters/discord-bot-classify.ts +32 -24
- package/src/channels/adapters/github/inbound.ts +19 -2
- package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
- package/src/channels/adapters/kakaotalk-classify.ts +8 -1
- package/src/channels/adapters/kakaotalk.ts +19 -11
- package/src/channels/adapters/slack-bot-classify.ts +30 -14
- package/src/channels/adapters/slack-bot.ts +3 -2
- package/src/channels/adapters/telegram-bot-classify.ts +36 -13
- package/src/channels/adapters/telegram-bot.ts +3 -3
- package/src/channels/outbound-flood-filter.ts +57 -0
- package/src/channels/router.ts +93 -5
- package/src/channels/types.ts +52 -1
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/mount.ts +157 -0
- package/src/cli/update.ts +6 -4
- package/src/config/mounts-mutation.ts +161 -0
- package/src/init/hatching.ts +1 -1
- package/src/plugin/index.ts +6 -0
- package/src/plugin/load-skill.ts +99 -0
- package/src/run/bundled-plugins.ts +2 -0
- package/src/run/index.ts +14 -1
- package/src/secrets/codex-auth-json.ts +67 -0
- package/src/secrets/export-codex-auth-file.ts +243 -0
- package/src/secrets/index.ts +6 -0
- package/src/server/command-runner.ts +2 -1
- package/src/server/index.ts +3 -2
- package/src/shared/index.ts +7 -1
- package/src/shared/local-time.ts +32 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
- package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
- package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
- package/src/update/index.ts +95 -26
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { LoadableSkill } from '@/plugin'
|
|
2
|
+
|
|
3
|
+
export const GENERAL_REVIEW_SKILL_NAME = 'general'
|
|
4
|
+
|
|
5
|
+
export const GENERAL_REVIEW_SKILL_DESCRIPTION =
|
|
6
|
+
'Fallback for review targets that do not fit a specific domain skill: a written argument, a proposal, a draft, a mixed-format artifact. Apply the universal review philosophy without domain-specific shortcuts.'
|
|
7
|
+
|
|
8
|
+
export const GENERAL_REVIEW_SKILL_CONTENT = `# general
|
|
9
|
+
|
|
10
|
+
You have been asked to review something that does not clearly fit a specific domain skill (not a code PR, not a plan, not a design doc, not docs — or it is a mix). Apply the universal review philosophy on top of the reviewer's neutral output contract.
|
|
11
|
+
|
|
12
|
+
## How to acquire the target
|
|
13
|
+
|
|
14
|
+
- **A URL** — \`webfetch\` it. If it is a private resource the fetch cannot reach, say so in \`<summary>\` and review what was provided in the payload.
|
|
15
|
+
- **A file path** — \`read\` it. \`ls\` the parent directory if siblings might be relevant.
|
|
16
|
+
- **Inline text in the payload** — read the payload carefully; quote from it when forming evidence.
|
|
17
|
+
- **A reference to something the caller has** — ask the caller to provide it. Return a single \`blocker\` finding describing what you need and a \`comment\` verdict.
|
|
18
|
+
|
|
19
|
+
## How to read carefully
|
|
20
|
+
|
|
21
|
+
A general review is the hardest because there are no domain shortcuts. Replace shortcuts with discipline:
|
|
22
|
+
|
|
23
|
+
1. **State the target's purpose in your own words.** What is the artifact trying to achieve? Who is it for? Put this in \`<summary>\`. If you cannot state it after reading, that itself is a finding — the artifact does not communicate its purpose.
|
|
24
|
+
2. **Identify the load-bearing claims.** What does the artifact assert that, if wrong, would invalidate the whole thing? List them mentally before looking for issues.
|
|
25
|
+
3. **Stress-test the load-bearing claims.** For each one: is the evidence sufficient? Are the assumptions stated? Are the counter-arguments addressed?
|
|
26
|
+
4. **Stress-test the boundaries.** Where does the artifact's argument or design stop applying? Does it acknowledge that boundary, or does it overgeneralize?
|
|
27
|
+
5. **Stress-test the audience fit.** Will the intended reader understand it? Is the prerequisite knowledge stated? Are the unstated assumptions reasonable for that audience?
|
|
28
|
+
|
|
29
|
+
## What to look for
|
|
30
|
+
|
|
31
|
+
- **Internal contradiction.** Two statements that cannot both be true. The artifact must reconcile them or pick one.
|
|
32
|
+
- **Unsupported claims.** Any assertion the artifact relies on but does not justify. The author may have a reason — say so and ask, do not assume incompetence.
|
|
33
|
+
- **Hidden assumptions.** Things the argument quietly requires to be true but does not state. These are the most common failure mode in general writing.
|
|
34
|
+
- **Missing alternatives.** If the artifact recommends X, did it explain why not Y? A serious proposal acknowledges the alternatives it rejected.
|
|
35
|
+
- **Scope drift.** The artifact promises to cover A but spends half its bytes on B. Either the scope is wrong or the title is wrong.
|
|
36
|
+
- **Verifiability.** If the artifact claims success criteria, are they measurable? "Better performance" with no metric is unverifiable.
|
|
37
|
+
- **Logical structure.** Premises → reasoning → conclusion. Where the chain breaks, point at the break.
|
|
38
|
+
|
|
39
|
+
## What NOT to find
|
|
40
|
+
|
|
41
|
+
- **Stylistic preferences.** Sentence rhythm, word choice variation, paragraph length. Skip unless they actively impede understanding.
|
|
42
|
+
- **Re-summarizing the artifact as a finding.** "This document discusses X" is not a review.
|
|
43
|
+
- **Generic feedback.** "Could be clearer" without pointing at a specific passage is noise.
|
|
44
|
+
- **Disagreements that are taste, not error.** If the author chose path A and you would have chosen B, that is not a finding unless A is actually worse for a stated reason.
|
|
45
|
+
|
|
46
|
+
## Severity hints
|
|
47
|
+
|
|
48
|
+
- **blocker** — A logical break, a fatal contradiction, a load-bearing claim that is verifiably false, an audience-fit problem so severe the intended reader cannot use the artifact.
|
|
49
|
+
- **concern** — An unsupported claim that needs justification, a missing alternative that weakens the recommendation, a scope ambiguity that will mislead readers.
|
|
50
|
+
- **nit** — A small clarity issue, a passage that could be tightened, a minor inconsistency.
|
|
51
|
+
- **praise** — A non-obvious insight, a tricky trade-off well-handled, a passage that earns the reader's trust. Rare.
|
|
52
|
+
|
|
53
|
+
## Verdict mapping
|
|
54
|
+
|
|
55
|
+
- **approve** — No blockers. The artifact stands on its own.
|
|
56
|
+
- **request-changes** — At least one blocker.
|
|
57
|
+
- **comment** — Useful observations without a clean accept/reject. Common for early drafts, exploratory documents, or partial reviews.
|
|
58
|
+
|
|
59
|
+
## Final output
|
|
60
|
+
|
|
61
|
+
Return findings inside the reviewer's neutral \`<review>\` block. Do NOT invent your own output format.
|
|
62
|
+
`
|
|
63
|
+
|
|
64
|
+
export const GENERAL_REVIEW_SKILL: LoadableSkill = {
|
|
65
|
+
name: GENERAL_REVIEW_SKILL_NAME,
|
|
66
|
+
description: GENERAL_REVIEW_SKILL_DESCRIPTION,
|
|
67
|
+
content: GENERAL_REVIEW_SKILL_CONTENT,
|
|
68
|
+
}
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
} from 'agent-messenger/discordbot'
|
|
7
7
|
|
|
8
8
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
9
|
-
import type { InboundMessage } from '@/channels/types'
|
|
9
|
+
import type { InboundAttachment, InboundMessage } from '@/channels/types'
|
|
10
10
|
|
|
11
11
|
export type InboundDropReason =
|
|
12
12
|
| 'self_author' // event.author.id === botUserId; we never route our own messages back to ourselves
|
|
@@ -35,7 +35,7 @@ export function classifyInbound(
|
|
|
35
35
|
if (botUserId !== null && event.author.id === botUserId) {
|
|
36
36
|
return { kind: 'drop', reason: 'self_author' }
|
|
37
37
|
}
|
|
38
|
-
const text =
|
|
38
|
+
const { text, attachments } = splitInbound(event)
|
|
39
39
|
if (text === '') return { kind: 'drop', reason: 'empty_content' }
|
|
40
40
|
|
|
41
41
|
const isDm = event.guild_id === undefined
|
|
@@ -80,6 +80,7 @@ export function classifyInbound(
|
|
|
80
80
|
chat: event.channel_id,
|
|
81
81
|
thread: null,
|
|
82
82
|
text,
|
|
83
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
83
84
|
externalMessageId: event.id,
|
|
84
85
|
authorId: event.author.id,
|
|
85
86
|
// Discord's post-2023 username system allows pure-numeric handles (e.g.
|
|
@@ -107,38 +108,45 @@ function isReplyToBot(event: DiscordGatewayMessageCreateEvent, botUserId: string
|
|
|
107
108
|
return (event.mentions ?? []).some((m) => m.id === botUserId)
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
111
|
+
type SplitInbound = { text: string; attachments: InboundAttachment[] }
|
|
112
|
+
|
|
113
|
+
function splitInbound(event: DiscordGatewayMessageCreateEvent): SplitInbound {
|
|
114
|
+
const attachments = describeDiscordMedia(event)
|
|
115
|
+
if (attachments.length === 0) return { text: event.content, attachments: [] }
|
|
116
|
+
const summary = attachments.map(renderPlaceholder).join('\n')
|
|
117
|
+
const text = event.content === '' ? summary : `${event.content}\n${summary}`
|
|
118
|
+
return { text, attachments }
|
|
115
119
|
}
|
|
116
120
|
|
|
117
|
-
function
|
|
121
|
+
function describeDiscordMedia(event: DiscordGatewayMessageCreateEvent): InboundAttachment[] {
|
|
118
122
|
return [
|
|
119
|
-
...(event.attachments ?? []).map(
|
|
120
|
-
...(event.embeds ?? []).map(
|
|
121
|
-
...(event.sticker_items ?? []).map(
|
|
122
|
-
]
|
|
123
|
+
...(event.attachments ?? []).map(describeAttachment),
|
|
124
|
+
...(event.embeds ?? []).map(describeEmbed),
|
|
125
|
+
...(event.sticker_items ?? []).map(describeSticker),
|
|
126
|
+
].map((attachment, index) => ({ ...attachment, id: index + 1 }))
|
|
123
127
|
}
|
|
124
128
|
|
|
125
|
-
function
|
|
126
|
-
return
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
attachment.
|
|
130
|
-
|
|
129
|
+
function describeAttachment(attachment: DiscordFile): Omit<InboundAttachment, 'id'> {
|
|
130
|
+
return {
|
|
131
|
+
kind: 'file',
|
|
132
|
+
ref: attachment.url,
|
|
133
|
+
filename: attachment.filename,
|
|
134
|
+
...(attachment.content_type !== undefined ? { mimetype: attachment.content_type } : {}),
|
|
135
|
+
}
|
|
131
136
|
}
|
|
132
137
|
|
|
133
|
-
function
|
|
138
|
+
function describeEmbed(embed: DiscordGatewayEmbed): Omit<InboundAttachment, 'id'> {
|
|
134
139
|
const label = embed.title ?? embed.description ?? embed.url ?? embed.type ?? 'embed'
|
|
135
|
-
return
|
|
140
|
+
return { kind: 'embed', ref: embed.url ?? '', filename: label }
|
|
136
141
|
}
|
|
137
142
|
|
|
138
|
-
function
|
|
139
|
-
return
|
|
143
|
+
function describeSticker(sticker: DiscordGatewayStickerItem): Omit<InboundAttachment, 'id'> {
|
|
144
|
+
return { kind: 'sticker', ref: '', filename: sticker.name }
|
|
140
145
|
}
|
|
141
146
|
|
|
142
|
-
function
|
|
143
|
-
|
|
147
|
+
function renderPlaceholder(attachment: InboundAttachment): string {
|
|
148
|
+
const parts: string[] = [`Discord attachment #${attachment.id}: ${attachment.kind}`]
|
|
149
|
+
if (attachment.mimetype !== undefined) parts.push(attachment.mimetype)
|
|
150
|
+
if (attachment.filename !== undefined) parts.push(`name=${attachment.filename}`)
|
|
151
|
+
return `[${parts.join(' ')}]`
|
|
144
152
|
}
|
|
@@ -44,11 +44,17 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
|
|
|
44
44
|
if (!isGithubEventAllowed(options.allowlist(), event, action)) return ok()
|
|
45
45
|
|
|
46
46
|
const selfId = options.selfId()
|
|
47
|
+
const selfLogin = options.selfLogin()
|
|
47
48
|
const author = readAuthor(payload)
|
|
48
|
-
if (
|
|
49
|
+
if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
|
|
50
|
+
options.logger.info(
|
|
51
|
+
`[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
|
|
52
|
+
)
|
|
53
|
+
return ok()
|
|
54
|
+
}
|
|
49
55
|
|
|
50
56
|
const teamIsBotMember = await resolveTeamMembership(event, payload, options)
|
|
51
|
-
const classified = classifyGithubInbound(event, payload,
|
|
57
|
+
const classified = classifyGithubInbound(event, payload, selfLogin, {
|
|
52
58
|
teamIsBotMember,
|
|
53
59
|
})
|
|
54
60
|
if (classified === null) return ok()
|
|
@@ -366,6 +372,17 @@ function readAuthor(payload: Record<string, unknown>): GithubUser | null {
|
|
|
366
372
|
return null
|
|
367
373
|
}
|
|
368
374
|
|
|
375
|
+
// Matches by id OR login. Issue #452 captured a self-responding loop where
|
|
376
|
+
// the id-only guard didn't fire and the bot replied to its own comments ~8
|
|
377
|
+
// times in a row. Login is the second line of defense and aligns with the
|
|
378
|
+
// slack/discord/telegram/kakaotalk adapters, which all drop self-authored
|
|
379
|
+
// events at the classifier layer.
|
|
380
|
+
function isSelfAuthor(author: GithubUser, selfId: string | null, selfLogin: string | null): boolean {
|
|
381
|
+
if (selfId !== null && String(author.id) === selfId) return true
|
|
382
|
+
if (selfLogin !== null && author.login === selfLogin) return true
|
|
383
|
+
return false
|
|
384
|
+
}
|
|
385
|
+
|
|
369
386
|
type GithubUser = { login: string; id: number; type?: string }
|
|
370
387
|
|
|
371
388
|
function readUser(value: unknown): GithubUser | null {
|
|
@@ -7,79 +7,84 @@ import {
|
|
|
7
7
|
type KakaoTalkPushMessageEvent,
|
|
8
8
|
} from 'agent-messenger/kakaotalk'
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
// the
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// (video, audio, voice, file, contact, multi-photo, ...) the SDK has
|
|
17
|
-
// neither test fixtures nor field documentation, so we fall back to a
|
|
18
|
-
// generic JSON-keys preview that still gives the agent something useful
|
|
19
|
-
// to reason about.
|
|
10
|
+
import type { InboundAttachment } from '@/channels/types'
|
|
11
|
+
|
|
12
|
+
// Splits an inbound KakaoTalk event into (text, attachments[]). Text is
|
|
13
|
+
// what the agent sees in its prompt; attachments[] carries the fetchable
|
|
14
|
+
// `ref` (URL or file id) plus safe-to-print metadata that the router uses
|
|
15
|
+
// to resolve `channel_fetch_attachment` / `look_at` calls by `attachment_id`.
|
|
20
16
|
//
|
|
21
|
-
// The
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
17
|
+
// The placeholder rendered into text is intentionally REF-FREE. Past
|
|
18
|
+
// regressions where the agent pasted a malformed ref (a bare KakaoCDN
|
|
19
|
+
// `k` key, an expired pre-signed URL, the wrong dialect across adapters)
|
|
20
|
+
// all stemmed from welding the ref into the prompt text. Keeping the ref
|
|
21
|
+
// out of the LLM's view means there is exactly ONE way to fetch an
|
|
22
|
+
// attachment — by its in-turn id — and the router validates that id
|
|
23
|
+
// against the actual inbounds, blocking hallucinated attachments by
|
|
24
|
+
// construction.
|
|
25
|
+
|
|
29
26
|
type InboundLike = {
|
|
30
27
|
message: string
|
|
31
28
|
message_type: number
|
|
32
29
|
attachment: Record<string, unknown> | null
|
|
33
30
|
}
|
|
34
31
|
|
|
35
|
-
export
|
|
32
|
+
export type SplitInbound = {
|
|
33
|
+
text: string
|
|
34
|
+
attachments: InboundAttachment[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function splitInbound(event: InboundLike, startId = 1): SplitInbound {
|
|
36
38
|
const rawText = event.message ?? ''
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// (src/channels/adapters/discord-bot-classify.ts) but adds Kakao-specific
|
|
48
|
-
// fields the agent can use to disambiguate which sticker the user sent.
|
|
49
|
-
export function formatEmoticonText(
|
|
39
|
+
const attachment = describeAttachment(event)
|
|
40
|
+
if (attachment === null) return { text: rawText, attachments: [] }
|
|
41
|
+
|
|
42
|
+
const id = startId
|
|
43
|
+
const placeholder = renderPlaceholder(id, attachment)
|
|
44
|
+
const text = rawText === '' ? placeholder : `${rawText}\n${placeholder}`
|
|
45
|
+
return { text, attachments: [{ ...attachment, id }] }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function splitEmoticonInbound(
|
|
50
49
|
event: Pick<KakaoTalkPushEmoticonEvent, 'emoticon_kind' | 'pack_id' | 'sticker_path'>,
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
startId = 1,
|
|
51
|
+
): SplitInbound {
|
|
52
|
+
const id = startId
|
|
53
|
+
const attachment = describeEmoticon(event, id)
|
|
54
|
+
const placeholder = renderPlaceholder(id, attachment)
|
|
55
|
+
return { text: placeholder, attachments: [attachment] }
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
export function splitHistoryInbound(message: KakaoMessage, startId = 1): SplitInbound {
|
|
59
|
+
return splitInbound(
|
|
60
|
+
{
|
|
61
|
+
message: message.message,
|
|
62
|
+
message_type: message.type,
|
|
63
|
+
attachment: message.attachment,
|
|
64
|
+
},
|
|
65
|
+
startId,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type DescribedAttachment = Omit<InboundAttachment, 'id'>
|
|
70
|
+
|
|
71
|
+
function describeAttachment(event: InboundLike): DescribedAttachment | null {
|
|
62
72
|
switch (event.message_type) {
|
|
63
73
|
case KAKAO_MESSAGE_TYPE.TEXT:
|
|
64
74
|
return null
|
|
65
75
|
case KAKAO_MESSAGE_TYPE.PHOTO:
|
|
66
|
-
return
|
|
76
|
+
return describePhoto(event.attachment)
|
|
67
77
|
case KAKAO_MESSAGE_TYPE.VIDEO:
|
|
68
|
-
return
|
|
78
|
+
return describeGeneric('video', event.attachment)
|
|
69
79
|
case KAKAO_MESSAGE_TYPE.AUDIO:
|
|
70
|
-
return
|
|
80
|
+
return describeGeneric('audio', event.attachment)
|
|
71
81
|
case KAKAO_MESSAGE_TYPE.FILE:
|
|
72
|
-
return
|
|
82
|
+
return describeFile(event.attachment)
|
|
73
83
|
case KAKAO_MESSAGE_TYPE.MULTIPHOTO:
|
|
74
|
-
return
|
|
84
|
+
return describeGeneric('multiphoto', event.attachment)
|
|
75
85
|
default:
|
|
76
|
-
// Emoticon types route through the dedicated emoticon event before
|
|
77
|
-
// they reach this function, but a history fetch can still return
|
|
78
|
-
// them as plain KakaoMessage rows. Render them with the same
|
|
79
|
-
// sticker shape so chronology is consistent across live and
|
|
80
|
-
// history paths.
|
|
81
86
|
if (isEmoticonType(event.message_type)) {
|
|
82
|
-
return
|
|
87
|
+
return describeHistoricalEmoticon(event.message_type, event.attachment)
|
|
83
88
|
}
|
|
84
89
|
return null
|
|
85
90
|
}
|
|
@@ -89,86 +94,105 @@ function isEmoticonType(type: number): boolean {
|
|
|
89
94
|
return type in KAKAO_EMOTICON_KIND_BY_TYPE
|
|
90
95
|
}
|
|
91
96
|
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
function describePhoto(attachment: Record<string, unknown> | null): DescribedAttachment {
|
|
98
|
+
const base: DescribedAttachment = { kind: 'photo', ref: '' }
|
|
99
|
+
if (attachment === null) return base
|
|
95
100
|
const width = numericField(attachment, 'w')
|
|
96
101
|
const height = numericField(attachment, 'h')
|
|
97
|
-
if (width !== null && height !== null) parts.push(`${width}x${height}`)
|
|
98
102
|
const mime = stringField(attachment, 'mt')
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
103
|
+
// Prefer the public pre-signed URL; fall back to the CDN key only as a
|
|
104
|
+
// diagnostic hint in metadata, NEVER as `ref`. The bare key is not a
|
|
105
|
+
// valid HTTPS URL and historically caused agents to call
|
|
106
|
+
// channel_fetch_attachment with a malformed string. Without a real URL,
|
|
107
|
+
// the agent will still see the placeholder ("a photo arrived") and can
|
|
108
|
+
// ask the user to re-share if needed.
|
|
109
|
+
const url = stringField(attachment, 'url')
|
|
110
|
+
const out: DescribedAttachment = {
|
|
111
|
+
...base,
|
|
112
|
+
ref: url ?? '',
|
|
113
|
+
...(mime !== null ? { mimetype: mime } : {}),
|
|
114
|
+
...(width !== null ? { width } : {}),
|
|
115
|
+
...(height !== null ? { height } : {}),
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function describeFile(attachment: Record<string, unknown> | null): DescribedAttachment {
|
|
121
|
+
const base: DescribedAttachment = { kind: 'file', ref: '' }
|
|
122
|
+
if (attachment === null) return base
|
|
115
123
|
const name = stringField(attachment, 'name')
|
|
116
|
-
if (name !== null) parts.push(name)
|
|
117
124
|
const mime = stringField(attachment, 'mt')
|
|
118
|
-
if (mime !== null) parts.push(`(${mime})`)
|
|
119
125
|
const size = numericField(attachment, 'size') ?? numericField(attachment, 's')
|
|
120
|
-
if (size !== null) parts.push(`size=${size}`)
|
|
121
|
-
const url = stringField(attachment, 'url')
|
|
122
|
-
if (url !== null) parts.push(url)
|
|
123
|
-
return parts.length === 1 ? `file ${attachmentKeysSummary(attachment)}` : parts.join(' ')
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function summarizeGeneric(label: string, attachment: Record<string, unknown> | null): string {
|
|
127
|
-
if (attachment === null) return label
|
|
128
|
-
// Prefer a dereferenceable URL over a keys-only preview: the agent uses
|
|
129
|
-
// the URL as the `ref` for channel_fetch_attachment, so making it visible
|
|
130
|
-
// in the placeholder is what turns video/audio/multiphoto from
|
|
131
|
-
// "described" into "fetchable". When the SDK hands us an opaque payload
|
|
132
|
-
// with no `url` (the documented case for these types), fall back to
|
|
133
|
-
// listing the available keys so we never lie about what arrived.
|
|
134
126
|
const url = stringField(attachment, 'url')
|
|
135
|
-
|
|
136
|
-
|
|
127
|
+
return {
|
|
128
|
+
...base,
|
|
129
|
+
ref: url ?? '',
|
|
130
|
+
...(name !== null ? { filename: name } : {}),
|
|
131
|
+
...(mime !== null ? { mimetype: mime } : {}),
|
|
132
|
+
...(size !== null ? { sizeBytes: size } : {}),
|
|
133
|
+
}
|
|
137
134
|
}
|
|
138
135
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
136
|
+
function describeGeneric(
|
|
137
|
+
kind: 'video' | 'audio' | 'multiphoto',
|
|
138
|
+
attachment: Record<string, unknown> | null,
|
|
139
|
+
): DescribedAttachment {
|
|
140
|
+
const base: DescribedAttachment = { kind, ref: '' }
|
|
141
|
+
if (attachment === null) return base
|
|
142
|
+
const url = stringField(attachment, 'url')
|
|
143
|
+
const mime = stringField(attachment, 'mt')
|
|
144
|
+
return {
|
|
145
|
+
...base,
|
|
146
|
+
ref: url ?? '',
|
|
147
|
+
...(mime !== null ? { mimetype: mime } : {}),
|
|
148
|
+
}
|
|
147
149
|
}
|
|
148
150
|
|
|
149
|
-
function
|
|
151
|
+
function describeEmoticon(
|
|
150
152
|
event: Pick<KakaoTalkPushEmoticonEvent, 'emoticon_kind' | 'pack_id' | 'sticker_path'>,
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
id: number,
|
|
154
|
+
): InboundAttachment {
|
|
155
|
+
// Stickers have no fetchable ref in the LOCO push payload; they are
|
|
156
|
+
// rendered client-side from `pack_id` + `sticker_path` against a
|
|
157
|
+
// packaged sprite set. Surface those as filename so the placeholder is
|
|
158
|
+
// informative, and leave `ref` empty — channel_fetch_attachment will
|
|
159
|
+
// refuse the lookup and tell the agent the sticker is unfetchable.
|
|
160
|
+
const filename =
|
|
161
|
+
event.sticker_path !== null && event.sticker_path !== '' ? event.sticker_path : `sticker-${event.emoticon_kind}`
|
|
162
|
+
return {
|
|
163
|
+
id,
|
|
164
|
+
kind: 'sticker',
|
|
165
|
+
ref: '',
|
|
166
|
+
filename,
|
|
167
|
+
}
|
|
156
168
|
}
|
|
157
169
|
|
|
158
|
-
function
|
|
170
|
+
function describeHistoricalEmoticon(
|
|
171
|
+
messageType: number,
|
|
172
|
+
attachment: Record<string, unknown> | null,
|
|
173
|
+
): DescribedAttachment {
|
|
159
174
|
const kind: KakaoEmoticonKind | undefined =
|
|
160
175
|
KAKAO_EMOTICON_KIND_BY_TYPE[messageType as keyof typeof KAKAO_EMOTICON_KIND_BY_TYPE]
|
|
161
|
-
|
|
176
|
+
let filename: string | null = null
|
|
162
177
|
if (attachment !== null) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
178
|
+
filename = stringField(attachment, 'path') ?? stringField(attachment, 'emoticonItemPath')
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
kind: 'sticker',
|
|
182
|
+
ref: '',
|
|
183
|
+
...(filename !== null ? { filename } : { filename: kind ?? `sticker-${messageType}` }),
|
|
170
184
|
}
|
|
171
|
-
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function renderPlaceholder(id: number, attachment: DescribedAttachment | InboundAttachment): string {
|
|
188
|
+
const parts: string[] = [`KakaoTalk attachment #${id}: ${attachment.kind}`]
|
|
189
|
+
if (attachment.width !== undefined && attachment.height !== undefined) {
|
|
190
|
+
parts.push(`${attachment.width}x${attachment.height}`)
|
|
191
|
+
}
|
|
192
|
+
if (attachment.mimetype !== undefined) parts.push(attachment.mimetype)
|
|
193
|
+
if (attachment.filename !== undefined) parts.push(`name=${attachment.filename}`)
|
|
194
|
+
if (attachment.sizeBytes !== undefined) parts.push(`size=${attachment.sizeBytes}`)
|
|
195
|
+
return `[${parts.join(' ')}]`
|
|
172
196
|
}
|
|
173
197
|
|
|
174
198
|
function stringField(record: Record<string, unknown>, key: string): string | null {
|
|
@@ -181,34 +205,17 @@ function numericField(record: Record<string, unknown>, key: string): number | nu
|
|
|
181
205
|
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
182
206
|
}
|
|
183
207
|
|
|
184
|
-
// Wraps a KakaoTalk emoticon push event into the MSG-shaped payload that
|
|
185
|
-
// `classifyInbound` expects. We synthesize `message` from the sticker
|
|
186
|
-
// metadata so the classifier's empty-text drop doesn't fire on stickers,
|
|
187
|
-
// and we carry the original message_type through so a later code path
|
|
188
|
-
// can still distinguish stickers from text if needed.
|
|
189
208
|
export function emoticonEventToMessageEvent(event: KakaoTalkPushEmoticonEvent): KakaoTalkPushMessageEvent {
|
|
209
|
+
const { text } = splitEmoticonInbound(event)
|
|
190
210
|
return {
|
|
191
211
|
type: 'MSG',
|
|
192
212
|
chat_id: event.chat_id,
|
|
193
213
|
log_id: event.log_id,
|
|
194
214
|
author_id: event.author_id,
|
|
195
215
|
author_name: event.author_name,
|
|
196
|
-
message:
|
|
216
|
+
message: text,
|
|
197
217
|
message_type: event.message_type,
|
|
198
218
|
attachment: null,
|
|
199
219
|
sent_at: event.sent_at,
|
|
200
220
|
}
|
|
201
221
|
}
|
|
202
|
-
|
|
203
|
-
// Helper used by the history callback to convert a KakaoMessage (which
|
|
204
|
-
// shares the same `attachment` shape as the push event) into displayable
|
|
205
|
-
// text. Kept separate from `formatInboundText` so the live and history
|
|
206
|
-
// paths can evolve independently — e.g. history may eventually surface
|
|
207
|
-
// thumbnails or extra fields the push event doesn't carry.
|
|
208
|
-
export function formatHistoryText(message: KakaoMessage): string {
|
|
209
|
-
return formatInboundText({
|
|
210
|
-
message: message.message,
|
|
211
|
-
message_type: message.type,
|
|
212
|
-
attachment: message.attachment,
|
|
213
|
-
})
|
|
214
|
-
}
|
|
@@ -2,7 +2,7 @@ import type { KakaoTalkPushMessageEvent } from 'agent-messenger/kakaotalk'
|
|
|
2
2
|
|
|
3
3
|
import { matchesAnyAlias } from '@/channels/engagement'
|
|
4
4
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
5
|
-
import type { InboundMessage } from '@/channels/types'
|
|
5
|
+
import type { InboundAttachment, InboundMessage } from '@/channels/types'
|
|
6
6
|
|
|
7
7
|
export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect' | 'bot_message'
|
|
8
8
|
|
|
@@ -28,6 +28,10 @@ export type KakaoInboundContext = {
|
|
|
28
28
|
selfUserId: string | null
|
|
29
29
|
lookupChat: KakaoChatLookup
|
|
30
30
|
selfAliases?: readonly string[]
|
|
31
|
+
// The adapter splits attachment refs out of prompt-visible text before
|
|
32
|
+
// classification. Keeping them on context makes classifyInbound's payload
|
|
33
|
+
// construction the single place that stamps InboundMessage fields.
|
|
34
|
+
attachments?: readonly InboundAttachment[]
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export function classifyInbound(
|
|
@@ -70,6 +74,9 @@ export function classifyInbound(
|
|
|
70
74
|
chat: event.chat_id,
|
|
71
75
|
thread: null,
|
|
72
76
|
text,
|
|
77
|
+
...(context.attachments !== undefined && context.attachments.length > 0
|
|
78
|
+
? { attachments: context.attachments }
|
|
79
|
+
: {}),
|
|
73
80
|
externalMessageId: event.log_id,
|
|
74
81
|
authorId: String(event.author_id),
|
|
75
82
|
authorName: event.author_name ?? String(event.author_id),
|