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.
- package/README.md +8 -8
- package/dist/cli.js +146 -2
- package/docs/.coverage-gaps.json +21 -0
- package/docs/.last-generated +1 -1
- package/docs/features/agent-intelligence.md +36 -14
- package/docs/features/chat.md +33 -56
- package/docs/features/cost-usage.md +14 -10
- package/docs/features/dashboard-kanban.md +30 -13
- package/docs/features/delivery-channels.md +198 -0
- package/docs/features/design-system.md +10 -10
- package/docs/features/documents.md +8 -8
- package/docs/features/home-workspace.md +20 -15
- package/docs/features/inbox-notifications.md +22 -10
- package/docs/features/keyboard-navigation.md +11 -11
- package/docs/features/monitoring.md +1 -1
- package/docs/features/playbook.md +30 -32
- package/docs/features/profiles.md +33 -11
- package/docs/features/projects.md +2 -2
- package/docs/features/provider-runtimes.md +58 -14
- package/docs/features/schedules.md +70 -40
- package/docs/features/settings.md +74 -46
- package/docs/features/shared-components.md +7 -15
- package/docs/features/tool-permissions.md +9 -9
- package/docs/features/workflows.md +32 -21
- package/docs/getting-started.md +33 -9
- package/docs/index.md +25 -16
- package/docs/journeys/developer.md +124 -207
- package/docs/journeys/personal-use.md +70 -79
- package/docs/journeys/power-user.md +107 -151
- package/docs/journeys/work-use.md +81 -113
- package/docs/manifest.json +77 -45
- package/docs/superpowers/plans/2026-03-30-finish-in-progress-features.md +547 -0
- package/docs/use-cases/agency-operator.md +84 -0
- package/docs/use-cases/solo-founder.md +75 -0
- package/docs/why-stagent.md +59 -0
- package/package.json +10 -3
- package/src/app/api/channels/[id]/route.ts +103 -0
- package/src/app/api/channels/[id]/test/route.ts +52 -0
- package/src/app/api/channels/inbound/slack/route.ts +109 -0
- package/src/app/api/channels/inbound/telegram/poll/route.ts +128 -0
- package/src/app/api/channels/inbound/telegram/route.ts +76 -0
- package/src/app/api/channels/route.ts +71 -0
- package/src/app/api/chat/conversations/route.ts +15 -0
- package/src/app/api/chat/entities/search/route.ts +46 -31
- package/src/app/api/environment/profiles/suggest/route.ts +19 -3
- package/src/app/api/environment/scan/route.ts +8 -1
- package/src/app/api/handoffs/[id]/route.ts +76 -0
- package/src/app/api/handoffs/route.ts +89 -0
- package/src/app/api/memory/route.ts +181 -0
- package/src/app/api/profiles/[id]/route.ts +16 -1
- package/src/app/api/profiles/[id]/test/route.ts +4 -0
- package/src/app/api/profiles/[id]/test-results/route.ts +22 -0
- package/src/app/api/profiles/[id]/test-single/route.ts +64 -0
- package/src/app/api/profiles/assist/route.ts +35 -0
- package/src/app/api/profiles/import-repo/apply-updates/route.ts +123 -0
- package/src/app/api/profiles/import-repo/check-updates/route.ts +163 -0
- package/src/app/api/profiles/import-repo/confirm/route.ts +118 -0
- package/src/app/api/profiles/import-repo/preview/route.ts +107 -0
- package/src/app/api/profiles/import-repo/route.ts +29 -0
- package/src/app/api/profiles/import-repo/scan/route.ts +25 -0
- package/src/app/api/profiles/route.ts +73 -22
- package/src/app/api/runtimes/ollama/route.ts +86 -0
- package/src/app/api/runtimes/suggest/route.ts +29 -0
- package/src/app/api/schedules/[id]/heartbeat-history/route.ts +77 -0
- package/src/app/api/schedules/[id]/route.ts +41 -3
- package/src/app/api/schedules/parse/route.ts +66 -0
- package/src/app/api/schedules/route.ts +71 -12
- package/src/app/api/settings/author-default/route.ts +7 -0
- package/src/app/api/settings/learning/route.ts +41 -0
- package/src/app/api/settings/ollama/route.ts +34 -0
- package/src/app/api/settings/providers/route.ts +57 -0
- package/src/app/api/settings/routing/route.ts +24 -0
- package/src/app/api/settings/web-search/route.ts +28 -0
- package/src/app/api/tasks/[id]/execute/route.ts +13 -1
- package/src/app/documents/page.tsx +3 -0
- package/src/app/environment/page.tsx +8 -1
- package/src/app/settings/page.tsx +10 -4
- package/src/app/workflows/[id]/edit/page.tsx +2 -0
- package/src/app/workflows/new/page.tsx +2 -0
- package/src/components/chat/chat-command-popover.tsx +22 -19
- package/src/components/chat/chat-input.tsx +5 -0
- package/src/components/chat/chat-model-selector.tsx +42 -1
- package/src/components/chat/chat-shell.tsx +2 -0
- package/src/components/dashboard/welcome-landing.tsx +9 -9
- package/src/components/environment/artifact-card.tsx +27 -1
- package/src/components/environment/environment-dashboard.tsx +50 -2
- package/src/components/environment/environment-summary-card.tsx +5 -2
- package/src/components/environment/suggested-profiles.tsx +117 -52
- package/src/components/handoffs/handoff-approval-card.tsx +159 -0
- package/src/components/memory/memory-browser.tsx +315 -0
- package/src/components/profiles/learned-context-panel.tsx +4 -4
- package/src/components/profiles/profile-assist-panel.tsx +512 -0
- package/src/components/profiles/profile-browser.tsx +109 -8
- package/src/components/profiles/profile-card.tsx +29 -1
- package/src/components/profiles/profile-detail-view.tsx +200 -28
- package/src/components/profiles/profile-form-view.tsx +220 -82
- package/src/components/profiles/repo-import-wizard.tsx +648 -0
- package/src/components/profiles/smoke-test-editor.tsx +106 -0
- package/src/components/schedules/schedule-create-sheet.tsx +9 -1
- package/src/components/schedules/schedule-form.tsx +348 -9
- package/src/components/schedules/schedule-list.tsx +15 -2
- package/src/components/settings/auth-method-selector.tsx +7 -1
- package/src/components/settings/budget-guardrails-section.tsx +111 -48
- package/src/components/settings/channels-section.tsx +526 -0
- package/src/components/settings/chat-settings-section.tsx +27 -1
- package/src/components/settings/data-management-section.tsx +8 -6
- package/src/components/settings/learning-context-section.tsx +124 -0
- package/src/components/settings/ollama-section.tsx +270 -0
- package/src/components/settings/providers-runtimes-section.tsx +499 -0
- package/src/components/settings/web-search-section.tsx +101 -0
- package/src/components/shared/tag-input.tsx +156 -0
- package/src/components/tasks/kanban-board.tsx +32 -0
- package/src/components/tasks/kanban-column.tsx +4 -2
- package/src/components/tasks/task-card.tsx +1 -0
- package/src/components/tasks/task-chip-bar.tsx +6 -1
- package/src/components/tasks/task-create-panel.tsx +55 -5
- package/src/components/workflows/workflow-form-view.tsx +38 -3
- package/src/hooks/use-chat-autocomplete.ts +24 -26
- package/src/hooks/use-project-skills.ts +66 -0
- package/src/hooks/use-tag-suggestions.ts +31 -0
- package/src/instrumentation.ts +4 -1
- package/src/lib/agents/__tests__/claude-agent.test.ts +3 -0
- package/src/lib/agents/__tests__/learned-context.test.ts +10 -0
- package/src/lib/agents/agentic-loop.ts +235 -0
- package/src/lib/agents/browser-mcp.ts +59 -4
- package/src/lib/agents/claude-agent.ts +26 -199
- package/src/lib/agents/handoff/bus.ts +164 -0
- package/src/lib/agents/handoff/governance.ts +47 -0
- package/src/lib/agents/handoff/types.ts +16 -0
- package/src/lib/agents/learned-context.ts +27 -7
- package/src/lib/agents/memory/decay.ts +61 -0
- package/src/lib/agents/memory/extractor.ts +181 -0
- package/src/lib/agents/memory/retrieval.ts +96 -0
- package/src/lib/agents/memory/types.ts +6 -0
- package/src/lib/agents/profiles/__tests__/project-profiles.test.ts +119 -0
- package/src/lib/agents/profiles/__tests__/registry.test.ts +11 -3
- package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/content-creator/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/content-creator/profile.yaml +27 -0
- package/src/lib/agents/profiles/builtins/customer-support-agent/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/customer-support-agent/profile.yaml +26 -0
- package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/financial-analyst/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/financial-analyst/profile.yaml +24 -0
- package/src/lib/agents/profiles/builtins/general/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/marketing-strategist/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/marketing-strategist/profile.yaml +27 -0
- package/src/lib/agents/profiles/builtins/operations-coordinator/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/operations-coordinator/profile.yaml +26 -0
- package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/researcher/SKILL.md +1 -0
- package/src/lib/agents/profiles/builtins/researcher/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/sales-researcher/SKILL.md +19 -0
- package/src/lib/agents/profiles/builtins/sales-researcher/profile.yaml +26 -0
- package/src/lib/agents/profiles/builtins/shopping-assistant/SKILL.md +1 -0
- package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/sweep/profile.yaml +1 -1
- package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/travel-planner/SKILL.md +2 -0
- package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +2 -2
- package/src/lib/agents/profiles/builtins/wealth-manager/SKILL.md +2 -0
- package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +2 -2
- package/src/lib/agents/profiles/project-profiles.ts +193 -0
- package/src/lib/agents/profiles/registry.ts +130 -6
- package/src/lib/agents/profiles/types.ts +28 -0
- package/src/lib/agents/router.ts +174 -2
- package/src/lib/agents/runtime/__tests__/catalog.test.ts +15 -4
- package/src/lib/agents/runtime/anthropic-direct.ts +644 -0
- package/src/lib/agents/runtime/catalog.ts +57 -2
- package/src/lib/agents/runtime/claude.ts +205 -1
- package/src/lib/agents/runtime/index.ts +22 -0
- package/src/lib/agents/runtime/ollama-adapter.ts +409 -0
- package/src/lib/agents/runtime/openai-direct.ts +514 -0
- package/src/lib/agents/runtime/profile-assist-types.ts +30 -0
- package/src/lib/agents/runtime/types.ts +2 -0
- package/src/lib/agents/tool-permissions.ts +203 -0
- package/src/lib/channels/gateway.ts +321 -0
- package/src/lib/channels/poller.ts +268 -0
- package/src/lib/channels/registry.ts +90 -0
- package/src/lib/channels/slack-adapter.ts +188 -0
- package/src/lib/channels/telegram-adapter.ts +218 -0
- package/src/lib/channels/types.ts +43 -0
- package/src/lib/channels/webhook-adapter.ts +74 -0
- package/src/lib/chat/context-builder.ts +22 -2
- package/src/lib/chat/engine.ts +95 -13
- package/src/lib/chat/ollama-engine.ts +198 -0
- package/src/lib/chat/stagent-tools.ts +106 -20
- package/src/lib/chat/tool-catalog.ts +24 -0
- package/src/lib/chat/tool-registry.ts +90 -0
- package/src/lib/chat/tools/chat-history-tools.ts +4 -4
- package/src/lib/chat/tools/document-tools.ts +7 -7
- package/src/lib/chat/tools/handoff-tools.ts +70 -0
- package/src/lib/chat/tools/notification-tools.ts +4 -4
- package/src/lib/chat/tools/profile-tools.ts +3 -3
- package/src/lib/chat/tools/project-tools.ts +3 -3
- package/src/lib/chat/tools/schedule-tools.ts +29 -13
- package/src/lib/chat/tools/settings-tools.ts +2 -2
- package/src/lib/chat/tools/task-tools.ts +66 -11
- package/src/lib/chat/tools/usage-tools.ts +2 -2
- package/src/lib/chat/tools/workflow-tools.ts +8 -8
- package/src/lib/chat/types.ts +11 -5
- package/src/lib/constants/known-tools.ts +19 -0
- package/src/lib/constants/prose-styles.ts +1 -1
- package/src/lib/constants/settings.ts +7 -0
- package/src/lib/data/channel-bindings.ts +85 -0
- package/src/lib/data/clear.ts +22 -0
- package/src/lib/data/profile-test-results.ts +48 -0
- package/src/lib/data/seed-data/conversations.ts +196 -0
- package/src/lib/data/seed-data/learned-context.ts +99 -0
- package/src/lib/data/seed-data/notifications.ts +54 -1
- package/src/lib/data/seed-data/profile-test-results.ts +96 -0
- package/src/lib/data/seed-data/repo-imports.ts +51 -0
- package/src/lib/data/seed-data/views.ts +60 -0
- package/src/lib/data/seed.ts +51 -0
- package/src/lib/db/bootstrap.ts +162 -0
- package/src/lib/db/migrations/0013_add_repo_imports.sql +15 -0
- package/src/lib/db/migrations/0014_add_linked_profile_id.sql +3 -0
- package/src/lib/db/migrations/0015_add_channel_bindings.sql +23 -0
- package/src/lib/db/schema.ts +187 -1
- package/src/lib/environment/__tests__/auto-scan.test.ts +86 -0
- package/src/lib/environment/__tests__/profile-linker.test.ts +187 -0
- package/src/lib/environment/auto-scan.ts +48 -0
- package/src/lib/environment/data.ts +25 -0
- package/src/lib/environment/profile-generator.ts +40 -10
- package/src/lib/environment/profile-linker.ts +143 -0
- package/src/lib/environment/profile-rules.ts +96 -0
- package/src/lib/import/dedup.ts +149 -0
- package/src/lib/import/format-adapter.ts +631 -0
- package/src/lib/import/github-api.ts +219 -0
- package/src/lib/import/repo-scanner.ts +251 -0
- package/src/lib/schedules/__tests__/nlp-parser.test.ts +330 -0
- package/src/lib/schedules/active-hours.ts +120 -0
- package/src/lib/schedules/heartbeat-parser.ts +224 -0
- package/src/lib/schedules/heartbeat-prompt.ts +153 -0
- package/src/lib/schedules/nlp-parser.ts +357 -0
- package/src/lib/schedules/scheduler.ts +218 -3
- package/src/lib/settings/__tests__/budget-guardrails.test.ts +39 -1
- package/src/lib/settings/helpers.ts +6 -0
- package/src/lib/settings/routing.ts +24 -0
- package/src/lib/settings/runtime-setup.ts +28 -1
- package/src/lib/usage/ledger.ts +2 -1
- package/src/lib/validators/__tests__/settings.test.ts +9 -0
- package/src/lib/validators/profile.ts +39 -0
- package/src/lib/workflows/blueprints/builtins/business-daily-briefing.yaml +102 -0
- package/src/lib/workflows/blueprints/builtins/content-marketing-pipeline.yaml +90 -0
- package/src/lib/workflows/blueprints/builtins/customer-support-triage.yaml +107 -0
- package/src/lib/workflows/blueprints/builtins/financial-reporting.yaml +104 -0
- 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
|
+
}
|