typeclaw 0.1.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/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- package/typeclaw.schema.json +826 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import { TelegramBotClient, TelegramBotListener } from 'agent-messenger/telegrambot'
|
|
2
|
+
import type { TelegramBotUser, TelegramMessage } from 'agent-messenger/telegrambot'
|
|
3
|
+
|
|
4
|
+
import type { MembershipResolver, MembershipResolverFailure, MembershipResolverResult } from '@/channels/membership'
|
|
5
|
+
import type { ChannelRouter } from '@/channels/router'
|
|
6
|
+
import { isAllowed, type ChannelAdapterConfig } from '@/channels/schema'
|
|
7
|
+
import type {
|
|
8
|
+
ChannelNameResolver,
|
|
9
|
+
FetchAttachmentCallback,
|
|
10
|
+
OutboundCallback,
|
|
11
|
+
OutboundMessage,
|
|
12
|
+
ResolvedChannelNames,
|
|
13
|
+
SendResult,
|
|
14
|
+
TypingCallback,
|
|
15
|
+
TypingTarget,
|
|
16
|
+
} from '@/channels/types'
|
|
17
|
+
|
|
18
|
+
import { classifyInbound, type InboundDropReason, TELEGRAM_WORKSPACE } from './telegram-bot-classify'
|
|
19
|
+
import { toTelegramMarkdownV2 } from './telegram-bot-format'
|
|
20
|
+
|
|
21
|
+
export const TELEGRAM_API_BASE = 'https://api.telegram.org'
|
|
22
|
+
|
|
23
|
+
// Only subscribe to update kinds the adapter actually classifies. Edits
|
|
24
|
+
// (`edited_message`, `edited_channel_post`) are deliberately omitted so
|
|
25
|
+
// Telegram does not deliver them — discord-bot.ts and slack-bot.ts also
|
|
26
|
+
// skip edits today, and quietly receiving them via `allowedUpdates` would
|
|
27
|
+
// advance the SDK's offset past the edit without any classification or
|
|
28
|
+
// log line, making "agent missed an edit" invisible.
|
|
29
|
+
const TELEGRAM_ALLOWED_UPDATES = ['message', 'channel_post']
|
|
30
|
+
|
|
31
|
+
// Outbound is rendered through `toTelegramMarkdownV2` and sent with
|
|
32
|
+
// `parse_mode: 'MarkdownV2'`. The formatter takes the model's common
|
|
33
|
+
// Markdown (`**bold**`, `*italic*`, `` `code` ``, fenced blocks,
|
|
34
|
+
// `[label](url)`) and emits MarkdownV2 with every reserved char escaped
|
|
35
|
+
// in the right region (outside-entity vs `code`/`pre` vs link-url),
|
|
36
|
+
// guaranteeing Telegram's parser will never reject the output. See
|
|
37
|
+
// `telegram-bot-format.ts` for the exact rules. Plain text — no
|
|
38
|
+
// formatting markers — round-trips through the formatter unchanged
|
|
39
|
+
// modulo escaped specials, so this is a safe default with no opt-out.
|
|
40
|
+
|
|
41
|
+
export type TelegramBotAdapterLogger = {
|
|
42
|
+
info: (msg: string) => void
|
|
43
|
+
warn: (msg: string) => void
|
|
44
|
+
error: (msg: string) => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const consoleLogger: TelegramBotAdapterLogger = {
|
|
48
|
+
info: (m) => console.log(m),
|
|
49
|
+
warn: (m) => console.warn(m),
|
|
50
|
+
error: (m) => console.error(m),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Test seams for `createTelegramBotAdapter`. Production callers omit these
|
|
54
|
+
// and the real SDK constructors are used; tests inject fakes to drive
|
|
55
|
+
// listener events deterministically (especially the silent-startup and
|
|
56
|
+
// inflight-during-stop paths that the real SDK doesn't expose hooks for).
|
|
57
|
+
export type TelegramBotClientFactory = () => TelegramBotClient
|
|
58
|
+
export type TelegramBotListenerFactory = (
|
|
59
|
+
client: TelegramBotClient,
|
|
60
|
+
options: ConstructorParameters<typeof TelegramBotListener>[1],
|
|
61
|
+
) => TelegramBotListener
|
|
62
|
+
|
|
63
|
+
export type TelegramBotAdapterOptions = {
|
|
64
|
+
router: ChannelRouter
|
|
65
|
+
configRef: () => ChannelAdapterConfig
|
|
66
|
+
token: string
|
|
67
|
+
logger?: TelegramBotAdapterLogger
|
|
68
|
+
createClient?: TelegramBotClientFactory
|
|
69
|
+
createListener?: TelegramBotListenerFactory
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type TelegramBotAdapter = {
|
|
73
|
+
start: () => Promise<void>
|
|
74
|
+
stop: () => Promise<void>
|
|
75
|
+
isConnected: () => boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createTypingCallback(deps: {
|
|
79
|
+
token: string
|
|
80
|
+
configRef: () => ChannelAdapterConfig
|
|
81
|
+
logger: TelegramBotAdapterLogger
|
|
82
|
+
formatChannelTag?: (chat: string) => Promise<string>
|
|
83
|
+
fetchImpl?: typeof fetch
|
|
84
|
+
}): TypingCallback {
|
|
85
|
+
const { token, configRef, logger, formatChannelTag } = deps
|
|
86
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
87
|
+
return async (target: TypingTarget): Promise<void> => {
|
|
88
|
+
if (target.adapter !== 'telegram-bot') return
|
|
89
|
+
// Telegram's `sendChatAction` indicator auto-expires after ~5s. We
|
|
90
|
+
// re-fire on each router tick (every 8s while debouncing/generating);
|
|
91
|
+
// a missed beat just gaps the indicator. There is no explicit clear,
|
|
92
|
+
// so the 'stop' phase is a no-op.
|
|
93
|
+
if (target.phase === 'stop') return
|
|
94
|
+
const config = configRef()
|
|
95
|
+
if (!isAllowed(config.allow, target.workspace, target.chat)) return
|
|
96
|
+
const tag = formatChannelTag ? await formatChannelTag(target.chat) : `chat=${target.chat}`
|
|
97
|
+
const body: Record<string, unknown> = { chat_id: target.chat, action: 'typing' }
|
|
98
|
+
const threadId = parseThreadId(target.thread)
|
|
99
|
+
if (threadId !== undefined) body.message_thread_id = threadId
|
|
100
|
+
try {
|
|
101
|
+
const response = await fetchImpl(`${TELEGRAM_API_BASE}/bot${token}/sendChatAction`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'content-type': 'application/json' },
|
|
104
|
+
body: JSON.stringify(body),
|
|
105
|
+
})
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
logger.warn(`[telegram-bot] typing ${tag} status=${response.status}`)
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
logger.warn(`[telegram-bot] typing ${tag} failed: ${describe(err)}`)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createChannelNameResolver(deps: {
|
|
116
|
+
client: Pick<TelegramBotClient, 'getChat'>
|
|
117
|
+
ttlMs?: number
|
|
118
|
+
now?: () => number
|
|
119
|
+
}): ChannelNameResolver {
|
|
120
|
+
const ttlMs = deps.ttlMs ?? 24 * 60 * 60 * 1000
|
|
121
|
+
const now = deps.now ?? Date.now
|
|
122
|
+
const cache = new Map<string, { value: string; expiresAt: number }>()
|
|
123
|
+
|
|
124
|
+
return async (key): Promise<ResolvedChannelNames> => {
|
|
125
|
+
if (key.adapter !== 'telegram-bot') return {}
|
|
126
|
+
const cached = cache.get(key.chat)
|
|
127
|
+
if (cached && cached.expiresAt > now()) {
|
|
128
|
+
return { chatName: cached.value }
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const chat = await deps.client.getChat(key.chat)
|
|
132
|
+
const name = chatLabel(chat)
|
|
133
|
+
if (name === null) return {}
|
|
134
|
+
cache.set(key.chat, { value: name, expiresAt: now() + ttlMs })
|
|
135
|
+
return { chatName: name }
|
|
136
|
+
} catch {
|
|
137
|
+
return {}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function chatLabel(chat: {
|
|
143
|
+
title?: string
|
|
144
|
+
username?: string
|
|
145
|
+
first_name?: string
|
|
146
|
+
last_name?: string
|
|
147
|
+
}): string | null {
|
|
148
|
+
if (chat.title !== undefined && chat.title !== '') return chat.title
|
|
149
|
+
if (chat.username !== undefined && chat.username !== '') return `@${chat.username}`
|
|
150
|
+
const first = chat.first_name ?? ''
|
|
151
|
+
const last = chat.last_name ?? ''
|
|
152
|
+
if (first === '' && last === '') return null
|
|
153
|
+
return last === '' ? first : `${first} ${last}`
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createTelegramMembershipResolver(deps: {
|
|
157
|
+
client: Pick<TelegramBotClient, 'getChat' | 'getChatMemberCount'>
|
|
158
|
+
logger: TelegramBotAdapterLogger
|
|
159
|
+
now?: () => number
|
|
160
|
+
}): MembershipResolver {
|
|
161
|
+
const now = deps.now ?? Date.now
|
|
162
|
+
return async (key): Promise<MembershipResolverResult> => {
|
|
163
|
+
if (key.adapter !== 'telegram-bot') return { kind: 'permanent' } satisfies MembershipResolverFailure
|
|
164
|
+
try {
|
|
165
|
+
const chat = await deps.client.getChat(key.chat)
|
|
166
|
+
// 1:1 chats have no /members endpoint and are exactly the bot + the
|
|
167
|
+
// user; report the canonical pair so the engagement layer can apply
|
|
168
|
+
// the DM trigger without a network round-trip per inbound.
|
|
169
|
+
if (chat.type === 'private') {
|
|
170
|
+
return { humans: 1, bots: 1, fetchedAt: now(), truncated: false }
|
|
171
|
+
}
|
|
172
|
+
const count = await deps.client.getChatMemberCount(key.chat)
|
|
173
|
+
const total = Math.max(0, Math.floor(count))
|
|
174
|
+
// Telegram's Bot API does not expose a per-member listing for groups
|
|
175
|
+
// beyond `getChatAdministrators`, so we cannot split humans from
|
|
176
|
+
// bots cheaply. We KNOW the bot itself is a member of any group it
|
|
177
|
+
// received a message from, so report `bots: 1` and put the rest in
|
|
178
|
+
// `humans` — that is the minimal honest split. Returning `bots: 0`
|
|
179
|
+
// would falsely suggest the agent is alone with humans and break
|
|
180
|
+
// engagement's bot-loop suppression heuristics. We always set
|
|
181
|
+
// `truncated: true` for groups so engagement treats the count as
|
|
182
|
+
// approximate rather than authoritative.
|
|
183
|
+
const bots = 1
|
|
184
|
+
const humans = Math.max(0, total - bots)
|
|
185
|
+
return {
|
|
186
|
+
humans,
|
|
187
|
+
bots,
|
|
188
|
+
fetchedAt: now(),
|
|
189
|
+
truncated: true,
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
deps.logger.warn(`[telegram-bot] membership chat=${key.chat} failed: ${describe(err)}`)
|
|
193
|
+
return { kind: 'transient' } satisfies MembershipResolverFailure
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function createOutboundCallback(deps: {
|
|
199
|
+
client: Pick<TelegramBotClient, 'sendMessage' | 'sendDocument'>
|
|
200
|
+
configRef: () => ChannelAdapterConfig
|
|
201
|
+
logger: TelegramBotAdapterLogger
|
|
202
|
+
formatChannelTag: (chat: string) => Promise<string>
|
|
203
|
+
resolvePath?: (path: string) => string
|
|
204
|
+
}): OutboundCallback {
|
|
205
|
+
const { client, configRef, logger, formatChannelTag, resolvePath } = deps
|
|
206
|
+
return async (msg: OutboundMessage): Promise<SendResult> => {
|
|
207
|
+
if (msg.adapter !== 'telegram-bot') {
|
|
208
|
+
return { ok: false, error: `unknown adapter: ${msg.adapter}` }
|
|
209
|
+
}
|
|
210
|
+
const config = configRef()
|
|
211
|
+
if (!isAllowed(config.allow, msg.workspace, msg.chat)) {
|
|
212
|
+
logger.warn(`[telegram-bot] outbound denied by allow rules: ${msg.workspace}/${msg.chat}`)
|
|
213
|
+
return { ok: false, error: 'denied by allow rules' }
|
|
214
|
+
}
|
|
215
|
+
const text = msg.text ?? ''
|
|
216
|
+
const attachments = msg.attachments ?? []
|
|
217
|
+
if (text === '' && attachments.length === 0) {
|
|
218
|
+
return { ok: false, error: 'message has neither text nor attachments' }
|
|
219
|
+
}
|
|
220
|
+
const tag = await formatChannelTag(msg.chat)
|
|
221
|
+
logger.info(
|
|
222
|
+
`[telegram-bot] outbound ${tag} text_len=${text.length} attachments=${attachments.length}${msg.thread !== null && msg.thread !== undefined ? ` thread=${msg.thread}` : ''}`,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
// Telegram has no combined "text + attachment" send for arbitrary
|
|
226
|
+
// documents — `sendDocument` accepts a `caption` but it shares
|
|
227
|
+
// Telegram's 1024-char limit, so we send them as separate calls
|
|
228
|
+
// (uploads first so the agent's text comment lands in the chat after
|
|
229
|
+
// the file the user is meant to read). Failure on any upload aborts:
|
|
230
|
+
// the file is the load-bearing piece, the text post is best-effort
|
|
231
|
+
// after every upload succeeds.
|
|
232
|
+
//
|
|
233
|
+
// Forum-topic asymmetry: agent-messenger's `sendDocument` does not
|
|
234
|
+
// accept `message_thread_id`, so when the session is in a forum
|
|
235
|
+
// topic the file lands in the chat root while the text post below
|
|
236
|
+
// does carry the topic id. Mirror discord-bot.ts:389-394's warning
|
|
237
|
+
// so the gap shows up in operator triage.
|
|
238
|
+
const threadId = parseThreadId(msg.thread)
|
|
239
|
+
for (const attachment of attachments) {
|
|
240
|
+
const path = resolvePath ? resolvePath(attachment.path) : attachment.path
|
|
241
|
+
try {
|
|
242
|
+
const sent = await client.sendDocument(msg.chat, path)
|
|
243
|
+
logger.info(`[telegram-bot] uploaded message_id=${sent.message_id} ${tag}`)
|
|
244
|
+
if (threadId !== undefined) {
|
|
245
|
+
logger.warn(
|
|
246
|
+
`[telegram-bot] uploaded file landed in chat root, not topic ${threadId}: ` +
|
|
247
|
+
'agent-messenger sendDocument does not accept message_thread_id',
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
} catch (err) {
|
|
251
|
+
const message = describe(err)
|
|
252
|
+
logger.error(`[telegram-bot] sendDocument failed for ${path}: ${message}`)
|
|
253
|
+
return { ok: false, error: `sendDocument failed: ${message}` }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (text === '') {
|
|
258
|
+
return { ok: true }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const rendered = toTelegramMarkdownV2(text)
|
|
263
|
+
const sendOptions: { message_thread_id?: number; parse_mode: 'MarkdownV2' } = { parse_mode: 'MarkdownV2' }
|
|
264
|
+
if (threadId !== undefined) sendOptions.message_thread_id = threadId
|
|
265
|
+
const sent = await client.sendMessage(msg.chat, rendered, sendOptions)
|
|
266
|
+
logger.info(`[telegram-bot] sent message_id=${sent.message_id} ${tag}`)
|
|
267
|
+
return { ok: true }
|
|
268
|
+
} catch (err) {
|
|
269
|
+
const message = describe(err)
|
|
270
|
+
logger.error(`[telegram-bot] sendMessage failed: ${message}`)
|
|
271
|
+
return { ok: false, error: message }
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parseThreadId(thread: string | null | undefined): number | undefined {
|
|
277
|
+
if (thread === null || thread === undefined || thread === '') return undefined
|
|
278
|
+
const n = Number(thread)
|
|
279
|
+
return Number.isFinite(n) ? n : undefined
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
type TelegramFileResponse = {
|
|
283
|
+
ok: boolean
|
|
284
|
+
result?: { file_id: string; file_unique_id: string; file_size?: number; file_path?: string }
|
|
285
|
+
description?: string
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Telegram's file download is a two-step protocol: `getFile` returns a
|
|
289
|
+
// short-lived `file_path`, then the file lives at
|
|
290
|
+
// `api.telegram.org/file/bot<TOKEN>/<file_path>`. `ref` here is the
|
|
291
|
+
// `file_id` carried in the inbound classifier's `[Telegram message with
|
|
292
|
+
// document: ... file_id=<id>]` summary; the agent passes it back through
|
|
293
|
+
// the `channel_fetch_attachment` tool.
|
|
294
|
+
//
|
|
295
|
+
// SSRF boundary: `ref` is `encodeURIComponent`'d into a query parameter
|
|
296
|
+
// of a fixed `api.telegram.org/bot<TOKEN>/getFile?file_id=...` URL, so
|
|
297
|
+
// no `ref` value can redirect the request off-platform. We reject empty
|
|
298
|
+
// strings to fail fast with a clear error and `://` to catch the
|
|
299
|
+
// obvious "agent passed a URL" mistake before round-tripping it to
|
|
300
|
+
// Telegram, which would return a useless 400. We do NOT block `/` —
|
|
301
|
+
// real Telegram file_ids never contain it, but if a future SDK encodes
|
|
302
|
+
// extra metadata that does, we want the call to reach Telegram and
|
|
303
|
+
// surface the real error rather than ours.
|
|
304
|
+
export function createFetchAttachmentCallback(deps: {
|
|
305
|
+
token: string
|
|
306
|
+
logger: TelegramBotAdapterLogger
|
|
307
|
+
fetchImpl?: typeof fetch
|
|
308
|
+
}): FetchAttachmentCallback {
|
|
309
|
+
const { token, logger } = deps
|
|
310
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
311
|
+
return async ({ ref, filename }) => {
|
|
312
|
+
if (ref === '' || ref.includes('://')) {
|
|
313
|
+
return { ok: false, error: `invalid Telegram file_id: ${ref}` }
|
|
314
|
+
}
|
|
315
|
+
let metaResponse: Response
|
|
316
|
+
try {
|
|
317
|
+
metaResponse = await fetchImpl(`${TELEGRAM_API_BASE}/bot${token}/getFile?file_id=${encodeURIComponent(ref)}`)
|
|
318
|
+
} catch (err) {
|
|
319
|
+
const message = describe(err)
|
|
320
|
+
logger.error(`[telegram-bot] getFile failed for ${ref}: ${message}`)
|
|
321
|
+
return { ok: false, error: `getFile failed: ${message}` }
|
|
322
|
+
}
|
|
323
|
+
if (!metaResponse.ok) {
|
|
324
|
+
const body = await metaResponse.text().catch(() => '')
|
|
325
|
+
const message = `getFile ${metaResponse.status} ${metaResponse.statusText}${body !== '' ? `: ${body.slice(0, 200)}` : ''}`
|
|
326
|
+
logger.error(`[telegram-bot] getFile failed for ${ref}: ${message}`)
|
|
327
|
+
return { ok: false, error: message }
|
|
328
|
+
}
|
|
329
|
+
let meta: TelegramFileResponse
|
|
330
|
+
try {
|
|
331
|
+
meta = (await metaResponse.json()) as TelegramFileResponse
|
|
332
|
+
} catch (err) {
|
|
333
|
+
return { ok: false, error: `getFile parse failed: ${describe(err)}` }
|
|
334
|
+
}
|
|
335
|
+
if (!meta.ok || meta.result === undefined || meta.result.file_path === undefined) {
|
|
336
|
+
const message = meta.description ?? 'getFile returned no file_path'
|
|
337
|
+
return { ok: false, error: message }
|
|
338
|
+
}
|
|
339
|
+
const filePath = meta.result.file_path
|
|
340
|
+
const downloadUrl = `${TELEGRAM_API_BASE}/file/bot${token}/${filePath}`
|
|
341
|
+
let response: Response
|
|
342
|
+
try {
|
|
343
|
+
response = await fetchImpl(downloadUrl)
|
|
344
|
+
} catch (err) {
|
|
345
|
+
const message = describe(err)
|
|
346
|
+
logger.error(`[telegram-bot] download failed for ${ref}: ${message}`)
|
|
347
|
+
return { ok: false, error: message }
|
|
348
|
+
}
|
|
349
|
+
if (!response.ok) {
|
|
350
|
+
const body = await response.text().catch(() => '')
|
|
351
|
+
const message = `download ${response.status} ${response.statusText}${body !== '' ? `: ${body.slice(0, 200)}` : ''}`
|
|
352
|
+
logger.error(`[telegram-bot] download failed for ${ref}: ${message}`)
|
|
353
|
+
return { ok: false, error: message }
|
|
354
|
+
}
|
|
355
|
+
const arrayBuffer = await response.arrayBuffer()
|
|
356
|
+
const buffer = Buffer.from(arrayBuffer)
|
|
357
|
+
const inferredFilename = filename ?? filePath.split('/').pop() ?? 'attachment'
|
|
358
|
+
const contentType = response.headers.get('content-type') ?? undefined
|
|
359
|
+
logger.info(
|
|
360
|
+
`[telegram-bot] downloaded file_id=${ref} name=${inferredFilename} size=${buffer.length}${contentType !== undefined ? ` type=${contentType}` : ''}`,
|
|
361
|
+
)
|
|
362
|
+
return {
|
|
363
|
+
ok: true,
|
|
364
|
+
buffer,
|
|
365
|
+
filename: inferredFilename,
|
|
366
|
+
...(contentType !== undefined ? { mimetype: contentType } : {}),
|
|
367
|
+
size: buffer.length,
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): TelegramBotAdapter {
|
|
373
|
+
const logger = options.logger ?? consoleLogger
|
|
374
|
+
const createClient = options.createClient ?? (() => new TelegramBotClient())
|
|
375
|
+
const createListener =
|
|
376
|
+
options.createListener ?? ((client, listenerOptions) => new TelegramBotListener(client, listenerOptions))
|
|
377
|
+
const client = createClient()
|
|
378
|
+
let listener: TelegramBotListener | null = null
|
|
379
|
+
let botUser: TelegramBotUser | null = null
|
|
380
|
+
let started = false
|
|
381
|
+
let inflightInbounds = 0
|
|
382
|
+
let stopWaiters: Array<() => void> = []
|
|
383
|
+
|
|
384
|
+
const channelResolver = createChannelNameResolver({ client })
|
|
385
|
+
|
|
386
|
+
const formatChannelTag = async (chat: string): Promise<string> => {
|
|
387
|
+
const names = await channelResolver({
|
|
388
|
+
adapter: 'telegram-bot',
|
|
389
|
+
workspace: TELEGRAM_WORKSPACE,
|
|
390
|
+
chat,
|
|
391
|
+
thread: null,
|
|
392
|
+
}).catch((): ResolvedChannelNames => ({}))
|
|
393
|
+
const label = names.chatName ?? null
|
|
394
|
+
return label === null || label === chat ? `chat=${chat}` : `chat=${label}(${chat})`
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const typingCallback = createTypingCallback({
|
|
398
|
+
token: options.token,
|
|
399
|
+
configRef: options.configRef,
|
|
400
|
+
logger,
|
|
401
|
+
formatChannelTag,
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
const membershipResolver = createTelegramMembershipResolver({ client, logger })
|
|
405
|
+
|
|
406
|
+
const outboundCallback = createOutboundCallback({
|
|
407
|
+
client,
|
|
408
|
+
configRef: options.configRef,
|
|
409
|
+
logger,
|
|
410
|
+
formatChannelTag,
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
const fetchAttachmentCallback = createFetchAttachmentCallback({ token: options.token, logger })
|
|
414
|
+
|
|
415
|
+
const handleMessage = async (event: TelegramMessage): Promise<void> => {
|
|
416
|
+
inflightInbounds++
|
|
417
|
+
// Snapshot bot identity at dispatch time. `botUser` is module-level
|
|
418
|
+
// mutable state and `stop()` may null it concurrently with our awaits
|
|
419
|
+
// below; without this snapshot, an inbound that was already dispatched
|
|
420
|
+
// before `stop()` arrived could resume with `botUser=null` and drop
|
|
421
|
+
// as `pre_connect`, losing a legitimate message.
|
|
422
|
+
const botSnapshot = botUser
|
|
423
|
+
try {
|
|
424
|
+
const tag = await formatChannelTag(String(event.chat.id))
|
|
425
|
+
const fromLabel = event.from?.username ?? event.from?.first_name ?? String(event.from?.id ?? '?')
|
|
426
|
+
const text = event.text ?? event.caption ?? ''
|
|
427
|
+
logger.info(
|
|
428
|
+
`[telegram-bot] inbound message_id=${event.message_id} author=${fromLabel} ${tag} text_len=${text.length}`,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
const verdict = classifyInbound(event, options.configRef(), botSnapshot)
|
|
432
|
+
if (verdict.kind === 'drop') {
|
|
433
|
+
logger.info(
|
|
434
|
+
`[telegram-bot] dropped message_id=${event.message_id} reason=${verdict.reason}${dropHint(verdict.reason)}`,
|
|
435
|
+
)
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
logger.info(
|
|
440
|
+
`[telegram-bot] routed message_id=${event.message_id} ${tag} mention=${verdict.payload.isBotMention} reply=${verdict.payload.replyToBotMessageId !== null}`,
|
|
441
|
+
)
|
|
442
|
+
await options.router.route(verdict.payload)
|
|
443
|
+
} catch (err) {
|
|
444
|
+
logger.error(`[telegram-bot] handleInbound failed: ${describe(err)}`)
|
|
445
|
+
} finally {
|
|
446
|
+
inflightInbounds--
|
|
447
|
+
if (inflightInbounds === 0 && stopWaiters.length > 0) {
|
|
448
|
+
const waiters = stopWaiters
|
|
449
|
+
stopWaiters = []
|
|
450
|
+
for (const w of waiters) w()
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
async start(): Promise<void> {
|
|
457
|
+
if (started) return
|
|
458
|
+
started = true
|
|
459
|
+
try {
|
|
460
|
+
await client.login({ token: options.token })
|
|
461
|
+
} catch (err) {
|
|
462
|
+
started = false
|
|
463
|
+
logger.error(`[telegram-bot] login failed: ${describe(err)}`)
|
|
464
|
+
throw err
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Preflight `getMe()` so an invalid token surfaces here as a thrown
|
|
468
|
+
// error instead of silently emitting `'error'` from inside the
|
|
469
|
+
// listener and leaving us in `started=true` with a dead poller. The
|
|
470
|
+
// listener itself calls `getMe()` internally on `start()` but
|
|
471
|
+
// catches the failure and returns normally — see
|
|
472
|
+
// node_modules/agent-messenger/dist/src/platforms/telegrambot/listener.js
|
|
473
|
+
// around the `try { this.cachedUser = await getMe() }` block.
|
|
474
|
+
try {
|
|
475
|
+
botUser = await client.getMe()
|
|
476
|
+
const handle = botUser.username !== undefined ? `@${botUser.username}` : botUser.first_name
|
|
477
|
+
logger.info(`[telegram-bot] authenticated as ${handle} (${botUser.id})`)
|
|
478
|
+
} catch (err) {
|
|
479
|
+
started = false
|
|
480
|
+
botUser = null
|
|
481
|
+
logger.error(`[telegram-bot] getMe failed (likely invalid token): ${describe(err)}`)
|
|
482
|
+
throw err
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
listener = createListener(client, {
|
|
486
|
+
timeoutSeconds: 30,
|
|
487
|
+
allowedUpdates: TELEGRAM_ALLOWED_UPDATES,
|
|
488
|
+
dropPendingUpdates: true,
|
|
489
|
+
})
|
|
490
|
+
// Track whether the listener emitted `connected` during start(). The
|
|
491
|
+
// SDK's `start()` returns normally even when `deleteWebhook` or
|
|
492
|
+
// (less importantly, since we already preflighted) `getMe` fails
|
|
493
|
+
// internally — see
|
|
494
|
+
// node_modules/agent-messenger/dist/src/platforms/telegrambot/listener.js
|
|
495
|
+
// lines 36-60 (try/catch around each setup step that emits 'error'
|
|
496
|
+
// and returns rather than throwing). Without this flag, a failed
|
|
497
|
+
// startup leaves us with `started=true`, callbacks registered, and
|
|
498
|
+
// a dead poller. We use the SDK's own `connected` event as the
|
|
499
|
+
// single source of truth for "the listener is actually running".
|
|
500
|
+
let listenerConnected = false
|
|
501
|
+
let listenerStartupError: Error | null = null
|
|
502
|
+
listener.on('connected', (info) => {
|
|
503
|
+
listenerConnected = true
|
|
504
|
+
botUser = info.user
|
|
505
|
+
})
|
|
506
|
+
listener.on('disconnected', () => {
|
|
507
|
+
logger.warn('[telegram-bot] disconnected; SDK will reconnect with backoff')
|
|
508
|
+
})
|
|
509
|
+
listener.on('error', (err) => {
|
|
510
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
511
|
+
if (!listenerConnected && listenerStartupError === null) {
|
|
512
|
+
listenerStartupError = error
|
|
513
|
+
}
|
|
514
|
+
logger.error(`[telegram-bot] listener error: ${describe(error)}`)
|
|
515
|
+
})
|
|
516
|
+
listener.on('message', (event) => {
|
|
517
|
+
void handleMessage(event)
|
|
518
|
+
})
|
|
519
|
+
listener.on('channel_post', (event) => {
|
|
520
|
+
void handleMessage(event)
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
options.router.registerOutbound('telegram-bot', outboundCallback)
|
|
524
|
+
options.router.registerTyping('telegram-bot', typingCallback)
|
|
525
|
+
options.router.registerChannelNameResolver('telegram-bot', channelResolver)
|
|
526
|
+
options.router.registerFetchAttachment('telegram-bot', fetchAttachmentCallback)
|
|
527
|
+
options.router.registerMembership('telegram-bot', membershipResolver)
|
|
528
|
+
|
|
529
|
+
const rollbackStart = (reason: string, cause: Error): never => {
|
|
530
|
+
options.router.unregisterOutbound('telegram-bot', outboundCallback)
|
|
531
|
+
options.router.unregisterTyping('telegram-bot', typingCallback)
|
|
532
|
+
options.router.unregisterChannelNameResolver('telegram-bot', channelResolver)
|
|
533
|
+
options.router.unregisterFetchAttachment('telegram-bot', fetchAttachmentCallback)
|
|
534
|
+
options.router.unregisterMembership('telegram-bot', membershipResolver)
|
|
535
|
+
listener?.stop()
|
|
536
|
+
listener = null
|
|
537
|
+
botUser = null
|
|
538
|
+
started = false
|
|
539
|
+
logger.error(`[telegram-bot] ${reason}: ${describe(cause)}`)
|
|
540
|
+
throw cause
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
await listener.start()
|
|
545
|
+
} catch (err) {
|
|
546
|
+
rollbackStart('listener start threw', err instanceof Error ? err : new Error(String(err)))
|
|
547
|
+
}
|
|
548
|
+
if (!listenerConnected) {
|
|
549
|
+
const cause = listenerStartupError ?? new Error('listener.start() returned without emitting connected')
|
|
550
|
+
rollbackStart('listener start failed silently', cause)
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
|
|
554
|
+
async stop(): Promise<void> {
|
|
555
|
+
if (!started) return
|
|
556
|
+
started = false
|
|
557
|
+
options.router.unregisterOutbound('telegram-bot', outboundCallback)
|
|
558
|
+
options.router.unregisterTyping('telegram-bot', typingCallback)
|
|
559
|
+
options.router.unregisterChannelNameResolver('telegram-bot', channelResolver)
|
|
560
|
+
options.router.unregisterFetchAttachment('telegram-bot', fetchAttachmentCallback)
|
|
561
|
+
options.router.unregisterMembership('telegram-bot', membershipResolver)
|
|
562
|
+
// Stop the listener BEFORE waiting for inflight handlers. The SDK's
|
|
563
|
+
// `stop()` aborts the in-flight `getUpdates` long-poll and
|
|
564
|
+
// increments its generation counter so any pending dispatch is
|
|
565
|
+
// dropped. Doing this before the wait bounds the drain: nothing
|
|
566
|
+
// new can land in `handleMessage()`, so `inflightInbounds` only
|
|
567
|
+
// decreases.
|
|
568
|
+
listener?.stop()
|
|
569
|
+
listener = null
|
|
570
|
+
if (inflightInbounds > 0) {
|
|
571
|
+
await new Promise<void>((resolve) => {
|
|
572
|
+
stopWaiters.push(resolve)
|
|
573
|
+
})
|
|
574
|
+
}
|
|
575
|
+
// Null `botUser` only AFTER inflight handlers have drained.
|
|
576
|
+
// `handleMessage` snapshots `botUser` at dispatch time so this is
|
|
577
|
+
// belt-and-suspenders, but freeing the reference here keeps
|
|
578
|
+
// `isConnected()` honest after stop completes.
|
|
579
|
+
botUser = null
|
|
580
|
+
},
|
|
581
|
+
|
|
582
|
+
isConnected(): boolean {
|
|
583
|
+
return botUser !== null
|
|
584
|
+
},
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function describe(err: unknown): string {
|
|
589
|
+
return err instanceof Error ? err.message : String(err)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function dropHint(reason: InboundDropReason): string {
|
|
593
|
+
switch (reason) {
|
|
594
|
+
case 'no_user':
|
|
595
|
+
return ' (channel post / anonymous; cannot attribute to an author)'
|
|
596
|
+
case 'empty_text':
|
|
597
|
+
return ' (message had no text and no recognized media; check Telegram privacy mode in @BotFather)'
|
|
598
|
+
case 'not_in_allow_list':
|
|
599
|
+
return ' (extend channels.telegram-bot.allow in typeclaw.json to admit this chat)'
|
|
600
|
+
case 'pre_connect':
|
|
601
|
+
case 'self_author':
|
|
602
|
+
return ''
|
|
603
|
+
}
|
|
604
|
+
}
|