typeclaw 0.14.0 → 0.15.1
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 +2 -2
- package/src/agent/system-prompt.ts +10 -9
- package/src/agent/tools/channel-reply.ts +37 -27
- package/src/agent/tools/channel-send.ts +13 -8
- package/src/agent/tools/runtime-notice.ts +28 -0
- package/src/agent/tools/webfetch/tool.ts +1 -0
- package/src/agent/tools/websearch.ts +2 -1
- package/src/channels/adapters/discord-bot.ts +8 -1
- package/src/channels/adapters/kakaotalk-format.ts +239 -0
- package/src/channels/adapters/kakaotalk.ts +54 -5
- package/src/channels/adapters/telegram-bot.ts +11 -1
- package/src/channels/router.ts +204 -21
- package/src/channels/types.ts +22 -0
- package/src/cli/inspect.ts +29 -25
- package/src/config/providers.ts +17 -4
- package/src/container/start.ts +17 -0
- package/src/init/dockerfile.ts +21 -1
- package/src/inspect/live.ts +13 -3
- package/src/sandbox/availability.ts +35 -0
- package/src/sandbox/build.ts +128 -0
- package/src/sandbox/errors.ts +20 -0
- package/src/sandbox/index.ts +14 -0
- package/src/sandbox/policy.ts +47 -0
- package/src/sandbox/quote.ts +18 -0
- package/src/server/index.ts +16 -2
- package/src/shared/index.ts +1 -7
- package/src/shared/local-time.ts +14 -22
- package/src/shared/protocol.ts +4 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +11 -9
- package/typeclaw.schema.json +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typeclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"homepage": "https://github.com/typeclaw/typeclaw#readme",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/typeclaw/typeclaw/issues"
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@mariozechner/pi-coding-agent": "^0.67.3",
|
|
47
47
|
"@mariozechner/pi-tui": "^0.67.3",
|
|
48
48
|
"@mozilla/readability": "^0.6.0",
|
|
49
|
-
"agent-messenger": "2.
|
|
49
|
+
"agent-messenger": "2.19.0",
|
|
50
50
|
"cheerio": "^1.2.0",
|
|
51
51
|
"citty": "^0.2.2",
|
|
52
52
|
"cron-parser": "^5.5.0",
|
|
@@ -151,19 +151,20 @@ TypeClaw runtime version: ${version}.`
|
|
|
151
151
|
// would already be re-billed on each turn's user message — so this is
|
|
152
152
|
// cache-free relative to the previous "## Now" placement.
|
|
153
153
|
//
|
|
154
|
-
// The block emits
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
// (`<system-reminder>` etc.), so
|
|
161
|
-
//
|
|
154
|
+
// The block emits the English weekday name alongside the ISO timestamp
|
|
155
|
+
// because models frequently compute weekday-from-ISO incorrectly;
|
|
156
|
+
// pre-computing it removes that arithmetic step entirely. English only:
|
|
157
|
+
// TypeClaw's users are global, so the anchor uses one canonical language
|
|
158
|
+
// and leaves reply language to each agent's SOUL.md. The framing is a
|
|
159
|
+
// single `<current-time>` XML tag for parity with other runtime-injected
|
|
160
|
+
// per-turn blocks the agent already sees (`<system-reminder>` etc.), so
|
|
161
|
+
// the model reads it as a structured anchor rather than as content
|
|
162
|
+
// authored by a human in the chat.
|
|
162
163
|
export function renderTurnTimeAnchor(now: Date = new Date()): string {
|
|
163
164
|
const iso = formatLocalDateTime(now)
|
|
164
165
|
const zone = resolveLocalTimezoneName()
|
|
165
166
|
const weekday = formatLocalWeekday(now)
|
|
166
|
-
return `<current-time>${iso} (${zone}, ${weekday
|
|
167
|
+
return `<current-time>${iso} (${zone}, ${weekday})</current-time>`
|
|
167
168
|
}
|
|
168
169
|
|
|
169
170
|
// Compact replacement for DEFAULT_SYSTEM_PROMPT, used by non-interactive
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import type { AdapterId } from '@/channels/schema'
|
|
11
11
|
|
|
12
12
|
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
13
|
-
import { fenceRuntimeNotice } from './runtime-notice'
|
|
13
|
+
import { fenceRuntimeNotice, fenceToolResult } from './runtime-notice'
|
|
14
14
|
|
|
15
15
|
export type ChannelReplyOrigin = {
|
|
16
16
|
adapter: AdapterId
|
|
@@ -138,34 +138,37 @@ export function createChannelReplyTool({
|
|
|
138
138
|
// Without this echo, a model that splits a multi-part reply has no
|
|
139
139
|
// way to tell "did I already send part 1?" from "I haven't started
|
|
140
140
|
// yet", and routinely re-sends near-duplicates within the same turn
|
|
141
|
-
// (observed in production: two consecutive identical
|
|
142
|
-
//
|
|
141
|
+
// (observed in production: two consecutive identical greeting messages
|
|
142
|
+
// to one prompt).
|
|
143
143
|
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
144
|
+
// The echo is the model's OWN words, which is uniquely seductive to
|
|
145
|
+
// "reply" to, so on the success path we wrap the whole result in the
|
|
146
|
+
// strong SYSTEM MESSAGE fence (`fenceToolResult`) rather than the weak
|
|
147
|
+
// `[system: tool result...]` prefix — the prefix did not stop Kimi from
|
|
148
|
+
// answering its own echo and looping (PR #481). Denials carry no echoed
|
|
149
|
+
// prose (just machine error text), so they keep the lighter prefix.
|
|
150
|
+
if (result.ok) {
|
|
151
|
+
const echo = renderOutboundEcho(text, attachments)
|
|
152
|
+
const receipt = `posted to ${origin.adapter}:${origin.workspace}/${origin.chat}: ${echo}`
|
|
153
|
+
const hint = consecutiveSendHint(
|
|
154
|
+
router.getConsecutiveSendCount({
|
|
155
|
+
adapter: origin.adapter,
|
|
156
|
+
workspace: origin.workspace,
|
|
157
|
+
chat: origin.chat,
|
|
158
|
+
thread: origin.thread,
|
|
159
|
+
}),
|
|
160
|
+
)
|
|
161
|
+
// Keep fenceToolResult here — do NOT "unify" the success branch back to
|
|
162
|
+
// TOOL_RESULT_PREFIX to match the denial branch below. The prefix is
|
|
163
|
+
// intentionally weaker and is safe ONLY because denials carry no echoed
|
|
164
|
+
// prose; the success result does, and the weak prefix let Kimi loop.
|
|
165
|
+
return {
|
|
166
|
+
content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hint}` }],
|
|
167
|
+
details,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
167
170
|
return {
|
|
168
|
-
content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}${
|
|
171
|
+
content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}channel_reply denied: ${result.error}` }],
|
|
169
172
|
details,
|
|
170
173
|
}
|
|
171
174
|
},
|
|
@@ -188,6 +191,13 @@ export function renderEcho(text: string): string {
|
|
|
188
191
|
return `${JSON.stringify(text.slice(0, ECHO_MAX_CHARS))}... (${text.length} chars total)`
|
|
189
192
|
}
|
|
190
193
|
|
|
194
|
+
// DO NOT remove this echo or replace it with a hash/length-only "receipt" to
|
|
195
|
+
// stop the self-reply loop (PR #481). That trade was tried and rejected: the
|
|
196
|
+
// echo is the model's only view of what it already said (the inbound path
|
|
197
|
+
// drops self-authored messages), so without the FULL text a split reply
|
|
198
|
+
// re-sends near-duplicates — the exact bug 58c62c1 added the echo to fix, and
|
|
199
|
+
// a fingerprint cannot catch paraphrased near-dupes. The loop is solved by
|
|
200
|
+
// FENCING this echo (see fenceToolResult call site below), not by removing it.
|
|
191
201
|
export function renderOutboundEcho(
|
|
192
202
|
text: string | undefined,
|
|
193
203
|
attachments: ReadonlyArray<{ path: string; filename?: string }> | undefined,
|
|
@@ -11,7 +11,7 @@ import { ADAPTER_IDS, type AdapterId } from '@/channels/schema'
|
|
|
11
11
|
|
|
12
12
|
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
13
13
|
import { renderOutboundEcho, TOOL_RESULT_PREFIX } from './channel-reply'
|
|
14
|
-
import { fenceRuntimeNotice } from './runtime-notice'
|
|
14
|
+
import { fenceRuntimeNotice, fenceToolResult } from './runtime-notice'
|
|
15
15
|
|
|
16
16
|
export type ChannelSendOrigin = {
|
|
17
17
|
adapter: AdapterId
|
|
@@ -154,12 +154,13 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
154
154
|
)
|
|
155
155
|
}
|
|
156
156
|
const details: { ok: boolean; error?: string } = result.ok ? { ok: true } : { ok: false, error: result.error }
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
: `channel_send denied: ${result.error}`
|
|
161
|
-
const hints: string[] = []
|
|
157
|
+
// Success wraps the echoed sent text in the strong SYSTEM MESSAGE fence;
|
|
158
|
+
// denials keep the lighter prefix. See channel-reply.ts for the full
|
|
159
|
+
// rationale (PR #481 self-reply loop).
|
|
162
160
|
if (result.ok) {
|
|
161
|
+
const echo = renderOutboundEcho(bodyText, attachments)
|
|
162
|
+
const receipt = `posted to ${params.adapter}:${params.workspace}/${params.chat}: ${echo}`
|
|
163
|
+
const hints: string[] = []
|
|
163
164
|
const consecutive = consecutiveSendHint(
|
|
164
165
|
router.getConsecutiveSendCount({
|
|
165
166
|
adapter,
|
|
@@ -177,10 +178,14 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
177
178
|
thread: params.thread,
|
|
178
179
|
})
|
|
179
180
|
if (threadMismatch) hints.push(threadMismatch)
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hints.join('')}` }],
|
|
184
|
+
details,
|
|
185
|
+
}
|
|
180
186
|
}
|
|
181
|
-
const body = hints.length > 0 ? `${baseText}${hints.join('')}` : baseText
|
|
182
187
|
return {
|
|
183
|
-
content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}${
|
|
188
|
+
content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}channel_send denied: ${result.error}` }],
|
|
184
189
|
details,
|
|
185
190
|
}
|
|
186
191
|
},
|
|
@@ -39,3 +39,31 @@ export function fenceRuntimeNotice(body: string): string {
|
|
|
39
39
|
'---'
|
|
40
40
|
)
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
// Wraps a channel tool result (delivery confirmation + echoed sent text) in the
|
|
44
|
+
// SAME canonical SYSTEM MESSAGE framing as fenceRuntimeNotice — but as the
|
|
45
|
+
// ENTIRE result body, not an appended hint, so there is no unfenced prose for
|
|
46
|
+
// the model to read as conversation.
|
|
47
|
+
//
|
|
48
|
+
// The echoed sent text is load-bearing (the bot has no other view of what it
|
|
49
|
+
// just said — the inbound path drops self-authored messages — so without it a
|
|
50
|
+
// split reply re-sends near-duplicates). But that text is the model's OWN
|
|
51
|
+
// words, which is uniquely seductive to "reply" to: a persona-rich model
|
|
52
|
+
// (Kimi K2 on the GitHub channel, PR #481) read its own delivered prose as a
|
|
53
|
+
// fresh user turn and answered it ("you're welcome!", "aww thanks!") until the
|
|
54
|
+
// per-turn send cap. The weak `[system: tool result...]` prefix did not stop
|
|
55
|
+
// the misread; the full fence — bracketed marker, horizontal-rule fences,
|
|
56
|
+
// explicit "Do not reply" closer — has months of production track record
|
|
57
|
+
// against Kimi (it already wraps the consecutive-send and thread-mismatch
|
|
58
|
+
// hints). Reusing the exact same shape extends that protection to the echo.
|
|
59
|
+
export function fenceToolResult(receipt: string): string {
|
|
60
|
+
return (
|
|
61
|
+
'---\n' +
|
|
62
|
+
'**[SYSTEM MESSAGE — not from a human]**\n\n' +
|
|
63
|
+
receipt +
|
|
64
|
+
'\n\nThe text above is your OWN already-delivered message, echoed back so ' +
|
|
65
|
+
'you can see what you sent — it is NOT a new message from anyone in the ' +
|
|
66
|
+
'chat. **Do not acknowledge or reply to it.**\n' +
|
|
67
|
+
'---'
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -24,6 +24,7 @@ export const webfetchTool = defineTool({
|
|
|
24
24
|
description:
|
|
25
25
|
'Fetch a single HTTP(S) URL and return the body, optionally compacted by a strategy. ' +
|
|
26
26
|
'Use this when the user references a specific URL or when websearch surfaced a result you need to read in full. ' +
|
|
27
|
+
'If `spawn_subagent` is available to you, PREFER delegating to the `scout` subagent by default: spawn it whenever you expect more than one fetch, an "across multiple sources" task, or any search-then-fetch loop. Scout runs the noisy fetching in its own context window and returns a distilled, citation-backed answer, keeping bulky page bodies out of yours. Only call this tool directly for a single known URL whose content you will cite immediately — or whenever you cannot spawn subagents (e.g. you are yourself a subagent), in which case fetch here. ' +
|
|
27
28
|
'Outbound requests impersonate Chrome 136 at the TLS, HTTP/2, and header layers ' +
|
|
28
29
|
'(via curl-impersonate), which helps with TLS/header fingerprint gates on sites behind Cloudflare/Akamai. ' +
|
|
29
30
|
'It does NOT solve JavaScript challenges, behavioural fingerprinting (mouse/scroll/timing), interactive CAPTCHAs, ' +
|
|
@@ -20,7 +20,8 @@ export const websearchTool = defineTool({
|
|
|
20
20
|
name: 'websearch',
|
|
21
21
|
label: 'Web Search',
|
|
22
22
|
description:
|
|
23
|
-
'Search the public web. Returns a ranked list of {title, url, snippet} entries. Use `source: "wikipedia"` for encyclopedic lookups; otherwise default to general web results from DuckDuckGo. Pair this with the `read` tool by visiting URLs you find with `bash` (curl) when you need full page contents
|
|
23
|
+
'Search the public web. Returns a ranked list of {title, url, snippet} entries. Use `source: "wikipedia"` for encyclopedic lookups; otherwise default to general web results from DuckDuckGo. Pair this with the `read` tool by visiting URLs you find with `bash` (curl) when you need full page contents.\n' +
|
|
24
|
+
'If `spawn_subagent` is available to you, PREFER delegating to the `scout` subagent by default: spawn it whenever the research is non-trivial (more than 1-2 queries, any "across multiple sources" framing, or follow-up fetches of the results). Scout runs `websearch`/`webfetch` in its own context window and returns a distilled, citation-backed answer, so the search churn never pollutes yours. Only call this tool directly for a single query whose top result you will cite immediately — or whenever you cannot spawn subagents (e.g. you are yourself a subagent), in which case run the searches here.',
|
|
24
25
|
parameters: Type.Object({
|
|
25
26
|
query: Type.String({ description: 'The search query.' }),
|
|
26
27
|
limit: Type.Optional(
|
|
@@ -393,7 +393,14 @@ export function createOutboundCallback(deps: {
|
|
|
393
393
|
}
|
|
394
394
|
|
|
395
395
|
try {
|
|
396
|
-
const
|
|
396
|
+
const sendOptions: { thread_id?: string; reply_to?: string } = {}
|
|
397
|
+
if (msg.thread) sendOptions.thread_id = msg.thread
|
|
398
|
+
if (msg.replyTo?.externalMessageId) sendOptions.reply_to = msg.replyTo.externalMessageId
|
|
399
|
+
const sent = await client.sendMessage(
|
|
400
|
+
msg.chat,
|
|
401
|
+
text,
|
|
402
|
+
Object.keys(sendOptions).length > 0 ? sendOptions : undefined,
|
|
403
|
+
)
|
|
397
404
|
logger.info(`[discord-bot] sent id=${sent.id} ${tag}`)
|
|
398
405
|
return { ok: true }
|
|
399
406
|
} catch (err) {
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// KakaoTalk's LOCO protocol renders no rich text — bytes display verbatim, so
|
|
2
|
+
// the agent's Markdown (`**bold**`, `### heading`, fenced ```blocks```) leaks
|
|
3
|
+
// literal `*`/`#`/backtick noise into the chat. This strips the formatting
|
|
4
|
+
// markers and keeps the content. Mirrors telegram-bot-format.ts, but emits
|
|
5
|
+
// plain content instead of re-encoding to MarkdownV2. Links collapse to
|
|
6
|
+
// `label (url)` so the destination survives; list/quote markers stay (they
|
|
7
|
+
// read fine unrendered).
|
|
8
|
+
|
|
9
|
+
export function toKakaoPlainText(input: string): string {
|
|
10
|
+
// Pull fenced code out first so a `*` inside a block is not re-tokenized as
|
|
11
|
+
// italic.
|
|
12
|
+
const out: string[] = []
|
|
13
|
+
let i = 0
|
|
14
|
+
while (i < input.length) {
|
|
15
|
+
if (matchesAt(input, i, '```')) {
|
|
16
|
+
const fenceEnd = findFenceEnd(input, i + 3)
|
|
17
|
+
if (fenceEnd !== -1) {
|
|
18
|
+
out.push(renderFence(input.slice(i + 3, fenceEnd)))
|
|
19
|
+
i = fenceEnd + 3
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
// Unterminated fence — strip the open backticks and render the rest
|
|
23
|
+
// inline so we never infinite-loop and never drop the tail.
|
|
24
|
+
out.push(renderInline(stripLeadingFence(input.slice(i + 3))))
|
|
25
|
+
break
|
|
26
|
+
}
|
|
27
|
+
const nextFence = input.indexOf('```', i)
|
|
28
|
+
const segmentEnd = nextFence === -1 ? input.length : nextFence
|
|
29
|
+
out.push(renderLines(input.slice(i, segmentEnd)))
|
|
30
|
+
i = segmentEnd
|
|
31
|
+
}
|
|
32
|
+
return out.join('')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function matchesAt(s: string, idx: number, needle: string): boolean {
|
|
36
|
+
return s.slice(idx, idx + needle.length) === needle
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findFenceEnd(s: string, start: number): number {
|
|
40
|
+
return s.indexOf('```', start)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stripLeadingFence(inner: string): string {
|
|
44
|
+
// Drop an optional language hint and the newline after an opening fence.
|
|
45
|
+
const newline = inner.indexOf('\n')
|
|
46
|
+
if (newline === -1) return inner
|
|
47
|
+
const candidate = inner.slice(0, newline).trim()
|
|
48
|
+
if (candidate === '' || /^[A-Za-z0-9_+\-.]+$/.test(candidate)) {
|
|
49
|
+
return inner.slice(newline + 1)
|
|
50
|
+
}
|
|
51
|
+
return inner
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderFence(inner: string): string {
|
|
55
|
+
// Keep the code body verbatim, drop the fences and any language hint.
|
|
56
|
+
let body = inner
|
|
57
|
+
const newline = inner.indexOf('\n')
|
|
58
|
+
if (newline !== -1) {
|
|
59
|
+
const candidate = inner.slice(0, newline).trim()
|
|
60
|
+
if (candidate === '' || /^[A-Za-z0-9_+\-.]+$/.test(candidate)) {
|
|
61
|
+
body = inner.slice(newline + 1)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (body.endsWith('\n')) body = body.slice(0, -1)
|
|
65
|
+
return body
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Strip per-line block markers (heading hashes, blockquote arrows) before
|
|
69
|
+
// running the inline tokenizer on each line. List markers (`- `, `* `, `1.`)
|
|
70
|
+
// are left intact — they read fine as plain text and signal structure.
|
|
71
|
+
function renderLines(text: string): string {
|
|
72
|
+
const lines = text.split('\n')
|
|
73
|
+
const rendered = lines.map((line) => renderInline(stripBlockMarkers(line)))
|
|
74
|
+
return rendered.join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function stripBlockMarkers(line: string): string {
|
|
78
|
+
// `### heading` → `heading`; `> quote` → `quote`. Only acts on leading
|
|
79
|
+
// markers after optional indentation so mid-line `#`/`>` stay literal.
|
|
80
|
+
const heading = /^(\s*)#{1,6}\s+(.*)$/.exec(line)
|
|
81
|
+
if (heading !== null) return heading[1]! + heading[2]!
|
|
82
|
+
const quote = /^(\s*)>\s?(.*)$/.exec(line)
|
|
83
|
+
if (quote !== null) return quote[1]! + quote[2]!
|
|
84
|
+
return line
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Inline tokenizer. Recognizes (in priority order):
|
|
88
|
+
// 1. Inline code: `code` → code
|
|
89
|
+
// 2. Links: [text](url) → text (url)
|
|
90
|
+
// 3. Bold: **text** / __text__ → text
|
|
91
|
+
// 4. Strikethrough: ~~text~~ → text
|
|
92
|
+
// 5. Italic: *text* / _text_ → text
|
|
93
|
+
//
|
|
94
|
+
// Bold is checked before italic so `**` is not eaten as two italic markers.
|
|
95
|
+
// Word-boundary guards keep snake_case identifiers and `a*b` math from being
|
|
96
|
+
// mistaken for emphasis — the same rules the Telegram formatter uses.
|
|
97
|
+
function renderInline(text: string): string {
|
|
98
|
+
const out: string[] = []
|
|
99
|
+
let i = 0
|
|
100
|
+
while (i < text.length) {
|
|
101
|
+
const ch = text[i]!
|
|
102
|
+
|
|
103
|
+
if (ch === '`') {
|
|
104
|
+
const close = text.indexOf('`', i + 1)
|
|
105
|
+
if (close !== -1) {
|
|
106
|
+
out.push(text.slice(i + 1, close))
|
|
107
|
+
i = close + 1
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (ch === '[') {
|
|
113
|
+
const link = parseLink(text, i)
|
|
114
|
+
if (link !== null) {
|
|
115
|
+
const label = renderInline(link.label)
|
|
116
|
+
out.push(link.url === '' ? label : `${label} (${link.url})`)
|
|
117
|
+
i = link.end
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (ch === '*' && text[i + 1] === '*') {
|
|
123
|
+
const close = findClose(text, i + 2, '**')
|
|
124
|
+
if (close !== -1 && close > i + 2) {
|
|
125
|
+
out.push(renderInline(text.slice(i + 2, close)))
|
|
126
|
+
i = close + 2
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (ch === '_' && text[i + 1] === '_' && !isWordChar(text[i - 1])) {
|
|
131
|
+
const close = findClose(text, i + 2, '__')
|
|
132
|
+
if (close !== -1 && close > i + 2 && !isWordChar(text[close + 2])) {
|
|
133
|
+
out.push(renderInline(text.slice(i + 2, close)))
|
|
134
|
+
i = close + 2
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (ch === '~' && text[i + 1] === '~') {
|
|
140
|
+
const close = findClose(text, i + 2, '~~')
|
|
141
|
+
if (close !== -1 && close > i + 2) {
|
|
142
|
+
out.push(renderInline(text.slice(i + 2, close)))
|
|
143
|
+
i = close + 2
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (ch === '*' && !isWordChar(text[i - 1])) {
|
|
149
|
+
const close = findInlineClose(text, i + 1, '*')
|
|
150
|
+
if (close !== -1 && !isWordChar(text[close + 1])) {
|
|
151
|
+
const inner = text.slice(i + 1, close)
|
|
152
|
+
if (inner !== '' && !/^\s|\s$/.test(inner)) {
|
|
153
|
+
out.push(renderInline(inner))
|
|
154
|
+
i = close + 1
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (ch === '_' && !isWordChar(text[i - 1])) {
|
|
160
|
+
const close = findInlineClose(text, i + 1, '_')
|
|
161
|
+
if (close !== -1 && !isWordChar(text[close + 1])) {
|
|
162
|
+
const inner = text.slice(i + 1, close)
|
|
163
|
+
if (inner !== '' && !/^\s|\s$/.test(inner)) {
|
|
164
|
+
out.push(renderInline(inner))
|
|
165
|
+
i = close + 1
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
out.push(ch)
|
|
172
|
+
i++
|
|
173
|
+
}
|
|
174
|
+
return out.join('')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function findClose(text: string, from: number, marker: string): number {
|
|
178
|
+
let i = from
|
|
179
|
+
while (i <= text.length - marker.length) {
|
|
180
|
+
if (text[i] === '\\') {
|
|
181
|
+
i += 2
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
if (matchesAt(text, i, marker)) return i
|
|
185
|
+
i++
|
|
186
|
+
}
|
|
187
|
+
return -1
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function findInlineClose(text: string, from: number, marker: string): number {
|
|
191
|
+
let i = from
|
|
192
|
+
while (i < text.length) {
|
|
193
|
+
if (text[i] === '\n') return -1
|
|
194
|
+
if (text[i] === '\\') {
|
|
195
|
+
i += 2
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
if (matchesAt(text, i, marker)) return i
|
|
199
|
+
i++
|
|
200
|
+
}
|
|
201
|
+
return -1
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseLink(text: string, start: number): { label: string; url: string; end: number } | null {
|
|
205
|
+
let i = start + 1
|
|
206
|
+
const labelStart = i
|
|
207
|
+
while (i < text.length) {
|
|
208
|
+
const c = text[i]!
|
|
209
|
+
if (c === '\\') {
|
|
210
|
+
i += 2
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (c === ']') break
|
|
214
|
+
if (c === '\n') return null
|
|
215
|
+
i++
|
|
216
|
+
}
|
|
217
|
+
if (text[i] !== ']' || text[i + 1] !== '(') return null
|
|
218
|
+
const label = text.slice(labelStart, i)
|
|
219
|
+
const urlStart = i + 2
|
|
220
|
+
let j = urlStart
|
|
221
|
+
while (j < text.length) {
|
|
222
|
+
const c = text[j]!
|
|
223
|
+
if (c === '\\') {
|
|
224
|
+
j += 2
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
if (c === ')') break
|
|
228
|
+
if (c === '(') return null
|
|
229
|
+
if (c === '\n') return null
|
|
230
|
+
j++
|
|
231
|
+
}
|
|
232
|
+
if (text[j] !== ')') return null
|
|
233
|
+
return { label, url: text.slice(urlStart, j), end: j + 1 }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isWordChar(ch: string | undefined): boolean {
|
|
237
|
+
if (ch === undefined) return false
|
|
238
|
+
return /[A-Za-z0-9_]/.test(ch)
|
|
239
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type KakaoMember,
|
|
9
9
|
type KakaoMessage,
|
|
10
10
|
type KakaoProfile,
|
|
11
|
+
type KakaoReplyTarget,
|
|
11
12
|
type KakaoSendResult,
|
|
12
13
|
type KakaoTalkListenerEventMap,
|
|
13
14
|
type KakaoTalkPushEmoticonEvent,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
} from 'agent-messenger/kakaotalk'
|
|
16
17
|
import type { KakaoAccountCredentials, KakaoConfig, PendingLoginState } from 'agent-messenger/kakaotalk'
|
|
17
18
|
|
|
18
|
-
import type
|
|
19
|
+
import { prependQuoteAnchor, type ChannelRouter } from '@/channels/router'
|
|
19
20
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
20
21
|
import type {
|
|
21
22
|
ChannelHistoryMessage,
|
|
@@ -39,6 +40,7 @@ import { createKakaoAuthorResolver, type KakaoAuthorResolver } from './kakaotalk
|
|
|
39
40
|
import { createKakaoChannelResolver, type KakaoChannelResolver } from './kakaotalk-channel-resolver'
|
|
40
41
|
import { classifyInbound, type InboundDropReason } from './kakaotalk-classify'
|
|
41
42
|
import { createFetchAttachmentCallback } from './kakaotalk-fetch-attachment'
|
|
43
|
+
import { toKakaoPlainText } from './kakaotalk-format'
|
|
42
44
|
|
|
43
45
|
// Structural duck-type of the upstream KakaoTalkClient class. The upstream
|
|
44
46
|
// type is a class with private fields, and TypeScript treats those
|
|
@@ -53,7 +55,7 @@ export interface KakaoTalkClient {
|
|
|
53
55
|
): Promise<this>
|
|
54
56
|
getChats(options?: { all?: boolean; search?: string }): Promise<KakaoChat[]>
|
|
55
57
|
getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]>
|
|
56
|
-
sendMessage(chatId: string, text: string): Promise<KakaoSendResult>
|
|
58
|
+
sendMessage(chatId: string, text: string, options?: { replyTo?: KakaoReplyTarget }): Promise<KakaoSendResult>
|
|
57
59
|
sendAttachment(
|
|
58
60
|
chatId: string,
|
|
59
61
|
data: Uint8Array | Buffer,
|
|
@@ -160,6 +162,11 @@ export type KakaotalkAdapter = {
|
|
|
160
162
|
|
|
161
163
|
export const KAKAO_HISTORY_LIMIT_MAX = 200
|
|
162
164
|
|
|
165
|
+
// How far back to scan for a reply target's source message. Matches the upstream
|
|
166
|
+
// CLI's window; an anchored reply targets the message just answered, so the
|
|
167
|
+
// target is almost always near the head of this window.
|
|
168
|
+
const KAKAO_REPLY_LOOKUP_COUNT = 100
|
|
169
|
+
|
|
163
170
|
function formatLabel(name: string | undefined, id: string, prefix = ''): string {
|
|
164
171
|
if (name === undefined || name === '' || name === id) return id
|
|
165
172
|
return `${prefix}${name}(${id})`
|
|
@@ -171,7 +178,7 @@ async function readAttachmentBuffer(path: string): Promise<Buffer> {
|
|
|
171
178
|
}
|
|
172
179
|
|
|
173
180
|
export function createOutboundCallback(deps: {
|
|
174
|
-
client: Pick<KakaoTalkClient, 'sendMessage' | 'sendAttachment'>
|
|
181
|
+
client: Pick<KakaoTalkClient, 'sendMessage' | 'sendAttachment' | 'getMessages'>
|
|
175
182
|
logger: KakaotalkAdapterLogger
|
|
176
183
|
formatChannelTag: (workspace: string, chat: string) => Promise<string>
|
|
177
184
|
readFile?: (path: string) => Promise<Buffer>
|
|
@@ -182,7 +189,7 @@ export function createOutboundCallback(deps: {
|
|
|
182
189
|
if (msg.adapter !== 'kakaotalk') {
|
|
183
190
|
return { ok: false, error: `unknown adapter: ${msg.adapter}` }
|
|
184
191
|
}
|
|
185
|
-
const text = msg.text ?? ''
|
|
192
|
+
const text = toKakaoPlainText(msg.text ?? '')
|
|
186
193
|
const attachments = msg.attachments ?? []
|
|
187
194
|
if (text === '' && attachments.length === 0) {
|
|
188
195
|
return { ok: false, error: 'message has neither text nor attachments' }
|
|
@@ -221,8 +228,26 @@ export function createOutboundCallback(deps: {
|
|
|
221
228
|
}
|
|
222
229
|
|
|
223
230
|
if (text !== '') {
|
|
231
|
+
// KakaoTalk's native reply payload is built from the *source* message
|
|
232
|
+
// (author, original text, type), which the SDK does not derive from a
|
|
233
|
+
// bare log_id — we resolve it from recent history. If that lookup can't
|
|
234
|
+
// find the target (scrolled past the window, or the fetch failed), we
|
|
235
|
+
// degrade to the same blockquote anchor the router uses for quote-mode
|
|
236
|
+
// adapters, so the reply still visibly references the right message.
|
|
237
|
+
let outboundText = text
|
|
238
|
+
let replyTarget: KakaoReplyTarget | undefined
|
|
239
|
+
if (msg.replyTo !== undefined) {
|
|
240
|
+
replyTarget = await resolveKakaoReplyTarget(client, msg.chat, msg.replyTo.externalMessageId, logger)
|
|
241
|
+
if (replyTarget === undefined && msg.replyTo.source !== undefined) {
|
|
242
|
+
outboundText = prependQuoteAnchor(text, msg.replyTo.source)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
224
245
|
try {
|
|
225
|
-
const result = await client.sendMessage(
|
|
246
|
+
const result = await client.sendMessage(
|
|
247
|
+
msg.chat,
|
|
248
|
+
outboundText,
|
|
249
|
+
replyTarget !== undefined ? { replyTo: replyTarget } : undefined,
|
|
250
|
+
)
|
|
226
251
|
if (!result.success) {
|
|
227
252
|
logger.error(`[kakaotalk] sendMessage status_code=${result.status_code} ${tag}`)
|
|
228
253
|
return { ok: false, error: `kakaotalk send failed with status ${result.status_code}` }
|
|
@@ -239,6 +264,30 @@ export function createOutboundCallback(deps: {
|
|
|
239
264
|
}
|
|
240
265
|
}
|
|
241
266
|
|
|
267
|
+
// KakaoTalk replies need the full source message, not just its log_id. Resolve
|
|
268
|
+
// it from the chat's recent history (matching the upstream CLI's approach).
|
|
269
|
+
// Returns undefined when the target isn't in the fetched window or the fetch
|
|
270
|
+
// throws — the caller degrades to the blockquote fallback in that case.
|
|
271
|
+
async function resolveKakaoReplyTarget(
|
|
272
|
+
client: Pick<KakaoTalkClient, 'getMessages'>,
|
|
273
|
+
chatId: string,
|
|
274
|
+
externalMessageId: string,
|
|
275
|
+
logger: KakaotalkAdapterLogger,
|
|
276
|
+
): Promise<KakaoReplyTarget | undefined> {
|
|
277
|
+
try {
|
|
278
|
+
const messages = await client.getMessages(chatId, { count: KAKAO_REPLY_LOOKUP_COUNT })
|
|
279
|
+
const target = messages.find((m) => m.log_id === externalMessageId)
|
|
280
|
+
if (target === undefined) {
|
|
281
|
+
logger.warn(`[kakaotalk] reply target log_id=${externalMessageId} not in last ${KAKAO_REPLY_LOOKUP_COUNT}`)
|
|
282
|
+
return undefined
|
|
283
|
+
}
|
|
284
|
+
return { log_id: target.log_id, author_id: target.author_id, message: target.message, type: target.type }
|
|
285
|
+
} catch (err) {
|
|
286
|
+
logger.warn(`[kakaotalk] reply target lookup failed: ${describe(err)}`)
|
|
287
|
+
return undefined
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
242
291
|
export function createKakaoHistoryCallback(deps: {
|
|
243
292
|
client: Pick<KakaoTalkClient, 'getMessages'>
|
|
244
293
|
logger: KakaotalkAdapterLogger
|
|
@@ -251,8 +251,12 @@ export function createOutboundCallback(deps: {
|
|
|
251
251
|
|
|
252
252
|
try {
|
|
253
253
|
const rendered = toTelegramMarkdownV2(text)
|
|
254
|
-
const sendOptions: { message_thread_id?: number; parse_mode: 'MarkdownV2' } = {
|
|
254
|
+
const sendOptions: { message_thread_id?: number; reply_to_message_id?: number; parse_mode: 'MarkdownV2' } = {
|
|
255
|
+
parse_mode: 'MarkdownV2',
|
|
256
|
+
}
|
|
255
257
|
if (threadId !== undefined) sendOptions.message_thread_id = threadId
|
|
258
|
+
const replyToId = parseTelegramMessageId(msg.replyTo?.externalMessageId)
|
|
259
|
+
if (replyToId !== undefined) sendOptions.reply_to_message_id = replyToId
|
|
256
260
|
const sent = await client.sendMessage(msg.chat, rendered, sendOptions)
|
|
257
261
|
logger.info(`[telegram-bot] sent message_id=${sent.message_id} ${tag}`)
|
|
258
262
|
return { ok: true }
|
|
@@ -270,6 +274,12 @@ function parseThreadId(thread: string | null | undefined): number | undefined {
|
|
|
270
274
|
return Number.isFinite(n) ? n : undefined
|
|
271
275
|
}
|
|
272
276
|
|
|
277
|
+
function parseTelegramMessageId(id: string | null | undefined): number | undefined {
|
|
278
|
+
if (id === null || id === undefined || id === '') return undefined
|
|
279
|
+
const n = Number(id)
|
|
280
|
+
return Number.isInteger(n) && n > 0 ? n : undefined
|
|
281
|
+
}
|
|
282
|
+
|
|
273
283
|
type TelegramFileResponse = {
|
|
274
284
|
ok: boolean
|
|
275
285
|
result?: { file_id: string; file_unique_id: string; file_size?: number; file_path?: string }
|