@johpaz/hive-core 0.1.1

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 (50) hide show
  1. package/package.json +43 -0
  2. package/src/agent/compaction.ts +161 -0
  3. package/src/agent/context-guard.ts +91 -0
  4. package/src/agent/context.ts +148 -0
  5. package/src/agent/ethics.ts +102 -0
  6. package/src/agent/hooks.ts +166 -0
  7. package/src/agent/index.ts +67 -0
  8. package/src/agent/providers/index.ts +278 -0
  9. package/src/agent/providers.ts +1 -0
  10. package/src/agent/soul.ts +89 -0
  11. package/src/agent/stuck-loop.ts +133 -0
  12. package/src/agent/user.ts +86 -0
  13. package/src/channels/base.ts +91 -0
  14. package/src/channels/discord.ts +185 -0
  15. package/src/channels/index.ts +7 -0
  16. package/src/channels/manager.ts +204 -0
  17. package/src/channels/slack.ts +209 -0
  18. package/src/channels/telegram.ts +177 -0
  19. package/src/channels/webchat.ts +83 -0
  20. package/src/channels/whatsapp.ts +305 -0
  21. package/src/config/index.ts +1 -0
  22. package/src/config/loader.ts +508 -0
  23. package/src/gateway/index.ts +5 -0
  24. package/src/gateway/lane-queue.ts +169 -0
  25. package/src/gateway/router.ts +124 -0
  26. package/src/gateway/server.ts +347 -0
  27. package/src/gateway/session.ts +131 -0
  28. package/src/gateway/slash-commands.ts +176 -0
  29. package/src/heartbeat/index.ts +157 -0
  30. package/src/index.ts +21 -0
  31. package/src/memory/index.ts +1 -0
  32. package/src/memory/notes.ts +170 -0
  33. package/src/multi-agent/bindings.ts +171 -0
  34. package/src/multi-agent/index.ts +4 -0
  35. package/src/multi-agent/manager.ts +182 -0
  36. package/src/multi-agent/sandbox.ts +130 -0
  37. package/src/multi-agent/subagents.ts +302 -0
  38. package/src/security/index.ts +187 -0
  39. package/src/tools/cron.ts +156 -0
  40. package/src/tools/exec.ts +105 -0
  41. package/src/tools/index.ts +6 -0
  42. package/src/tools/memory.ts +176 -0
  43. package/src/tools/notify.ts +53 -0
  44. package/src/tools/read.ts +154 -0
  45. package/src/tools/registry.ts +115 -0
  46. package/src/tools/web.ts +186 -0
  47. package/src/utils/crypto.ts +73 -0
  48. package/src/utils/index.ts +3 -0
  49. package/src/utils/logger.ts +254 -0
  50. package/src/utils/retry.ts +70 -0
