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.
Files changed (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. 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
+ }