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.
Files changed (62) hide show
  1. package/package.json +1 -1
  2. package/scripts/dump-system-prompt.ts +12 -11
  3. package/src/agent/index.ts +15 -22
  4. package/src/agent/loop-guard.ts +170 -0
  5. package/src/agent/model-fallback.ts +2 -1
  6. package/src/agent/multimodal/index.ts +1 -1
  7. package/src/agent/multimodal/look-at.ts +118 -55
  8. package/src/agent/plugin-tools.ts +57 -0
  9. package/src/agent/subagents.ts +2 -1
  10. package/src/agent/system-prompt.ts +39 -26
  11. package/src/agent/tools/channel-fetch-attachment.ts +45 -16
  12. package/src/agent/tools/normalize-ref.ts +11 -0
  13. package/src/agent/tools/skip-response.ts +24 -32
  14. package/src/agent/tools/spawn-subagent.ts +2 -0
  15. package/src/bundled-plugins/reviewer/index.ts +11 -0
  16. package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
  17. package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
  18. package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
  19. package/src/channels/adapters/discord-bot-classify.ts +32 -24
  20. package/src/channels/adapters/github/inbound.ts +63 -7
  21. package/src/channels/adapters/github/index.ts +32 -0
  22. package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
  23. package/src/channels/adapters/kakaotalk-classify.ts +8 -1
  24. package/src/channels/adapters/kakaotalk.ts +19 -11
  25. package/src/channels/adapters/slack-bot-classify.ts +30 -14
  26. package/src/channels/adapters/slack-bot.ts +3 -2
  27. package/src/channels/adapters/telegram-bot-classify.ts +36 -13
  28. package/src/channels/adapters/telegram-bot.ts +3 -3
  29. package/src/channels/outbound-flood-filter.ts +57 -0
  30. package/src/channels/router.ts +114 -15
  31. package/src/channels/types.ts +52 -1
  32. package/src/cli/builtins.ts +1 -0
  33. package/src/cli/index.ts +1 -0
  34. package/src/cli/mount.ts +157 -0
  35. package/src/cli/update.ts +6 -4
  36. package/src/config/mounts-mutation.ts +161 -0
  37. package/src/doctor/channel-checks.ts +328 -0
  38. package/src/doctor/checks.ts +2 -0
  39. package/src/init/dockerfile.ts +24 -7
  40. package/src/init/hatching.ts +1 -1
  41. package/src/plugin/index.ts +6 -0
  42. package/src/plugin/load-skill.ts +99 -0
  43. package/src/run/bundled-plugins.ts +2 -0
  44. package/src/run/index.ts +31 -1
  45. package/src/secrets/claude-credentials-json.ts +129 -0
  46. package/src/secrets/codex-auth-json.ts +67 -0
  47. package/src/secrets/export-claude-credentials-file.ts +279 -0
  48. package/src/secrets/export-codex-auth-file.ts +243 -0
  49. package/src/secrets/index.ts +16 -0
  50. package/src/server/command-runner.ts +2 -1
  51. package/src/server/index.ts +3 -2
  52. package/src/shared/index.ts +7 -1
  53. package/src/shared/local-time.ts +32 -0
  54. package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
  55. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
  56. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
  57. package/src/skills/typeclaw-claude-code/SKILL.md +5 -4
  58. package/src/skills/typeclaw-claude-code/references/auth-flow.md +35 -0
  59. package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
  60. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
  61. package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
  62. 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
