stagent 0.5.0 → 0.6.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 (252) hide show
  1. package/README.md +8 -8
  2. package/dist/cli.js +146 -2
  3. package/docs/.coverage-gaps.json +21 -0
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +36 -14
  6. package/docs/features/chat.md +33 -56
  7. package/docs/features/cost-usage.md +14 -10
  8. package/docs/features/dashboard-kanban.md +30 -13
  9. package/docs/features/delivery-channels.md +198 -0
  10. package/docs/features/design-system.md +10 -10
  11. package/docs/features/documents.md +8 -8
  12. package/docs/features/home-workspace.md +20 -15
  13. package/docs/features/inbox-notifications.md +22 -10
  14. package/docs/features/keyboard-navigation.md +11 -11
  15. package/docs/features/monitoring.md +1 -1
  16. package/docs/features/playbook.md +30 -32
  17. package/docs/features/profiles.md +33 -11
  18. package/docs/features/projects.md +2 -2
  19. package/docs/features/provider-runtimes.md +58 -14
  20. package/docs/features/schedules.md +70 -40
  21. package/docs/features/settings.md +74 -46
  22. package/docs/features/shared-components.md +7 -15
  23. package/docs/features/tool-permissions.md +9 -9
  24. package/docs/features/workflows.md +32 -21
  25. package/docs/getting-started.md +33 -9
  26. package/docs/index.md +25 -16
  27. package/docs/journeys/developer.md +124 -207
  28. package/docs/journeys/personal-use.md +70 -79
  29. package/docs/journeys/power-user.md +107 -151
  30. package/docs/journeys/work-use.md +81 -113
  31. package/docs/manifest.json +77 -45
  32. package/docs/superpowers/plans/2026-03-30-finish-in-progress-features.md +547 -0
  33. package/docs/use-cases/agency-operator.md +84 -0
  34. package/docs/use-cases/solo-founder.md +75 -0
  35. package/docs/why-stagent.md +59 -0
  36. package/package.json +10 -3
  37. package/src/app/api/channels/[id]/route.ts +103 -0
  38. package/src/app/api/channels/[id]/test/route.ts +52 -0
  39. package/src/app/api/channels/inbound/slack/route.ts +109 -0
  40. package/src/app/api/channels/inbound/telegram/poll/route.ts +128 -0
  41. package/src/app/api/channels/inbound/telegram/route.ts +76 -0
  42. package/src/app/api/channels/route.ts +71 -0
  43. package/src/app/api/chat/conversations/route.ts +15 -0
  44. package/src/app/api/chat/entities/search/route.ts +46 -31
  45. package/src/app/api/environment/profiles/suggest/route.ts +19 -3
  46. package/src/app/api/environment/scan/route.ts +8 -1
  47. package/src/app/api/handoffs/[id]/route.ts +76 -0
  48. package/src/app/api/handoffs/route.ts +89 -0
  49. package/src/app/api/memory/route.ts +181 -0
  50. package/src/app/api/profiles/[id]/route.ts +16 -1
  51. package/src/app/api/profiles/[id]/test/route.ts +4 -0
  52. package/src/app/api/profiles/[id]/test-results/route.ts +22 -0
  53. package/src/app/api/profiles/[id]/test-single/route.ts +64 -0
  54. package/src/app/api/profiles/assist/route.ts +35 -0
  55. package/src/app/api/profiles/import-repo/apply-updates/route.ts +123 -0
  56. package/src/app/api/profiles/import-repo/check-updates/route.ts +163 -0
  57. package/src/app/api/profiles/import-repo/confirm/route.ts +118 -0
  58. package/src/app/api/profiles/import-repo/preview/route.ts +107 -0
  59. package/src/app/api/profiles/import-repo/route.ts +29 -0
  60. package/src/app/api/profiles/import-repo/scan/route.ts +25 -0
  61. package/src/app/api/profiles/route.ts +73 -22
  62. package/src/app/api/runtimes/ollama/route.ts +86 -0
  63. package/src/app/api/runtimes/suggest/route.ts +29 -0
  64. package/src/app/api/schedules/[id]/heartbeat-history/route.ts +77 -0
  65. package/src/app/api/schedules/[id]/route.ts +41 -3
  66. package/src/app/api/schedules/parse/route.ts +66 -0
  67. package/src/app/api/schedules/route.ts +71 -12
  68. package/src/app/api/settings/author-default/route.ts +7 -0
  69. package/src/app/api/settings/learning/route.ts +41 -0
  70. package/src/app/api/settings/ollama/route.ts +34 -0
  71. package/src/app/api/settings/providers/route.ts +57 -0
  72. package/src/app/api/settings/routing/route.ts +24 -0
  73. package/src/app/api/settings/web-search/route.ts +28 -0
  74. package/src/app/api/tasks/[id]/execute/route.ts +13 -1
  75. package/src/app/documents/page.tsx +3 -0
  76. package/src/app/environment/page.tsx +8 -1
  77. package/src/app/settings/page.tsx +10 -4
  78. package/src/app/workflows/[id]/edit/page.tsx +2 -0
  79. package/src/app/workflows/new/page.tsx +2 -0
  80. package/src/components/chat/chat-command-popover.tsx +22 -19
  81. package/src/components/chat/chat-input.tsx +5 -0
  82. package/src/components/chat/chat-model-selector.tsx +42 -1
  83. package/src/components/chat/chat-shell.tsx +2 -0
  84. package/src/components/dashboard/welcome-landing.tsx +9 -9
  85. package/src/components/environment/artifact-card.tsx +27 -1
  86. package/src/components/environment/environment-dashboard.tsx +50 -2
  87. package/src/components/environment/environment-summary-card.tsx +5 -2
  88. package/src/components/environment/suggested-profiles.tsx +117 -52
  89. package/src/components/handoffs/handoff-approval-card.tsx +159 -0
  90. package/src/components/memory/memory-browser.tsx +315 -0
  91. package/src/components/profiles/learned-context-panel.tsx +4 -4
  92. package/src/components/profiles/profile-assist-panel.tsx +512 -0
  93. package/src/components/profiles/profile-browser.tsx +109 -8
  94. package/src/components/profiles/profile-card.tsx +29 -1
  95. package/src/components/profiles/profile-detail-view.tsx +200 -28
  96. package/src/components/profiles/profile-form-view.tsx +220 -82
  97. package/src/components/profiles/repo-import-wizard.tsx +648 -0
  98. package/src/components/profiles/smoke-test-editor.tsx +106 -0
  99. package/src/components/schedules/schedule-create-sheet.tsx +9 -1
  100. package/src/components/schedules/schedule-form.tsx +348 -9
  101. package/src/components/schedules/schedule-list.tsx +15 -2
  102. package/src/components/settings/auth-method-selector.tsx +7 -1
  103. package/src/components/settings/budget-guardrails-section.tsx +111 -48
  104. package/src/components/settings/channels-section.tsx +526 -0
  105. package/src/components/settings/chat-settings-section.tsx +27 -1
  106. package/src/components/settings/data-management-section.tsx +8 -6
  107. package/src/components/settings/learning-context-section.tsx +124 -0
  108. package/src/components/settings/ollama-section.tsx +270 -0
  109. package/src/components/settings/providers-runtimes-section.tsx +499 -0
  110. package/src/components/settings/web-search-section.tsx +101 -0
  111. package/src/components/shared/tag-input.tsx +156 -0
  112. package/src/components/tasks/kanban-board.tsx +32 -0
  113. package/src/components/tasks/kanban-column.tsx +4 -2
  114. package/src/components/tasks/task-card.tsx +1 -0
  115. package/src/components/tasks/task-chip-bar.tsx +6 -1
  116. package/src/components/tasks/task-create-panel.tsx +55 -5
  117. package/src/components/workflows/workflow-form-view.tsx +38 -3
  118. package/src/hooks/use-chat-autocomplete.ts +24 -26
  119. package/src/hooks/use-project-skills.ts +66 -0
  120. package/src/hooks/use-tag-suggestions.ts +31 -0
  121. package/src/instrumentation.ts +4 -1
  122. package/src/lib/agents/__tests__/claude-agent.test.ts +3 -0
  123. package/src/lib/agents/__tests__/learned-context.test.ts +10 -0
  124. package/src/lib/agents/agentic-loop.ts +235 -0
  125. package/src/lib/agents/browser-mcp.ts +59 -4
  126. package/src/lib/agents/claude-agent.ts +26 -199
  127. package/src/lib/agents/handoff/bus.ts +164 -0
  128. package/src/lib/agents/handoff/governance.ts +47 -0
  129. package/src/lib/agents/handoff/types.ts +16 -0
  130. package/src/lib/agents/learned-context.ts +27 -7
  131. package/src/lib/agents/memory/decay.ts +61 -0
  132. package/src/lib/agents/memory/extractor.ts +181 -0
  133. package/src/lib/agents/memory/retrieval.ts +96 -0
  134. package/src/lib/agents/memory/types.ts +6 -0
  135. package/src/lib/agents/profiles/__tests__/project-profiles.test.ts +119 -0
  136. package/src/lib/agents/profiles/__tests__/registry.test.ts +11 -3
  137. package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +2 -2
  138. package/src/lib/agents/profiles/builtins/content-creator/SKILL.md +19 -0
  139. package/src/lib/agents/profiles/builtins/content-creator/profile.yaml +27 -0
  140. package/src/lib/agents/profiles/builtins/customer-support-agent/SKILL.md +19 -0
  141. package/src/lib/agents/profiles/builtins/customer-support-agent/profile.yaml +26 -0
  142. package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +2 -2
  143. package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +2 -2
  144. package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +2 -2
  145. package/src/lib/agents/profiles/builtins/financial-analyst/SKILL.md +19 -0
  146. package/src/lib/agents/profiles/builtins/financial-analyst/profile.yaml +24 -0
  147. package/src/lib/agents/profiles/builtins/general/profile.yaml +2 -2
  148. package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +2 -2
  149. package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +2 -2
  150. package/src/lib/agents/profiles/builtins/marketing-strategist/SKILL.md +19 -0
  151. package/src/lib/agents/profiles/builtins/marketing-strategist/profile.yaml +27 -0
  152. package/src/lib/agents/profiles/builtins/operations-coordinator/SKILL.md +19 -0
  153. package/src/lib/agents/profiles/builtins/operations-coordinator/profile.yaml +26 -0
  154. package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +2 -2
  155. package/src/lib/agents/profiles/builtins/researcher/SKILL.md +1 -0
  156. package/src/lib/agents/profiles/builtins/researcher/profile.yaml +2 -2
  157. package/src/lib/agents/profiles/builtins/sales-researcher/SKILL.md +19 -0
  158. package/src/lib/agents/profiles/builtins/sales-researcher/profile.yaml +26 -0
  159. package/src/lib/agents/profiles/builtins/shopping-assistant/SKILL.md +1 -0
  160. package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +2 -2
  161. package/src/lib/agents/profiles/builtins/sweep/profile.yaml +1 -1
  162. package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +2 -2
  163. package/src/lib/agents/profiles/builtins/travel-planner/SKILL.md +2 -0
  164. package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +2 -2
  165. package/src/lib/agents/profiles/builtins/wealth-manager/SKILL.md +2 -0
  166. package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +2 -2
  167. package/src/lib/agents/profiles/project-profiles.ts +193 -0
  168. package/src/lib/agents/profiles/registry.ts +130 -6
  169. package/src/lib/agents/profiles/types.ts +28 -0
  170. package/src/lib/agents/router.ts +174 -2
  171. package/src/lib/agents/runtime/__tests__/catalog.test.ts +15 -4
  172. package/src/lib/agents/runtime/anthropic-direct.ts +644 -0
  173. package/src/lib/agents/runtime/catalog.ts +57 -2
  174. package/src/lib/agents/runtime/claude.ts +205 -1
  175. package/src/lib/agents/runtime/index.ts +22 -0
  176. package/src/lib/agents/runtime/ollama-adapter.ts +409 -0
  177. package/src/lib/agents/runtime/openai-direct.ts +514 -0
  178. package/src/lib/agents/runtime/profile-assist-types.ts +30 -0
  179. package/src/lib/agents/runtime/types.ts +2 -0
  180. package/src/lib/agents/tool-permissions.ts +203 -0
  181. package/src/lib/channels/gateway.ts +321 -0
  182. package/src/lib/channels/poller.ts +268 -0
  183. package/src/lib/channels/registry.ts +90 -0
  184. package/src/lib/channels/slack-adapter.ts +188 -0
  185. package/src/lib/channels/telegram-adapter.ts +218 -0
  186. package/src/lib/channels/types.ts +43 -0
  187. package/src/lib/channels/webhook-adapter.ts +74 -0
  188. package/src/lib/chat/context-builder.ts +22 -2
  189. package/src/lib/chat/engine.ts +95 -13
  190. package/src/lib/chat/ollama-engine.ts +198 -0
  191. package/src/lib/chat/stagent-tools.ts +106 -20
  192. package/src/lib/chat/tool-catalog.ts +24 -0
  193. package/src/lib/chat/tool-registry.ts +90 -0
  194. package/src/lib/chat/tools/chat-history-tools.ts +4 -4
  195. package/src/lib/chat/tools/document-tools.ts +7 -7
  196. package/src/lib/chat/tools/handoff-tools.ts +70 -0
  197. package/src/lib/chat/tools/notification-tools.ts +4 -4
  198. package/src/lib/chat/tools/profile-tools.ts +3 -3
  199. package/src/lib/chat/tools/project-tools.ts +3 -3
  200. package/src/lib/chat/tools/schedule-tools.ts +29 -13
  201. package/src/lib/chat/tools/settings-tools.ts +2 -2
  202. package/src/lib/chat/tools/task-tools.ts +66 -11
  203. package/src/lib/chat/tools/usage-tools.ts +2 -2
  204. package/src/lib/chat/tools/workflow-tools.ts +8 -8
  205. package/src/lib/chat/types.ts +11 -5
  206. package/src/lib/constants/known-tools.ts +19 -0
  207. package/src/lib/constants/prose-styles.ts +1 -1
  208. package/src/lib/constants/settings.ts +7 -0
  209. package/src/lib/data/channel-bindings.ts +85 -0
  210. package/src/lib/data/clear.ts +22 -0
  211. package/src/lib/data/profile-test-results.ts +48 -0
  212. package/src/lib/data/seed-data/conversations.ts +196 -0
  213. package/src/lib/data/seed-data/learned-context.ts +99 -0
  214. package/src/lib/data/seed-data/notifications.ts +54 -1
  215. package/src/lib/data/seed-data/profile-test-results.ts +96 -0
  216. package/src/lib/data/seed-data/repo-imports.ts +51 -0
  217. package/src/lib/data/seed-data/views.ts +60 -0
  218. package/src/lib/data/seed.ts +51 -0
  219. package/src/lib/db/bootstrap.ts +162 -0
  220. package/src/lib/db/migrations/0013_add_repo_imports.sql +15 -0
  221. package/src/lib/db/migrations/0014_add_linked_profile_id.sql +3 -0
  222. package/src/lib/db/migrations/0015_add_channel_bindings.sql +23 -0
  223. package/src/lib/db/schema.ts +187 -1
  224. package/src/lib/environment/__tests__/auto-scan.test.ts +86 -0
  225. package/src/lib/environment/__tests__/profile-linker.test.ts +187 -0
  226. package/src/lib/environment/auto-scan.ts +48 -0
  227. package/src/lib/environment/data.ts +25 -0
  228. package/src/lib/environment/profile-generator.ts +40 -10
  229. package/src/lib/environment/profile-linker.ts +143 -0
  230. package/src/lib/environment/profile-rules.ts +96 -0
  231. package/src/lib/import/dedup.ts +149 -0
  232. package/src/lib/import/format-adapter.ts +631 -0
  233. package/src/lib/import/github-api.ts +219 -0
  234. package/src/lib/import/repo-scanner.ts +251 -0
  235. package/src/lib/schedules/__tests__/nlp-parser.test.ts +330 -0
  236. package/src/lib/schedules/active-hours.ts +120 -0
  237. package/src/lib/schedules/heartbeat-parser.ts +224 -0
  238. package/src/lib/schedules/heartbeat-prompt.ts +153 -0
  239. package/src/lib/schedules/nlp-parser.ts +357 -0
  240. package/src/lib/schedules/scheduler.ts +218 -3
  241. package/src/lib/settings/__tests__/budget-guardrails.test.ts +39 -1
  242. package/src/lib/settings/helpers.ts +6 -0
  243. package/src/lib/settings/routing.ts +24 -0
  244. package/src/lib/settings/runtime-setup.ts +28 -1
  245. package/src/lib/usage/ledger.ts +2 -1
  246. package/src/lib/validators/__tests__/settings.test.ts +9 -0
  247. package/src/lib/validators/profile.ts +39 -0
  248. package/src/lib/workflows/blueprints/builtins/business-daily-briefing.yaml +102 -0
  249. package/src/lib/workflows/blueprints/builtins/content-marketing-pipeline.yaml +90 -0
  250. package/src/lib/workflows/blueprints/builtins/customer-support-triage.yaml +107 -0
  251. package/src/lib/workflows/blueprints/builtins/financial-reporting.yaml +104 -0
  252. package/src/lib/workflows/blueprints/builtins/lead-research-pipeline.yaml +82 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Channel Poller — background service that polls bidirectional channels
