@swarmclawai/swarmclaw 0.7.1 → 0.7.3

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 (237) hide show
  1. package/README.md +155 -150
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +26 -0
  4. package/src/app/api/agents/[id]/thread/route.ts +37 -9
  5. package/src/app/api/agents/route.ts +13 -2
  6. package/src/app/api/auth/route.ts +76 -7
  7. package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
  8. package/src/app/api/{sessions → chats}/[id]/browser/route.ts +5 -1
  9. package/src/app/api/{sessions → chats}/[id]/chat/route.ts +7 -3
  10. package/src/app/api/{sessions → chats}/[id]/checkpoints/route.ts +1 -1
  11. package/src/app/api/chats/[id]/main-loop/route.ts +13 -0
  12. package/src/app/api/{sessions → chats}/[id]/messages/route.ts +19 -13
  13. package/src/app/api/{sessions → chats}/[id]/restore/route.ts +1 -1
  14. package/src/app/api/{sessions → chats}/[id]/route.ts +22 -52
  15. package/src/app/api/{sessions → chats}/[id]/stop/route.ts +6 -1
  16. package/src/app/api/{sessions → chats}/route.ts +21 -7
  17. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  18. package/src/app/api/connectors/doctor/route.ts +13 -0
  19. package/src/app/api/files/open/route.ts +16 -14
  20. package/src/app/api/memory/maintenance/route.ts +11 -1
  21. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  22. package/src/app/api/openclaw/skills/route.ts +11 -3
  23. package/src/app/api/plugins/dependencies/route.ts +24 -0
  24. package/src/app/api/plugins/install/route.ts +15 -92
  25. package/src/app/api/plugins/route.ts +6 -26
  26. package/src/app/api/plugins/settings/route.ts +40 -0
  27. package/src/app/api/plugins/ui/route.ts +1 -0
  28. package/src/app/api/settings/route.ts +49 -7
  29. package/src/app/api/tasks/[id]/route.ts +15 -6
  30. package/src/app/api/tasks/bulk/route.ts +2 -2
  31. package/src/app/api/tasks/route.ts +9 -4
  32. package/src/app/api/usage/route.ts +30 -0
  33. package/src/app/api/webhooks/[id]/route.ts +8 -1
  34. package/src/app/page.tsx +9 -2
  35. package/src/cli/index.js +39 -33
  36. package/src/cli/index.ts +43 -49
  37. package/src/cli/spec.js +29 -27
  38. package/src/components/agents/agent-card.tsx +16 -13
  39. package/src/components/agents/agent-chat-list.tsx +104 -4
  40. package/src/components/agents/agent-list.tsx +54 -22
  41. package/src/components/agents/agent-sheet.tsx +209 -18
  42. package/src/components/agents/cron-job-form.tsx +3 -3
  43. package/src/components/agents/inspector-panel.tsx +110 -50
  44. package/src/components/auth/access-key-gate.tsx +36 -97
  45. package/src/components/auth/setup-wizard.tsx +5 -38
  46. package/src/components/chat/chat-area.tsx +39 -27
  47. package/src/components/{sessions/session-card.tsx → chat/chat-card.tsx} +7 -23
  48. package/src/components/chat/chat-header.tsx +299 -314
  49. package/src/components/{sessions/session-list.tsx → chat/chat-list.tsx} +11 -14
  50. package/src/components/chat/chat-tool-toggles.tsx +26 -17
  51. package/src/components/chat/checkpoint-timeline.tsx +4 -4
  52. package/src/components/chat/message-bubble.tsx +4 -1
  53. package/src/components/chat/message-list.tsx +5 -3
  54. package/src/components/chat/session-debug-panel.tsx +1 -1
  55. package/src/components/chat/tool-request-banner.tsx +3 -3
  56. package/src/components/chatrooms/agent-hover-card.tsx +3 -3
  57. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +2 -2
  58. package/src/components/chatrooms/chatroom-view.tsx +347 -205
  59. package/src/components/connectors/connector-list.tsx +265 -127
  60. package/src/components/connectors/connector-sheet.tsx +218 -1
  61. package/src/components/home/home-view.tsx +129 -5
  62. package/src/components/layout/app-layout.tsx +392 -182
  63. package/src/components/layout/mobile-header.tsx +26 -8
  64. package/src/components/plugins/plugin-list.tsx +487 -254
  65. package/src/components/plugins/plugin-sheet.tsx +236 -13
  66. package/src/components/projects/project-detail.tsx +183 -0
  67. package/src/components/settings/gateway-connection-panel.tsx +1 -1
  68. package/src/components/shared/agent-picker-list.tsx +2 -2
  69. package/src/components/shared/command-palette.tsx +111 -25
  70. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  71. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  72. package/src/components/shared/settings/section-heartbeat.tsx +78 -1
  73. package/src/components/shared/settings/section-orchestrator.tsx +3 -3
  74. package/src/components/shared/settings/section-providers.tsx +1 -1
  75. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  76. package/src/components/shared/settings/section-secrets.tsx +6 -6
  77. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  78. package/src/components/shared/settings/section-voice.tsx +5 -1
  79. package/src/components/shared/settings/section-web-search.tsx +10 -2
  80. package/src/components/shared/settings/settings-page.tsx +244 -56
  81. package/src/components/tasks/approvals-panel.tsx +205 -18
  82. package/src/components/tasks/task-board.tsx +242 -46
  83. package/src/components/usage/metrics-dashboard.tsx +147 -1
  84. package/src/components/wallets/wallet-panel.tsx +17 -5
  85. package/src/components/webhooks/webhook-sheet.tsx +8 -8
  86. package/src/lib/auth.ts +17 -0
  87. package/src/lib/chat-streaming-state.test.ts +108 -0
  88. package/src/lib/chat-streaming-state.ts +108 -0
  89. package/src/lib/chat.ts +1 -1
  90. package/src/lib/{sessions.ts → chats.ts} +28 -18
  91. package/src/lib/openclaw-agent-id.test.ts +14 -0
  92. package/src/lib/openclaw-agent-id.ts +31 -0
  93. package/src/lib/providers/claude-cli.ts +1 -1
  94. package/src/lib/server/agent-assignment.test.ts +112 -0
  95. package/src/lib/server/agent-assignment.ts +169 -0
  96. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  97. package/src/lib/server/approvals-auto-approve.test.ts +205 -0
  98. package/src/lib/server/approvals.ts +483 -75
  99. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  100. package/src/lib/server/browser-state.test.ts +118 -0
  101. package/src/lib/server/browser-state.ts +123 -0
  102. package/src/lib/server/build-llm.test.ts +36 -0
  103. package/src/lib/server/build-llm.ts +11 -4
  104. package/src/lib/server/builtin-plugins.ts +34 -0
  105. package/src/lib/server/capability-router.ts +10 -8
  106. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  107. package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
  108. package/src/lib/server/chat-execution.ts +285 -165
  109. package/src/lib/server/chatroom-health.test.ts +26 -0
  110. package/src/lib/server/chatroom-health.ts +2 -3
  111. package/src/lib/server/chatroom-helpers.test.ts +67 -2
  112. package/src/lib/server/chatroom-helpers.ts +48 -8
  113. package/src/lib/server/connectors/discord.ts +175 -11
  114. package/src/lib/server/connectors/doctor.test.ts +80 -0
  115. package/src/lib/server/connectors/doctor.ts +116 -0
  116. package/src/lib/server/connectors/manager.ts +948 -112
  117. package/src/lib/server/connectors/policy.test.ts +222 -0
  118. package/src/lib/server/connectors/policy.ts +452 -0
  119. package/src/lib/server/connectors/slack.ts +188 -9
  120. package/src/lib/server/connectors/telegram.ts +65 -15
  121. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  122. package/src/lib/server/connectors/thread-context.ts +72 -0
  123. package/src/lib/server/connectors/types.ts +41 -11
  124. package/src/lib/server/cost.ts +34 -1
  125. package/src/lib/server/daemon-state.ts +61 -3
  126. package/src/lib/server/data-dir.ts +13 -0
  127. package/src/lib/server/delegation-jobs.test.ts +140 -0
  128. package/src/lib/server/delegation-jobs.ts +248 -0
  129. package/src/lib/server/document-utils.test.ts +47 -0
  130. package/src/lib/server/document-utils.ts +397 -0
  131. package/src/lib/server/heartbeat-service.ts +14 -40
  132. package/src/lib/server/heartbeat-source.test.ts +22 -0
  133. package/src/lib/server/heartbeat-source.ts +7 -0
  134. package/src/lib/server/identity-continuity.test.ts +77 -0
  135. package/src/lib/server/identity-continuity.ts +127 -0
  136. package/src/lib/server/mailbox-utils.ts +347 -0
  137. package/src/lib/server/main-agent-loop.ts +28 -1103
  138. package/src/lib/server/memory-db.ts +4 -6
  139. package/src/lib/server/memory-tiers.ts +40 -0
  140. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  141. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  142. package/src/lib/server/openclaw-exec-config.ts +5 -6
  143. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  144. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  145. package/src/lib/server/openclaw-sync.ts +3 -2
  146. package/src/lib/server/orchestrator-lg.ts +20 -9
  147. package/src/lib/server/orchestrator.ts +7 -7
  148. package/src/lib/server/playwright-proxy.mjs +27 -3
  149. package/src/lib/server/plugins.test.ts +207 -0
  150. package/src/lib/server/plugins.ts +927 -66
  151. package/src/lib/server/provider-health.ts +38 -6
  152. package/src/lib/server/queue.ts +13 -28
  153. package/src/lib/server/scheduler.ts +2 -0
  154. package/src/lib/server/session-archive-memory.test.ts +85 -0
  155. package/src/lib/server/session-archive-memory.ts +230 -0
  156. package/src/lib/server/session-mailbox.ts +8 -18
  157. package/src/lib/server/session-reset-policy.test.ts +99 -0
  158. package/src/lib/server/session-reset-policy.ts +311 -0
  159. package/src/lib/server/session-run-manager.ts +33 -82
  160. package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
  161. package/src/lib/server/session-tools/calendar.ts +366 -0
  162. package/src/lib/server/session-tools/canvas.ts +1 -1
  163. package/src/lib/server/session-tools/chatroom.ts +4 -2
  164. package/src/lib/server/session-tools/connector.ts +114 -10
  165. package/src/lib/server/session-tools/context.ts +21 -5
  166. package/src/lib/server/session-tools/crawl.ts +447 -0
  167. package/src/lib/server/session-tools/crud.ts +74 -28
  168. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  169. package/src/lib/server/session-tools/delegate.ts +497 -24
  170. package/src/lib/server/session-tools/discovery.ts +24 -6
  171. package/src/lib/server/session-tools/document.ts +283 -0
  172. package/src/lib/server/session-tools/edit_file.ts +4 -2
  173. package/src/lib/server/session-tools/email.ts +320 -0
  174. package/src/lib/server/session-tools/extract.ts +137 -0
  175. package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
  176. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  177. package/src/lib/server/session-tools/file.ts +241 -25
  178. package/src/lib/server/session-tools/git.ts +1 -1
  179. package/src/lib/server/session-tools/http.ts +1 -1
  180. package/src/lib/server/session-tools/human-loop.ts +227 -0
  181. package/src/lib/server/session-tools/image-gen.ts +380 -0
  182. package/src/lib/server/session-tools/index.ts +130 -50
  183. package/src/lib/server/session-tools/mailbox.ts +276 -0
  184. package/src/lib/server/session-tools/memory.ts +172 -3
  185. package/src/lib/server/session-tools/monitor.ts +151 -8
  186. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  187. package/src/lib/server/session-tools/openclaw-nodes.ts +1 -1
  188. package/src/lib/server/session-tools/openclaw-workspace.ts +1 -1
  189. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  190. package/src/lib/server/session-tools/platform.ts +148 -7
  191. package/src/lib/server/session-tools/plugin-creator.ts +89 -26
  192. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  193. package/src/lib/server/session-tools/replicate.ts +301 -0
  194. package/src/lib/server/session-tools/sample-ui.ts +1 -1
  195. package/src/lib/server/session-tools/sandbox.ts +4 -2
  196. package/src/lib/server/session-tools/schedule.ts +24 -12
  197. package/src/lib/server/session-tools/session-info.ts +43 -7
  198. package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
  199. package/src/lib/server/session-tools/shell.ts +5 -2
  200. package/src/lib/server/session-tools/subagent.ts +194 -28
  201. package/src/lib/server/session-tools/table.ts +587 -0
  202. package/src/lib/server/session-tools/wallet.ts +42 -12
  203. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  204. package/src/lib/server/session-tools/web.ts +926 -91
  205. package/src/lib/server/storage.ts +255 -16
  206. package/src/lib/server/stream-agent-chat.ts +116 -268
  207. package/src/lib/server/structured-extract.test.ts +72 -0
  208. package/src/lib/server/structured-extract.ts +373 -0
  209. package/src/lib/server/task-mention.test.ts +16 -2
  210. package/src/lib/server/task-mention.ts +61 -10
  211. package/src/lib/server/tool-aliases.ts +66 -18
  212. package/src/lib/server/tool-capability-policy.test.ts +9 -9
  213. package/src/lib/server/tool-capability-policy.ts +38 -27
  214. package/src/lib/server/tool-retry.ts +2 -0
  215. package/src/lib/server/watch-jobs.test.ts +173 -0
  216. package/src/lib/server/watch-jobs.ts +532 -0
  217. package/src/lib/server/ws-hub.ts +5 -3
  218. package/src/lib/tool-definitions.ts +4 -0
  219. package/src/lib/validation/schemas.test.ts +26 -0
  220. package/src/lib/validation/schemas.ts +10 -1
  221. package/src/lib/ws-client.ts +14 -12
  222. package/src/proxy.ts +5 -5
  223. package/src/stores/use-app-store.ts +5 -11
  224. package/src/stores/use-chat-store.ts +38 -9
  225. package/src/types/index.ts +352 -47
  226. package/src/app/api/sessions/[id]/main-loop/route.ts +0 -94
  227. package/src/components/sessions/new-session-sheet.tsx +0 -253
  228. package/src/lib/server/main-session.ts +0 -24
  229. package/src/lib/server/session-run-manager.test.ts +0 -23
  230. /package/src/app/api/{sessions → chats}/[id]/clear/route.ts +0 -0
  231. /package/src/app/api/{sessions → chats}/[id]/deploy/route.ts +0 -0
  232. /package/src/app/api/{sessions → chats}/[id]/devserver/route.ts +0 -0
  233. /package/src/app/api/{sessions → chats}/[id]/edit-resend/route.ts +0 -0
  234. /package/src/app/api/{sessions → chats}/[id]/fork/route.ts +0 -0
  235. /package/src/app/api/{sessions → chats}/[id]/mailbox/route.ts +0 -0
  236. /package/src/app/api/{sessions → chats}/[id]/retry/route.ts +0 -0
  237. /package/src/app/api/{sessions → chats}/heartbeat/route.ts +0 -0
