@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.
- package/LICENSE +21 -0
- package/README.md +416 -0
- package/dist/adapter-factory.d.ts +30 -0
- package/dist/adapter-factory.js +198 -0
- package/dist/attachments.d.ts +13 -0
- package/dist/attachments.js +74 -0
- package/dist/channel.d.ts +112 -0
- package/dist/channel.js +60 -0
- package/dist/chat-gateway.d.ts +58 -0
- package/dist/chat-gateway.js +232 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +259 -0
- package/dist/codeshell.d.ts +6 -0
- package/dist/codeshell.js +6 -0
- package/dist/config.d.ts +125 -0
- package/dist/config.js +469 -0
- package/dist/delivery-queue.d.ts +93 -0
- package/dist/delivery-queue.js +401 -0
- package/dist/desktop-control-client.d.ts +51 -0
- package/dist/desktop-control-client.js +309 -0
- package/dist/dingtalk.d.ts +19 -0
- package/dist/dingtalk.js +93 -0
- package/dist/discord.d.ts +23 -0
- package/dist/discord.js +134 -0
- package/dist/gateway.d.ts +34 -0
- package/dist/gateway.js +287 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/ingress.d.ts +8 -0
- package/dist/ingress.js +61 -0
- package/dist/instance-lock.d.ts +24 -0
- package/dist/instance-lock.js +100 -0
- package/dist/lark.d.ts +20 -0
- package/dist/lark.js +184 -0
- package/dist/lifecycle.d.ts +7 -0
- package/dist/lifecycle.js +16 -0
- package/dist/line.d.ts +17 -0
- package/dist/line.js +123 -0
- package/dist/matrix.d.ts +21 -0
- package/dist/matrix.js +208 -0
- package/dist/mattermost.d.ts +18 -0
- package/dist/mattermost.js +170 -0
- package/dist/media.d.ts +39 -0
- package/dist/media.js +223 -0
- package/dist/notification-progress.d.ts +46 -0
- package/dist/notification-progress.js +299 -0
- package/dist/notification-relay.d.ts +43 -0
- package/dist/notification-relay.js +285 -0
- package/dist/platform-canary.d.ts +22 -0
- package/dist/platform-canary.js +55 -0
- package/dist/protocol.d.ts +102 -0
- package/dist/protocol.js +1 -0
- package/dist/service-manager.d.ts +47 -0
- package/dist/service-manager.js +222 -0
- package/dist/slack.d.ts +17 -0
- package/dist/slack.js +136 -0
- package/dist/teams.d.ts +26 -0
- package/dist/teams.js +193 -0
- package/dist/telegram.d.ts +33 -0
- package/dist/telegram.js +263 -0
- package/dist/webhook.d.ts +26 -0
- package/dist/webhook.js +91 -0
- package/dist/wechat-login.d.ts +21 -0
- package/dist/wechat-login.js +97 -0
- package/dist/wechat-storage.d.ts +61 -0
- package/dist/wechat-storage.js +251 -0
- package/dist/wechat.d.ts +129 -0
- package/dist/wechat.js +1082 -0
- package/dist/wecom.d.ts +16 -0
- package/dist/wecom.js +149 -0
- package/dist/whatsapp.d.ts +27 -0
- package/dist/whatsapp.js +218 -0
- package/package.json +123 -0
package/dist/lark.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import * as lark from "@larksuiteoapi/node-sdk";
|
|
2
|
+
import { BUILTIN_CHANNEL_CAPABILITIES } from "./channel.js";
|
|
3
|
+
import { dispatchSafely, waitForAbort } from "./lifecycle.js";
|
|
4
|
+
import { OutgoingDeliveryTracker, outgoingAttachments, safeAttachmentName } from "./media.js";
|
|
5
|
+
export class LarkAdapter {
|
|
6
|
+
channel = "lark";
|
|
7
|
+
capabilities = BUILTIN_CHANNEL_CAPABILITIES.lark;
|
|
8
|
+
client;
|
|
9
|
+
ws;
|
|
10
|
+
delivery = new OutgoingDeliveryTracker();
|
|
11
|
+
constructor(config) {
|
|
12
|
+
const credentials = {
|
|
13
|
+
appId: config.appId,
|
|
14
|
+
appSecret: config.appSecret,
|
|
15
|
+
...(config.domain ? { domain: config.domain } : {}),
|
|
16
|
+
};
|
|
17
|
+
this.client = new lark.Client(credentials);
|
|
18
|
+
this.ws = new lark.WSClient({ ...credentials, autoReconnect: true });
|
|
19
|
+
}
|
|
20
|
+
async run(handler, signal) {
|
|
21
|
+
const dispatcher = new lark.EventDispatcher({}).register({
|
|
22
|
+
"im.message.receive_v1": async (event) => {
|
|
23
|
+
const messageId = event.message.message_id;
|
|
24
|
+
const content = parseLarkContent(event.message.content);
|
|
25
|
+
const text = event.message.message_type === "text" ? (content.text ?? "") : "";
|
|
26
|
+
const attachment = messageId && event.message.message_type !== "text"
|
|
27
|
+
? this.toAttachment(messageId, event.message.message_type, content)
|
|
28
|
+
: undefined;
|
|
29
|
+
const sender = event.sender.sender_id?.open_id ??
|
|
30
|
+
event.sender.sender_id?.user_id ??
|
|
31
|
+
event.sender.sender_id?.union_id;
|
|
32
|
+
if ((!text && !attachment) || !sender)
|
|
33
|
+
return;
|
|
34
|
+
// dispatchSafely so a rejected delivery never escapes the Lark event
|
|
35
|
+
// dispatcher callback as an unhandled rejection.
|
|
36
|
+
await dispatchSafely(handler, {
|
|
37
|
+
channel: this.channel,
|
|
38
|
+
target: event.message.chat_id,
|
|
39
|
+
senderId: sender,
|
|
40
|
+
text,
|
|
41
|
+
...(event.message.message_id ? { messageId: event.message.message_id } : {}),
|
|
42
|
+
...(attachment ? { attachments: [attachment] } : {}),
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
signal.addEventListener("abort", () => this.ws.close({ force: true }), { once: true });
|
|
47
|
+
await this.ws.start({ eventDispatcher: dispatcher });
|
|
48
|
+
await waitForAbort(signal);
|
|
49
|
+
}
|
|
50
|
+
async send(target, message) {
|
|
51
|
+
const attachments = outgoingAttachments(message, this.capabilities.outbound.attachments);
|
|
52
|
+
const payload = message.button
|
|
53
|
+
? {
|
|
54
|
+
msg_type: "interactive",
|
|
55
|
+
content: JSON.stringify({
|
|
56
|
+
config: { wide_screen_mode: true },
|
|
57
|
+
elements: [
|
|
58
|
+
{ tag: "div", text: { tag: "plain_text", content: message.text } },
|
|
59
|
+
{
|
|
60
|
+
tag: "action",
|
|
61
|
+
actions: [
|
|
62
|
+
{
|
|
63
|
+
tag: "button",
|
|
64
|
+
type: "primary",
|
|
65
|
+
text: { tag: "plain_text", content: message.button.text },
|
|
66
|
+
url: message.button.url,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
}),
|
|
72
|
+
}
|
|
73
|
+
: { msg_type: "text", content: JSON.stringify({ text: message.text }) };
|
|
74
|
+
await this.delivery.run(message, () => [
|
|
75
|
+
...(message.text || message.button ? [() => this.createMessage(target, payload)] : []),
|
|
76
|
+
...attachments.map((attachment) => () => this.sendAttachment(target, attachment)),
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
toAttachment(messageId, messageType, content) {
|
|
80
|
+
const key = content.image_key ?? content.file_key;
|
|
81
|
+
if (!key || !["image", "file", "audio", "media"].includes(messageType))
|
|
82
|
+
return undefined;
|
|
83
|
+
const kind = messageType === "image"
|
|
84
|
+
? "image"
|
|
85
|
+
: messageType === "audio"
|
|
86
|
+
? "audio"
|
|
87
|
+
: messageType === "media"
|
|
88
|
+
? "video"
|
|
89
|
+
: "file";
|
|
90
|
+
const name = safeAttachmentName(content.file_name, `lark-${messageId}${kind === "image" ? ".jpg" : kind === "audio" ? ".opus" : kind === "video" ? ".mp4" : ""}`);
|
|
91
|
+
return {
|
|
92
|
+
id: `${messageId}:${key}`,
|
|
93
|
+
kind,
|
|
94
|
+
name,
|
|
95
|
+
...(kind === "image"
|
|
96
|
+
? { mimeType: "image/jpeg" }
|
|
97
|
+
: kind === "audio"
|
|
98
|
+
? { mimeType: "audio/ogg" }
|
|
99
|
+
: kind === "video"
|
|
100
|
+
? { mimeType: "video/mp4" }
|
|
101
|
+
: {}),
|
|
102
|
+
load: (signal) => this.loadMessageResource(messageId, key, messageType === "image" ? "image" : "file", signal),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async loadMessageResource(messageId, fileKey, type, signal) {
|
|
106
|
+
if (signal?.aborted)
|
|
107
|
+
throw signal.reason;
|
|
108
|
+
const resource = await this.client.im.v1.messageResource.get({
|
|
109
|
+
path: { message_id: messageId, file_key: fileKey },
|
|
110
|
+
params: { type },
|
|
111
|
+
});
|
|
112
|
+
const chunks = [];
|
|
113
|
+
let total = 0;
|
|
114
|
+
for await (const raw of resource.getReadableStream()) {
|
|
115
|
+
if (signal?.aborted)
|
|
116
|
+
throw signal.reason;
|
|
117
|
+
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
118
|
+
total += chunk.byteLength;
|
|
119
|
+
if (total > 10 * 1024 * 1024)
|
|
120
|
+
throw new Error("飞书附件超过大小限制");
|
|
121
|
+
chunks.push(chunk);
|
|
122
|
+
}
|
|
123
|
+
return Buffer.concat(chunks, total);
|
|
124
|
+
}
|
|
125
|
+
async sendAttachment(target, attachment) {
|
|
126
|
+
if (attachment.kind === "image") {
|
|
127
|
+
const uploaded = await this.client.im.v1.image.create({
|
|
128
|
+
data: { image_type: "message", image: Buffer.from(attachment.data) },
|
|
129
|
+
});
|
|
130
|
+
if (!uploaded?.image_key)
|
|
131
|
+
throw new Error("飞书图片上传未返回 image_key");
|
|
132
|
+
await this.createMessage(target, {
|
|
133
|
+
msg_type: "image",
|
|
134
|
+
content: JSON.stringify({ image_key: uploaded.image_key }),
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const extension = attachment.name.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
|
|
139
|
+
const fileType = attachment.kind === "audio" && ["opus", "ogg"].includes(extension ?? "")
|
|
140
|
+
? "opus"
|
|
141
|
+
: attachment.kind === "video" && extension === "mp4"
|
|
142
|
+
? "mp4"
|
|
143
|
+
: extension === "pdf"
|
|
144
|
+
? "pdf"
|
|
145
|
+
: ["doc", "docx"].includes(extension ?? "")
|
|
146
|
+
? "doc"
|
|
147
|
+
: ["xls", "xlsx"].includes(extension ?? "")
|
|
148
|
+
? "xls"
|
|
149
|
+
: ["ppt", "pptx"].includes(extension ?? "")
|
|
150
|
+
? "ppt"
|
|
151
|
+
: "stream";
|
|
152
|
+
const uploaded = await this.client.im.v1.file.create({
|
|
153
|
+
data: {
|
|
154
|
+
file_type: fileType,
|
|
155
|
+
file_name: attachment.name,
|
|
156
|
+
file: Buffer.from(attachment.data),
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
if (!uploaded?.file_key)
|
|
160
|
+
throw new Error("飞书文件上传未返回 file_key");
|
|
161
|
+
const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file";
|
|
162
|
+
await this.createMessage(target, {
|
|
163
|
+
msg_type: msgType,
|
|
164
|
+
content: JSON.stringify({ file_key: uploaded.file_key }),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
async createMessage(target, payload) {
|
|
168
|
+
const result = await this.client.im.v1.message.create({
|
|
169
|
+
params: { receive_id_type: "chat_id" },
|
|
170
|
+
data: { receive_id: target, ...payload },
|
|
171
|
+
});
|
|
172
|
+
if (result.code !== 0)
|
|
173
|
+
throw new Error(`飞书发送失败:${result.msg ?? result.code}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function parseLarkContent(content) {
|
|
177
|
+
try {
|
|
178
|
+
const parsed = JSON.parse(content);
|
|
179
|
+
return Object.fromEntries(Object.entries(parsed).filter((entry) => typeof entry[1] === "string"));
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return {};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ChannelMessage, ChannelMessageHandler } from "./channel.js";
|
|
2
|
+
export declare function waitForAbort(signal: AbortSignal): Promise<void>;
|
|
3
|
+
export declare function formatOutgoingMarkdown(text: string, button?: {
|
|
4
|
+
text: string;
|
|
5
|
+
url: string;
|
|
6
|
+
}): string;
|
|
7
|
+
export declare function dispatchSafely(handler: ChannelMessageHandler, message: ChannelMessage): Promise<void>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export async function waitForAbort(signal) {
|
|
2
|
+
if (signal.aborted)
|
|
3
|
+
return;
|
|
4
|
+
await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
5
|
+
}
|
|
6
|
+
export function formatOutgoingMarkdown(text, button) {
|
|
7
|
+
return button ? `${text}\n\n[${button.text.replaceAll("]", "\\]")}](${button.url})` : text;
|
|
8
|
+
}
|
|
9
|
+
export async function dispatchSafely(handler, message) {
|
|
10
|
+
try {
|
|
11
|
+
await handler(message);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
console.error(`[chat] ${message.channel} 消息处理失败:${error instanceof Error ? error.message : String(error)}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
package/dist/line.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { ChannelMessageHandler, OutgoingMessage, WebhookChannelAdapter } from "./channel.js";
|
|
3
|
+
export interface LineAdapterConfig {
|
|
4
|
+
channelSecret: string;
|
|
5
|
+
channelAccessToken: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class LineAdapter implements WebhookChannelAdapter {
|
|
8
|
+
private readonly config;
|
|
9
|
+
private readonly fetchFn;
|
|
10
|
+
readonly channel = "line";
|
|
11
|
+
readonly capabilities: import("./channel.js").ChannelCapabilities;
|
|
12
|
+
readonly webhookPath = "/webhooks/line";
|
|
13
|
+
constructor(config: LineAdapterConfig, fetchFn?: typeof fetch);
|
|
14
|
+
run(_handler: ChannelMessageHandler, signal: AbortSignal): Promise<void>;
|
|
15
|
+
handleWebhook(request: IncomingMessage, response: ServerResponse, handler: ChannelMessageHandler, maxBodyBytes: number): Promise<void>;
|
|
16
|
+
send(target: string, message: OutgoingMessage): Promise<void>;
|
|
17
|
+
}
|
package/dist/line.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { BUILTIN_CHANNEL_CAPABILITIES } from "./channel.js";
|
|
3
|
+
import { waitForAbort } from "./lifecycle.js";
|
|
4
|
+
import { remoteAttachment, safeAttachmentName } from "./media.js";
|
|
5
|
+
import { readRequestBody, sendResponse } from "./webhook.js";
|
|
6
|
+
export class LineAdapter {
|
|
7
|
+
config;
|
|
8
|
+
fetchFn;
|
|
9
|
+
channel = "line";
|
|
10
|
+
capabilities = BUILTIN_CHANNEL_CAPABILITIES.line;
|
|
11
|
+
webhookPath = "/webhooks/line";
|
|
12
|
+
constructor(config, fetchFn = fetch) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
this.fetchFn = fetchFn;
|
|
15
|
+
}
|
|
16
|
+
run(_handler, signal) {
|
|
17
|
+
return waitForAbort(signal);
|
|
18
|
+
}
|
|
19
|
+
async handleWebhook(request, response, handler, maxBodyBytes) {
|
|
20
|
+
if (request.method !== "POST") {
|
|
21
|
+
sendResponse(response, 405, "Method not allowed");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const raw = await readRequestBody(request, maxBodyBytes);
|
|
25
|
+
if (!verifyLineSignature(raw, request.headers["x-line-signature"], this.config.channelSecret)) {
|
|
26
|
+
sendResponse(response, 401, "Invalid signature");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
let body;
|
|
30
|
+
try {
|
|
31
|
+
body = JSON.parse(raw.toString("utf8"));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
sendResponse(response, 400, "Invalid JSON");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const messages = (body.events ?? []).flatMap((event) => {
|
|
38
|
+
const target = event.source?.groupId ?? event.source?.roomId ?? event.source?.userId;
|
|
39
|
+
const senderId = event.source?.userId;
|
|
40
|
+
if (event.type !== "message" || !event.message || !target || !senderId)
|
|
41
|
+
return [];
|
|
42
|
+
const { id, type } = event.message;
|
|
43
|
+
const mediaKind = type === "image"
|
|
44
|
+
? "image"
|
|
45
|
+
: type === "audio"
|
|
46
|
+
? "audio"
|
|
47
|
+
: type === "video"
|
|
48
|
+
? "video"
|
|
49
|
+
: type === "file"
|
|
50
|
+
? "file"
|
|
51
|
+
: undefined;
|
|
52
|
+
const attachment = id && mediaKind
|
|
53
|
+
? remoteAttachment({
|
|
54
|
+
id,
|
|
55
|
+
kind: mediaKind,
|
|
56
|
+
name: safeAttachmentName(event.message.fileName, `line-${id}${mediaKind === "image" ? ".jpg" : mediaKind === "video" ? ".mp4" : ""}`),
|
|
57
|
+
mimeType: mediaKind === "image"
|
|
58
|
+
? "image/jpeg"
|
|
59
|
+
: mediaKind === "video"
|
|
60
|
+
? "video/mp4"
|
|
61
|
+
: undefined,
|
|
62
|
+
size: event.message.fileSize,
|
|
63
|
+
url: `https://api-data.line.me/v2/bot/message/${encodeURIComponent(id)}/content`,
|
|
64
|
+
headers: { authorization: `Bearer ${this.config.channelAccessToken}` },
|
|
65
|
+
fetch: this.fetchFn,
|
|
66
|
+
})
|
|
67
|
+
: undefined;
|
|
68
|
+
const text = type === "text" ? (event.message.text ?? "") : "";
|
|
69
|
+
if (!text && !attachment)
|
|
70
|
+
return [];
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
channel: this.channel,
|
|
74
|
+
target,
|
|
75
|
+
senderId,
|
|
76
|
+
text,
|
|
77
|
+
...(id ? { messageId: id } : {}),
|
|
78
|
+
...(attachment ? { attachments: [attachment] } : {}),
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
});
|
|
82
|
+
await Promise.all(messages.map((message) => handler(message)));
|
|
83
|
+
sendResponse(response, 200, "OK");
|
|
84
|
+
}
|
|
85
|
+
async send(target, message) {
|
|
86
|
+
const messages = [{ type: "text", text: message.text }];
|
|
87
|
+
if (message.button) {
|
|
88
|
+
messages.push({
|
|
89
|
+
type: "template",
|
|
90
|
+
altText: message.button.text,
|
|
91
|
+
template: {
|
|
92
|
+
type: "buttons",
|
|
93
|
+
text: "配对入口 10 分钟内有效",
|
|
94
|
+
actions: [{ type: "uri", label: message.button.text, uri: message.button.url }],
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const response = await this.fetchFn("https://api.line.me/v2/bot/message/push", {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
authorization: `Bearer ${this.config.channelAccessToken}`,
|
|
102
|
+
"content-type": "application/json",
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify({ to: target, messages }),
|
|
105
|
+
signal: AbortSignal.timeout(15_000),
|
|
106
|
+
});
|
|
107
|
+
if (!response.ok)
|
|
108
|
+
throw new Error(`LINE 发送失败(HTTP ${response.status})`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function verifyLineSignature(body, header, secret) {
|
|
112
|
+
if (typeof header !== "string")
|
|
113
|
+
return false;
|
|
114
|
+
const expected = createHmac("sha256", secret).update(body).digest();
|
|
115
|
+
let actual;
|
|
116
|
+
try {
|
|
117
|
+
actual = Buffer.from(header, "base64");
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
123
|
+
}
|
package/dist/matrix.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ChannelAdapter, ChannelMessageHandler, OutgoingMessage } from "./channel.js";
|
|
2
|
+
export interface MatrixAdapterConfig {
|
|
3
|
+
homeserverUrl: string;
|
|
4
|
+
accessToken: string;
|
|
5
|
+
botUserId?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class MatrixAdapter implements ChannelAdapter {
|
|
8
|
+
private readonly config;
|
|
9
|
+
private readonly fetchFn;
|
|
10
|
+
readonly channel = "matrix";
|
|
11
|
+
readonly capabilities: import("./channel.js").ChannelCapabilities;
|
|
12
|
+
private readonly delivery;
|
|
13
|
+
constructor(config: MatrixAdapterConfig, fetchFn?: typeof fetch);
|
|
14
|
+
run(handler: ChannelMessageHandler, signal: AbortSignal): Promise<void>;
|
|
15
|
+
send(target: string, message: OutgoingMessage): Promise<void>;
|
|
16
|
+
private sendRoomMessage;
|
|
17
|
+
private uploadAttachment;
|
|
18
|
+
private toMessages;
|
|
19
|
+
private matrixAttachment;
|
|
20
|
+
private call;
|
|
21
|
+
}
|
package/dist/matrix.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { BUILTIN_CHANNEL_CAPABILITIES } from "./channel.js";
|
|
2
|
+
import { downloadRemoteAttachment, OutgoingDeliveryTracker, outgoingAttachments, safeAttachmentName, } from "./media.js";
|
|
3
|
+
export class MatrixAdapter {
|
|
4
|
+
config;
|
|
5
|
+
fetchFn;
|
|
6
|
+
channel = "matrix";
|
|
7
|
+
capabilities = BUILTIN_CHANNEL_CAPABILITIES.matrix;
|
|
8
|
+
delivery = new OutgoingDeliveryTracker();
|
|
9
|
+
constructor(config, fetchFn = fetch) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.fetchFn = fetchFn;
|
|
12
|
+
}
|
|
13
|
+
async run(handler, signal) {
|
|
14
|
+
let since;
|
|
15
|
+
let retryMs = 1_000;
|
|
16
|
+
while (!signal.aborted) {
|
|
17
|
+
try {
|
|
18
|
+
const query = new URLSearchParams({ timeout: "30000" });
|
|
19
|
+
if (since)
|
|
20
|
+
query.set("since", since);
|
|
21
|
+
const sync = await this.call(`/_matrix/client/v3/sync?${query}`, {
|
|
22
|
+
method: "GET",
|
|
23
|
+
signal,
|
|
24
|
+
});
|
|
25
|
+
retryMs = 1_000;
|
|
26
|
+
for (const message of this.toMessages(sync)) {
|
|
27
|
+
await handler(message);
|
|
28
|
+
}
|
|
29
|
+
// Matrix tokens acknowledge the whole sync batch. Advance only after
|
|
30
|
+
// every accepted event has reached the durable inbox.
|
|
31
|
+
since = sync.next_batch ?? since;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (signal.aborted)
|
|
35
|
+
return;
|
|
36
|
+
await abortableDelay(retryMs, signal);
|
|
37
|
+
retryMs = Math.min(retryMs * 2, 30_000);
|
|
38
|
+
if (error instanceof MatrixRateLimitError && error.retryAfterMs) {
|
|
39
|
+
retryMs = Math.max(retryMs, error.retryAfterMs);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async send(target, message) {
|
|
45
|
+
const attachments = outgoingAttachments(message, this.capabilities.outbound.attachments);
|
|
46
|
+
const body = message.button ? `${message.text}\n\n${message.button.text}` : message.text;
|
|
47
|
+
await this.delivery.run(message, () => [
|
|
48
|
+
...(body
|
|
49
|
+
? [
|
|
50
|
+
() => this.sendRoomMessage(target, {
|
|
51
|
+
msgtype: "m.text",
|
|
52
|
+
body,
|
|
53
|
+
...(message.button
|
|
54
|
+
? {
|
|
55
|
+
format: "org.matrix.custom.html",
|
|
56
|
+
formatted_body: `${escapeHtml(message.text).replaceAll("\n", "<br>")}<br><br><a href="${escapeHtml(message.button.url)}">${escapeHtml(message.button.text)}</a>`,
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
}).then(() => undefined),
|
|
60
|
+
]
|
|
61
|
+
: []),
|
|
62
|
+
...attachments.map((attachment) => async () => {
|
|
63
|
+
const contentUri = await this.uploadAttachment(attachment);
|
|
64
|
+
await this.sendRoomMessage(target, {
|
|
65
|
+
msgtype: attachment.kind === "image"
|
|
66
|
+
? "m.image"
|
|
67
|
+
: attachment.kind === "audio"
|
|
68
|
+
? "m.audio"
|
|
69
|
+
: attachment.kind === "video"
|
|
70
|
+
? "m.video"
|
|
71
|
+
: "m.file",
|
|
72
|
+
body: attachment.name,
|
|
73
|
+
filename: attachment.name,
|
|
74
|
+
url: contentUri,
|
|
75
|
+
info: { mimetype: attachment.mimeType, size: attachment.data.byteLength },
|
|
76
|
+
});
|
|
77
|
+
}),
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
sendRoomMessage(target, content) {
|
|
81
|
+
return this.call(`/_matrix/client/v3/rooms/${encodeURIComponent(target)}/send/m.room.message/${crypto.randomUUID()}`, {
|
|
82
|
+
method: "PUT",
|
|
83
|
+
headers: { "content-type": "application/json" },
|
|
84
|
+
body: JSON.stringify(content),
|
|
85
|
+
signal: AbortSignal.timeout(15_000),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
async uploadAttachment(attachment) {
|
|
89
|
+
const query = new URLSearchParams({ filename: safeAttachmentName(attachment.name) });
|
|
90
|
+
const result = await this.call(`/_matrix/media/v3/upload?${query}`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "content-type": attachment.mimeType },
|
|
93
|
+
body: new Uint8Array(attachment.data),
|
|
94
|
+
signal: AbortSignal.timeout(30_000),
|
|
95
|
+
});
|
|
96
|
+
if (!result.content_uri)
|
|
97
|
+
throw new Error("Matrix 媒体上传未返回 content_uri");
|
|
98
|
+
return result.content_uri;
|
|
99
|
+
}
|
|
100
|
+
toMessages(sync) {
|
|
101
|
+
const messages = [];
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
for (const [roomId, room] of Object.entries(sync.rooms?.join ?? {})) {
|
|
104
|
+
for (const event of room.timeline?.events ?? []) {
|
|
105
|
+
const content = event.content;
|
|
106
|
+
if (event.type !== "m.room.message" ||
|
|
107
|
+
!content?.msgtype ||
|
|
108
|
+
!event.sender ||
|
|
109
|
+
event.sender === this.config.botUserId ||
|
|
110
|
+
(event.origin_server_ts !== undefined && now - event.origin_server_ts > 5 * 60_000)) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const kind = content.msgtype === "m.image"
|
|
114
|
+
? "image"
|
|
115
|
+
: content.msgtype === "m.audio"
|
|
116
|
+
? "audio"
|
|
117
|
+
: content.msgtype === "m.video"
|
|
118
|
+
? "video"
|
|
119
|
+
: content.msgtype === "m.file"
|
|
120
|
+
? "file"
|
|
121
|
+
: undefined;
|
|
122
|
+
const attachment = kind && content.url
|
|
123
|
+
? this.matrixAttachment(event.event_id ?? `${roomId}:${content.url}`, kind, {
|
|
124
|
+
...content,
|
|
125
|
+
url: content.url,
|
|
126
|
+
})
|
|
127
|
+
: undefined;
|
|
128
|
+
const text = content.msgtype === "m.text" ? (content.body ?? "") : "";
|
|
129
|
+
if (!text && !attachment)
|
|
130
|
+
continue;
|
|
131
|
+
messages.push({
|
|
132
|
+
channel: "matrix",
|
|
133
|
+
target: roomId,
|
|
134
|
+
senderId: event.sender,
|
|
135
|
+
text,
|
|
136
|
+
...(event.event_id ? { messageId: event.event_id } : {}),
|
|
137
|
+
...(attachment ? { attachments: [attachment] } : {}),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return messages;
|
|
142
|
+
}
|
|
143
|
+
matrixAttachment(id, kind, content) {
|
|
144
|
+
const url = matrixMediaUrl(this.config.homeserverUrl, content.url);
|
|
145
|
+
return {
|
|
146
|
+
id,
|
|
147
|
+
kind,
|
|
148
|
+
name: safeAttachmentName(content.filename ?? content.body, `matrix-${id}`),
|
|
149
|
+
...(content.info?.mimetype ? { mimeType: content.info.mimetype } : {}),
|
|
150
|
+
...(content.info?.size !== undefined ? { size: content.info.size } : {}),
|
|
151
|
+
load: (signal) => downloadRemoteAttachment(this.fetchFn, url, {
|
|
152
|
+
headers: { authorization: `Bearer ${this.config.accessToken}` },
|
|
153
|
+
...(signal ? { signal } : {}),
|
|
154
|
+
allowPrivateNetwork: true,
|
|
155
|
+
}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
async call(path, init) {
|
|
159
|
+
const response = await this.fetchFn(`${this.config.homeserverUrl}${path}`, {
|
|
160
|
+
...init,
|
|
161
|
+
headers: {
|
|
162
|
+
authorization: `Bearer ${this.config.accessToken}`,
|
|
163
|
+
...init.headers,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
const body = (await response.json().catch(() => ({})));
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
if (response.status === 429) {
|
|
169
|
+
throw new MatrixRateLimitError(body.error ?? "Matrix 请求过于频繁", body.retry_after_ms);
|
|
170
|
+
}
|
|
171
|
+
throw new Error(body.error ?? `Matrix 请求失败(HTTP ${response.status})`);
|
|
172
|
+
}
|
|
173
|
+
return body;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function escapeHtml(value) {
|
|
177
|
+
return value
|
|
178
|
+
.replaceAll("&", "&")
|
|
179
|
+
.replaceAll('"', """)
|
|
180
|
+
.replaceAll("<", "<")
|
|
181
|
+
.replaceAll(">", ">");
|
|
182
|
+
}
|
|
183
|
+
class MatrixRateLimitError extends Error {
|
|
184
|
+
retryAfterMs;
|
|
185
|
+
constructor(message, retryAfterMs) {
|
|
186
|
+
super(message);
|
|
187
|
+
this.retryAfterMs = retryAfterMs;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function matrixMediaUrl(homeserverUrl, mxc) {
|
|
191
|
+
if (!mxc.startsWith("mxc://"))
|
|
192
|
+
throw new Error("Matrix 媒体 URI 无效");
|
|
193
|
+
const path = mxc.slice("mxc://".length).split("/").map(encodeURIComponent).join("/");
|
|
194
|
+
return `${homeserverUrl}/_matrix/client/v1/media/download/${path}`;
|
|
195
|
+
}
|
|
196
|
+
async function abortableDelay(ms, signal) {
|
|
197
|
+
if (signal.aborted)
|
|
198
|
+
return;
|
|
199
|
+
await new Promise((resolve) => {
|
|
200
|
+
const timer = setTimeout(done, ms);
|
|
201
|
+
signal.addEventListener("abort", done, { once: true });
|
|
202
|
+
function done() {
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
signal.removeEventListener("abort", done);
|
|
205
|
+
resolve();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ChannelAdapter, ChannelMessageHandler, OutgoingMessage } from "./channel.js";
|
|
2
|
+
export interface MattermostAdapterConfig {
|
|
3
|
+
serverUrl: string;
|
|
4
|
+
botToken: string;
|
|
5
|
+
botUserId?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class MattermostAdapter implements ChannelAdapter {
|
|
8
|
+
private readonly config;
|
|
9
|
+
private readonly fetchFn;
|
|
10
|
+
readonly channel = "mattermost";
|
|
11
|
+
readonly capabilities: import("./channel.js").ChannelCapabilities;
|
|
12
|
+
readonly combinesTextAndAttachmentsAtomically = true;
|
|
13
|
+
constructor(config: MattermostAdapterConfig, fetchFn?: typeof fetch);
|
|
14
|
+
run(handler: ChannelMessageHandler, signal: AbortSignal): Promise<void>;
|
|
15
|
+
send(target: string, message: OutgoingMessage): Promise<void>;
|
|
16
|
+
private connect;
|
|
17
|
+
private onMessage;
|
|
18
|
+
}
|