@cjhyy/code-shell-chat 0.8.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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +416 -0
  3. package/dist/adapter-factory.d.ts +30 -0
  4. package/dist/adapter-factory.js +198 -0
  5. package/dist/attachments.d.ts +13 -0
  6. package/dist/attachments.js +74 -0
  7. package/dist/channel.d.ts +112 -0
  8. package/dist/channel.js +60 -0
  9. package/dist/chat-gateway.d.ts +58 -0
  10. package/dist/chat-gateway.js +232 -0
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +259 -0
  13. package/dist/codeshell.d.ts +6 -0
  14. package/dist/codeshell.js +6 -0
  15. package/dist/config.d.ts +125 -0
  16. package/dist/config.js +469 -0
  17. package/dist/delivery-queue.d.ts +93 -0
  18. package/dist/delivery-queue.js +401 -0
  19. package/dist/desktop-control-client.d.ts +51 -0
  20. package/dist/desktop-control-client.js +309 -0
  21. package/dist/dingtalk.d.ts +19 -0
  22. package/dist/dingtalk.js +93 -0
  23. package/dist/discord.d.ts +23 -0
  24. package/dist/discord.js +134 -0
  25. package/dist/gateway.d.ts +34 -0
  26. package/dist/gateway.js +287 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.js +13 -0
  29. package/dist/ingress.d.ts +8 -0
  30. package/dist/ingress.js +61 -0
  31. package/dist/instance-lock.d.ts +24 -0
  32. package/dist/instance-lock.js +100 -0
  33. package/dist/lark.d.ts +20 -0
  34. package/dist/lark.js +184 -0
  35. package/dist/lifecycle.d.ts +7 -0
  36. package/dist/lifecycle.js +16 -0
  37. package/dist/line.d.ts +17 -0
  38. package/dist/line.js +123 -0
  39. package/dist/matrix.d.ts +21 -0
  40. package/dist/matrix.js +208 -0
  41. package/dist/mattermost.d.ts +18 -0
  42. package/dist/mattermost.js +170 -0
  43. package/dist/media.d.ts +39 -0
  44. package/dist/media.js +223 -0
  45. package/dist/notification-progress.d.ts +46 -0
  46. package/dist/notification-progress.js +299 -0
  47. package/dist/notification-relay.d.ts +43 -0
  48. package/dist/notification-relay.js +285 -0
  49. package/dist/platform-canary.d.ts +22 -0
  50. package/dist/platform-canary.js +55 -0
  51. package/dist/protocol.d.ts +102 -0
  52. package/dist/protocol.js +1 -0
  53. package/dist/service-manager.d.ts +47 -0
  54. package/dist/service-manager.js +222 -0
  55. package/dist/slack.d.ts +17 -0
  56. package/dist/slack.js +136 -0
  57. package/dist/teams.d.ts +26 -0
  58. package/dist/teams.js +193 -0
  59. package/dist/telegram.d.ts +33 -0
  60. package/dist/telegram.js +263 -0
  61. package/dist/webhook.d.ts +26 -0
  62. package/dist/webhook.js +91 -0
  63. package/dist/wechat-login.d.ts +21 -0
  64. package/dist/wechat-login.js +97 -0
  65. package/dist/wechat-storage.d.ts +61 -0
  66. package/dist/wechat-storage.js +251 -0
  67. package/dist/wechat.d.ts +129 -0
  68. package/dist/wechat.js +1082 -0
  69. package/dist/wecom.d.ts +16 -0
  70. package/dist/wecom.js +149 -0
  71. package/dist/whatsapp.d.ts +27 -0
  72. package/dist/whatsapp.js +218 -0
  73. package/package.json +123 -0
