@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/teams.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { ActivityTypes, CloudAdapter, ConfigurationBotFrameworkAuthentication, TurnContext, } from "botbuilder";
|
|
4
|
+
import { BUILTIN_CHANNEL_CAPABILITIES } from "./channel.js";
|
|
5
|
+
import { formatOutgoingMarkdown, waitForAbort } from "./lifecycle.js";
|
|
6
|
+
import { mediaKind, outgoingAttachments, remoteAttachment, safeAttachmentName } from "./media.js";
|
|
7
|
+
import { readRequestBody, sendResponse } from "./webhook.js";
|
|
8
|
+
export class TeamsAdapter {
|
|
9
|
+
channel = "teams";
|
|
10
|
+
capabilities = BUILTIN_CHANNEL_CAPABILITIES.teams;
|
|
11
|
+
combinesTextAndAttachmentsAtomically = true;
|
|
12
|
+
webhookPath = "/webhooks/teams";
|
|
13
|
+
adapter;
|
|
14
|
+
appId;
|
|
15
|
+
contexts = new Map();
|
|
16
|
+
references = new Map();
|
|
17
|
+
statePath;
|
|
18
|
+
constructor(config) {
|
|
19
|
+
this.appId = config.appId;
|
|
20
|
+
this.statePath = config.statePath;
|
|
21
|
+
const authentication = new ConfigurationBotFrameworkAuthentication({
|
|
22
|
+
MicrosoftAppType: config.appType ?? "MultiTenant",
|
|
23
|
+
MicrosoftAppId: config.appId,
|
|
24
|
+
MicrosoftAppPassword: config.appPassword,
|
|
25
|
+
...(config.tenantId ? { MicrosoftAppTenantId: config.tenantId } : {}),
|
|
26
|
+
});
|
|
27
|
+
this.adapter = new CloudAdapter(authentication);
|
|
28
|
+
this.loadReferences();
|
|
29
|
+
}
|
|
30
|
+
run(_handler, signal) {
|
|
31
|
+
return waitForAbort(signal);
|
|
32
|
+
}
|
|
33
|
+
async handleWebhook(request, response, handler, maxBodyBytes) {
|
|
34
|
+
if (request.method !== "POST") {
|
|
35
|
+
sendResponse(response, 405, "Method not allowed");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const raw = await readRequestBody(request, maxBodyBytes);
|
|
39
|
+
let body;
|
|
40
|
+
try {
|
|
41
|
+
body = JSON.parse(raw.toString("utf8"));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
sendResponse(response, 400, "Invalid JSON");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
await this.adapter.process({ method: request.method, headers: request.headers, body }, new BotFrameworkResponse(response), async (context) => {
|
|
48
|
+
const activity = context.activity;
|
|
49
|
+
const target = activity.conversation?.id;
|
|
50
|
+
const senderId = activity.from?.id;
|
|
51
|
+
if (activity.type !== ActivityTypes.Message || !target || !senderId)
|
|
52
|
+
return;
|
|
53
|
+
const text = activity.text
|
|
54
|
+
? TurnContext.removeRecipientMention(activity).trim() || activity.text
|
|
55
|
+
: "";
|
|
56
|
+
const attachments = (activity.attachments ?? []).flatMap((attachment, index) => {
|
|
57
|
+
const contentType = attachment.contentType?.toLowerCase() ?? "";
|
|
58
|
+
if (contentType.startsWith("application/vnd.microsoft.card.") ||
|
|
59
|
+
contentType === "text/html") {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const downloadInfo = contentType === "application/vnd.microsoft.teams.file.download.info" &&
|
|
63
|
+
attachment.content &&
|
|
64
|
+
typeof attachment.content === "object"
|
|
65
|
+
? attachment.content
|
|
66
|
+
: undefined;
|
|
67
|
+
const url = downloadInfo?.downloadUrl ?? attachment.contentUrl;
|
|
68
|
+
if (!url)
|
|
69
|
+
return [];
|
|
70
|
+
const mimeType = downloadInfo?.fileType && downloadInfo.fileType.includes("/")
|
|
71
|
+
? downloadInfo.fileType
|
|
72
|
+
: contentType === "application/vnd.microsoft.teams.file.download.info"
|
|
73
|
+
? undefined
|
|
74
|
+
: attachment.contentType;
|
|
75
|
+
const id = downloadInfo?.uniqueId ?? `${activity.id ?? target}:${index}`;
|
|
76
|
+
return [
|
|
77
|
+
remoteAttachment({
|
|
78
|
+
id,
|
|
79
|
+
kind: mediaKind(mimeType, attachment.name),
|
|
80
|
+
name: safeAttachmentName(attachment.name, `teams-${id}`),
|
|
81
|
+
mimeType,
|
|
82
|
+
url,
|
|
83
|
+
}),
|
|
84
|
+
];
|
|
85
|
+
});
|
|
86
|
+
if (!text && attachments.length === 0)
|
|
87
|
+
return;
|
|
88
|
+
this.references.set(target, TurnContext.getConversationReference(activity));
|
|
89
|
+
this.saveReferences();
|
|
90
|
+
this.contexts.set(target, context);
|
|
91
|
+
try {
|
|
92
|
+
await handler({
|
|
93
|
+
channel: this.channel,
|
|
94
|
+
target,
|
|
95
|
+
senderId,
|
|
96
|
+
text,
|
|
97
|
+
...(activity.id ? { messageId: activity.id } : {}),
|
|
98
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
this.contexts.delete(target);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
async send(target, message) {
|
|
107
|
+
const context = this.contexts.get(target);
|
|
108
|
+
const text = formatOutgoingMarkdown(message.text, message.button);
|
|
109
|
+
const outgoing = outgoingAttachments(message, this.capabilities.outbound.attachments);
|
|
110
|
+
const maximum = this.capabilities.outbound.maxAttachmentBytes ?? 1024 * 1024;
|
|
111
|
+
if (outgoing.some((attachment) => attachment.data.byteLength > maximum)) {
|
|
112
|
+
throw new Error(`Teams 内联图片不能超过 ${maximum} 字节`);
|
|
113
|
+
}
|
|
114
|
+
const attachments = outgoing.map((attachment) => ({
|
|
115
|
+
contentType: attachment.mimeType,
|
|
116
|
+
contentUrl: `data:${attachment.mimeType};base64,${Buffer.from(attachment.data).toString("base64")}`,
|
|
117
|
+
name: safeAttachmentName(attachment.name),
|
|
118
|
+
}));
|
|
119
|
+
const activity = {
|
|
120
|
+
type: ActivityTypes.Message,
|
|
121
|
+
text,
|
|
122
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
123
|
+
};
|
|
124
|
+
if (context) {
|
|
125
|
+
await context.sendActivity(activity);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const reference = this.references.get(target);
|
|
129
|
+
if (!reference)
|
|
130
|
+
throw new Error(`Teams 会话 ${target} 尚无 conversation reference`);
|
|
131
|
+
await this.adapter.continueConversationAsync(this.appId, reference, async (turn) => {
|
|
132
|
+
await turn.sendActivity(activity);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
loadReferences() {
|
|
136
|
+
if (!this.statePath)
|
|
137
|
+
return;
|
|
138
|
+
try {
|
|
139
|
+
const parsed = JSON.parse(readFileSync(this.statePath, "utf-8"));
|
|
140
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
141
|
+
return;
|
|
142
|
+
for (const [target, reference] of Object.entries(parsed).slice(0, 1_000)) {
|
|
143
|
+
if (target && reference && typeof reference === "object" && !Array.isArray(reference)) {
|
|
144
|
+
this.references.set(target, reference);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// First run, malformed legacy state, or an unavailable home directory.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
saveReferences() {
|
|
153
|
+
if (!this.statePath)
|
|
154
|
+
return;
|
|
155
|
+
const entries = [...this.references.entries()].slice(-1_000);
|
|
156
|
+
mkdirSync(dirname(this.statePath), { recursive: true, mode: 0o700 });
|
|
157
|
+
const temporary = `${this.statePath}.${process.pid}.tmp`;
|
|
158
|
+
writeFileSync(temporary, `${JSON.stringify(Object.fromEntries(entries))}\n`, {
|
|
159
|
+
encoding: "utf-8",
|
|
160
|
+
mode: 0o600,
|
|
161
|
+
});
|
|
162
|
+
renameSync(temporary, this.statePath);
|
|
163
|
+
if (process.platform !== "win32")
|
|
164
|
+
chmodSync(this.statePath, 0o600);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
class BotFrameworkResponse {
|
|
168
|
+
response;
|
|
169
|
+
socket;
|
|
170
|
+
constructor(response) {
|
|
171
|
+
this.response = response;
|
|
172
|
+
this.socket = response.socket;
|
|
173
|
+
}
|
|
174
|
+
status(code) {
|
|
175
|
+
this.response.statusCode = code;
|
|
176
|
+
return this;
|
|
177
|
+
}
|
|
178
|
+
header(name, value) {
|
|
179
|
+
this.response.setHeader(name, String(value));
|
|
180
|
+
return this;
|
|
181
|
+
}
|
|
182
|
+
send(...args) {
|
|
183
|
+
const body = args.at(-1);
|
|
184
|
+
if (body !== undefined) {
|
|
185
|
+
this.response.write(typeof body === "string" ? body : JSON.stringify(body));
|
|
186
|
+
}
|
|
187
|
+
return this;
|
|
188
|
+
}
|
|
189
|
+
end(...args) {
|
|
190
|
+
this.response.end(...args);
|
|
191
|
+
return this;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ChannelAdapter, ChannelMessageHandler, OutgoingMessage } from "./channel.js";
|
|
2
|
+
export interface TelegramAdapterConfig {
|
|
3
|
+
botToken: string;
|
|
4
|
+
apiBaseUrl?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface TelegramAdapterOptions {
|
|
7
|
+
fetch?: typeof fetch;
|
|
8
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
9
|
+
log?: (message: string) => void;
|
|
10
|
+
now?: () => number;
|
|
11
|
+
maxMessageAgeMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare class TelegramAdapter implements ChannelAdapter {
|
|
14
|
+
private readonly config;
|
|
15
|
+
readonly channel = "telegram";
|
|
16
|
+
readonly capabilities: import("./channel.js").ChannelCapabilities;
|
|
17
|
+
private readonly delivery;
|
|
18
|
+
readonly supportsOutgoingAttachments = true;
|
|
19
|
+
private readonly fetchFn;
|
|
20
|
+
private readonly sleepFn;
|
|
21
|
+
private readonly log;
|
|
22
|
+
private readonly now;
|
|
23
|
+
private readonly maxMessageAgeMs;
|
|
24
|
+
private readonly apiBaseUrl;
|
|
25
|
+
constructor(config: TelegramAdapterConfig, opts?: TelegramAdapterOptions);
|
|
26
|
+
run(handler: ChannelMessageHandler, signal: AbortSignal): Promise<void>;
|
|
27
|
+
send(target: string, message: OutgoingMessage): Promise<void>;
|
|
28
|
+
private call;
|
|
29
|
+
private callMultipart;
|
|
30
|
+
private readResponse;
|
|
31
|
+
private downloadFile;
|
|
32
|
+
private redact;
|
|
33
|
+
}
|
package/dist/telegram.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { BUILTIN_CHANNEL_CAPABILITIES } from "./channel.js";
|
|
2
|
+
import { OutgoingDeliveryTracker, outgoingAttachments } from "./media.js";
|
|
3
|
+
const DEFAULT_MAX_MESSAGE_AGE_MS = 5 * 60_000;
|
|
4
|
+
export class TelegramAdapter {
|
|
5
|
+
config;
|
|
6
|
+
channel = "telegram";
|
|
7
|
+
capabilities = BUILTIN_CHANNEL_CAPABILITIES.telegram;
|
|
8
|
+
delivery = new OutgoingDeliveryTracker();
|
|
9
|
+
supportsOutgoingAttachments = true;
|
|
10
|
+
fetchFn;
|
|
11
|
+
sleepFn;
|
|
12
|
+
log;
|
|
13
|
+
now;
|
|
14
|
+
maxMessageAgeMs;
|
|
15
|
+
apiBaseUrl;
|
|
16
|
+
constructor(config, opts = {}) {
|
|
17
|
+
this.config = config;
|
|
18
|
+
this.fetchFn = opts.fetch ?? fetch;
|
|
19
|
+
this.sleepFn = opts.sleep ?? abortableDelay;
|
|
20
|
+
this.log = opts.log ?? (() => undefined);
|
|
21
|
+
this.now = opts.now ?? Date.now;
|
|
22
|
+
this.maxMessageAgeMs = opts.maxMessageAgeMs ?? DEFAULT_MAX_MESSAGE_AGE_MS;
|
|
23
|
+
this.apiBaseUrl = config.apiBaseUrl?.replace(/\/$/, "") ?? "https://api.telegram.org";
|
|
24
|
+
}
|
|
25
|
+
async run(handler, signal) {
|
|
26
|
+
let offset;
|
|
27
|
+
let retryMs = 1_000;
|
|
28
|
+
while (!signal.aborted) {
|
|
29
|
+
try {
|
|
30
|
+
const updates = await this.call("getUpdates", {
|
|
31
|
+
offset,
|
|
32
|
+
timeout: 30,
|
|
33
|
+
allowed_updates: ["message"],
|
|
34
|
+
}, signal);
|
|
35
|
+
retryMs = 1_000;
|
|
36
|
+
for (const update of updates) {
|
|
37
|
+
const nextOffset = Math.max(offset ?? 0, update.update_id + 1);
|
|
38
|
+
if (isStale(update, this.now(), this.maxMessageAgeMs)) {
|
|
39
|
+
offset = nextOffset;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const message = toChannelMessage(update, (fileId, signal) => this.downloadFile(fileId, signal));
|
|
43
|
+
if (!message) {
|
|
44
|
+
offset = nextOffset;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
await handler(message);
|
|
49
|
+
// Commit the platform cursor only after ChatGateway has durably
|
|
50
|
+
// accepted the delivery. A full/broken inbox is retried by Telegram.
|
|
51
|
+
offset = nextOffset;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
this.log(`Telegram update ${update.update_id} 处理失败,${retryMs}ms 后重试:${this.redact(formatError(error))}`);
|
|
55
|
+
// Back off before re-polling. The offset is intentionally NOT
|
|
56
|
+
// advanced, so getUpdates returns the same pending batch instantly;
|
|
57
|
+
// without a delay the loop would hot-spin and hammer the API (and
|
|
58
|
+
// spam the log) until Telegram rate-limits us. Mirror the outer
|
|
59
|
+
// error path's backoff so a persistently-full inbox settles down.
|
|
60
|
+
await this.sleepFn(retryMs, signal).catch(() => undefined);
|
|
61
|
+
retryMs = Math.min(retryMs * 2, 30_000);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (signal.aborted)
|
|
68
|
+
return;
|
|
69
|
+
this.log(`Telegram long polling 失败,${retryMs}ms 后重试:${this.redact(formatError(error))}`);
|
|
70
|
+
await this.sleepFn(retryMs, signal).catch(() => undefined);
|
|
71
|
+
retryMs = Math.min(retryMs * 2, 30_000);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async send(target, message) {
|
|
76
|
+
const attachments = outgoingAttachments(message, this.capabilities.outbound.attachments);
|
|
77
|
+
await this.delivery.run(message, () => [
|
|
78
|
+
...(message.text || message.button
|
|
79
|
+
? [
|
|
80
|
+
() => this.call("sendMessage", {
|
|
81
|
+
chat_id: target,
|
|
82
|
+
text: message.text,
|
|
83
|
+
...(message.button
|
|
84
|
+
? {
|
|
85
|
+
reply_markup: {
|
|
86
|
+
inline_keyboard: [
|
|
87
|
+
[{ text: message.button.text, url: message.button.url }],
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
: {}),
|
|
92
|
+
}, AbortSignal.timeout(15_000)).then(() => undefined),
|
|
93
|
+
]
|
|
94
|
+
: []),
|
|
95
|
+
...attachments.map((attachment) => async () => {
|
|
96
|
+
if (attachment.kind === "image" &&
|
|
97
|
+
!["image/png", "image/jpeg", "image/gif", "image/webp"].includes(attachment.mimeType)) {
|
|
98
|
+
throw new Error("Telegram 待发送图片的类型不受支持");
|
|
99
|
+
}
|
|
100
|
+
const media = telegramOutboundMethod(attachment.kind, attachment.mimeType);
|
|
101
|
+
const form = new FormData();
|
|
102
|
+
const bytes = new Uint8Array(attachment.data.byteLength);
|
|
103
|
+
bytes.set(attachment.data);
|
|
104
|
+
form.set("chat_id", target);
|
|
105
|
+
form.set(media.field, new Blob([bytes], { type: attachment.mimeType }), attachment.name);
|
|
106
|
+
await this.callMultipart(media.method, form, AbortSignal.timeout(30_000));
|
|
107
|
+
}),
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
async call(method, body, signal) {
|
|
111
|
+
const response = await this.fetchFn(`${this.apiBaseUrl}/bot${this.config.botToken}/${method}`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "content-type": "application/json" },
|
|
114
|
+
body: JSON.stringify(body),
|
|
115
|
+
signal,
|
|
116
|
+
});
|
|
117
|
+
return this.readResponse(method, response);
|
|
118
|
+
}
|
|
119
|
+
async callMultipart(method, body, signal) {
|
|
120
|
+
const response = await this.fetchFn(`${this.apiBaseUrl}/bot${this.config.botToken}/${method}`, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
body,
|
|
123
|
+
signal,
|
|
124
|
+
});
|
|
125
|
+
return this.readResponse(method, response);
|
|
126
|
+
}
|
|
127
|
+
async readResponse(method, response) {
|
|
128
|
+
let result;
|
|
129
|
+
try {
|
|
130
|
+
result = (await response.json());
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new Error(`Telegram ${method} 返回无效 JSON(HTTP ${response.status})`);
|
|
134
|
+
}
|
|
135
|
+
if (!response.ok || !result.ok) {
|
|
136
|
+
throw new Error(result.description ?? `Telegram ${method} 失败(HTTP ${response.status})`);
|
|
137
|
+
}
|
|
138
|
+
return result.result;
|
|
139
|
+
}
|
|
140
|
+
async downloadFile(fileId, signal) {
|
|
141
|
+
const file = await this.call("getFile", { file_id: fileId }, signal ?? AbortSignal.timeout(30_000));
|
|
142
|
+
if (!file.file_path)
|
|
143
|
+
throw new Error("Telegram getFile 未返回文件路径");
|
|
144
|
+
const response = await this.fetchFn(`${this.apiBaseUrl}/file/bot${this.config.botToken}/${file.file_path}`, { signal: signal ?? AbortSignal.timeout(30_000) });
|
|
145
|
+
if (!response.ok)
|
|
146
|
+
throw new Error(`Telegram 文件下载失败(HTTP ${response.status})`);
|
|
147
|
+
return readBoundedResponse(response, 10 * 1024 * 1024);
|
|
148
|
+
}
|
|
149
|
+
redact(message) {
|
|
150
|
+
return message.split(this.config.botToken).join("[REDACTED]");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function telegramOutboundMethod(kind, mimeType) {
|
|
154
|
+
if (kind === "audio")
|
|
155
|
+
return { method: "sendAudio", field: "audio" };
|
|
156
|
+
if (kind === "video")
|
|
157
|
+
return { method: "sendVideo", field: "video" };
|
|
158
|
+
if (kind === "file" || mimeType === "image/webp") {
|
|
159
|
+
return { method: "sendDocument", field: "document" };
|
|
160
|
+
}
|
|
161
|
+
return mimeType === "image/gif"
|
|
162
|
+
? { method: "sendAnimation", field: "animation" }
|
|
163
|
+
: { method: "sendPhoto", field: "photo" };
|
|
164
|
+
}
|
|
165
|
+
function isStale(update, now, maxAgeMs) {
|
|
166
|
+
const sentAtSeconds = update.message?.date;
|
|
167
|
+
return typeof sentAtSeconds === "number" && now - sentAtSeconds * 1_000 > maxAgeMs;
|
|
168
|
+
}
|
|
169
|
+
function toChannelMessage(update, download) {
|
|
170
|
+
const message = update.message;
|
|
171
|
+
if (!message)
|
|
172
|
+
return undefined;
|
|
173
|
+
const sender = message.from?.id ?? message.sender_chat?.id;
|
|
174
|
+
if (sender === undefined)
|
|
175
|
+
return undefined;
|
|
176
|
+
const attachments = telegramAttachments(message, download);
|
|
177
|
+
const text = message.text ?? message.caption ?? "";
|
|
178
|
+
if (!text && attachments.length === 0)
|
|
179
|
+
return undefined;
|
|
180
|
+
return {
|
|
181
|
+
channel: "telegram",
|
|
182
|
+
target: String(message.chat.id),
|
|
183
|
+
senderId: String(sender),
|
|
184
|
+
text,
|
|
185
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
186
|
+
...(message.message_id === undefined ? {} : { messageId: String(message.message_id) }),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function telegramAttachments(message, download) {
|
|
190
|
+
const specs = [];
|
|
191
|
+
const photo = [...(message.photo ?? [])].sort((a, b) => (b.file_size ?? (b.width ?? 0) * (b.height ?? 0)) -
|
|
192
|
+
(a.file_size ?? (a.width ?? 0) * (a.height ?? 0)))[0];
|
|
193
|
+
if (photo)
|
|
194
|
+
specs.push({ media: photo, kind: "image", fallback: "telegram-photo.jpg" });
|
|
195
|
+
if (message.document)
|
|
196
|
+
specs.push({ media: message.document, kind: "file", fallback: "telegram-file" });
|
|
197
|
+
if (message.audio)
|
|
198
|
+
specs.push({ media: message.audio, kind: "audio", fallback: "telegram-audio" });
|
|
199
|
+
if (message.voice)
|
|
200
|
+
specs.push({ media: message.voice, kind: "audio", fallback: "telegram-voice.ogg" });
|
|
201
|
+
if (message.video)
|
|
202
|
+
specs.push({ media: message.video, kind: "video", fallback: "telegram-video.mp4" });
|
|
203
|
+
return specs.map(({ media, kind, fallback }) => ({
|
|
204
|
+
id: media.file_unique_id ?? media.file_id,
|
|
205
|
+
kind,
|
|
206
|
+
name: media.file_name ?? fallback,
|
|
207
|
+
...(media.mime_type
|
|
208
|
+
? { mimeType: media.mime_type }
|
|
209
|
+
: kind === "image"
|
|
210
|
+
? { mimeType: "image/jpeg" }
|
|
211
|
+
: {}),
|
|
212
|
+
...(media.file_size === undefined ? {} : { size: media.file_size }),
|
|
213
|
+
load: (signal) => download(media.file_id, signal),
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
async function readBoundedResponse(response, maxBytes) {
|
|
217
|
+
const declared = Number(response.headers.get("content-length"));
|
|
218
|
+
if (Number.isFinite(declared) && declared > maxBytes)
|
|
219
|
+
throw new Error("Telegram 附件超过大小限制");
|
|
220
|
+
if (!response.body) {
|
|
221
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
222
|
+
if (bytes.byteLength > maxBytes)
|
|
223
|
+
throw new Error("Telegram 附件超过大小限制");
|
|
224
|
+
return bytes;
|
|
225
|
+
}
|
|
226
|
+
const reader = response.body.getReader();
|
|
227
|
+
const chunks = [];
|
|
228
|
+
let total = 0;
|
|
229
|
+
while (true) {
|
|
230
|
+
const { done, value } = await reader.read();
|
|
231
|
+
if (done)
|
|
232
|
+
break;
|
|
233
|
+
total += value.byteLength;
|
|
234
|
+
if (total > maxBytes) {
|
|
235
|
+
await reader.cancel();
|
|
236
|
+
throw new Error("Telegram 附件超过大小限制");
|
|
237
|
+
}
|
|
238
|
+
chunks.push(value);
|
|
239
|
+
}
|
|
240
|
+
const output = new Uint8Array(total);
|
|
241
|
+
let offset = 0;
|
|
242
|
+
for (const chunk of chunks) {
|
|
243
|
+
output.set(chunk, offset);
|
|
244
|
+
offset += chunk.byteLength;
|
|
245
|
+
}
|
|
246
|
+
return output;
|
|
247
|
+
}
|
|
248
|
+
async function abortableDelay(ms, signal) {
|
|
249
|
+
if (signal.aborted)
|
|
250
|
+
return;
|
|
251
|
+
await new Promise((resolve) => {
|
|
252
|
+
const timer = setTimeout(done, ms);
|
|
253
|
+
signal.addEventListener("abort", done, { once: true });
|
|
254
|
+
function done() {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
signal.removeEventListener("abort", done);
|
|
257
|
+
resolve();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
function formatError(error) {
|
|
262
|
+
return error instanceof Error ? error.message : String(error);
|
|
263
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import type { ChannelMessageHandler, WebhookChannelAdapter } from "./channel.js";
|
|
3
|
+
export interface WebhookIngressConfig {
|
|
4
|
+
host: string;
|
|
5
|
+
port: number;
|
|
6
|
+
maxBodyBytes: number;
|
|
7
|
+
healthPath?: string;
|
|
8
|
+
readyPath?: string;
|
|
9
|
+
requestTimeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface WebhookRegistration {
|
|
12
|
+
adapter: WebhookChannelAdapter;
|
|
13
|
+
handler: ChannelMessageHandler;
|
|
14
|
+
}
|
|
15
|
+
export declare class WebhookIngressServer {
|
|
16
|
+
private readonly config;
|
|
17
|
+
private readonly registrations;
|
|
18
|
+
private readonly health;
|
|
19
|
+
private server?;
|
|
20
|
+
constructor(config: WebhookIngressConfig, registrations: WebhookRegistration[], health?: () => Record<string, unknown>);
|
|
21
|
+
run(signal: AbortSignal): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export declare class WebhookBodyTooLargeError extends Error {
|
|
24
|
+
}
|
|
25
|
+
export declare function readRequestBody(request: IncomingMessage, maxBytes: number): Promise<Buffer>;
|
|
26
|
+
export declare function sendResponse(response: ServerResponse, status: number, body?: string, contentType?: string): void;
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
export class WebhookIngressServer {
|
|
3
|
+
config;
|
|
4
|
+
registrations;
|
|
5
|
+
health;
|
|
6
|
+
server;
|
|
7
|
+
constructor(config, registrations, health = () => ({ status: "ready" })) {
|
|
8
|
+
this.config = config;
|
|
9
|
+
this.registrations = registrations;
|
|
10
|
+
this.health = health;
|
|
11
|
+
}
|
|
12
|
+
async run(signal) {
|
|
13
|
+
const routes = new Map(this.registrations.map((registration) => [registration.adapter.webhookPath, registration]));
|
|
14
|
+
this.server = createServer((request, response) => {
|
|
15
|
+
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
|
|
16
|
+
if (request.method === "GET" && pathname === (this.config.healthPath ?? "/healthz")) {
|
|
17
|
+
request.resume();
|
|
18
|
+
sendJson(response, 200, { status: "ok", ...this.health() });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (request.method === "GET" && pathname === (this.config.readyPath ?? "/readyz")) {
|
|
22
|
+
request.resume();
|
|
23
|
+
const body = this.health();
|
|
24
|
+
sendJson(response, body.status === "ready" ? 200 : 503, body);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const registration = routes.get(pathname);
|
|
28
|
+
if (!registration) {
|
|
29
|
+
sendResponse(response, 404, "Not found");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
void registration.adapter
|
|
33
|
+
.handleWebhook(request, response, registration.handler, this.config.maxBodyBytes)
|
|
34
|
+
.catch((error) => {
|
|
35
|
+
if (response.headersSent) {
|
|
36
|
+
response.end();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
sendResponse(response, error instanceof WebhookBodyTooLargeError ? 413 : 500, error instanceof WebhookBodyTooLargeError ? "Payload too large" : "Webhook error");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
this.server.requestTimeout = this.config.requestTimeoutMs ?? 15_000;
|
|
43
|
+
await listen(this.server, this.config.host, this.config.port);
|
|
44
|
+
const address = this.server.address();
|
|
45
|
+
console.log(`[chat] webhook ingress:http://${address.address}:${address.port}`);
|
|
46
|
+
if (signal.aborted)
|
|
47
|
+
await close(this.server);
|
|
48
|
+
else {
|
|
49
|
+
await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
50
|
+
await close(this.server);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function sendJson(response, status, body) {
|
|
55
|
+
sendResponse(response, status, JSON.stringify(body), "application/json; charset=utf-8");
|
|
56
|
+
}
|
|
57
|
+
export class WebhookBodyTooLargeError extends Error {
|
|
58
|
+
}
|
|
59
|
+
export async function readRequestBody(request, maxBytes) {
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let size = 0;
|
|
62
|
+
for await (const chunk of request) {
|
|
63
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
64
|
+
size += buffer.byteLength;
|
|
65
|
+
if (size > maxBytes) {
|
|
66
|
+
request.resume();
|
|
67
|
+
throw new WebhookBodyTooLargeError();
|
|
68
|
+
}
|
|
69
|
+
chunks.push(buffer);
|
|
70
|
+
}
|
|
71
|
+
return Buffer.concat(chunks);
|
|
72
|
+
}
|
|
73
|
+
export function sendResponse(response, status, body = "", contentType = "text/plain; charset=utf-8") {
|
|
74
|
+
response.statusCode = status;
|
|
75
|
+
response.setHeader("content-type", contentType);
|
|
76
|
+
response.end(body);
|
|
77
|
+
}
|
|
78
|
+
function listen(server, host, port) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
server.once("error", reject);
|
|
81
|
+
server.listen(port, host, () => {
|
|
82
|
+
server.off("error", reject);
|
|
83
|
+
resolve();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function close(server) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type WechatQrLoginOptions, type WechatQrLoginResult } from "./wechat.js";
|
|
2
|
+
export interface CodeShellWechatLoginOptions {
|
|
3
|
+
configPath?: string;
|
|
4
|
+
credentialsDir?: string;
|
|
5
|
+
signal?: AbortSignal;
|
|
6
|
+
onQrCode?: WechatQrLoginOptions["onQrCode"];
|
|
7
|
+
onStatus?: WechatQrLoginOptions["onStatus"];
|
|
8
|
+
requestVerificationCode?: WechatQrLoginOptions["requestVerificationCode"];
|
|
9
|
+
/** Test/integration seam; normal callers use Tencent's QR login implementation. */
|
|
10
|
+
login?: (options: WechatQrLoginOptions) => Promise<WechatQrLoginResult>;
|
|
11
|
+
}
|
|
12
|
+
export interface CodeShellWechatLoginResult {
|
|
13
|
+
accountId: string;
|
|
14
|
+
configPath: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Complete the personal-WeChat QR flow and persist both the owner-only token
|
|
18
|
+
* and the matching CodeShell gateway configuration. Shared by the CLI and the
|
|
19
|
+
* Desktop Link page so both entry points produce exactly the same files.
|
|
20
|
+
*/
|
|
21
|
+
export declare function loginCodeShellWechat(options?: CodeShellWechatLoginOptions): Promise<CodeShellWechatLoginResult>;
|