@@ -2,9 +2,104 @@ import { App, LogLevel } from '@slack/bolt'
2
2
  import fs from 'fs'
3
3
  import path from 'path'
4
4
  import type { Connector } from '@/types'
5
- import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
5
+ import type { PlatformConnector, ConnectorInstance, InboundMessage, InboundThreadHistoryEntry } from './types'
6
6
  import { downloadInboundMediaToUpload, inferInboundMediaType, mimeFromPath, isImageMime } from './media'
7
- import { isNoMessage } from './manager'
7
+ import { getConnectorReplySendOptions, isNoMessage, recordConnectorOutboundDelivery } from './manager'
8
+
9
+ function normalizeSlackEmoji(input: string): string {
10
+ const raw = input.trim().replace(/^:|:$/g, '')
11
+ if (!raw) return 'eyes'
12
+ if (raw === '👀') return 'eyes'
13
+ if (raw === '✅') return 'white_check_mark'
14
+ if (raw === '🤐') return 'zipper_mouth_face'
15
+ return raw
16
+ }
17
+
18
+ function parseSlackTimestamp(raw: unknown): number {
19
+ const value = typeof raw === 'string' ? Number.parseFloat(raw) : typeof raw === 'number' ? raw : Number.NaN
20
+ return Number.isFinite(value) ? value : 0
21
+ }
22
+
23
+ async function resolveSlackUserDisplayName(client: any, userId?: string): Promise<string | undefined> {
24
+ if (!userId) return undefined
25
+ try {
26
+ const userInfo = await client.users.info({ user: userId })
27
+ return userInfo.user?.real_name || userInfo.user?.name || userId
28
+ } catch {
29
+ return userId
30
+ }
31
+ }
32
+
33
+ function buildSlackThreadTitle(channelName: string, starterText: string, fallbackTs: string): string {
34
+ const snippet = starterText.replace(/\s+/g, ' ').trim().slice(0, 56)
35
+ if (snippet) return `${channelName} · ${snippet}`
36
+ return `${channelName} thread ${fallbackTs}`
37
+ }
38
+
39
+ async function hydrateSlackThreadContext(params: {
40
+ client: any
41
+ inbound: InboundMessage
42
+ currentTs?: string
43
+ botUserId?: string
44
+ }): Promise<void> {
45
+ const threadTs = params.inbound.threadId
46
+ if (!threadTs) return
47
+ try {
48
+ const result = await params.client.conversations.replies({
49
+ channel: params.inbound.channelId,
50
+ ts: threadTs,
51
+ limit: 12,
52
+ inclusive: true,
53
+ })
54
+ const messages = Array.isArray((result as any)?.messages) ? (result as any).messages as any[] : []
55
+ if (!messages.length) return
56
+
57
+ const userIds = [...new Set(messages.map((message) => typeof message?.user === 'string' ? message.user : '').filter(Boolean))]
58
+ const nameMap = new Map<string, string>()
59
+ await Promise.all(userIds.map(async (userId) => {
60
+ const name = await resolveSlackUserDisplayName(params.client, userId)
61
+ if (name) nameMap.set(userId, name)
62
+ }))
63
+
64
+ const starter = messages[0]
65
+ const starterText = typeof starter?.text === 'string' ? starter.text.trim() : ''
66
+ const starterSenderName = nameMap.get(starter?.user)
67
+ || starter?.username
68
+ || starter?.user
69
+ || (starter?.bot_id ? 'Slack Bot' : '')
70
+ const currentTsValue = parseSlackTimestamp(params.currentTs)
71
+ const history: InboundThreadHistoryEntry[] = messages
72
+ .filter((message) => {
73
+ const tsValue = parseSlackTimestamp(message?.ts)
74
+ if (!tsValue) return false
75
+ if (String(message?.ts) === String(threadTs)) return false
76
+ if (currentTsValue && tsValue >= currentTsValue) return false
77
+ return true
78
+ })
79
+ .slice(-6)
80
+ .map((message) => ({
81
+ role: (message?.bot_id || (params.botUserId && message?.user === params.botUserId) ? 'assistant' : 'user') as 'assistant' | 'user',
82
+ senderName: nameMap.get(message?.user) || message?.username || message?.user || (message?.bot_id ? 'Slack Bot' : 'Unknown'),
83
+ text: typeof message?.text === 'string' ? message.text : '',
84
+ messageId: typeof message?.ts === 'string' ? message.ts : undefined,
85
+ }))
86
+ .filter((entry) => entry.text.trim().length > 0)
87
+
88
+ params.inbound.threadParentChannelId = params.inbound.channelId
89
+ params.inbound.threadParentChannelName = params.inbound.channelName || params.inbound.channelId
90
+ params.inbound.threadStarterText = starterText || undefined
91
+ params.inbound.threadStarterSenderName = starterSenderName || undefined
92
+ params.inbound.threadTitle = buildSlackThreadTitle(
93
+ params.inbound.channelName || params.inbound.channelId,
94
+ starterText,
95
+ threadTs,
96
+ )
97
+ params.inbound.threadPersonaLabel = params.inbound.threadTitle
98
+ params.inbound.threadHistory = history.length ? history : undefined
99
+ } catch (err: unknown) {
100
+ console.warn(`[slack] Thread context bootstrap failed: ${err instanceof Error ? err.message : String(err)}`)
101
+ }
102
+ }
8
103
 
