@johpaz/hive-sdk 0.0.15 → 0.0.16
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/CHANGELOG.md +27 -1
- package/README.md +179 -57
- package/docs/API-AGENTS.md +9 -9
- package/docs/API-CONTEXT-COMPILER.md +13 -13
- package/docs/API-DAG-SCHEDULER.md +8 -8
- package/docs/API-TOOLS-SKILLS-CHANNELS.md +150 -93
- package/docs/API-WORKERS-EVENTS.md +206 -59
- package/docs/INDEX.md +99 -50
- package/docs/README.md +117 -24
- package/docs/TEMPLATE-HIVE-APP.md +360 -0
- package/package.json +13 -6
- package/packages/cli/bin/hive +2 -0
- package/packages/cli/src/commands/add-skill.ts +42 -0
- package/packages/cli/src/commands/add-tool.ts +45 -0
- package/packages/cli/src/commands/add-worker.ts +49 -0
- package/packages/cli/src/commands/create-app-utils.ts +32 -0
- package/packages/cli/src/commands/create-app.test.ts +151 -0
- package/packages/cli/src/commands/create-app.ts +35 -0
- package/packages/cli/src/index.ts +21 -5
- package/packages/cli/templates/hive-app/.env.example +17 -0
- package/packages/cli/templates/hive-app/docker-compose.yml +20 -0
- package/packages/cli/templates/hive-app/hive.config.ts +19 -0
- package/packages/cli/templates/hive-app/package.json +16 -0
- package/packages/cli/templates/hive-app/src/agents/coordinator.ts +9 -0
- package/packages/cli/templates/hive-app/src/main.ts +56 -0
- package/packages/core/src/auth/auth.ts +108 -0
- package/packages/core/src/auth/index.ts +1 -0
- package/packages/core/src/canvas/canvas.test.ts +32 -0
- package/packages/core/src/canvas/emitter.ts +1 -1
- package/packages/core/src/canvas/index.ts +3 -6
- package/packages/core/src/channels/base.ts +154 -0
- package/packages/core/src/channels/channels.test.ts +18 -0
- package/packages/core/src/channels/discord.ts +273 -0
- package/packages/core/src/channels/index.ts +7 -0
- package/packages/core/src/channels/manager.ts +450 -0
- package/packages/core/src/channels/slack.ts +323 -0
- package/packages/core/src/channels/telegram.ts +612 -0
- package/packages/core/src/channels/webchat.ts +139 -0
- package/packages/core/src/channels/whatsapp.ts +548 -0
- package/packages/core/src/events/agent-bus.ts +460 -0
- package/packages/core/src/events/event-bus.ts +169 -0
- package/packages/core/src/gateway/channel-notify.ts +32 -7
- package/packages/core/src/gateway/gateway.test.ts +38 -0
- package/packages/core/src/gateway/index.ts +2 -1
- package/packages/core/src/gateway/server.ts +139 -0
- package/packages/core/src/heartbeat/index.ts +157 -0
- package/packages/core/src/index.ts +44 -0
- package/packages/core/src/multimodal/index.ts +2 -2
- package/packages/core/src/multimodal/vision-service.ts +283 -0
- package/packages/core/src/plugins/api.ts +128 -0
- package/packages/core/src/plugins/index.ts +2 -0
- package/packages/core/src/plugins/loader.ts +365 -0
- package/packages/core/src/resilience/circuit-breaker.ts +225 -0
- package/packages/core/src/scheduler/CronScheduler.ts +699 -0
- package/packages/core/src/scheduler/dag/AgentExecutor.ts +53 -0
- package/packages/core/src/scheduler/dag/DAGScheduler.ts +250 -0
- package/packages/core/src/scheduler/dag/EventBridge.ts +122 -0
- package/packages/core/src/scheduler/dag/TaskGraph.ts +192 -0
- package/packages/core/src/scheduler/dag/TaskNode.ts +97 -0
- package/packages/core/src/scheduler/dag/TaskResult.ts +22 -0
- package/packages/core/src/scheduler/dag/errors.ts +37 -0
- package/packages/core/src/scheduler/dag/index.ts +26 -0
- package/packages/core/src/scheduler/dag/presets/ResearchPreset.ts +97 -0
- package/packages/core/src/scheduler/dag/strategies/ParallelStrategy.ts +21 -0
- package/packages/core/src/scheduler/dag/strategies/PriorityStrategy.ts +46 -0
- package/packages/core/src/scheduler/index.ts +22 -0
- package/packages/core/src/scheduler/integration.ts +237 -0
- package/packages/core/src/scheduler/scheduler.test.ts +19 -0
- package/packages/core/src/scheduler/types.ts +164 -0
- package/packages/core/src/security/google-chat.ts +269 -0
- package/packages/core/src/security/index.ts +192 -4
- package/packages/core/src/security/rate-limit.ts +270 -0
- package/packages/core/src/security/signal.ts +321 -0
- package/packages/core/src/storage/crypto.ts +198 -66
- package/packages/core/src/storage/storage.test.ts +37 -0
- package/packages/core/src/swarm/swarm.test.ts +24 -0
- package/packages/core/src/tool-runtime/index.ts +522 -0
- package/packages/core/src/tool-runtime/tool-runtime.test.ts +91 -0
- package/packages/core/src/tool-runtime/tool-worker.ts +125 -0
- package/packages/core/src/voice/index.ts +5 -18
- package/packages/core/src/workers/WorkerPool.ts +167 -0
- package/packages/core/src/workers/agent.worker.ts +68 -0
- package/packages/core/src/workers/createWorker.ts +144 -0
- package/packages/core/src/workers/index.ts +5 -0
- package/packages/core/src/workers/workers.test.ts +48 -0
- package/test/setup-db.ts +2 -2
- package/tsconfig.json +2 -1
- package/.github/CODEOWNERS +0 -9
- package/.github/workflows/publish.yml +0 -89
- package/.github/workflows/version-bump.yml +0 -102
- package/bun.lock +0 -543
- package/bunfig.toml +0 -7
- package/packages/core/src/agent/providers.ts +0 -1
- package/packages/core/src/gateway/channel-notify.test.ts +0 -14
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
export interface OutboundMessage {
|
|
2
|
+
type: "message" | "stream" | "status" | "error" | "pong" | "command_result" | "log" | "typing" | "audio";
|
|
3
|
+
sessionId: string;
|
|
4
|
+
id?: string; // Message ID for streaming
|
|
5
|
+
content?: string;
|
|
6
|
+
chunk?: string;
|
|
7
|
+
isChunk?: boolean; // True if this is a streaming chunk
|
|
8
|
+
isLast?: boolean;
|
|
9
|
+
isStep?: boolean;
|
|
10
|
+
stepType?: "plan" | "tool_call" | "tool_result" | "text";
|
|
11
|
+
audio?: {
|
|
12
|
+
buffer?: Buffer;
|
|
13
|
+
base64?: string;
|
|
14
|
+
mimeType?: string;
|
|
15
|
+
};
|
|
16
|
+
status?: {
|
|
17
|
+
state: string;
|
|
18
|
+
model?: string;
|
|
19
|
+
tokens?: number;
|
|
20
|
+
};
|
|
21
|
+
error?: string;
|
|
22
|
+
result?: unknown;
|
|
23
|
+
logEntry?: {
|
|
24
|
+
timestamp: string;
|
|
25
|
+
level: string;
|
|
26
|
+
source: string;
|
|
27
|
+
message: string;
|
|
28
|
+
meta?: Record<string, unknown>;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface IncomingMessage {
|
|
33
|
+
sessionId: string;
|
|
34
|
+
channel: string;
|
|
35
|
+
accountId: string;
|
|
36
|
+
peerId: string;
|
|
37
|
+
peerKind: "direct" | "group";
|
|
38
|
+
content: string;
|
|
39
|
+
audio?: {
|
|
40
|
+
buffer?: Buffer;
|
|
41
|
+
url?: string;
|
|
42
|
+
base64?: string;
|
|
43
|
+
mimeType?: string;
|
|
44
|
+
};
|
|
45
|
+
image?: {
|
|
46
|
+
url?: string;
|
|
47
|
+
base64?: string;
|
|
48
|
+
buffer?: Buffer;
|
|
49
|
+
mimeType?: string;
|
|
50
|
+
caption?: string;
|
|
51
|
+
};
|
|
52
|
+
document?: {
|
|
53
|
+
url?: string;
|
|
54
|
+
base64?: string;
|
|
55
|
+
buffer?: Buffer;
|
|
56
|
+
mimeType?: string;
|
|
57
|
+
fileName?: string;
|
|
58
|
+
};
|
|
59
|
+
metadata?: Record<string, unknown>;
|
|
60
|
+
replyToId?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ChannelConfig {
|
|
64
|
+
enabled: boolean;
|
|
65
|
+
dmPolicy: "open" | "pairing" | "allowlist";
|
|
66
|
+
allowFrom: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface IChannel {
|
|
70
|
+
name: string;
|
|
71
|
+
accountId: string;
|
|
72
|
+
config: ChannelConfig;
|
|
73
|
+
start(): Promise<void>;
|
|
74
|
+
stop(): Promise<void>;
|
|
75
|
+
send(sessionId: string, message: OutboundMessage): Promise<void>;
|
|
76
|
+
sendAudio?(sessionId: string, audio: Buffer, mimeType: string): Promise<void>;
|
|
77
|
+
onMessage(handler: MessageHandler): void;
|
|
78
|
+
isRunning(): boolean;
|
|
79
|
+
startTyping?(sessionId: string): Promise<void>;
|
|
80
|
+
stopTyping?(sessionId: string): Promise<void>;
|
|
81
|
+
markAsRead?(sessionId: string, messageId?: string): Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type MessageHandler = (message: IncomingMessage) => Promise<void>;
|
|
85
|
+
|
|
86
|
+
export abstract class BaseChannel implements IChannel {
|
|
87
|
+
abstract name: string;
|
|
88
|
+
abstract accountId: string;
|
|
89
|
+
abstract config: ChannelConfig;
|
|
90
|
+
|
|
91
|
+
protected messageHandler?: MessageHandler;
|
|
92
|
+
protected running = false;
|
|
93
|
+
protected typingIntervals: Map<string, Timer> = new Map();
|
|
94
|
+
|
|
95
|
+
abstract start(): Promise<void>;
|
|
96
|
+
abstract stop(): Promise<void>;
|
|
97
|
+
abstract send(sessionId: string, message: OutboundMessage): Promise<void>;
|
|
98
|
+
|
|
99
|
+
onMessage(handler: MessageHandler): void {
|
|
100
|
+
this.messageHandler = handler;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
isRunning(): boolean {
|
|
104
|
+
return this.running;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async startTyping(_sessionId: string): Promise<void> {
|
|
108
|
+
// Default: no-op, override in subclasses
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async stopTyping(sessionId: string): Promise<void> {
|
|
112
|
+
const interval = this.typingIntervals.get(sessionId);
|
|
113
|
+
if (interval) {
|
|
114
|
+
clearInterval(interval);
|
|
115
|
+
this.typingIntervals.delete(sessionId);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async markAsRead(_sessionId: string, _messageId?: string): Promise<void> {
|
|
120
|
+
// Default: no-op, override in subclasses
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
protected async handleMessage(message: IncomingMessage): Promise<void> {
|
|
124
|
+
if (this.messageHandler) {
|
|
125
|
+
await this.messageHandler(message);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
protected isUserAllowed(peerId: string): boolean {
|
|
130
|
+
if (this.config.dmPolicy === "open") {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const normalizedPeerId = `${this.name}:${peerId}`;
|
|
135
|
+
|
|
136
|
+
if (this.config.dmPolicy === "allowlist") {
|
|
137
|
+
return this.config.allowFrom.some(
|
|
138
|
+
(allowed) => allowed === peerId || allowed === normalizedPeerId
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (this.config.dmPolicy === "pairing") {
|
|
143
|
+
return this.config.allowFrom.some(
|
|
144
|
+
(allowed) => allowed === peerId || allowed === normalizedPeerId
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
protected formatSessionId(peerId: string, _kind: "direct" | "group"): string {
|
|
152
|
+
return peerId;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { ChannelManager } from "./manager.ts";
|
|
3
|
+
import { loadConfig } from "../config/loader.ts";
|
|
4
|
+
|
|
5
|
+
describe("ChannelManager", () => {
|
|
6
|
+
it("creates a channel manager", () => {
|
|
7
|
+
const config = loadConfig();
|
|
8
|
+
const manager = new ChannelManager(config);
|
|
9
|
+
expect(manager).toBeDefined();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("has onMessage handler", () => {
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
const manager = new ChannelManager(config);
|
|
15
|
+
manager.onMessage(async () => {});
|
|
16
|
+
expect(manager).toBeDefined();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,273 @@
|
|
|
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
|
+
import { getDb } from "../storage/SQLiteStorage.ts";
|
|
13
|
+
|
|
14
|
+
export interface DiscordConfig extends ChannelConfig {
|
|
15
|
+
botToken: string;
|
|
16
|
+
applicationId?: string;
|
|
17
|
+
guilds?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type DiscordTextChannel = TextChannel | DMChannel | NewsChannel;
|
|
21
|
+
|
|
22
|
+
export class DiscordChannel extends BaseChannel {
|
|
23
|
+
name = "discord";
|
|
24
|
+
accountId: string;
|
|
25
|
+
config: DiscordConfig;
|
|
26
|
+
|
|
27
|
+
private client?: Client;
|
|
28
|
+
private log = logger.child("discord");
|
|
29
|
+
private channelCache: Map<string, DiscordTextChannel> = new Map();
|
|
30
|
+
|
|
31
|
+
constructor(accountId: string, config: DiscordConfig) {
|
|
32
|
+
super();
|
|
33
|
+
this.accountId = accountId;
|
|
34
|
+
this.config = config;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async start(): Promise<void> {
|
|
38
|
+
if (!this.config.botToken) {
|
|
39
|
+
throw new Error("Discord bot token not configured");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this.client = new Client({
|
|
43
|
+
intents: [
|
|
44
|
+
GatewayIntentBits.Guilds,
|
|
45
|
+
GatewayIntentBits.GuildMessages,
|
|
46
|
+
GatewayIntentBits.DirectMessages,
|
|
47
|
+
GatewayIntentBits.MessageContent,
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
this.client.on(Events.MessageCreate, async (message) => {
|
|
52
|
+
await this.handleDiscordMessage(message);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
this.client.on(Events.Error, (error) => {
|
|
56
|
+
this.log.error(`Discord client error: ${error.message}`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
this.client.once(Events.ClientReady, () => {
|
|
60
|
+
this.log.info(`Discord bot started: ${this.client?.user?.tag ?? "unknown"}`);
|
|
61
|
+
this.running = true;
|
|
62
|
+
try {
|
|
63
|
+
getDb().query(`UPDATE channels SET status = 'connected' WHERE id = ?`).run(this.accountId);
|
|
64
|
+
} catch { /* ignore DB errors */ }
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await this.client.login(this.config.botToken);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
this.log.error(`Failed to login to Discord: ${(error as Error).message}`);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async handleDiscordMessage(message: Message): Promise<void> {
|
|
76
|
+
if (message.author.bot) return;
|
|
77
|
+
|
|
78
|
+
const isGuild = message.guild !== null;
|
|
79
|
+
const kind = isGuild ? "group" : "direct";
|
|
80
|
+
const peerId = isGuild
|
|
81
|
+
? `${message.guildId}:${message.channelId}:${message.author.id}`
|
|
82
|
+
: message.author.id;
|
|
83
|
+
|
|
84
|
+
if (!isGuild && !this.isUserAllowed(message.author.id)) {
|
|
85
|
+
this.log.debug(`Message from unauthorized user: ${message.author.id}`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sessionId = this.formatSessionId(peerId, kind);
|
|
90
|
+
if (message.channel.isTextBased()) {
|
|
91
|
+
this.channelCache.set(sessionId, message.channel as DiscordTextChannel);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const audioAttachment = message.attachments.find(a =>
|
|
95
|
+
a.contentType?.startsWith("audio/") || a.url.endsWith(".mp3") || a.url.endsWith(".ogg") || a.url.endsWith(".webm") || a.url.endsWith(".wav")
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const imageAttachment = message.attachments.find(a =>
|
|
99
|
+
a.contentType?.startsWith("image/") || a.url.endsWith(".jpg") || a.url.endsWith(".jpeg") || a.url.endsWith(".png") || a.url.endsWith(".gif") || a.url.endsWith(".webp")
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const documentAttachment = !audioAttachment && !imageAttachment
|
|
103
|
+
? message.attachments.find(a => a.contentType?.startsWith("application/") || a.url.endsWith(".pdf") || a.url.endsWith(".doc") || a.url.endsWith(".docx") || a.url.endsWith(".txt"))
|
|
104
|
+
: undefined;
|
|
105
|
+
|
|
106
|
+
const incomingMessage: IncomingMessage = {
|
|
107
|
+
sessionId,
|
|
108
|
+
channel: "discord",
|
|
109
|
+
accountId: this.accountId,
|
|
110
|
+
peerId,
|
|
111
|
+
peerKind: kind,
|
|
112
|
+
content: message.content || "",
|
|
113
|
+
audio: audioAttachment ? { url: audioAttachment.url, mimeType: audioAttachment.contentType || "audio/webm" } : undefined,
|
|
114
|
+
image: imageAttachment ? { url: imageAttachment.url, mimeType: imageAttachment.contentType || "image/png" } : undefined,
|
|
115
|
+
document: documentAttachment ? { url: documentAttachment.url, mimeType: documentAttachment.contentType || "application/octet-stream", fileName: documentAttachment.name } : undefined,
|
|
116
|
+
metadata: {
|
|
117
|
+
discord: {
|
|
118
|
+
guildId: message.guildId,
|
|
119
|
+
channelId: message.channelId,
|
|
120
|
+
userId: message.author.id,
|
|
121
|
+
username: message.author.username,
|
|
122
|
+
messageId: message.id,
|
|
123
|
+
roles: message.member?.roles.cache.map(r => r.id),
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
replyToId: message.reference?.messageId
|
|
127
|
+
? `discord:${message.reference.messageId}`
|
|
128
|
+
: undefined,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
await this.handleMessage(incomingMessage);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async stop(): Promise<void> {
|
|
135
|
+
if (this.client) {
|
|
136
|
+
this.client.destroy();
|
|
137
|
+
this.running = false;
|
|
138
|
+
this.log.info("Discord bot stopped");
|
|
139
|
+
try {
|
|
140
|
+
getDb().query(`UPDATE channels SET status = 'disconnected' WHERE id = ?`).run(this.accountId);
|
|
141
|
+
} catch { /* ignore DB errors */ }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async getChannel(sessionId: string): Promise<DiscordTextChannel | null> {
|
|
146
|
+
const cached = this.channelCache.get(sessionId);
|
|
147
|
+
if (cached) return cached;
|
|
148
|
+
|
|
149
|
+
if (!this.client) return null;
|
|
150
|
+
|
|
151
|
+
const parts = sessionId.split(":");
|
|
152
|
+
const peerPart = parts.slice(3).join(":");
|
|
153
|
+
|
|
154
|
+
let channelId: string;
|
|
155
|
+
if (peerPart.includes(":")) {
|
|
156
|
+
const channelPart = peerPart.split(":");
|
|
157
|
+
channelId = channelPart[1] ?? peerPart;
|
|
158
|
+
} else {
|
|
159
|
+
channelId = peerPart;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const channel = await this.client.channels.fetch(channelId);
|
|
164
|
+
if (channel && channel.isTextBased()) {
|
|
165
|
+
this.channelCache.set(sessionId, channel as DiscordTextChannel);
|
|
166
|
+
return channel as DiscordTextChannel;
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
// Channel not found
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async startTyping(sessionId: string): Promise<void> {
|
|
176
|
+
const channel = await this.getChannel(sessionId);
|
|
177
|
+
if (!channel) return;
|
|
178
|
+
|
|
179
|
+
await channel.sendTyping();
|
|
180
|
+
|
|
181
|
+
const interval = setInterval(async () => {
|
|
182
|
+
try {
|
|
183
|
+
await channel.sendTyping();
|
|
184
|
+
} catch {
|
|
185
|
+
this.stopTyping(sessionId);
|
|
186
|
+
}
|
|
187
|
+
}, 8000);
|
|
188
|
+
|
|
189
|
+
this.typingIntervals.set(sessionId, interval);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async stopTyping(sessionId: string): Promise<void> {
|
|
193
|
+
const interval = this.typingIntervals.get(sessionId);
|
|
194
|
+
if (interval) {
|
|
195
|
+
clearInterval(interval);
|
|
196
|
+
this.typingIntervals.delete(sessionId);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async send(sessionId: string, message: OutboundMessage): Promise<void> {
|
|
201
|
+
await this.stopTyping(sessionId);
|
|
202
|
+
|
|
203
|
+
const channel = await this.getChannel(sessionId);
|
|
204
|
+
|
|
205
|
+
if (!channel) {
|
|
206
|
+
throw new Error(`Channel not found for session: ${sessionId}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const content = message.content ?? "";
|
|
210
|
+
const maxLength = 2000;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
if (content.length <= maxLength) {
|
|
214
|
+
await channel.send(content);
|
|
215
|
+
} else {
|
|
216
|
+
const chunks = this.chunkMessage(content, maxLength);
|
|
217
|
+
for (const chunk of chunks) {
|
|
218
|
+
await channel.send(chunk);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
this.log.error(`Failed to send Discord message: ${(error as Error).message}`);
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private chunkMessage(content: string, maxLength: number): string[] {
|
|
228
|
+
const chunks: string[] = [];
|
|
229
|
+
let remaining = content;
|
|
230
|
+
|
|
231
|
+
while (remaining.length > 0) {
|
|
232
|
+
if (remaining.length <= maxLength) {
|
|
233
|
+
chunks.push(remaining);
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let splitPoint = remaining.lastIndexOf("\n", maxLength);
|
|
238
|
+
if (splitPoint === -1 || splitPoint < maxLength * 0.5) {
|
|
239
|
+
splitPoint = remaining.lastIndexOf(" ", maxLength);
|
|
240
|
+
}
|
|
241
|
+
if (splitPoint === -1 || splitPoint < maxLength * 0.5) {
|
|
242
|
+
splitPoint = maxLength;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
chunks.push(remaining.slice(0, splitPoint));
|
|
246
|
+
remaining = remaining.slice(splitPoint).trim();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return chunks;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async sendAudio(sessionId: string, audio: Buffer, mimeType: string): Promise<void> {
|
|
253
|
+
const channel = await this.getChannel(sessionId);
|
|
254
|
+
|
|
255
|
+
if (!channel) {
|
|
256
|
+
throw new Error(`Channel not found for session: ${sessionId}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const attachmentName = `response.${mimeType === "audio/mpeg" ? "mp3" : "ogg"}`;
|
|
261
|
+
await channel.send({
|
|
262
|
+
files: [{ attachment: audio, name: attachmentName }]
|
|
263
|
+
});
|
|
264
|
+
} catch (error) {
|
|
265
|
+
this.log.error(`Failed to send Discord audio: ${(error as Error).message}`);
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function createDiscordChannel(accountId: string, config: DiscordConfig): DiscordChannel {
|
|
272
|
+
return new DiscordChannel(accountId, config);
|
|
273
|
+
}
|