@@ -0,0 +1,91 @@
1
+ export interface OutboundMessage {
2
+ type: "message" | "stream" | "status" | "error" | "pong" | "command_result";
3
+ sessionId: string;
4
+ content?: string;
5
+ chunk?: string;
6
+ isLast?: boolean;
7
+ status?: {
8
+ state: string;
9
+ model?: string;
10
+ tokens?: number;
11
+ };
12
+ error?: string;
13
+ result?: unknown;
14
+ }
15
+
16
+ export interface IncomingMessage {
17
+ sessionId: string;
18
+ channel: string;
19
+ accountId: string;
20
+ peerId: string;
21
+ peerKind: "direct" | "group";
22
+ content: string;
23
+ metadata?: Record<string, unknown>;
24
+ replyToId?: string;
25
+ }
26
+
27
+ export interface ChannelConfig {
28
+ enabled: boolean;
29
+ dmPolicy: "open" | "pairing" | "allowlist";
30
+ allowFrom: string[];
31
+ }
32
+
33
+ export interface IChannel {
34
+ name: string;
35
+ accountId: string;
36
+ config: ChannelConfig;
37
+ start(): Promise<void>;
38
+ stop(): Promise<void>;
39
+ send(sessionId: string, message: OutboundMessage): Promise<void>;
40
+ onMessage(handler: MessageHandler): void;
41
+ isRunning(): boolean;
42
+ }
43
+
44
+ export type MessageHandler = (message: IncomingMessage) => Promise<void>;
45
+
46
+ export abstract class BaseChannel implements IChannel {
47
+ abstract name: string;
48
+ abstract accountId: string;
49
+ abstract config: ChannelConfig;
50
+
51
+ protected messageHandler?: MessageHandler;
52
+ protected running = false;
53
+
54
+ abstract start(): Promise<void>;
55
+ abstract stop(): Promise<void>;
56
+ abstract send(sessionId: string, message: OutboundMessage): Promise<void>;
57
+
58
+ onMessage(handler: MessageHandler): void {
59
+ this.messageHandler = handler;
60
+ }
61
+
62
+ isRunning(): boolean {
63
+ return this.running;
64
+ }
65
+
66
+ protected async handleMessage(message: IncomingMessage): Promise<void> {
67
+ if (this.messageHandler) {
68
+ await this.messageHandler(message);
69
+ }
70
+ }
71
+
72
+ protected isUserAllowed(peerId: string): boolean {
73
+ if (this.config.dmPolicy === "open") {
74
+ return true;
75
+ }
76
+
77
+ if (this.config.dmPolicy === "allowlist") {
78
+ return this.config.allowFrom.includes(peerId);
79
+ }
80
+
81
+ if (this.config.dmPolicy === "pairing") {
82
+ return this.config.allowFrom.includes(peerId);
83
+ }
84
+
85
+ return false;
86
+ }
87
+
88
+ protected formatSessionId(peerId: string, kind: "direct" | "group"): string {
89
+ return `agent:main:${this.name}:${kind}:${peerId}`;
90
+ }
91
+ }
@@ -0,0 +1,185 @@
1
+ import {
2
+ Client,
3
+ GatewayIntentBits,
4
+ Events,
5
+ type Message,
6
+ type TextChannel,
7
+ type DMChannel,
8
+ type NewsChannel,
9
+ } from "discord.js";
10
+ import { BaseChannel, type ChannelConfig, type IncomingMessage, type OutboundMessage } from "./base.ts";
11
+ import { logger } from "../utils/logger.ts";
12
+
13
+ export interface DiscordConfig extends ChannelConfig {
14
+ botToken: string;
15
+ applicationId?: string;
16
+ guilds?: Record<string, unknown>;
17
+ }
18
+
19
+ type DiscordTextChannel = TextChannel | DMChannel | NewsChannel;
20
+
21
+ export class DiscordChannel extends BaseChannel {
22
+ name = "discord";
23
+ accountId: string;
24
+ config: DiscordConfig;
25
+
26
+ private client?: Client;
27
+ private log = logger.child("discord");
28
+
29
+ constructor(accountId: string, config: DiscordConfig) {
30
+ super();
31
+ this.accountId = accountId;
32
+ this.config = config;
33
+ }
34
+
35
+ async start(): Promise<void> {
36
+ if (!this.config.botToken) {
37
+ throw new Error("Discord bot token not configured");
38
+ }
39
+
40
+ this.client = new Client({
41
+ intents: [
42
+ GatewayIntentBits.Guilds,
43
+ GatewayIntentBits.GuildMessages,
44
+ GatewayIntentBits.DirectMessages,
45
+ GatewayIntentBits.MessageContent,
46
+ ],
47
+ });
48
+
49
+ this.client.on(Events.MessageCreate, async (message) => {
50
+ await this.handleDiscordMessage(message);
51
+ });
52
+
53
+ this.client.on(Events.Error, (error) => {
54
+ this.log.error(`Discord client error: ${error.message}`);
55
+ });
56
+
57
+ this.client.once(Events.ClientReady, () => {
58
+ this.log.info(`Discord bot started: ${this.client?.user?.tag ?? "unknown"}`);
59
+ this.running = true;
60
+ });
61
+
62
+ try {
63
+ await this.client.login(this.config.botToken);
64
+ } catch (error) {
65
+ this.log.error(`Failed to login to Discord: ${(error as Error).message}`);
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ private async handleDiscordMessage(message: Message): Promise<void> {
71
+ if (message.author.bot) return;
72
+
73
+ const isGuild = message.guild !== null;
74
+ const kind = isGuild ? "group" : "direct";
75
+ const peerId = isGuild
76
+ ? `${message.guildId}:${message.channelId}:${message.author.id}`
77
+ : message.author.id;
78
+
79
+ if (!isGuild && !this.isUserAllowed(message.author.id)) {
80
+ this.log.debug(`Message from unauthorized user: ${message.author.id}`);
81
+ return;
82
+ }
83
+
84
+ const incomingMessage: IncomingMessage = {
85
+ sessionId: this.formatSessionId(peerId, kind),
86
+ channel: "discord",
87
+ accountId: this.accountId,
88
+ peerId,
89
+ peerKind: kind,
90
+ content: message.content,
91
+ metadata: {
92
+ discord: {
93
+ guildId: message.guildId,
94
+ channelId: message.channelId,
95
+ userId: message.author.id,
96
+ username: message.author.username,
97
+ messageId: message.id,
98
+ roles: message.member?.roles.cache.map(r => r.id),
99
+ },
100
+ },
101
+ replyToId: message.reference?.messageId
102
+ ? `discord:${message.reference.messageId}`
103
+ : undefined,
104
+ };
105
+
106
+ await this.handleMessage(incomingMessage);
107
+ }
108
+
109
+ async stop(): Promise<void> {
110
+ if (this.client) {
111
+ this.client.destroy();
112
+ this.running = false;
113
+ this.log.info("Discord bot stopped");
114
+ }
115
+ }
116
+
117
+ async send(sessionId: string, message: OutboundMessage): Promise<void> {
118
+ if (!this.client) {
119
+ throw new Error("Discord client not started");
120
+ }
121
+
122
+ const parts = sessionId.split(":");
123
+ const peerPart = parts.slice(3).join(":");
124
+
125
+ let channelId: string;
126
+ if (peerPart.includes(":")) {
127
+ const channelPart = peerPart.split(":");
128
+ channelId = channelPart[1] ?? peerPart;
129
+ } else {
130
+ channelId = peerPart;
131
+ }
132
+
133
+ const channel = await this.client.channels.fetch(channelId);
134
+
135
+ if (!channel || !channel.isTextBased()) {
136
+ throw new Error(`Channel not found or not text-based: ${channelId}`);
137
+ }
138
+
139
+ const content = message.content ?? "";
140
+ const maxLength = 2000;
141
+
142
+ try {
143
+ if (content.length <= maxLength) {
144
+ await (channel as DiscordTextChannel).send(content);
145
+ } else {
146
+ const chunks = this.chunkMessage(content, maxLength);
147
+ for (const chunk of chunks) {
148
+ await (channel as DiscordTextChannel).send(chunk);
149
+ }
150
+ }
151
+ } catch (error) {
152
+ this.log.error(`Failed to send Discord message: ${(error as Error).message}`);
153
+ throw error;
154
+ }
155
+ }
156
+
157
+ private chunkMessage(content: string, maxLength: number): string[] {
158
+ const chunks: string[] = [];
159
+ let remaining = content;
160
+
161
+ while (remaining.length > 0) {
162
+ if (remaining.length <= maxLength) {
163
+ chunks.push(remaining);
164
+ break;
165
+ }
166
+
167
+ let splitPoint = remaining.lastIndexOf("\n", maxLength);
168
+ if (splitPoint === -1 || splitPoint < maxLength * 0.5) {
169
+ splitPoint = remaining.lastIndexOf(" ", maxLength);
170
+ }
171
+ if (splitPoint === -1 || splitPoint < maxLength * 0.5) {
172
+ splitPoint = maxLength;
173
+ }
174
+
175
+ chunks.push(remaining.slice(0, splitPoint));
176
+ remaining = remaining.slice(splitPoint).trim();
177
+ }
178
+
179
+ return chunks;
180
+ }
181
+ }
182
+
183
+ export function createDiscordChannel(accountId: string, config: DiscordConfig): DiscordChannel {
184
+ return new DiscordChannel(accountId, config);
185
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./base.ts";
2
+ export * from "./telegram.ts";
3
+ export * from "./discord.ts";
4
+ export * from "./webchat.ts";
5
+ export * from "./whatsapp.ts";
6
+ export * from "./slack.ts";
7
+ export * from "./manager.ts";
@@ -0,0 +1,204 @@
1
+ import type { Config } from "../config/loader.ts";
2
+ import { logger } from "../utils/logger.ts";
3
+ import type { IChannel, IncomingMessage, MessageHandler } from "./base.ts";
4
+ import { createTelegramChannel, type TelegramConfig } from "./telegram.ts";
5
+ import { createDiscordChannel, type DiscordConfig } from "./discord.ts";
6
+ import { createWebChatChannel, type WebChatConfig } from "./webchat.ts";
7
+ import { createWhatsAppChannel, type WhatsAppConfig } from "./whatsapp.ts";
8
+ import { createSlackChannel, type SlackConfig } from "./slack.ts";
9
+
10
+ export class ChannelManager {
11
+ private config: Config;
12
+ private channels: Map<string, IChannel> = new Map();
13
+ private messageHandler?: MessageHandler;
14
+ private log = logger.child("channels");
15
+
16
+ constructor(config: Config) {
17
+ this.config = config;
18
+ }
19
+
20
+ onMessage(handler: MessageHandler): void {
21
+ this.messageHandler = handler;
22
+ }
23
+
24
+ async initialize(): Promise<void> {
25
+ const channelConfigs = this.config.channels ?? {};
26
+
27
+ for (const [channelName, channelConfig] of Object.entries(channelConfigs)) {
28
+ if (!channelConfig.enabled) {
29
+ this.log.debug(`Channel ${channelName} is disabled`);
30
+ continue;
31
+ }
32
+
33
+ if ((channelConfig as any)._experimental) {
34
+ this.log.warn(`Channel ${channelName} is EXPERIMENTAL - may be unstable`);
35
+ if ((channelConfig as any)._warning) {
36
+ this.log.warn((channelConfig as any)._warning);
37
+ }
38
+ }
39
+
40
+ const accounts = channelConfig.accounts ?? { default: channelConfig };
41
+
42
+ for (const [accountId, accountConfig] of Object.entries(accounts)) {
43
+ const fullConfig = { ...channelConfig, ...accountConfig };
44
+ await this.createChannel(channelName, accountId, fullConfig);
45
+ }
46
+ }
47
+
48
+ this.log.info(`Initialized ${this.channels.size} channel(s)`);
49
+ }
50
+
51
+ private async createChannel(
52
+ channelName: string,
53
+ accountId: string,
54
+ config: Record<string, unknown>
55
+ ): Promise<void> {
56
+ let channel: IChannel;
57
+
58
+ try {
59
+ switch (channelName) {
60
+ case "telegram":
61
+ channel = createTelegramChannel(accountId, {
62
+ enabled: true,
63
+ botToken: config.botToken as string,
64
+ dmPolicy: (config.dmPolicy as "open" | "pairing" | "allowlist") ?? "pairing",
65
+ allowFrom: (config.allowFrom as string[]) ?? [],
66
+ groups: (config.groups as boolean) ?? false,
67
+ } as TelegramConfig);
68
+ break;
69
+
70
+ case "discord":
71
+ channel = createDiscordChannel(accountId, {
72
+ enabled: true,
73
+ botToken: config.botToken as string,
74
+ applicationId: config.applicationId as string,
75
+ dmPolicy: (config.dmPolicy as "open" | "pairing" | "allowlist") ?? "allowlist",
76
+ allowFrom: (config.allowFrom as string[]) ?? [],
77
+ } as DiscordConfig);
78
+ break;
79
+
80
+ case "webchat":
81
+ channel = createWebChatChannel({
82
+ enabled: true,
83
+ dmPolicy: "open",
84
+ allowFrom: [],
85
+ } as WebChatConfig);
86
+ break;
87
+
88
+ case "whatsapp":
89
+ channel = createWhatsAppChannel({
90
+ enabled: true,
91
+ accountId,
92
+ agentId: (config.agentId as string) ?? "main",
93
+ dmPolicy: (config.dmPolicy as "open" | "pairing" | "allowlist") ?? "allowlist",
94
+ allowFrom: (config.allowFrom as string[]) ?? [],
95
+ reconnectMaxAttempts: (config.reconnectMaxAttempts as number) ?? 10,
96
+ reconnectBaseDelayMs: (config.reconnectBaseDelayMs as number) ?? 5000,
97
+ } as WhatsAppConfig);
98
+ break;
99
+
100
+ case "slack":
101
+ channel = createSlackChannel({
102
+ enabled: true,
103
+ botToken: config.botToken as string,
104
+ appToken: config.appToken as string,
105
+ signingSecret: config.signingSecret as string,
106
+ port: (config.port as number) ?? 3000,
107
+ dmPolicy: (config.dmPolicy as "open" | "pairing" | "allowlist") ?? "allowlist",
108
+ allowFrom: (config.allowFrom as string[]) ?? [],
109
+ } as SlackConfig);
110
+ break;
111
+
112
+ default:
113
+ this.log.warn(`Unknown channel type: ${channelName}`);
114
+ return;
115
+ }
116
+
117
+ channel.onMessage(async (message: IncomingMessage) => {
118
+ if (this.messageHandler) {
119
+ await this.messageHandler(message);
120
+ }
121
+ });
122
+
123
+ const key = `${channelName}:${accountId}`;
124
+ this.channels.set(key, channel);
125
+
126
+ this.log.info(`Created channel: ${key}`);
127
+ } catch (error) {
128
+ this.log.error(`Failed to create channel ${channelName}:${accountId}: ${(error as Error).message}`);
129
+ }
130
+ }
131
+
132
+ async startAll(): Promise<void> {
133
+ const promises: Promise<void>[] = [];
134
+
135
+ for (const [key, channel] of this.channels) {
136
+ promises.push(
137
+ channel.start().catch((error) => {
138
+ this.log.error(`Failed to start channel ${key}: ${error.message}`);
139
+ })
140
+ );
141
+ }
142
+
143
+ await Promise.allSettled(promises);
144
+ this.log.info("All channels started");
145
+ }
146
+
147
+ async stopAll(): Promise<void> {
148
+ const promises: Promise<void>[] = [];
149
+
150
+ for (const [key, channel] of this.channels) {
151
+ promises.push(
152
+ channel.stop().catch((error) => {
153
+ this.log.error(`Failed to stop channel ${key}: ${error.message}`);
154
+ })
155
+ );
156
+ }
157
+
158
+ await Promise.allSettled(promises);
159
+ this.log.info("All channels stopped");
160
+ }
161
+
162
+ getChannel(channelName: string, accountId?: string): IChannel | undefined {
163
+ if (accountId) {
164
+ return this.channels.get(`${channelName}:${accountId}`);
165
+ }
166
+
167
+ for (const [key, channel] of this.channels) {
168
+ if (key.startsWith(channelName)) {
169
+ return channel;
170
+ }
171
+ }
172
+
173
+ return undefined;
174
+ }
175
+
176
+ listChannels(): Array<{ name: string; accountId: string; running: boolean }> {
177
+ return Array.from(this.channels.entries()).map(([key, channel]) => {
178
+ const [name, accountId] = key.split(":");
179
+ return {
180
+ name: name ?? "unknown",
181
+ accountId: accountId ?? "default",
182
+ running: channel.isRunning(),
183
+ };
184
+ });
185
+ }
186
+
187
+ async send(
188
+ channelName: string,
189
+ sessionId: string,
190
+ message: unknown
191
+ ): Promise<void> {
192
+ const channel = this.getChannel(channelName);
193
+
194
+ if (!channel) {
195
+ throw new Error(`Channel not found: ${channelName}`);
196
+ }
197
+
198
+ await channel.send(sessionId, message as any);
199
+ }
200
+ }
201
+
202
+ export function createChannelManager(config: Config): ChannelManager {
203
+ return new ChannelManager(config);
204
+ }
@@ -0,0 +1,209 @@
1
+ import { App, ExpressReceiver, type SlashCommand } from "@slack/bolt";
2
+ import type { ChannelConfig, IncomingMessage, OutboundMessage } from "./base.ts";
3
+ import { BaseChannel } from "./base.ts";
4
+ import { logger } from "../utils/logger.ts";
5
+
6
+ export interface SlackConfig extends ChannelConfig {
7
+ botToken: string;
8
+ appToken: string;
9
+ signingSecret: string;
10
+ port?: number;
11
+ }
12
+
13
+ export interface SlackConnectionState {
14
+ status: "connecting" | "connected" | "disconnected" | "error";
15
+ error?: string;
16
+ }
17
+
18
+ export class SlackChannel extends BaseChannel {
19
+ name = "slack";
20
+ accountId: string;
21
+ config: SlackConfig;
22
+
23
+ private app: App | null = null;
24
+ private connectionState: SlackConnectionState = {
25
+ status: "disconnected",
26
+ };
27
+ private log = logger.child("slack");
28
+
29
+ constructor(config: SlackConfig) {
30
+ super();
31
+ this.config = config;
32
+ this.accountId = config.botToken.split(":")[0]?.replace("xoxb-", "") ?? "default";
33
+ }
34
+
35
+ async start(): Promise<void> {
36
+ this.running = true;
37
+ this.connectionState.status = "connecting";
38
+
39
+ this.log.warn(`
40
+ ╔══════════════════════════════════════════════════════════════════╗
41
+ ║ SLACK CHANNEL SETUP REQUIREMENT ║
42
+ ║ ║
43
+ ║ Slack requires a PUBLIC URL for webhooks. ║
44
+ ║ For local development, use one of: ║
45
+ ║ ║
46
+ ║ 1. ngrok: ngrok http 3000 ║
47
+ ║ 2. Tailscale: tailscale fun ║
48
+ ║ 3. cloudflared tunnel --url http://localhost:3000 ║
49
+ ║ ║
50
+ ║ Then configure in Slack App settings: ║
51
+ ║ - Request URL: https://your-tunnel-url/slack/events ║
52
+ ╚══════════════════════════════════════════════════════════════════╝
53
+ `);
54
+
55
+ try {
56
+ const receiver = new ExpressReceiver({
57
+ signingSecret: this.config.signingSecret,
58
+ endpoints: "/slack/events",
59
+ processBeforeResponse: true,
60
+ });
61
+
62
+ this.app = new App({
63
+ token: this.config.botToken,
64
+ appToken: this.config.appToken,
65
+ receiver,
66
+ });
67
+
68
+ this.app.event("app_mention", async ({ event }) => {
69
+ await this.handleMention(event);
70
+ });
71
+
72
+ this.app.event("message", async ({ event }) => {
73
+ if ((event as any).channel_type === "im") {
74
+ await this.handleDirectMessage(event as any);
75
+ }
76
+ });
77
+
78
+ this.app.command("/ai", async ({ command, ack }) => {
79
+ await ack();
80
+ await this.handleSlashCommand(command);
81
+ });
82
+
83
+ const port = this.config.port ?? 3000;
84
+
85
+ await this.app.start(port);
86
+
87
+ this.connectionState.status = "connected";
88
+ this.log.info(`Slack channel started on port ${port}`);
89
+
90
+ } catch (error) {
91
+ this.connectionState.status = "error";
92
+ this.connectionState.error = (error as Error).message;
93
+ this.log.error(`Slack connection error: ${(error as Error).message}`);
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ async stop(): Promise<void> {
99
+ this.running = false;
100
+
101
+ if (this.app) {
102
+ try {
103
+ await this.app.stop();
104
+ } catch {
105
+ // Ignore close errors
106
+ }
107
+ this.app = null;
108
+ }
109
+
110
+ this.connectionState.status = "disconnected";
111
+ this.log.info("Slack channel stopped");
112
+ }
113
+
114
+ private async handleMention(event: { user?: string; text?: string; channel?: string; ts?: string }): Promise<void> {
115
+ if (!event.user || !event.text || !event.channel) return;
116
+
117
+ const content = event.text.replace(/<@[A-Z0-9]+>/g, "").trim();
118
+ if (!content) return;
119
+
120
+ const incoming: IncomingMessage = {
121
+ sessionId: this.formatSessionId(event.channel, "group"),
122
+ channel: "slack",
123
+ accountId: this.accountId,
124
+ peerId: event.channel,
125
+ peerKind: "group",
126
+ content,
127
+ metadata: {
128
+ userId: event.user,
129
+ timestamp: event.ts,
130
+ },
131
+ };
132
+
133
+ await this.handleMessage(incoming);
134
+ }
135
+
136
+ private async handleDirectMessage(event: { user?: string; text?: string; channel?: string; ts?: string }): Promise<void> {
137
+ if (!event.user || !event.text || !event.channel) return;
138
+ if (event.text.startsWith("/")) return;
139
+
140
+ const incoming: IncomingMessage = {
141
+ sessionId: this.formatSessionId(event.user, "direct"),
142
+ channel: "slack",
143
+ accountId: this.accountId,
144
+ peerId: event.user,
145
+ peerKind: "direct",
146
+ content: event.text,
147
+ metadata: {
148
+ channel: event.channel,
149
+ timestamp: event.ts,
150
+ },
151
+ };
152
+
153
+ await this.handleMessage(incoming);
154
+ }
155
+
156
+ private async handleSlashCommand(command: SlashCommand): Promise<void> {
157
+ const incoming: IncomingMessage = {
158
+ sessionId: this.formatSessionId(command.user_id, "direct"),
159
+ channel: "slack",
160
+ accountId: this.accountId,
161
+ peerId: command.user_id,
162
+ peerKind: "direct",
163
+ content: command.text,
164
+ metadata: {
165
+ channelId: command.channel_id,
166
+ command: command.command,
167
+ triggerId: command.trigger_id,
168
+ },
169
+ };
170
+
171
+ await this.handleMessage(incoming);
172
+ }
173
+
174
+ async send(sessionId: string, message: OutboundMessage): Promise<void> {
175
+ if (!this.app) {
176
+ throw new Error("Slack not connected");
177
+ }
178
+
179
+ const text = message.content ?? message.chunk ?? "";
180
+ if (!text) return;
181
+
182
+ const peerId = this.extractPeerId(sessionId);
183
+
184
+ try {
185
+ await this.app.client.chat.postMessage({
186
+ channel: peerId,
187
+ text,
188
+ });
189
+
190
+ this.log.debug(`Sent message to ${peerId}`);
191
+ } catch (error) {
192
+ this.log.error(`Failed to send Slack message: ${(error as Error).message}`);
193
+ throw error;
194
+ }
195
+ }
196
+
197
+ private extractPeerId(sessionId: string): string {
198
+ const parts = sessionId.split(":");
199
+ return parts[parts.length - 1] ?? "";
200
+ }
201
+
202
+ getState(): SlackConnectionState {
203
+ return { ...this.connectionState };
204
+ }
205
+ }
206
+
207
+ export function createSlackChannel(config: SlackConfig): SlackChannel {
208
+ return new SlackChannel(config);
209
+ }