@@ -0,0 +1,309 @@
1
+ import { constants } from "node:fs";
2
+ import { chmod, lstat, mkdir, open, rename, rm, writeFile } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { dirname } from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { DESKTOP_CONTROL_PROTOCOL_VERSION, } from "./protocol.js";
7
+ const MAX_DESKTOP_CONTROL_DESCRIPTOR_BYTES = 64 * 1024;
8
+ const MAX_DESKTOP_EVENT_CHECKPOINT_BYTES = 16 * 1024;
9
+ export class DesktopControlUnavailableError extends Error {
10
+ constructor(message = "桌面端未在线") {
11
+ super(message);
12
+ this.name = "DesktopControlUnavailableError";
13
+ }
14
+ }
15
+ export class DesktopControlOperationError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "DesktopControlOperationError";
19
+ }
20
+ }
21
+ export class DesktopControlClient {
22
+ config;
23
+ fetchFn;
24
+ spawnFn;
25
+ readDescriptorFn;
26
+ sleepFn;
27
+ constructor(config, opts = {}) {
28
+ this.config = config;
29
+ this.fetchFn = opts.fetch ?? fetch;
30
+ this.spawnFn = opts.spawn ?? spawn;
31
+ this.readDescriptorFn =
32
+ opts.readDescriptor ?? (() => readSecureDescriptor(this.config.descriptorPath));
33
+ this.sleepFn = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
34
+ }
35
+ async open() {
36
+ await this.ensureDesktopAvailable();
37
+ return this.request("POST", "/v1/open", 120_000);
38
+ }
39
+ async close() {
40
+ await this.request("POST", "/v1/close", 15_000);
41
+ }
42
+ status() {
43
+ return this.request("GET", "/v1/status", 5_000);
44
+ }
45
+ pairingUrl() {
46
+ return this.request("POST", "/v1/pairing-url", 10_000);
47
+ }
48
+ async petChat(input) {
49
+ await this.ensureDesktopAvailable();
50
+ return this.request("POST", "/v1/pet/chat", 150_000, input);
51
+ }
52
+ events(after = 0, waitMs = 25_000, signal) {
53
+ if (!Number.isSafeInteger(after) || after < 0 || waitMs < 0 || waitMs > 25_000) {
54
+ throw new Error("invalid desktop event cursor");
55
+ }
56
+ return this.request("GET", `/v1/events?after=${after}&waitMs=${Math.floor(waitMs)}`, waitMs + 5_000, undefined, signal);
57
+ }
58
+ async watchEvents(signal, handle, options = {}) {
59
+ const saved = options.checkpointPath
60
+ ? await readEventCheckpoint(options.checkpointPath)
61
+ : undefined;
62
+ let streamId = saved?.streamId;
63
+ let cursor = saved?.cursor ?? 0;
64
+ let retryMs = options.retryBaseMs ?? 1_000;
65
+ const retryMaxMs = options.retryMaxMs ?? 30_000;
66
+ let recovering = false;
67
+ while (!signal.aborted) {
68
+ try {
69
+ const page = await this.events(cursor, 25_000, signal);
70
+ if ((streamId !== undefined && streamId !== page.streamId) || page.resetCursor === true) {
71
+ // Reset before reading a replacement/rolled-back outbox or a high old
72
+ // cursor could mask new events forever.
73
+ streamId = page.streamId;
74
+ cursor = 0;
75
+ continue;
76
+ }
77
+ streamId = page.streamId;
78
+ for (const event of page.events) {
79
+ if (signal.aborted)
80
+ return;
81
+ await handle(event, { streamId });
82
+ const nextCursor = Math.max(cursor, event.id);
83
+ if (options.checkpointPath) {
84
+ await writeEventCheckpoint(options.checkpointPath, {
85
+ version: 1,
86
+ streamId,
87
+ cursor: nextCursor,
88
+ });
89
+ }
90
+ // Advance the acknowledgement cursor only after its durable
91
+ // checkpoint succeeds. Otherwise a failed checkpoint write followed
92
+ // by another poll could make Desktop prune an event this process
93
+ // cannot prove it consumed across a restart.
94
+ cursor = nextCursor;
95
+ }
96
+ cursor = Math.max(cursor, page.cursor);
97
+ if (recovering) {
98
+ recovering = false;
99
+ options.onRecovered?.();
100
+ }
101
+ retryMs = options.retryBaseMs ?? 1_000;
102
+ }
103
+ catch (error) {
104
+ if (signal.aborted)
105
+ return;
106
+ recovering = true;
107
+ options.onError?.(error);
108
+ await Promise.race([this.sleepFn(retryMs), waitForAbort(signal)]);
109
+ retryMs = Math.min(retryMaxMs, retryMs * 2);
110
+ }
111
+ }
112
+ }
113
+ async ensureDesktopAvailable() {
114
+ try {
115
+ await this.status();
116
+ return;
117
+ }
118
+ catch (error) {
119
+ if (!(error instanceof DesktopControlUnavailableError))
120
+ throw error;
121
+ }
122
+ if (!this.config.autoLaunch || !this.config.command) {
123
+ throw new DesktopControlUnavailableError("桌面端未在线,且自动唤起已关闭");
124
+ }
125
+ try {
126
+ const child = this.spawnFn(this.config.command, this.config.args, {
127
+ detached: true,
128
+ stdio: "ignore",
129
+ });
130
+ child.unref();
131
+ }
132
+ catch (error) {
133
+ throw new DesktopControlUnavailableError(`无法唤起桌面端:${error instanceof Error ? error.message : String(error)}`);
134
+ }
135
+ const deadline = Date.now() + this.config.startupTimeoutMs;
136
+ while (Date.now() < deadline) {
137
+ await this.sleepFn(500);
138
+ try {
139
+ await this.status();
140
+ return;
141
+ }
142
+ catch (error) {
143
+ if (!(error instanceof DesktopControlUnavailableError))
144
+ throw error;
145
+ }
146
+ }
147
+ throw new DesktopControlUnavailableError("已唤起桌面端,但本地控制面未在超时前就绪");
148
+ }
149
+ async request(method, path, timeoutMs, payload, signal) {
150
+ let descriptor;
151
+ try {
152
+ descriptor = parseDescriptor(await this.readDescriptorFn());
153
+ }
154
+ catch (error) {
155
+ if (error instanceof DesktopControlUnavailableError)
156
+ throw error;
157
+ throw new DesktopControlUnavailableError(`无法读取桌面端控制信息:${error instanceof Error ? error.message : String(error)}`);
158
+ }
159
+ const controller = new AbortController();
160
+ const abortRequest = () => controller.abort();
161
+ if (signal?.aborted)
162
+ controller.abort();
163
+ else
164
+ signal?.addEventListener("abort", abortRequest, { once: true });
165
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
166
+ let response;
167
+ try {
168
+ response = await this.fetchFn(`${descriptor.baseUrl}${path}`, {
169
+ method,
170
+ headers: {
171
+ authorization: `Bearer ${descriptor.token}`,
172
+ ...(payload === undefined ? {} : { "content-type": "application/json" }),
173
+ },
174
+ ...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
175
+ signal: controller.signal,
176
+ });
177
+ }
178
+ catch (error) {
179
+ throw new DesktopControlUnavailableError(`无法连接桌面端:${error instanceof Error ? error.message : String(error)}`);
180
+ }
181
+ finally {
182
+ clearTimeout(timer);
183
+ signal?.removeEventListener("abort", abortRequest);
184
+ }
185
+ let body;
186
+ try {
187
+ body = await response.json();
188
+ }
189
+ catch {
190
+ throw new DesktopControlOperationError(`桌面端返回了无效响应(HTTP ${response.status})`);
191
+ }
192
+ if (!response.ok) {
193
+ throw new DesktopControlOperationError(typeof body?.message === "string"
194
+ ? body.message
195
+ : `桌面端操作失败(HTTP ${response.status})`);
196
+ }
197
+ return body;
198
+ }
199
+ }
200
+ async function waitForAbort(signal) {
201
+ if (signal.aborted)
202
+ return;
203
+ await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
204
+ }
205
+ export function parseDescriptor(raw) {
206
+ let parsed;
207
+ try {
208
+ parsed = JSON.parse(raw);
209
+ }
210
+ catch {
211
+ throw new DesktopControlUnavailableError("桌面端控制信息不是有效 JSON");
212
+ }
213
+ if (parsed.version !== DESKTOP_CONTROL_PROTOCOL_VERSION ||
214
+ typeof parsed.pid !== "number" ||
215
+ typeof parsed.baseUrl !== "string" ||
216
+ typeof parsed.token !== "string" ||
217
+ !/^[a-f0-9]{64}$/.test(parsed.token) ||
218
+ typeof parsed.startedAt !== "number") {
219
+ throw new DesktopControlUnavailableError("桌面端控制协议版本或字段无效");
220
+ }
221
+ const url = new URL(parsed.baseUrl);
222
+ if (url.protocol !== "http:" ||
223
+ url.hostname !== "127.0.0.1" ||
224
+ !url.port ||
225
+ url.pathname !== "/" ||
226
+ url.username ||
227
+ url.password ||
228
+ url.search ||
229
+ url.hash) {
230
+ throw new DesktopControlUnavailableError("桌面端控制地址必须是 127.0.0.1 loopback HTTP");
231
+ }
232
+ return parsed;
233
+ }
234
+ async function readSecureDescriptor(path) {
235
+ return readOwnerOnlyFile(path, MAX_DESKTOP_CONTROL_DESCRIPTOR_BYTES, "桌面端控制信息");
236
+ }
237
+ async function readEventCheckpoint(path) {
238
+ try {
239
+ const raw = await readOwnerOnlyFile(path, MAX_DESKTOP_EVENT_CHECKPOINT_BYTES, "Desktop event checkpoint");
240
+ const parsed = JSON.parse(raw);
241
+ if (parsed.version !== 1 ||
242
+ typeof parsed.streamId !== "string" ||
243
+ !/^[a-f0-9]{32}$/.test(parsed.streamId) ||
244
+ !Number.isSafeInteger(parsed.cursor) ||
245
+ (parsed.cursor ?? -1) < 0) {
246
+ throw new Error(`Invalid Desktop event checkpoint: ${path}`);
247
+ }
248
+ return parsed;
249
+ }
250
+ catch (error) {
251
+ if (error.code === "ENOENT")
252
+ return undefined;
253
+ throw error;
254
+ }
255
+ }
256
+ async function writeEventCheckpoint(path, checkpoint) {
257
+ const dir = dirname(path);
258
+ await mkdir(dir, { recursive: true, mode: 0o700 });
259
+ const dirInfo = await lstat(dir);
260
+ if (dirInfo.isSymbolicLink() || !dirInfo.isDirectory()) {
261
+ throw new Error(`Desktop event checkpoint parent is not a regular directory: ${dir}`);
262
+ }
263
+ await chmod(dir, 0o700).catch(() => undefined);
264
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
265
+ try {
266
+ await writeFile(temporary, `${JSON.stringify(checkpoint)}\n`, {
267
+ encoding: "utf-8",
268
+ mode: 0o600,
269
+ flag: "wx",
270
+ });
271
+ await rename(temporary, path);
272
+ await chmod(path, 0o600).catch(() => undefined);
273
+ }
274
+ catch (error) {
275
+ await rm(temporary, { force: true }).catch(() => undefined);
276
+ throw error;
277
+ }
278
+ }
279
+ async function readOwnerOnlyFile(path, maxBytes, label) {
280
+ const pathInfo = await lstat(path);
281
+ if (pathInfo.isSymbolicLink() || !pathInfo.isFile()) {
282
+ throw new Error(`${label} is not a regular file: ${path}`);
283
+ }
284
+ if (process.platform !== "win32" && (pathInfo.mode & 0o077) !== 0) {
285
+ throw new Error(`${label} permissions must be 0600: ${path}`);
286
+ }
287
+ if (pathInfo.size > maxBytes)
288
+ throw new Error(`${label} is too large: ${path}`);
289
+ const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
290
+ const handle = await open(path, constants.O_RDONLY | noFollow);
291
+ try {
292
+ const openedInfo = await handle.stat();
293
+ if (!openedInfo.isFile())
294
+ throw new Error(`${label} is not a regular file: ${path}`);
295
+ if (process.platform !== "win32" && (openedInfo.mode & 0o077) !== 0) {
296
+ throw new Error(`${label} permissions must be 0600: ${path}`);
297
+ }
298
+ if (openedInfo.size > maxBytes)
299
+ throw new Error(`${label} is too large: ${path}`);
300
+ const raw = await handle.readFile("utf-8");
301
+ if (Buffer.byteLength(raw, "utf-8") > maxBytes) {
302
+ throw new Error(`${label} is too large: ${path}`);
303
+ }
304
+ return raw;
305
+ }
306
+ finally {
307
+ await handle.close();
308
+ }
309
+ }
@@ -0,0 +1,19 @@
1
+ import type { ChannelAdapter, ChannelMessage, ChannelMessageHandler, OutgoingMessage } from "./channel.js";
2
+ export interface DingTalkAdapterConfig {
3
+ clientId: string;
4
+ clientSecret: string;
5
+ onConnected?: () => void;
6
+ }
7
+ /** Parse one Stream frame without exposing the SDK client, useful for discovery and tests. */
8
+ export declare function parseDingTalkTextMessage(data: string, messageId?: string): ChannelMessage | undefined;
9
+ export declare class DingTalkAdapter implements ChannelAdapter {
10
+ private readonly fetchFn;
11
+ readonly channel = "dingtalk";
12
+ readonly capabilities: import("./channel.js").ChannelCapabilities;
13
+ private readonly client;
14
+ private readonly onConnected?;
15
+ private readonly responseUrls;
16
+ constructor(config: DingTalkAdapterConfig, fetchFn?: typeof fetch);
17
+ run(handler: ChannelMessageHandler, signal: AbortSignal): Promise<void>;
18
+ send(target: string, message: OutgoingMessage): Promise<void>;
19
+ }
@@ -0,0 +1,93 @@
1
+ import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
2
+ import { BUILTIN_CHANNEL_CAPABILITIES } from "./channel.js";
3
+ import { dispatchSafely, waitForAbort } from "./lifecycle.js";
4
+ /** Parse one Stream frame without exposing the SDK client, useful for discovery and tests. */
5
+ export function parseDingTalkTextMessage(data, messageId) {
6
+ let message;
7
+ try {
8
+ message = JSON.parse(data);
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ if (message.msgtype !== "text" || !message.text?.content || !message.conversationId) {
14
+ return undefined;
15
+ }
16
+ return {
17
+ channel: "dingtalk",
18
+ target: message.conversationId,
19
+ senderId: message.senderStaffId || message.senderId,
20
+ text: message.text.content,
21
+ ...(messageId ? { messageId } : {}),
22
+ metadata: {
23
+ ...(typeof message.conversationTitle === "string" && message.conversationTitle.trim()
24
+ ? { conversationTitle: message.conversationTitle.trim() }
25
+ : {}),
26
+ ...(typeof message.conversationType === "string"
27
+ ? { conversationType: message.conversationType }
28
+ : {}),
29
+ ...(typeof message.senderNick === "string" && message.senderNick.trim()
30
+ ? { senderName: message.senderNick.trim() }
31
+ : {}),
32
+ },
33
+ };
34
+ }
35
+ export class DingTalkAdapter {
36
+ fetchFn;
37
+ channel = "dingtalk";
38
+ capabilities = BUILTIN_CHANNEL_CAPABILITIES.dingtalk;
39
+ client;
40
+ onConnected;
41
+ responseUrls = new Map();
42
+ constructor(config, fetchFn = fetch) {
43
+ this.fetchFn = fetchFn;
44
+ this.client = new DWClient({ clientId: config.clientId, clientSecret: config.clientSecret });
45
+ this.onConnected = config.onConnected;
46
+ }
47
+ async run(handler, signal) {
48
+ this.client.registerCallbackListener(TOPIC_ROBOT, async (frame) => {
49
+ const incoming = parseDingTalkTextMessage(frame.data, frame.headers.messageId);
50
+ if (!incoming)
51
+ return;
52
+ const message = JSON.parse(frame.data);
53
+ this.responseUrls.set(incoming.target, message.sessionWebhook);
54
+ // dispatchSafely so a rejected delivery never escapes this stream
55
+ // callback as an unhandled rejection (which would crash the process);
56
+ // the SUCCESS ack below must still run so DingTalk stops redelivering.
57
+ await dispatchSafely(handler, incoming);
58
+ this.client.socketCallBackResponse(frame.headers.messageId, { status: "SUCCESS" });
59
+ });
60
+ await this.client.connect();
61
+ this.onConnected?.();
62
+ try {
63
+ await waitForAbort(signal);
64
+ }
65
+ finally {
66
+ this.client.disconnect();
67
+ }
68
+ }
69
+ async send(target, message) {
70
+ const responseUrl = this.responseUrls.get(target);
71
+ if (!responseUrl)
72
+ throw new Error(`钉钉会话 ${target} 没有可用的临时回复地址`);
73
+ const body = message.button
74
+ ? {
75
+ msgtype: "actionCard",
76
+ actionCard: {
77
+ title: message.title ?? "Chat message",
78
+ text: message.text,
79
+ singleTitle: message.button.text,
80
+ singleURL: message.button.url,
81
+ },
82
+ }
83
+ : { msgtype: "text", text: { content: message.text } };
84
+ const response = await this.fetchFn(responseUrl, {
85
+ method: "POST",
86
+ headers: { "content-type": "application/json" },
87
+ body: JSON.stringify(body),
88
+ signal: AbortSignal.timeout(15_000),
89
+ });
90
+ if (!response.ok)
91
+ throw new Error(`钉钉回复失败(HTTP ${response.status})`);
92
+ }
93
+ }
@@ -0,0 +1,23 @@
1
+ import type { ChannelAdapter, ChannelMessageHandler, ChatCommandDefinition, OutgoingMessage } from "./channel.js";
2
+ export interface DiscordAdapterConfig {
3
+ botToken: string;
4
+ }
5
+ export interface DiscordAdapterOptions {
6
+ commands?: readonly ChatCommandDefinition[];
7
+ fetch?: typeof fetch;
8
+ }
9
+ export declare class DiscordAdapter implements ChannelAdapter {
10
+ private readonly config;
11
+ readonly channel = "discord";
12
+ readonly capabilities: import("./channel.js").ChannelCapabilities;
13
+ readonly combinesTextAndAttachmentsAtomically = true;
14
+ private readonly client;
15
+ private readonly interactionContext;
16
+ private readonly commands;
17
+ private readonly fetchFn;
18
+ constructor(config: DiscordAdapterConfig, options?: DiscordAdapterOptions);
19
+ run(handler: ChannelMessageHandler, signal: AbortSignal): Promise<void>;
20
+ send(target: string, message: OutgoingMessage): Promise<void>;
21
+ private handleInteraction;
22
+ private registerCommands;
23
+ }
@@ -0,0 +1,134 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, Client, Events, GatewayIntentBits, Partials, } from "discord.js";
3
+ import { BUILTIN_CHANNEL_CAPABILITIES } from "./channel.js";
4
+ import { dispatchSafely, waitForAbort } from "./lifecycle.js";
5
+ import { mediaKind, outgoingAttachments, remoteAttachment } from "./media.js";
6
+ export class DiscordAdapter {
7
+ config;
8
+ channel = "discord";
9
+ capabilities = BUILTIN_CHANNEL_CAPABILITIES.discord;
10
+ combinesTextAndAttachmentsAtomically = true;
11
+ client;
12
+ interactionContext = new AsyncLocalStorage();
13
+ commands;
14
+ fetchFn;
15
+ constructor(config, options = {}) {
16
+ this.config = config;
17
+ this.commands = options.commands ?? [];
18
+ this.fetchFn = options.fetch ?? fetch;
19
+ this.client = new Client({
20
+ intents: [
21
+ GatewayIntentBits.Guilds,
22
+ GatewayIntentBits.GuildMessages,
23
+ GatewayIntentBits.DirectMessages,
24
+ GatewayIntentBits.MessageContent,
25
+ ],
26
+ partials: [Partials.Channel],
27
+ });
28
+ }
29
+ async run(handler, signal) {
30
+ const onMessage = (message) => {
31
+ if (message.author.bot || (!message.content && message.attachments.size === 0))
32
+ return;
33
+ const attachments = message.attachments.map((attachment) => remoteAttachment({
34
+ id: attachment.id,
35
+ kind: mediaKind(attachment.contentType ?? undefined, attachment.name),
36
+ name: attachment.name,
37
+ mimeType: attachment.contentType ?? undefined,
38
+ size: attachment.size,
39
+ url: attachment.url,
40
+ fetch: this.fetchFn,
41
+ }));
42
+ void dispatchSafely(handler, {
43
+ channel: this.channel,
44
+ target: message.channelId,
45
+ senderId: message.author.id,
46
+ text: message.content,
47
+ messageId: message.id,
48
+ ...(attachments.length > 0 ? { attachments } : {}),
49
+ });
50
+ };
51
+ const onInteraction = (interaction) => {
52
+ if (!interaction.isChatInputCommand() || !interaction.channelId)
53
+ return;
54
+ if (!this.commands.some(({ name }) => name === interaction.commandName))
55
+ return;
56
+ void this.handleInteraction(interaction, handler);
57
+ };
58
+ this.client.on(Events.MessageCreate, onMessage);
59
+ this.client.on(Events.InteractionCreate, onInteraction);
60
+ await this.client.login(this.config.botToken);
61
+ if (this.commands.length > 0)
62
+ await this.registerCommands();
63
+ try {
64
+ await waitForAbort(signal);
65
+ }
66
+ finally {
67
+ this.client.off(Events.MessageCreate, onMessage);
68
+ this.client.off(Events.InteractionCreate, onInteraction);
69
+ this.client.destroy();
70
+ }
71
+ }
72
+ async send(target, message) {
73
+ const payload = toDiscordPayload(message);
74
+ const interactionState = this.interactionContext.getStore();
75
+ if (interactionState?.interaction.channelId === target) {
76
+ interactionState.replied = true;
77
+ await interactionState.interaction.editReply(payload);
78
+ return;
79
+ }
80
+ const channel = await this.client.channels.fetch(target);
81
+ if (!channel || channel.type === ChannelType.GuildCategory || !channel.isSendable()) {
82
+ throw new Error(`Discord channel ${target} 不支持发送消息`);
83
+ }
84
+ await channel.send(payload);
85
+ }
86
+ async handleInteraction(interaction, handler) {
87
+ const target = interaction.channelId;
88
+ await interaction.deferReply({ ephemeral: interaction.inGuild() });
89
+ const state = { interaction, replied: false };
90
+ await this.interactionContext.run(state, () => dispatchSafely(handler, {
91
+ channel: this.channel,
92
+ target,
93
+ senderId: interaction.user.id,
94
+ text: `/${interaction.commandName}`,
95
+ messageId: interaction.id,
96
+ }));
97
+ if (!state.replied)
98
+ await interaction.deleteReply().catch(() => undefined);
99
+ }
100
+ async registerCommands() {
101
+ const commands = this.client.application?.commands;
102
+ if (!commands)
103
+ throw new Error("Discord application command manager 不可用");
104
+ const existing = await commands.fetch();
105
+ for (const command of this.commands) {
106
+ if (!existing.some(({ name }) => name === command.name))
107
+ await commands.create(command);
108
+ }
109
+ }
110
+ }
111
+ function toDiscordPayload(message) {
112
+ const attachments = outgoingAttachments(message, BUILTIN_CHANNEL_CAPABILITIES.discord.outbound.attachments);
113
+ return {
114
+ content: message.text,
115
+ ...(attachments.length > 0
116
+ ? {
117
+ files: attachments.map((attachment) => ({
118
+ attachment: Buffer.from(attachment.data),
119
+ name: attachment.name,
120
+ })),
121
+ }
122
+ : {}),
123
+ ...(message.button
124
+ ? {
125
+ components: [
126
+ new ActionRowBuilder().addComponents(new ButtonBuilder()
127
+ .setStyle(ButtonStyle.Link)
128
+ .setLabel(message.button.text)
129
+ .setURL(message.button.url)),
130
+ ],
131
+ }
132
+ : {}),
133
+ };
134
+ }
@@ -0,0 +1,34 @@
1
+ import type { ChatMiddleware } from "./chat-gateway.js";
2
+ import { type ChannelAdapter } from "./channel.js";
3
+ import { type DesktopControlClient } from "./desktop-control-client.js";
4
+ export interface CodeShellRemoteCommandsOptions {
5
+ desktop: Pick<DesktopControlClient, "open" | "close" | "status">;
6
+ }
7
+ export interface MimiPetChatOptions {
8
+ desktop: Pick<DesktopControlClient, "petChat">;
9
+ /** Enabled adapters whose declared capabilities back first-level Gateway discovery. */
10
+ channels?: readonly ChannelAdapter[];
11
+ }
12
+ export declare const CODE_SHELL_REMOTE_COMMANDS: readonly [{
13
+ readonly name: "open";
14
+ readonly description: "打开 CodeShell 手机遥控隧道";
15
+ }, {
16
+ readonly name: "close";
17
+ readonly description: "关闭 CodeShell 手机遥控隧道";
18
+ }, {
19
+ readonly name: "status";
20
+ readonly description: "查看 CodeShell 手机遥控状态";
21
+ }];
22
+ /** Optional CodeShell integration. Unknown messages fall through to the next middleware. */
23
+ export declare function createCodeShellRemoteCommands(options: CodeShellRemoteCommandsOptions): ChatMiddleware;
24
+ export type GatewayCommand = "open" | "close" | "status" | "unsupported";
25
+ export declare function parseGatewayCommand(text: string): GatewayCommand;
26
+ /**
27
+ * Only explicit slash commands short-circuit here. Every natural-language
28
+ * message — including "打开手机遥控" or "给我隧道地址" — flows to Mimi, whose
29
+ * MobileRemote tool decides the intent; regex alias matching was removed on
30
+ * purpose so intent understanding lives in one place (the model), not two.
31
+ */
32
+ export declare function parseGatewayIntent(text: string): GatewayCommand;
33
+ /** Final middleware: route ordinary text/media turns into the durable Mimi Pet session. */
34
+ export declare function createMimiPetChat(options: MimiPetChatOptions): ChatMiddleware;