3
+ * for inbound messages when webhooks can't reach the server (e.g., localhost).
4
+ *
5
+ * Runs alongside the scheduler via instrumentation.ts.
6
+ * Only polls channels with direction="bidirectional" and status="active".
7
+ */
8
+
9
+ import { db } from "@/lib/db";
10
+ import { channelConfigs } from "@/lib/db/schema";
11
+ import { eq, and } from "drizzle-orm";
12
+ import { telegramAdapter } from "./telegram-adapter";
13
+ import { handleInboundMessage } from "./gateway";
14
+ import type { InboundMessage } from "./types";
15
+
16
+ const POLL_INTERVAL_MS = 5_000; // 5 seconds
17
+ let intervalHandle: ReturnType<typeof setInterval> | null = null;
18
+
19
+ /** Per-channel offset tracking (in-memory, resets on restart). */
20
+ const channelOffsets = new Map<string, number>();
21
+
22
+ /** Per-channel Slack timestamp tracking (in-memory). */
23
+ const slackTimestamps = new Map<string, string>();
24
+
25
+ /** Lock to prevent overlapping ticks. */
26
+ let ticking = false;
27
+
28
+ export function startChannelPoller(): void {
29
+ if (intervalHandle !== null) return;
30
+ intervalHandle = setInterval(() => {
31
+ tickPoller().catch((err) => {
32
+ console.error("[channel-poller] tick error:", err);
33
+ });
34
+ }, POLL_INTERVAL_MS);
35
+ console.log(`[channel-poller] started — polling every ${POLL_INTERVAL_MS / 1000}s`);
36
+ }
37
+
38
+ export function stopChannelPoller(): void {
39
+ if (intervalHandle !== null) {
40
+ clearInterval(intervalHandle);
41
+ intervalHandle = null;
42
+ console.log("[channel-poller] stopped");
43
+ }
44
+ }
45
+
46
+ async function tickPoller(): Promise<void> {
47
+ if (ticking) return; // Skip if previous tick still running
48
+ ticking = true;
49
+ try {
50
+ // Find all active bidirectional channels
51
+ const channels = db
52
+ .select()
53
+ .from(channelConfigs)
54
+ .where(
55
+ and(
56
+ eq(channelConfigs.direction, "bidirectional"),
57
+ eq(channelConfigs.status, "active")
58
+ )
59
+ )
60
+ .all();
61
+
62
+ for (const channel of channels) {
63
+ try {
64
+ await pollChannel(channel);
65
+ } catch (err) {
66
+ console.error(`[channel-poller] error polling ${channel.channelType}/${channel.id}:`, err);
67
+ }
68
+ }
69
+ } finally {
70
+ ticking = false;
71
+ }
72
+ }
73
+
74
+ async function pollChannel(
75
+ channel: typeof channelConfigs.$inferSelect
76
+ ): Promise<void> {
77
+ if (channel.channelType === "telegram") {
78
+ await pollTelegram(channel);
79
+ } else if (channel.channelType === "slack") {
80
+ await pollSlack(channel);
81
+ }
82
+ // Webhook channels are push-based — no polling needed
83
+ }
84
+
85
+ async function pollTelegram(
86
+ channel: typeof channelConfigs.$inferSelect
87
+ ): Promise<void> {
88
+ let parsedConfig: Record<string, unknown>;
89
+ try {
90
+ parsedConfig = JSON.parse(channel.config) as Record<string, unknown>;
91
+ } catch {
92
+ return;
93
+ }
94
+
95
+ const botToken = parsedConfig.botToken as string;
96
+ if (!botToken) return;
97
+
98
+ const offset = channelOffsets.get(channel.id);
99
+ const params: Record<string, unknown> = { timeout: 0, limit: 20 };
100
+ if (offset) {
101
+ params.offset = offset;
102
+ }
103
+
104
+ let updates: TelegramUpdate[];
105
+ try {
106
+ const res = await fetch(
107
+ `https://api.telegram.org/bot${botToken}/getUpdates`,
108
+ {
109
+ method: "POST",
110
+ headers: { "Content-Type": "application/json" },
111
+ body: JSON.stringify(params),
112
+ }
113
+ );
114
+ const data = (await res.json()) as { ok: boolean; result: TelegramUpdate[] };
115
+ if (!data.ok) return;
116
+ updates = data.result;
117
+ } catch {
118
+ return; // Network error — retry next tick
119
+ }
120
+
121
+ if (updates.length === 0) return;
122
+
123
+ let maxUpdateId = 0;
124
+ for (const update of updates) {
125
+ if (update.update_id > maxUpdateId) {
126
+ maxUpdateId = update.update_id;
127
+ }
128
+
129
+ const message = telegramAdapter.parseInbound!(update, {});
130
+ if (!message || message.isBot) continue;
131
+
132
+ try {
133
+ await handleInboundMessage({
134
+ channelConfigId: channel.id,
135
+ message,
136
+ });
137
+ } catch (err) {
138
+ console.error(`[channel-poller] error processing telegram update ${update.update_id}:`, err);
139
+ }
140
+ }
141
+
142
+ // Advance offset so Telegram doesn't return these again
143
+ if (maxUpdateId > 0) {
144
+ const nextOffset = maxUpdateId + 1;
145
+ channelOffsets.set(channel.id, nextOffset);
146
+
147
+ // Acknowledge with Telegram
148
+ try {
149
+ await fetch(
150
+ `https://api.telegram.org/bot${botToken}/getUpdates`,
151
+ {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/json" },
154
+ body: JSON.stringify({ offset: nextOffset, timeout: 0 }),
155
+ }
156
+ );
157
+ } catch {
158
+ // Non-fatal
159
+ }
160
+ }
161
+ }
162
+
163
+ async function pollSlack(
164
+ channel: typeof channelConfigs.$inferSelect
165
+ ): Promise<void> {
166
+ let parsedConfig: Record<string, unknown>;
167
+ try {
168
+ parsedConfig = JSON.parse(channel.config) as Record<string, unknown>;
169
+ } catch {
170
+ return;
171
+ }
172
+
173
+ const botToken = parsedConfig.botToken as string;
174
+ const slackChannelId = parsedConfig.slackChannelId as string;
175
+ if (!botToken || !slackChannelId) return;
176
+
177
+ // Use oldest timestamp to only fetch new messages
178
+ const oldest = slackTimestamps.get(channel.id);
179
+
180
+ const params = new URLSearchParams({
181
+ channel: slackChannelId,
182
+ limit: "20",
183
+ });
184
+ if (oldest) {
185
+ params.set("oldest", oldest);
186
+ }
187
+
188
+ let messages: SlackMessage[];
189
+ try {
190
+ const res = await fetch(
191
+ `https://slack.com/api/conversations.history?${params.toString()}`,
192
+ {
193
+ headers: { Authorization: `Bearer ${botToken}` },
194
+ }
195
+ );
196
+ const data = (await res.json()) as {
197
+ ok: boolean;
198
+ messages?: SlackMessage[];
199
+ error?: string;
200
+ };
201
+ if (!data.ok || !data.messages) return;
202
+ messages = data.messages;
203
+ } catch {
204
+ return; // Network error — retry next tick
205
+ }
206
+
207
+ if (messages.length === 0) return;
208
+
209
+ // Slack returns newest first — reverse to process chronologically
210
+ messages.reverse();
211
+
212
+ let maxTs = oldest ?? "0";
213
+ for (const msg of messages) {
214
+ // Skip bot messages, subtypes (joins, edits), and already-seen messages
215
+ if (msg.bot_id || msg.subtype) continue;
216
+ if (oldest && msg.ts <= oldest) continue;
217
+
218
+ if (msg.ts > maxTs) {
219
+ maxTs = msg.ts;
220
+ }
221
+
222
+ const inbound: InboundMessage = {
223
+ text: msg.text ?? "",
224
+ senderId: msg.user,
225
+ senderName: msg.user,
226
+ externalThreadId: msg.thread_ts ?? msg.ts,
227
+ externalMessageId: msg.ts,
228
+ isBot: !!msg.bot_id,
229
+ };
230
+
231
+ if (!inbound.text) continue;
232
+
233
+ try {
234
+ await handleInboundMessage({
235
+ channelConfigId: channel.id,
236
+ message: inbound,
237
+ });
238
+ } catch (err) {
239
+ console.error(`[channel-poller] error processing slack message ${msg.ts}:`, err);
240
+ }
241
+ }
242
+
243
+ // Track the latest timestamp so we don't re-process
244
+ if (maxTs > (oldest ?? "0")) {
245
+ slackTimestamps.set(channel.id, maxTs);
246
+ }
247
+ }
248
+
249
+ interface SlackMessage {
250
+ type: string;
251
+ text?: string;
252
+ user?: string;
253
+ bot_id?: string;
254
+ subtype?: string;
255
+ ts: string;
256
+ thread_ts?: string;
257
+ }
258
+
259
+ interface TelegramUpdate {
260
+ update_id: number;
261
+ message?: {
262
+ message_id: number;
263
+ from?: { id: number; is_bot: boolean; first_name: string };
264
+ chat: { id: number; type: string };
265
+ date: number;
266
+ text?: string;
267
+ };
268
+ }
@@ -0,0 +1,90 @@
1
+ import { db } from "@/lib/db";
2
+ import { channelConfigs } from "@/lib/db/schema";
3
+ import { eq, inArray } from "drizzle-orm";
4
+ import type { ChannelAdapter, ChannelMessage, ChannelDeliveryResult } from "./types";
5
+ import { slackAdapter } from "./slack-adapter";
6
+ import { telegramAdapter } from "./telegram-adapter";
7
+ import { webhookAdapter } from "./webhook-adapter";
8
+
9
+ const adapters: Record<string, ChannelAdapter> = {
10
+ slack: slackAdapter,
11
+ telegram: telegramAdapter,
12
+ webhook: webhookAdapter,
13
+ };
14
+
15
+ /**
16
+ * Get a channel adapter by type.
17
+ */
18
+ export function getChannelAdapter(channelType: string): ChannelAdapter {
19
+ const adapter = adapters[channelType];
20
+ if (!adapter) {
21
+ throw new Error(`Unknown channel type: ${channelType}`);
22
+ }
23
+ return adapter;
24
+ }
25
+
26
+ /**
27
+ * Send a message to multiple channels by their config IDs.
28
+ * Skips disabled channels. Returns results for each channel attempted.
29
+ */
30
+ export async function sendToChannels(
31
+ channelIds: string[],
32
+ message: ChannelMessage
33
+ ): Promise<ChannelDeliveryResult[]> {
34
+ if (channelIds.length === 0) return [];
35
+
36
+ const configs = await db
37
+ .select()
38
+ .from(channelConfigs)
39
+ .where(inArray(channelConfigs.id, channelIds));
40
+
41
+ const results: ChannelDeliveryResult[] = [];
42
+
43
+ for (const config of configs) {
44
+ if (config.status === "disabled") {
45
+ results.push({
46
+ success: false,
47
+ channelId: config.id,
48
+ error: "Channel is disabled",
49
+ });
50
+ continue;
51
+ }
52
+
53
+ const adapter = adapters[config.channelType];
54
+ if (!adapter) {
55
+ results.push({
56
+ success: false,
57
+ channelId: config.id,
58
+ error: `Unknown channel type: ${config.channelType}`,
59
+ });
60
+ continue;
61
+ }
62
+
63
+ let parsedConfig: Record<string, unknown>;
64
+ try {
65
+ parsedConfig = JSON.parse(config.config) as Record<string, unknown>;
66
+ } catch {
67
+ results.push({
68
+ success: false,
69
+ channelId: config.id,
70
+ error: "Invalid channel config JSON",
71
+ });
72
+ continue;
73
+ }
74
+
75
+ const result = await adapter.send(message, parsedConfig);
76
+ results.push({ ...result, channelId: config.id });
77
+
78
+ // Update test status based on send result
79
+ const now = new Date();
80
+ db.update(channelConfigs)
81
+ .set({
82
+ testStatus: result.success ? "ok" : "failed",
83
+ updatedAt: now,
84
+ })
85
+ .where(eq(channelConfigs.id, config.id))
86
+ .run();
87
+ }
88
+
89
+ return results;
90
+ }
@@ -0,0 +1,188 @@
1
+ import { createHmac, timingSafeEqual } from "crypto";
2
+ import type { ChannelAdapter, ChannelMessage, ChannelDeliveryResult, InboundMessage } from "./types";
3
+
4
+ /**
5
+ * Convert basic Markdown to Slack mrkdwn format.
6
+ * - **bold** -> *bold*
7
+ * - `code` stays as-is
8
+ * - Links stay as-is
9
+ */
10
+ function toSlackMrkdwn(md: string): string {
11
+ return md
12
+ .replace(/\*\*(.+?)\*\*/g, "*$1*") // bold
13
+ .replace(/~~(.+?)~~/g, "~$1~"); // strikethrough
14
+ }
15
+
16
+ export const slackAdapter: ChannelAdapter = {
17
+ channelType: "slack",
18
+
19
+ async send(message: ChannelMessage, config: Record<string, unknown>): Promise<ChannelDeliveryResult> {
20
+ const webhookUrl = config.webhookUrl as string;
21
+ if (!webhookUrl) {
22
+ return { success: false, error: "Missing webhookUrl in config" };
23
+ }
24
+
25
+ const text = message.format === "markdown"
26
+ ? toSlackMrkdwn(`*${message.subject}*\n\n${message.body}`)
27
+ : `${message.subject}\n\n${message.body}`;
28
+
29
+ try {
30
+ const res = await fetch(webhookUrl, {
31
+ method: "POST",
32
+ headers: { "Content-Type": "application/json" },
33
+ body: JSON.stringify({ text }),
34
+ });
35
+
36
+ if (!res.ok) {
37
+ const body = await res.text();
38
+ return { success: false, error: `Slack webhook returned ${res.status}: ${body}` };
39
+ }
40
+
41
+ return { success: true };
42
+ } catch (err) {
43
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
44
+ }
45
+ },
46
+
47
+ async testConnection(config: Record<string, unknown>): Promise<{ ok: boolean; error?: string }> {
48
+ const webhookUrl = config.webhookUrl as string;
49
+ if (!webhookUrl) {
50
+ return { ok: false, error: "Missing webhookUrl" };
51
+ }
52
+
53
+ try {
54
+ const res = await fetch(webhookUrl, {
55
+ method: "POST",
56
+ headers: { "Content-Type": "application/json" },
57
+ body: JSON.stringify({ text: "Stagent channel test - connection OK" }),
58
+ });
59
+
60
+ if (!res.ok) {
61
+ const body = await res.text();
62
+ return { ok: false, error: `Webhook returned ${res.status}: ${body}` };
63
+ }
64
+
65
+ return { ok: true };
66
+ } catch (err) {
67
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
68
+ }
69
+ },
70
+
71
+ // ── Bidirectional support ────────────────────────────────────────────
72
+
73
+ parseInbound(rawBody: unknown): InboundMessage | null {
74
+ const payload = rawBody as SlackEventPayload;
75
+ const event = payload?.event;
76
+ if (!event || event.type !== "message" || !event.text) return null;
77
+
78
+ // Filter bot messages and message_changed subtypes
79
+ if (event.bot_id || event.subtype) return null;
80
+
81
+ return {
82
+ text: event.text,
83
+ senderName: event.user,
84
+ senderId: event.user,
85
+ // Use thread_ts if in a thread, otherwise message ts becomes the thread root
86
+ externalThreadId: event.thread_ts ?? event.ts,
87
+ externalMessageId: event.ts,
88
+ isBot: !!event.bot_id,
89
+ raw: rawBody,
90
+ };
91
+ },
92
+
93
+ verifySignature(
94
+ rawBody: string,
95
+ headers: Record<string, string>,
96
+ config: Record<string, unknown>
97
+ ): boolean {
98
+ const signingSecret = config.signingSecret as string;
99
+ if (!signingSecret) return false;
100
+
101
+ const timestamp = headers["x-slack-request-timestamp"];
102
+ const signature = headers["x-slack-signature"];
103
+ if (!timestamp || !signature) return false;
104
+
105
+ // Reject requests older than 5 minutes (replay attack protection)
106
+ const now = Math.floor(Date.now() / 1000);
107
+ if (Math.abs(now - Number(timestamp)) > 300) return false;
108
+
109
+ const sigBasestring = `v0:${timestamp}:${rawBody}`;
110
+ const mySignature = `v0=${createHmac("sha256", signingSecret)
111
+ .update(sigBasestring)
112
+ .digest("hex")}`;
113
+
114
+ try {
115
+ return timingSafeEqual(
116
+ Buffer.from(mySignature, "utf8"),
117
+ Buffer.from(signature, "utf8")
118
+ );
119
+ } catch {
120
+ return false;
121
+ }
122
+ },
123
+
124
+ async sendReply(
125
+ message: ChannelMessage,
126
+ config: Record<string, unknown>,
127
+ threadId?: string
128
+ ): Promise<ChannelDeliveryResult> {
129
+ const botToken = config.botToken as string;
130
+ const channelId = config.slackChannelId as string;
131
+
132
+ if (!botToken || !channelId) {
133
+ return { success: false, error: "Missing botToken or slackChannelId for reply" };
134
+ }
135
+
136
+ const text = message.body || message.subject;
137
+ if (!text) {
138
+ return { success: false, error: "Empty message body" };
139
+ }
140
+
141
+ try {
142
+ const res = await fetch("https://slack.com/api/chat.postMessage", {
143
+ method: "POST",
144
+ headers: {
145
+ Authorization: `Bearer ${botToken}`,
146
+ "Content-Type": "application/json",
147
+ },
148
+ body: JSON.stringify({
149
+ channel: channelId,
150
+ text: toSlackMrkdwn(text),
151
+ ...(threadId ? { thread_ts: threadId } : {}),
152
+ }),
153
+ });
154
+
155
+ const data = (await res.json()) as {
156
+ ok: boolean;
157
+ ts?: string;
158
+ error?: string;
159
+ };
160
+
161
+ if (!data.ok) {
162
+ return { success: false, error: data.error ?? `Slack API error` };
163
+ }
164
+
165
+ return { success: true, externalId: data.ts };
166
+ } catch (err) {
167
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
168
+ }
169
+ },
170
+ };
171
+
172
+ // ── Slack event types (minimal subset) ─────────────────────────────────
173
+
174
+ interface SlackEventPayload {
175
+ type: string; // "url_verification" | "event_callback"
176
+ challenge?: string;
177
+ token?: string;
178
+ event?: {
179
+ type: string; // "message"
180
+ text?: string;
181
+ user?: string;
182
+ bot_id?: string;
183
+ subtype?: string;
184
+ ts?: string;
185
+ thread_ts?: string;
186
+ channel?: string;
187
+ };
188
+ }