typeclaw 0.12.0 → 0.14.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 +39 -26
- package/src/agent/tools/channel-fetch-attachment.ts +45 -16
- package/src/agent/tools/normalize-ref.ts +11 -0
- package/src/agent/tools/skip-response.ts +24 -32
- package/src/agent/tools/spawn-subagent.ts +2 -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 +63 -7
- package/src/channels/adapters/github/index.ts +32 -0
- 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 +114 -15
- 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/doctor/channel-checks.ts +328 -0
- package/src/doctor/checks.ts +2 -0
- package/src/init/dockerfile.ts +24 -7
- 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 +31 -1
- package/src/secrets/claude-credentials-json.ts +129 -0
- package/src/secrets/codex-auth-json.ts +67 -0
- package/src/secrets/export-claude-credentials-file.ts +279 -0
- package/src/secrets/export-codex-auth-file.ts +243 -0
- package/src/secrets/index.ts +16 -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-claude-code/SKILL.md +5 -4
- package/src/skills/typeclaw-claude-code/references/auth-flow.md +35 -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
|
@@ -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),
|
|
@@ -26,9 +26,15 @@ import type {
|
|
|
26
26
|
OutboundMessage,
|
|
27
27
|
ResolvedChannelNames,
|
|
28
28
|
SendResult,
|
|
29
|
+
InboundAttachment,
|
|
29
30
|
} from '@/channels/types'
|
|
30
31
|
|
|
31
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
emoticonEventToMessageEvent,
|
|
34
|
+
splitEmoticonInbound,
|
|
35
|
+
splitHistoryInbound,
|
|
36
|
+
splitInbound,
|
|
37
|
+
} from './kakaotalk-attachment'
|
|
32
38
|
import { createKakaoAuthorResolver, type KakaoAuthorResolver } from './kakaotalk-author-resolver'
|
|
33
39
|
import { createKakaoChannelResolver, type KakaoChannelResolver } from './kakaotalk-channel-resolver'
|
|
34
40
|
import { classifyInbound, type InboundDropReason } from './kakaotalk-classify'
|
|
@@ -252,11 +258,13 @@ export function createKakaoHistoryCallback(deps: {
|
|
|
252
258
|
messages.map(async (m) => {
|
|
253
259
|
const authorId = String(m.author_id)
|
|
254
260
|
const authorName = m.author_name ?? (await authorResolver.resolve(authorId, args.chat)) ?? authorId
|
|
261
|
+
const { text, attachments } = splitHistoryInbound(m)
|
|
255
262
|
return {
|
|
256
263
|
externalMessageId: m.log_id,
|
|
257
264
|
authorId,
|
|
258
265
|
authorName,
|
|
259
|
-
text
|
|
266
|
+
text,
|
|
267
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
260
268
|
ts: m.sent_at * 1000,
|
|
261
269
|
isBot: selfId !== null && authorId === selfId,
|
|
262
270
|
replyToBotMessageId: null,
|
|
@@ -331,13 +339,8 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
|
|
|
331
339
|
const fetchAttachmentCallback = createFetchAttachmentCallback({ logger })
|
|
332
340
|
|
|
333
341
|
const handleMessageEvent = async (event: KakaoTalkPushMessageEvent): Promise<void> => {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
// drop and reach the agent with a `[KakaoTalk message with ...]`
|
|
337
|
-
// placeholder. For text-only messages this is a no-op —
|
|
338
|
-
// formatInboundText returns event.message unchanged. See
|
|
339
|
-
// kakaotalk-attachment.ts for the per-message-type rules.
|
|
340
|
-
await processInbound({ ...event, message: formatInboundText(event) })
|
|
342
|
+
const { text, attachments } = splitInbound(event)
|
|
343
|
+
await processInbound({ ...event, message: text }, attachments)
|
|
341
344
|
}
|
|
342
345
|
|
|
343
346
|
const handleEmoticonEvent = async (event: KakaoTalkPushEmoticonEvent): Promise<void> => {
|
|
@@ -347,10 +350,14 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
|
|
|
347
350
|
// self-author / unknown-chat rules apply identically across plain
|
|
348
351
|
// messages and stickers — there is no second classifier to keep in
|
|
349
352
|
// sync.
|
|
350
|
-
|
|
353
|
+
const { attachments } = splitEmoticonInbound(event)
|
|
354
|
+
await processInbound(emoticonEventToMessageEvent(event), attachments)
|
|
351
355
|
}
|
|
352
356
|
|
|
353
|
-
const processInbound = async (
|
|
357
|
+
const processInbound = async (
|
|
358
|
+
event: KakaoTalkPushMessageEvent,
|
|
359
|
+
attachments: readonly InboundAttachment[] = [],
|
|
360
|
+
): Promise<void> => {
|
|
354
361
|
inflightInbounds++
|
|
355
362
|
try {
|
|
356
363
|
if (channelResolver.lookupChat(event.chat_id) === null) {
|
|
@@ -391,6 +398,7 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
|
|
|
391
398
|
const verdict = classifyInbound(event, options.configRef(), {
|
|
392
399
|
selfUserId,
|
|
393
400
|
lookupChat: (id) => channelResolver.lookupChat(id),
|
|
401
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
394
402
|
...(options.selfAliasesRef ? { selfAliases: options.selfAliasesRef() } : {}),
|
|
395
403
|
})
|
|
396
404
|
if (verdict.kind === 'drop') {
|
|
@@ -2,7 +2,7 @@ import type { SlackFile, SlackSocketModeAppMentionEvent, SlackSocketModeMessageE
|
|
|
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
|
import { slackTsToMillis } from './slack-bot-time'
|
|
8
8
|
|
|
@@ -61,7 +61,7 @@ export function classifyInbound(
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
const rawText = event.text ?? ''
|
|
64
|
-
const text =
|
|
64
|
+
const { text, attachments } = splitInbound(event)
|
|
65
65
|
if (text === '') return { kind: 'drop', reason: 'empty_text' }
|
|
66
66
|
|
|
67
67
|
const isDm = event.channel_type === 'im'
|
|
@@ -72,8 +72,8 @@ export function classifyInbound(
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
// Mention parsing runs against the raw user-typed text only — the
|
|
75
|
-
// appended `[Slack
|
|
76
|
-
//
|
|
75
|
+
// appended `[Slack attachment #N: ...]` placeholder contains metadata
|
|
76
|
+
// that must not be misread as mentions or group broadcasts.
|
|
77
77
|
// Group mentions (`<!here>`, `<!channel>`, `<!everyone>`) are coerced to
|
|
78
78
|
// direct mentions: the user fired a broadcast that explicitly includes the
|
|
79
79
|
// bot, and from the engagement layer's perspective there is no meaningful
|
|
@@ -131,6 +131,7 @@ export function classifyInbound(
|
|
|
131
131
|
chat: event.channel,
|
|
132
132
|
thread,
|
|
133
133
|
text,
|
|
134
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
134
135
|
externalMessageId: event.ts,
|
|
135
136
|
authorId: event.user,
|
|
136
137
|
authorName: event.user,
|
|
@@ -166,19 +167,34 @@ function extractMentionedUserIds(text: string): string[] {
|
|
|
166
167
|
return Array.from(seen)
|
|
167
168
|
}
|
|
168
169
|
|
|
169
|
-
|
|
170
|
+
type SplitInbound = { text: string; attachments: InboundAttachment[] }
|
|
171
|
+
|
|
172
|
+
function splitInbound(event: SlackInboundMessageEvent): SplitInbound {
|
|
170
173
|
const rawText = event.text ?? ''
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
173
|
-
const summary =
|
|
174
|
-
|
|
174
|
+
const attachments = describeSlackMedia(event)
|
|
175
|
+
if (attachments.length === 0) return { text: rawText, attachments: [] }
|
|
176
|
+
const summary = attachments.map(renderPlaceholder).join('\n')
|
|
177
|
+
const text = rawText === '' ? summary : `${rawText}\n${summary}`
|
|
178
|
+
return { text, attachments }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function describeSlackMedia(event: SlackInboundMessageEvent): InboundAttachment[] {
|
|
182
|
+
return (event.files ?? []).map((file, index) => describeSlackFile(file, index + 1))
|
|
175
183
|
}
|
|
176
184
|
|
|
177
|
-
function
|
|
178
|
-
return
|
|
185
|
+
function describeSlackFile(file: SlackFile, id: number): InboundAttachment {
|
|
186
|
+
return {
|
|
187
|
+
id,
|
|
188
|
+
kind: 'file',
|
|
189
|
+
ref: file.id,
|
|
190
|
+
filename: file.name,
|
|
191
|
+
mimetype: file.mimetype,
|
|
192
|
+
}
|
|
179
193
|
}
|
|
180
194
|
|
|
181
|
-
function
|
|
182
|
-
const parts: string[] = [`attachment
|
|
183
|
-
|
|
195
|
+
function renderPlaceholder(attachment: InboundAttachment): string {
|
|
196
|
+
const parts: string[] = [`Slack attachment #${attachment.id}: ${attachment.kind}`]
|
|
197
|
+
if (attachment.mimetype !== undefined) parts.push(attachment.mimetype)
|
|
198
|
+
if (attachment.filename !== undefined) parts.push(`name=${attachment.filename}`)
|
|
199
|
+
return `[${parts.join(' ')}]`
|
|
184
200
|
}
|
|
@@ -692,8 +692,9 @@ export function createOutboundCallback(deps: {
|
|
|
692
692
|
// plain HTTP tool. Routing through the SDK's `downloadFile(fileId)` is
|
|
693
693
|
// the only path that works — it issues `files.info` to fetch metadata
|
|
694
694
|
// (mimetype + name) then GETs `url_private` with the bot token. The
|
|
695
|
-
//
|
|
696
|
-
//
|
|
695
|
+
// classifiers now keep the bare `Fxxxx` id in structured InboundAttachment.ref
|
|
696
|
+
// (legacy persisted state may still carry the old prompt-visible `id=` shape,
|
|
697
|
+
// which channel_fetch_attachment strips before reaching this callback).
|
|
697
698
|
export function createFetchAttachmentCallback(deps: {
|
|
698
699
|
client: Pick<SlackBotClient, 'downloadFile'>
|
|
699
700
|
logger: SlackBotAdapterLogger
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { TelegramBotUser, TelegramMessage, TelegramMessageEntity } from 'agent-messenger/telegrambot'
|
|
2
2
|
|
|
3
3
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
4
|
-
import type { InboundMessage } from '@/channels/types'
|
|
4
|
+
import type { InboundAttachment, InboundMessage } from '@/channels/types'
|
|
5
5
|
|
|
6
6
|
export type InboundDropReason = 'self_author' | 'no_user' | 'empty_text' | 'pre_connect'
|
|
7
7
|
|
|
@@ -31,7 +31,7 @@ export function classifyInbound(
|
|
|
31
31
|
return { kind: 'drop', reason: 'self_author' }
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const text =
|
|
34
|
+
const { text, attachments } = splitInbound(event)
|
|
35
35
|
if (text === '') return { kind: 'drop', reason: 'empty_text' }
|
|
36
36
|
|
|
37
37
|
const chat = String(event.chat.id)
|
|
@@ -70,6 +70,7 @@ export function classifyInbound(
|
|
|
70
70
|
chat,
|
|
71
71
|
thread,
|
|
72
72
|
text,
|
|
73
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
73
74
|
externalMessageId: String(event.message_id),
|
|
74
75
|
authorId: String(author.id),
|
|
75
76
|
authorName: formatAuthorName(author),
|
|
@@ -130,24 +131,46 @@ function isUserMentionForBot(
|
|
|
130
131
|
return false
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
|
|
134
|
+
type SplitInbound = { text: string; attachments: InboundAttachment[] }
|
|
135
|
+
|
|
136
|
+
function splitInbound(event: TelegramMessage): SplitInbound {
|
|
134
137
|
const body = event.text ?? event.caption ?? ''
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
137
|
-
const summary =
|
|
138
|
-
|
|
138
|
+
const attachments = describeMedia(event)
|
|
139
|
+
if (attachments.length === 0) return { text: body, attachments: [] }
|
|
140
|
+
const summary = attachments.map(renderPlaceholder).join('\n')
|
|
141
|
+
const text = body === '' ? summary : `${body}\n${summary}`
|
|
142
|
+
return { text, attachments }
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
function
|
|
142
|
-
const parts:
|
|
145
|
+
function describeMedia(event: TelegramMessage): InboundAttachment[] {
|
|
146
|
+
const parts: InboundAttachment[] = []
|
|
143
147
|
if (event.document !== undefined) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
148
|
+
parts.push({
|
|
149
|
+
id: parts.length + 1,
|
|
150
|
+
kind: 'file',
|
|
151
|
+
ref: event.document.file_id,
|
|
152
|
+
...(event.document.file_name !== undefined ? { filename: event.document.file_name } : {}),
|
|
153
|
+
...(event.document.mime_type !== undefined ? { mimetype: event.document.mime_type } : {}),
|
|
154
|
+
})
|
|
147
155
|
}
|
|
148
156
|
if (event.photo !== undefined && event.photo.length > 0) {
|
|
149
157
|
const largest = event.photo[event.photo.length - 1]!
|
|
150
|
-
parts.push(
|
|
158
|
+
parts.push({
|
|
159
|
+
id: parts.length + 1,
|
|
160
|
+
kind: 'photo',
|
|
161
|
+
ref: largest.file_id,
|
|
162
|
+
width: largest.width,
|
|
163
|
+
height: largest.height,
|
|
164
|
+
})
|
|
151
165
|
}
|
|
152
166
|
return parts
|
|
153
167
|
}
|
|
168
|
+
|
|
169
|
+
function renderPlaceholder(attachment: InboundAttachment): string {
|
|
170
|
+
const parts: string[] = [`Telegram attachment #${attachment.id}: ${attachment.kind}`]
|
|
171
|
+
if (attachment.width !== undefined && attachment.height !== undefined)
|
|
172
|
+
parts.push(`${attachment.width}x${attachment.height}`)
|
|
173
|
+
if (attachment.mimetype !== undefined) parts.push(attachment.mimetype)
|
|
174
|
+
if (attachment.filename !== undefined) parts.push(`name=${attachment.filename}`)
|
|
175
|
+
return `[${parts.join(' ')}]`
|
|
176
|
+
}
|
|
@@ -279,9 +279,9 @@ type TelegramFileResponse = {
|
|
|
279
279
|
// Telegram's file download is a two-step protocol: `getFile` returns a
|
|
280
280
|
// short-lived `file_path`, then the file lives at
|
|
281
281
|
// `api.telegram.org/file/bot<TOKEN>/<file_path>`. `ref` here is the
|
|
282
|
-
// `file_id` carried in
|
|
283
|
-
//
|
|
284
|
-
//
|
|
282
|
+
// `file_id` carried in structured InboundAttachment.ref. The agent only sees
|
|
283
|
+
// `[Telegram attachment #N: ...]` and passes that id through the
|
|
284
|
+
// `channel_fetch_attachment` tool; the router resolves it to this callback.
|
|
285
285
|
//
|
|
286
286
|
// SSRF boundary: `ref` is `encodeURIComponent`'d into a query parameter
|
|
287
287
|
// of a fixed `api.telegram.org/bot<TOKEN>/getFile?file_id=...` URL, so
|