9
104
  const slack: PlatformConnector = {
10
105
  async start(connector, botToken, onMessage): Promise<ConnectorInstance> {
@@ -136,24 +231,50 @@ const slack: PlatformConnector = {
136
231
  senderId: msg.user,
137
232
  senderName,
138
233
  text: msg.text || (media.length > 0 ? '(media message)' : ''),
234
+ isGroup: !String(channelId).startsWith('D'),
235
+ messageId: msg.ts || undefined,
236
+ replyToMessageId: msg.thread_ts && msg.thread_ts !== msg.ts ? msg.thread_ts : undefined,
237
+ threadId: msg.thread_ts || undefined,
238
+ mentionsBot: !!(botUserId && typeof msg.text === 'string' && msg.text.includes(`<@${botUserId}>`)),
139
239
  imageUrl: media.find((m) => m.type === 'image')?.url,
140
240
  media,
141
241
  }
242
+ await hydrateSlackThreadContext({ client, inbound, currentTs: msg.ts || undefined, botUserId })
142
243
 
143
244
  try {
144
245
  const response = await onMessage(inbound)
145
246
 
146
247
  if (isNoMessage(response)) return
147
248
 
249
+ const replyOptions = getConnectorReplySendOptions({ connectorId: connector.id, inbound })
250
+ const threadTs = replyOptions.threadId || replyOptions.replyToMessageId
251
+ let lastMessageId: string | undefined
252
+
148
253
  // Slack has a 4000 char limit for messages
149
254
  if (response.length <= 4000) {
150
- await say(response)
255
+ const sent = await client.chat.postMessage({
256
+ channel: channelId,
257
+ text: response,
258
+ thread_ts: threadTs,
259
+ })
260
+ lastMessageId = sent.ts || undefined
151
261
  } else {
152
262
  const chunks = response.match(/[\s\S]{1,3990}/g) || [response]
153
263
  for (const chunk of chunks) {
154
- await say(chunk)
264
+ const sent = await client.chat.postMessage({
265
+ channel: channelId,
266
+ text: chunk,
267
+ thread_ts: threadTs,
268
+ })
269
+ lastMessageId = sent.ts || undefined
155
270
  }
156
271
  }
272
+ await recordConnectorOutboundDelivery({
273
+ connectorId: connector.id,
274
+ inbound,
275
+ messageId: lastMessageId,
276
+ state: 'sent',
277
+ })
157
278
  } catch (err: any) {
158
279
  console.error(`[slack] Error handling message:`, err.message)
159
280
  try {
@@ -179,12 +300,36 @@ const slack: PlatformConnector = {
179
300
  senderId: event.user || 'unknown',
180
301
  senderName,
181
302
  text: event.text.replace(/<@[^>]+>/g, '').trim(), // Strip @mentions
303
+ isGroup: !String(event.channel).startsWith('D'),
304
+ messageId: (event as any).ts || undefined,
305
+ replyToMessageId: (event as any).thread_ts && (event as any).thread_ts !== (event as any).ts
306
+ ? (event as any).thread_ts
307
+ : undefined,
308
+ threadId: (event as any).thread_ts || undefined,
309
+ mentionsBot: true,
182
310
  }
311
+ await hydrateSlackThreadContext({
312
+ client,
313
+ inbound,
314
+ currentTs: (event as any).ts || undefined,
315
+ botUserId,
316
+ })
183
317
 
184
318
  try {
185
319
  const response = await onMessage(inbound)
186
320
  if (isNoMessage(response)) return
187
- await say(response)
321
+ const replyOptions = getConnectorReplySendOptions({ connectorId: connector.id, inbound })
322
+ const sent = await client.chat.postMessage({
323
+ channel: event.channel,
324
+ text: response,
325
+ thread_ts: replyOptions.threadId || replyOptions.replyToMessageId,
326
+ })
327
+ await recordConnectorOutboundDelivery({
328
+ connectorId: connector.id,
329
+ inbound,
330
+ messageId: sent.ts || undefined,
331
+ state: 'sent',
332
+ })
188
333
  } catch (err: any) {
189
334
  console.error(`[slack] Error handling mention:`, err.message)
190
335
  }
@@ -202,6 +347,7 @@ const slack: PlatformConnector = {
202
347
  },
203
348
  async sendMessage(channelId, text, options) {
204
349
  const webClient = app.client
350
+ const threadTs = options?.threadId?.trim() || options?.replyToMessageId?.trim() || undefined
205
351
 
206
352
  // File upload (local path or URL)
207
353
  const hasMedia = options?.mediaPath || options?.imageUrl || options?.fileUrl
@@ -219,18 +365,25 @@ const slack: PlatformConnector = {
219
365
  }
220
366
 
221
367
  if (fileContent) {
222
- const result = await webClient.filesUploadV2({
368
+ const uploadArgsBase = {
223
369
  channel_id: channelId,
224
370
  file: fileContent,
225
371
  filename: fileName,
226
372
  initial_comment: options?.caption || text || undefined,
227
- })
373
+ }
374
+ const result = threadTs
375
+ ? await webClient.filesUploadV2({
376
+ ...uploadArgsBase,
377
+ thread_ts: threadTs,
378
+ })
379
+ : await webClient.filesUploadV2(uploadArgsBase)
228
380
  return { messageId: (result as any)?.files?.[0]?.id }
229
381
  } else if (fileUrl) {
230
382
  // Send URL as message with unfurl
231
383
  const msg = await webClient.chat.postMessage({
232
384
  channel: channelId,
233
385
  text: `${options?.caption || text || ''}\n${fileUrl}`.trim(),
386
+ thread_ts: threadTs,
234
387
  unfurl_links: true,
235
388
  unfurl_media: true,
236
389
  })
@@ -241,17 +394,43 @@ const slack: PlatformConnector = {
241
394
  // Text only
242
395
  const payload = text || options?.caption || ''
243
396
  if (payload.length <= 4000) {
244
- const msg = await webClient.chat.postMessage({ channel: channelId, text: payload })
397
+ const msg = await webClient.chat.postMessage({ channel: channelId, text: payload, thread_ts: threadTs })
245
398
  return { messageId: msg.ts || undefined }
246
399
  }
247
400
  const chunks = payload.match(/[\s\S]{1,3990}/g) || [payload]
248
401
  let lastTs: string | undefined
249
402
  for (const chunk of chunks) {
250
- const msg = await webClient.chat.postMessage({ channel: channelId, text: chunk })
403
+ const msg = await webClient.chat.postMessage({ channel: channelId, text: chunk, thread_ts: threadTs })
251
404
  lastTs = msg.ts || undefined
252
405
  }
253
406
  return { messageId: lastTs }
254
407
  },
408
+ async sendReaction(channelId, messageId, emoji) {
409
+ await app.client.reactions.add({
410
+ channel: channelId,
411
+ timestamp: messageId,
412
+ name: normalizeSlackEmoji(emoji),
413
+ })
414
+ },
415
+ async editMessage(channelId, messageId, newText) {
416
+ await app.client.chat.update({
417
+ channel: channelId,
418
+ ts: messageId,
419
+ text: newText,
420
+ })
421
+ },
422
+ async deleteMessage(channelId, messageId) {
423
+ await app.client.chat.delete({
424
+ channel: channelId,
425
+ ts: messageId,
426
+ })
427
+ },
428
+ async pinMessage(channelId, messageId) {
429
+ await app.client.pins.add({
430
+ channel: channelId,
431
+ timestamp: messageId,
432
+ })
433
+ },
255
434
  async stop() {
256
435
  appStopped = true
257
436
  await app.stop()
@@ -4,11 +4,12 @@ import path from 'path'
4
4
  import type { Connector } from '@/types'
5
5
  import type { PlatformConnector, ConnectorInstance, InboundMessage, InboundMediaType } from './types'
6
6
  import { downloadInboundMediaToUpload, inferInboundMediaType, mimeFromPath, isImageMime, isAudioMime } from './media'
7
- import { isNoMessage } from './manager'
7
+ import { getConnectorReplySendOptions, isNoMessage, recordConnectorOutboundDelivery } from './manager'
8
8
 
9
9
  const telegram: PlatformConnector = {
10
10
  async start(connector, botToken, onMessage): Promise<ConnectorInstance> {
11
11
  const bot = new Bot(botToken)
12
+ let botUsername = ''
12
13
 
13
14
  // Optional: restrict to specific chat IDs
14
15
  const allowedChats = connector.config.chatIds
@@ -140,6 +141,11 @@ const telegram: PlatformConnector = {
140
141
  senderId: String(ctx.from.id),
141
142
  senderName: ctx.from.first_name + (ctx.from.last_name ? ` ${ctx.from.last_name}` : ''),
142
143
  text: text || (media.length > 0 ? '(media message)' : ''),
144
+ isGroup: ctx.chat.type !== 'private',
145
+ messageId: String(raw.message_id),
146
+ replyToMessageId: raw.reply_to_message?.message_id ? String(raw.reply_to_message.message_id) : undefined,
147
+ threadId: raw.message_thread_id ? String(raw.message_thread_id) : undefined,
148
+ mentionsBot: !!(botUsername && text && new RegExp(`@${botUsername}\\b`, 'i').test(String(text))),
143
149
  imageUrl: media.find((m) => m.type === 'image')?.url,
144
150
  media,
145
151
  }
@@ -150,15 +156,34 @@ const telegram: PlatformConnector = {
150
156
 
151
157
  if (isNoMessage(response)) return
152
158
 
159
+ const replyOptions = getConnectorReplySendOptions({ connectorId: connector.id, inbound })
160
+ const baseOptions: Record<string, unknown> = {}
161
+ if (replyOptions.replyToMessageId) {
162
+ baseOptions.reply_parameters = { message_id: Number(replyOptions.replyToMessageId) }
163
+ }
164
+ if (replyOptions.threadId) {
165
+ baseOptions.message_thread_id = Number(replyOptions.threadId)
166
+ }
167
+
168
+ let lastMessageId: string | undefined
169
+
153
170
  // Telegram has a 4096 char limit
154
171
  if (response.length <= 4096) {
155
- await ctx.reply(response)
172
+ const sent = await ctx.api.sendMessage(ctx.chat.id, response, baseOptions as any)
173
+ lastMessageId = String(sent.message_id)
156
174
  } else {
157
175
  const chunks = response.match(/[\s\S]{1,4090}/g) || [response]
158
- for (const chunk of chunks) {
159
- await ctx.api.sendMessage(ctx.chat.id, chunk)
176
+ for (let i = 0; i < chunks.length; i += 1) {
177
+ const sent = await ctx.api.sendMessage(ctx.chat.id, chunks[i], (i === 0 ? baseOptions : {}) as any)
178
+ lastMessageId = String(sent.message_id)
160
179
  }
161
180
  }
181
+ await recordConnectorOutboundDelivery({
182
+ connectorId: connector.id,
183
+ inbound,
184
+ messageId: lastMessageId,
185
+ state: 'sent',
186
+ })
162
187
  } catch (err: any) {
163
188
  console.error(`[telegram] Error handling message:`, err.message)
164
189
  try {
@@ -174,6 +199,7 @@ const telegram: PlatformConnector = {
174
199
  bot.start({
175
200
  allowed_updates: ['message', 'edited_message'],
176
201
  onStart: (botInfo) => {
202
+ botUsername = botInfo.username || ''
177
203
  console.log(`[telegram] Bot started as @${botInfo.username} — polling for updates`)
178
204
  },
179
205
  }).catch((err) => {
@@ -189,6 +215,13 @@ const telegram: PlatformConnector = {
189
215
  async sendMessage(channelId, text, options) {
190
216
  const chatId = channelId
191
217
  const caption = options?.caption || text || undefined
218
+ const extra: Record<string, unknown> = {}
219
+ if (options?.replyToMessageId) {
220
+ extra.reply_parameters = { message_id: Number(options.replyToMessageId) }
221
+ }
222
+ if (options?.threadId) {
223
+ extra.message_thread_id = Number(options.threadId)
224
+ }
192
225
 
193
226
  // Local file
194
227
  if (options?.mediaPath) {
@@ -196,21 +229,21 @@ const telegram: PlatformConnector = {
196
229
  const mime = options.mimeType || mimeFromPath(options.mediaPath)
197
230
  const inputFile = new InputFile(options.mediaPath, options.fileName || path.basename(options.mediaPath))
198
231
  if (isImageMime(mime)) {
199
- const msg = await bot.api.sendPhoto(chatId, inputFile, { caption })
232
+ const msg = await bot.api.sendPhoto(chatId, inputFile, { caption, ...(extra as any) })
200
233
  return { messageId: String(msg.message_id) }
201
234
  } else if (isAudioMime(mime)) {
202
235
  const msg = options?.ptt
203
- ? await bot.api.sendVoice(chatId, inputFile, { caption })
204
- : await bot.api.sendAudio(chatId, inputFile, { caption })
236
+ ? await bot.api.sendVoice(chatId, inputFile, { caption, ...(extra as any) })
237
+ : await bot.api.sendAudio(chatId, inputFile, { caption, ...(extra as any) })
205
238
  return { messageId: String(msg.message_id) }
206
239
  } else {
207
- const msg = await bot.api.sendDocument(chatId, inputFile, { caption })
240
+ const msg = await bot.api.sendDocument(chatId, inputFile, { caption, ...(extra as any) })
208
241
  return { messageId: String(msg.message_id) }
209
242
  }
210
243
  }
211
244
  // URL-based image
212
245
  if (options?.imageUrl) {
213
- const msg = await bot.api.sendPhoto(chatId, options.imageUrl, { caption })
246
+ const msg = await bot.api.sendPhoto(chatId, options.imageUrl, { caption, ...(extra as any) })
214
247
  return { messageId: String(msg.message_id) }
215
248
  }
216
249
  // URL-based file
@@ -218,25 +251,42 @@ const telegram: PlatformConnector = {
218
251
  const mime = options.mimeType || ''
219
252
  const msg = isAudioMime(mime)
220
253
  ? options?.ptt
221
- ? await bot.api.sendVoice(chatId, options.fileUrl, { caption })
222
- : await bot.api.sendAudio(chatId, options.fileUrl, { caption })
223
- : await bot.api.sendDocument(chatId, options.fileUrl, { caption })
254
+ ? await bot.api.sendVoice(chatId, options.fileUrl, { caption, ...(extra as any) })
255
+ : await bot.api.sendAudio(chatId, options.fileUrl, { caption, ...(extra as any) })
256
+ : await bot.api.sendDocument(chatId, options.fileUrl, { caption, ...(extra as any) })
224
257
  return { messageId: String(msg.message_id) }
225
258
  }
226
259
  // Text only
227
260
  const payload = text || caption || ''
228
261
  if (payload.length <= 4096) {
229
- const msg = await bot.api.sendMessage(chatId, payload)
262
+ const msg = await bot.api.sendMessage(chatId, payload, extra as any)
230
263
  return { messageId: String(msg.message_id) }
231
264
  }
232
265
  const chunks = payload.match(/[\s\S]{1,4090}/g) || [payload]
233
266
  let lastId: string | undefined
234
- for (const chunk of chunks) {
235
- const msg = await bot.api.sendMessage(chatId, chunk)
267
+ for (let i = 0; i < chunks.length; i += 1) {
268
+ const msg = await bot.api.sendMessage(chatId, chunks[i], (i === 0 ? extra : {}) as any)
236
269
  lastId = String(msg.message_id)
237
270
  }
238
271
  return { messageId: lastId }
239
272
  },
273
+ async sendReaction(channelId, messageId, emoji) {
274
+ const fn = (bot.api as any).setMessageReaction
275
+ if (typeof fn !== 'function') return
276
+ await fn.call(bot.api, channelId, Number(messageId), [{ type: 'emoji', emoji }])
277
+ },
278
+ async editMessage(channelId, messageId, newText) {
279
+ await bot.api.editMessageText(channelId, Number(messageId), newText)
280
+ },
281
+ async deleteMessage(channelId, messageId) {
282
+ await bot.api.deleteMessage(channelId, Number(messageId))
283
+ },
284
+ async pinMessage(channelId, messageId) {
285
+ await bot.api.pinChatMessage(channelId, Number(messageId))
286
+ },
287
+ async sendTyping(channelId) {
288
+ await bot.api.sendChatAction(channelId, 'typing')
289
+ },
240
290
  async stop() {
241
291
  botRunning = false
242
292
  await bot.stop()
@@ -0,0 +1,44 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { buildConnectorThreadContextBlock, resolveThreadPersonaLabel } from './thread-context'
4
+
5
+ test('resolveThreadPersonaLabel prefers explicit and title-based labels', () => {
6
+ assert.equal(resolveThreadPersonaLabel({
7
+ platform: 'slack',
8
+ threadPersonaLabel: 'Incident Bridge',
9
+ threadTitle: 'ignored',
10
+ threadStarterText: 'ignored',
11
+ threadId: 't1',
12
+ channelName: 'ops',
13
+ }), 'Incident Bridge')
14
+
15
+ assert.equal(resolveThreadPersonaLabel({
16
+ platform: 'discord',
17
+ threadPersonaLabel: undefined,
18
+ threadTitle: 'Release Coordination',
19
+ threadStarterText: 'root message',
20
+ threadId: 't1',
21
+ channelName: 'deploys',
22
+ }), 'Release Coordination')
23
+ })
24
+
25
+ test('buildConnectorThreadContextBlock includes starter, history, and first-turn note', () => {
26
+ const block = buildConnectorThreadContextBlock({
27
+ platform: 'slack',
28
+ threadId: 'thread-1',
29
+ threadTitle: 'Checkout Incident',
30
+ threadStarterText: 'Prod checkout is returning 500s.',
31
+ threadStarterSenderName: 'Alice',
32
+ threadParentChannelName: 'incidents',
33
+ threadHistory: [
34
+ { role: 'assistant', senderName: 'Swarmy', text: 'I am tracing the failing service now.' },
35
+ { role: 'user', senderName: 'Bob', text: 'Looks isolated to EU traffic.' },
36
+ ],
37
+ }, { isFirstThreadTurn: true })
38
+
39
+ assert.match(block, /Native Thread Context/)
40
+ assert.match(block, /Thread persona: Checkout Incident/)
41
+ assert.match(block, /Thread starter: Alice: Prod checkout is returning 500s\./)
42
+ assert.match(block, /first turn in a thread-bound session/i)
43
+ assert.match(block, /\[assistant\] Swarmy: I am tracing the failing service now\./)
44
+ })
@@ -0,0 +1,72 @@
1
+ import type { InboundMessage, InboundThreadHistoryEntry } from './types'
2
+
3
+ function normalizeText(value: unknown, maxChars: number): string {
4
+ return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxChars)
5
+ }
6
+
7
+ export function resolveThreadPersonaLabel(msg: Pick<InboundMessage, 'threadPersonaLabel' | 'threadTitle' | 'threadStarterText' | 'threadId' | 'channelName' | 'platform'>): string | null {
8
+ const explicit = normalizeText(msg.threadPersonaLabel, 120)
9
+ if (explicit) return explicit
10
+ const title = normalizeText(msg.threadTitle, 120)
11
+ if (title) return title
12
+ const starter = normalizeText(msg.threadStarterText, 72)
13
+ if (starter) {
14
+ return `${msg.platform} thread: ${starter}`.slice(0, 120)
15
+ }
16
+ const channel = normalizeText(msg.channelName, 64)
17
+ if (msg.threadId && channel) return `${channel} thread`
18
+ if (msg.threadId) return `${msg.platform} thread`
19
+ return null
20
+ }
21
+
22
+ function formatHistoryEntry(entry: InboundThreadHistoryEntry): string {
23
+ const speaker = normalizeText(entry.senderName, 60) || (entry.role === 'assistant' ? 'assistant' : 'user')
24
+ const text = normalizeText(entry.text, 220)
25
+ return `- [${entry.role}] ${speaker}: ${text}`
26
+ }
27
+
28
+ export function buildConnectorThreadContextBlock(
29
+ msg: Pick<
30
+ InboundMessage,
31
+ 'platform'
32
+ | 'threadId'
33
+ | 'replyToMessageId'
34
+ | 'threadTitle'
35
+ | 'threadStarterText'
36
+ | 'threadStarterSenderName'
37
+ | 'threadParentChannelName'
38
+ | 'threadHistory'
39
+ | 'threadPersonaLabel'
40
+ >,
41
+ opts?: { isFirstThreadTurn?: boolean },
42
+ ): string {
43
+ const hasThreadContext = !!(
44
+ msg.threadId
45
+ || msg.replyToMessageId
46
+ || msg.threadTitle
47
+ || msg.threadStarterText
48
+ || (Array.isArray(msg.threadHistory) && msg.threadHistory.length > 0)
49
+ )
50
+ if (!hasThreadContext) return ''
51
+
52
+ const persona = resolveThreadPersonaLabel(msg)
53
+ const lines = ['## Native Thread Context']
54
+ if (persona) lines.push(`Thread persona: ${persona}`)
55
+ if (msg.threadTitle) lines.push(`Thread title: ${normalizeText(msg.threadTitle, 140)}`)
56
+ if (msg.threadParentChannelName) lines.push(`Parent channel: ${normalizeText(msg.threadParentChannelName, 100)}`)
57
+ if (opts?.isFirstThreadTurn) {
58
+ lines.push('This is the first turn in a thread-bound session. Treat the starter and history below as earlier context from the same conversation.')
59
+ }
60
+ if (msg.threadStarterText) {
61
+ const speaker = normalizeText(msg.threadStarterSenderName, 60) || 'unknown'
62
+ lines.push(`Thread starter: ${speaker}: ${normalizeText(msg.threadStarterText, 260)}`)
63
+ }
64
+ if (Array.isArray(msg.threadHistory) && msg.threadHistory.length > 0) {
65
+ lines.push('Recent thread history before this turn:')
66
+ for (const entry of msg.threadHistory.slice(-6)) {
67
+ lines.push(formatHistoryEntry(entry))
68
+ }
69
+ }
70
+ lines.push('Respond as part of this ongoing thread, not as if the message started a brand new conversation.')
71
+ return lines.join('\n')
72
+ }
@@ -2,6 +2,13 @@ import type { Connector } from '@/types'
2
2
 
3
3
  export type InboundMediaType = 'image' | 'video' | 'audio' | 'document' | 'file'
4
4
 
5
+ export interface InboundThreadHistoryEntry {
6
+ role: 'user' | 'assistant'
7
+ senderName: string
8
+ text: string
9
+ messageId?: string
10
+ }
11
+
5
12
  export interface InboundMedia {
6
13
  type: InboundMediaType
7
14
  fileName?: string
@@ -22,12 +29,43 @@ export interface InboundMessage {
22
29
  senderName: string // display name
23
30
  text: string
24
31
  isGroup?: boolean
32
+ messageId?: string
25
33
  imageUrl?: string
26
34
  media?: InboundMedia[]
27
35
  replyToMessageId?: string
36
+ threadId?: string
37
+ threadTitle?: string
38
+ threadStarterText?: string
39
+ threadStarterSenderName?: string
40
+ threadPersonaLabel?: string
41
+ threadParentChannelId?: string
42
+ threadParentChannelName?: string
43
+ threadHistory?: InboundThreadHistoryEntry[]
44
+ mentionsBot?: boolean
28
45
  agentIdOverride?: string
29
46
  }
30
47
 
48
+ export interface OutboundSendOptions {
49
+ imageUrl?: string
50
+ fileUrl?: string
51
+ /** Absolute local file path (e.g. screenshot saved to disk) */
52
+ mediaPath?: string
53
+ mimeType?: string
54
+ fileName?: string
55
+ caption?: string
56
+ /** Send audio as a WhatsApp voice note (push-to-talk) */
57
+ ptt?: boolean
58
+ /** Platform-native reply target when supported */
59
+ replyToMessageId?: string
60
+ /** Platform-native thread or topic identifier when supported */
61
+ threadId?: string
62
+ }
63
+
64
+ export interface OutboundTypingOptions {
65
+ /** Platform-native thread or topic identifier when supported */
66
+ threadId?: string
67
+ }
68
+
31
69
  /** A running connector instance */
32
70
  export interface ConnectorInstance {
33
71
  connector: Connector
@@ -36,17 +74,7 @@ export interface ConnectorInstance {
36
74
  sendMessage?: (
37
75
  channelId: string,
38
76
  text: string,
39
- options?: {
40
- imageUrl?: string
41
- fileUrl?: string
42
- /** Absolute local file path (e.g. screenshot saved to disk) */
43
- mediaPath?: string
44
- mimeType?: string
45
- fileName?: string
46
- caption?: string
47
- /** Send audio as a WhatsApp voice note (push-to-talk) */
48
- ptt?: boolean
49
- },
77
+ options?: OutboundSendOptions,
50
78
  ) => Promise<{ messageId?: string } | void>
51
79
  /** Current QR code data URL (WhatsApp only, null when paired) */
52
80
  qrDataUrl?: string | null
@@ -62,6 +90,8 @@ export interface ConnectorInstance {
62
90
  deleteMessage?: (channelId: string, messageId: string) => Promise<void>
63
91
  /** Rich messaging: pin a message */
64
92
  pinMessage?: (channelId: string, messageId: string) => Promise<void>
93
+ /** Best-effort typing or "working" indicator for the target conversation */
94
+ sendTyping?: (channelId: string, options?: OutboundTypingOptions) => Promise<void>
65
95
  /** Health check: returns true if the underlying connection is alive */
66
96
  isAlive?: () => boolean
67
97
  }