- // agent-messenger 2.15.0 added two inbound surfaces that 2.14.1 hid from
11
- // the adapter: `KakaoTalkPushMessageEvent.attachment` (photos, files, etc.)
12
- // and a separate `emoticon` listener event for stickers. The SDK leaves
13
- // the `attachment` Record opaque on purpose ("treat it as opaque and
14
- // narrow per `type`", docs/sdk/kakaotalk.mdx). For photos (type=2) the
15
- // keys are documented (`k`, `w`, `h`, `mt`, `url`). For everything else
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 synthesized text follows the same `[KakaoTalk message with ...]`
22
- // convention used by Slack/Discord/Telegram inbound classifiers, so the
23
- // agent sees a consistent placeholder shape across platforms.
24
-
25
- // Non-text inputs that the adapter accepts. We use a thin shared shape
26
- // rather than the SDK's union so the same formatter can serve both push
27
- // events (no `attachment` on emoticon events emoticon fields live on
28
- // the event itself) and history messages.
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 function formatInboundText(event: InboundLike): string {
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 summary = summarizeAttachment(event)
38
- if (summary === null) return rawText
39
- const wrapped = `[KakaoTalk message with ${summary}]`
40
- return rawText === '' ? wrapped : `${rawText}\n${wrapped}`
41
- }
42
-
43
- // Synthesizes the displayed text for a sticker / emoticon event. Stickers
44
- // have no `message` field on the push event — the SDK extracts `pack_id`
45
- // and `sticker_path` from the LOCO attachment for us, so we render those
46
- // directly into the placeholder. Matches Discord's `sticker: name` shape
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
- ): string {
52
- return `[KakaoTalk message with ${summarizeEmoticon(event)}]`
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 summarizeAttachment(event: InboundLike): string | null {
56
- // Narrow to message types we know how to render. Anything else (system
57
- // events, deleted messages, future LOCO control packets that the SDK
58
- // surfaces as MSG with empty text) intentionally falls through to a
59
- // null summary so classifyInbound's empty_text drop fires and the
60
- // agent isn't woken up by phantom `[KakaoTalk message with type=N]`
61
- // placeholders for noise.
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 summarizePhoto(event.attachment)
76
+ return describePhoto(event.attachment)
67
77
  case KAKAO_MESSAGE_TYPE.VIDEO:
68
- return summarizeGeneric('video', event.attachment)
78
+ return describeGeneric('video', event.attachment)
69
79
  case KAKAO_MESSAGE_TYPE.AUDIO:
70
- return summarizeGeneric('audio', event.attachment)
80
+ return describeGeneric('audio', event.attachment)
71
81
  case KAKAO_MESSAGE_TYPE.FILE:
72
- return summarizeFile(event.attachment)
82
+ return describeFile(event.attachment)
73
83
  case KAKAO_MESSAGE_TYPE.MULTIPHOTO:
74
- return summarizeGeneric('multiphoto', event.attachment)
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 summarizeHistoricalEmoticon(event.message_type, event.attachment)
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 summarizePhoto(attachment: Record<string, unknown> | null): string {
93
- if (attachment === null) return 'photo'
94
- const parts = ['photo']
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
- if (mime !== null) parts.push(`(${mime})`)
100
- // Prefer the public URL over the CDN key the URL is dereferenceable,
101
- // the key is an internal CDN path. Either is acceptable as a `ref` if
102
- // we ever wire fetchAttachment for photos.
103
- const url = stringField(attachment, 'url') ?? stringField(attachment, 'k')
104
- if (url !== null) parts.push(url)
105
- return parts.join(' ')
106
- }
107
-
108
- function summarizeFile(attachment: Record<string, unknown> | null): string {
109
- if (attachment === null) return 'file'
110
- const parts = ['file']
111
- // File attachments are not documented by the SDK; these field names are
112
- // best-effort common keys (`name`, `size`, `mt`, `url`) used by similar
113
- // protocols. If a key is absent we just omit it rather than fabricating
114
- // a value.
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
- if (url !== null) return `${label} (${attachmentKeysSummary(attachment)}) ${url}`
136
- return `${label} ${attachmentKeysSummary(attachment)}`
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
- // Last-resort renderer: list the attachment's keys so the agent at least
140
- // knows what shape the payload had. We deliberately do NOT dump values —
141
- // some attachment payloads contain long base64 strings or large URLs that
142
- // would blow the agent's context window if pasted whole.
143
- function attachmentKeysSummary(attachment: Record<string, unknown>): string {
144
- const keys = Object.keys(attachment).sort()
145
- if (keys.length === 0) return '(empty)'
146
- return `keys=[${keys.join(',')}]`
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 summarizeEmoticon(
151
+ function describeEmoticon(
150
152
  event: Pick<KakaoTalkPushEmoticonEvent, 'emoticon_kind' | 'pack_id' | 'sticker_path'>,
151
- ): string {
152
- const parts = [`sticker (${event.emoticon_kind})`]
153
- if (event.pack_id !== null) parts.push(`pack=${event.pack_id}`)
154
- if (event.sticker_path !== null) parts.push(`path=${event.sticker_path}`)
155
- return parts.join(' ')
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 summarizeHistoricalEmoticon(messageType: number, attachment: Record<string, unknown> | null): string {
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
- const parts = [`sticker (${kind ?? `type=${messageType}`})`]
176
+ let filename: string | null = null
162
177
  if (attachment !== null) {
163
- const path = stringField(attachment, 'path') ?? stringField(attachment, 'emoticonItemPath')
164
- if (path !== null) {
165
- const dotIndex = path.indexOf('.')
166
- const head = dotIndex > 0 ? path.slice(0, dotIndex) : null
167
- if (head !== null && /^\d+$/.test(head)) parts.push(`pack=${head}`)
168
- parts.push(`path=${path}`)
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
- return parts.join(' ')
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: formatEmoticonText(event),
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 { emoticonEventToMessageEvent, formatHistoryText, formatInboundText } from './kakaotalk-attachment'
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: formatHistoryText(m),
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
- // Synthesize the displayed text BEFORE classify so attachments
335
- // (photo, file, video, ...) survive classifyInbound's empty_text
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
- await processInbound(emoticonEventToMessageEvent(event))
353
+ const { attachments } = splitEmoticonInbound(event)
354
+ await processInbound(emoticonEventToMessageEvent(event), attachments)
351
355
  }
352
356
 
353
- const processInbound = async (event: KakaoTalkPushMessageEvent): Promise<void> => {
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 = inboundText(event)
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 message with attachment: ...]` summary contains URLs
76
- // and ids that must not be misread as mentions or group broadcasts.
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
- function inboundText(event: SlackInboundMessageEvent): string {
170
+ type SplitInbound = { text: string; attachments: InboundAttachment[] }
171
+
172
+ function splitInbound(event: SlackInboundMessageEvent): SplitInbound {
170
173
  const rawText = event.text ?? ''
171
- const mediaSummary = summarizeSlackMedia(event)
172
- if (mediaSummary.length === 0) return rawText
173
- const summary = `[Slack message with ${mediaSummary.join('; ')}]`
174
- return rawText === '' ? summary : `${rawText}\n${summary}`
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 summarizeSlackMedia(event: SlackInboundMessageEvent): string[] {
178
- return (event.files ?? []).map(summarizeSlackFile)
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 summarizeSlackFile(file: SlackFile): string {
182
- const parts: string[] = [`attachment: ${file.name}`, `(${file.mimetype})`, `id=${file.id}`]
183
- return parts.join(' ')
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
- // classifier emits `id=Fxxxx` in the inbound text exactly so the agent
696
- // can hand the id back to this callback.
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 = inboundText(event)
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
- function inboundText(event: TelegramMessage): string {
134
+ type SplitInbound = { text: string; attachments: InboundAttachment[] }
135
+
136
+ function splitInbound(event: TelegramMessage): SplitInbound {
134
137
  const body = event.text ?? event.caption ?? ''
135
- const mediaSummary = summarizeMedia(event)
136
- if (mediaSummary.length === 0) return body
137
- const summary = `[Telegram message with ${mediaSummary.join('; ')}]`
138
- return body === '' ? summary : `${body}\n${summary}`
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 summarizeMedia(event: TelegramMessage): string[] {
142
- const parts: string[] = []
145
+ function describeMedia(event: TelegramMessage): InboundAttachment[] {
146
+ const parts: InboundAttachment[] = []
143
147
  if (event.document !== undefined) {
144
- const name = event.document.file_name ?? event.document.file_id
145
- const mime = event.document.mime_type !== undefined ? ` (${event.document.mime_type})` : ''
146
- parts.push(`document: ${name}${mime} file_id=${event.document.file_id}`)
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(`photo: ${largest.width}x${largest.height} file_id=${largest.file_id}`)
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 the inbound classifier's `[Telegram message with
283
- // document: ... file_id=<id>]` summary; the agent passes it back through
284
- // the `channel_fetch_attachment` tool.
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