koishi-plugin-dynamic-bot 0.0.0-dev
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/AGENTS.md +6 -0
- package/LICENSE +201 -0
- package/package.json +33 -0
- package/src/dbk/avatar.ts +58 -0
- package/src/dbk/bots.ts +261 -0
- package/src/dbk/codec.ts +55 -0
- package/src/dbk/error.ts +11 -0
- package/src/dbk/gateway.ts +155 -0
- package/src/dbk/get-target.ts +275 -0
- package/src/dbk/handlers.ts +29 -0
- package/src/dbk/list-targets.ts +459 -0
- package/src/dbk/protocol.ts +100 -0
- package/src/dbk/recall.ts +222 -0
- package/src/dbk/send.ts +398 -0
- package/src/dbk/session.ts +239 -0
- package/src/dbk/socket.ts +52 -0
- package/src/dbk/version.ts +65 -0
- package/src/gen/dbk/v1/common_pb.ts +238 -0
- package/src/gen/dbk/v1/frame_pb.ts +114 -0
- package/src/gen/dbk/v1/rpc_pb.ts +868 -0
- package/src/index.ts +104 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { create } from "@bufbuild/protobuf";
|
|
2
|
+
import type { Bot, Context } from "koishi";
|
|
3
|
+
import { ErrorCode, SendStatus } from "../gen/dbk/v1/common_pb";
|
|
4
|
+
import { RecallResultSchema, type RecallParams, type RecallResult } from "../gen/dbk/v1/rpc_pb";
|
|
5
|
+
import { DbkRpcError } from "./error";
|
|
6
|
+
|
|
7
|
+
/** Koishi/Satori `Status`: OFFLINE=0 ONLINE=1 CONNECT=2 DISCONNECT=3 RECONNECT=4 */
|
|
8
|
+
const KOISHI_STATUS_ONLINE = 1;
|
|
9
|
+
const KOISHI_STATUS_CONNECT = 2;
|
|
10
|
+
const KOISHI_STATUS_RECONNECT = 4;
|
|
11
|
+
|
|
12
|
+
const RECALL_TIMEOUT_MS = 25_000;
|
|
13
|
+
|
|
14
|
+
export async function recallMessage(ctx: Context, params: RecallParams): Promise<RecallResult> {
|
|
15
|
+
const bot = findBot(ctx, params.botKey);
|
|
16
|
+
if (!bot) {
|
|
17
|
+
throw new DbkRpcError(ErrorCode.NOT_FOUND, `bot not found: ${params.botKey.trim() || "(empty)"}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!botCanRecall(bot)) {
|
|
21
|
+
return failed("bot has no message.recall capability", false);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const notReady = notReadyResult(bot);
|
|
25
|
+
if (notReady) return notReady;
|
|
26
|
+
|
|
27
|
+
const messageId = params.messageId.trim();
|
|
28
|
+
if (!messageId) {
|
|
29
|
+
return failed("message_id is empty", false);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const channelId = params.target?.id.trim() ?? "";
|
|
33
|
+
if (!channelId) {
|
|
34
|
+
return failed("target id is empty", false);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await withTimeout(Promise.resolve(bot.deleteMessage(channelId, messageId)), RECALL_TIMEOUT_MS);
|
|
39
|
+
return create(RecallResultSchema, {
|
|
40
|
+
status: SendStatus.OK,
|
|
41
|
+
reason: "",
|
|
42
|
+
retryable: false,
|
|
43
|
+
});
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return classifyRecallError(error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function findBot(ctx: Context, botKey: string): Bot | undefined {
|
|
50
|
+
const key = botKey.trim();
|
|
51
|
+
if (!key) return undefined;
|
|
52
|
+
return ctx.bots.find((bot) => botKeyOf(bot) === key);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function botKeyOf(bot: Bot): string {
|
|
56
|
+
const platform = bot.platform ?? "";
|
|
57
|
+
const selfId = bot.selfId ?? "";
|
|
58
|
+
return platform && selfId ? `${platform}:${selfId}` : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function botCanRecall(bot: Bot): boolean {
|
|
62
|
+
if (typeof bot.deleteMessage !== "function") return false;
|
|
63
|
+
try {
|
|
64
|
+
const src = Function.prototype.toString.call(bot.deleteMessage);
|
|
65
|
+
if (/not implemented|NotImplementedError/i.test(src)) return false;
|
|
66
|
+
} catch {
|
|
67
|
+
// toString can throw on native/bound functions; treat as implemented.
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function notReadyResult(bot: Bot): RecallResult | undefined {
|
|
73
|
+
switch (bot.status) {
|
|
74
|
+
case KOISHI_STATUS_ONLINE:
|
|
75
|
+
return undefined;
|
|
76
|
+
case KOISHI_STATUS_CONNECT:
|
|
77
|
+
case KOISHI_STATUS_RECONNECT:
|
|
78
|
+
return failed("bot is connecting", true);
|
|
79
|
+
default:
|
|
80
|
+
return failed("bot is unavailable", false);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function failed(reason: string, retryable: boolean): RecallResult {
|
|
85
|
+
return create(RecallResultSchema, {
|
|
86
|
+
status: SendStatus.FAILED,
|
|
87
|
+
reason,
|
|
88
|
+
retryable,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function unknown(reason: string): RecallResult {
|
|
93
|
+
return create(RecallResultSchema, {
|
|
94
|
+
status: SendStatus.UNKNOWN,
|
|
95
|
+
reason,
|
|
96
|
+
retryable: false,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function classifyRecallError(error: unknown): RecallResult {
|
|
101
|
+
if (error instanceof RecallTimeoutError) {
|
|
102
|
+
return unknown(error.message);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const text = errorMessage(error);
|
|
106
|
+
const lower = text.toLowerCase();
|
|
107
|
+
const status = httpStatusOf(error);
|
|
108
|
+
|
|
109
|
+
if (isTimeout(error, lower, status)) {
|
|
110
|
+
return unknown(text || "message.recall timed out");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (isNotImplemented(lower)) {
|
|
114
|
+
return failed("bot has no message.recall capability", false);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (status === 401 || status === 403 || isForbidden(lower)) {
|
|
118
|
+
return failed(text || "not permitted to recall message", false);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (status === 400 || status === 404) {
|
|
122
|
+
return failed(text || "recall rejected", false);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (status === 429 || (status !== undefined && status >= 500) || isRetryableNetwork(error, lower)) {
|
|
126
|
+
return failed(text || "recall failed", true);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return failed(text || "message.recall failed", false);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function httpStatusOf(error: unknown): number | undefined {
|
|
133
|
+
if (!error || typeof error !== "object") return undefined;
|
|
134
|
+
const record = error as Record<string, unknown>;
|
|
135
|
+
if (typeof record.status === "number") return record.status;
|
|
136
|
+
if (typeof record.statusCode === "number") return record.statusCode;
|
|
137
|
+
const response = record.response;
|
|
138
|
+
if (response && typeof response === "object") {
|
|
139
|
+
const nested = (response as Record<string, unknown>).status;
|
|
140
|
+
if (typeof nested === "number") return nested;
|
|
141
|
+
}
|
|
142
|
+
if (typeof record.code === "number" && record.code >= 400 && record.code < 600) return record.code;
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function errorMessage(error: unknown): string {
|
|
147
|
+
if (error instanceof Error) return error.message.trim();
|
|
148
|
+
if (typeof error === "string") return error.trim();
|
|
149
|
+
return String(error);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isTimeout(error: unknown, lower: string, status: number | undefined): boolean {
|
|
153
|
+
if (status === 408 || status === 504) return true;
|
|
154
|
+
if (error && typeof error === "object") {
|
|
155
|
+
const name = (error as { name?: unknown }).name;
|
|
156
|
+
if (name === "TimeoutError" || name === "AbortError") return true;
|
|
157
|
+
const code = (error as { code?: unknown }).code;
|
|
158
|
+
if (code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR") return true;
|
|
159
|
+
}
|
|
160
|
+
return lower.includes("timed out") || lower.includes("timeout");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isNotImplemented(lower: string): boolean {
|
|
164
|
+
return lower.includes("not implemented") || lower.includes("unsupported");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isForbidden(lower: string): boolean {
|
|
168
|
+
return lower.includes("forbidden")
|
|
169
|
+
|| lower.includes("not permitted")
|
|
170
|
+
|| lower.includes("missing permission")
|
|
171
|
+
|| lower.includes("missing access")
|
|
172
|
+
|| lower.includes("can't delete")
|
|
173
|
+
|| lower.includes("cannot delete")
|
|
174
|
+
|| lower.includes("message can't be deleted")
|
|
175
|
+
|| lower.includes("not enough rights");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isRetryableNetwork(error: unknown, lower: string): boolean {
|
|
179
|
+
if (error && typeof error === "object") {
|
|
180
|
+
const code = (error as { code?: unknown }).code;
|
|
181
|
+
if (
|
|
182
|
+
code === "ECONNRESET"
|
|
183
|
+
|| code === "ECONNREFUSED"
|
|
184
|
+
|| code === "ECONNABORTED"
|
|
185
|
+
|| code === "ENOTFOUND"
|
|
186
|
+
|| code === "EAI_AGAIN"
|
|
187
|
+
|| code === "EPIPE"
|
|
188
|
+
|| code === "UND_ERR_SOCKET"
|
|
189
|
+
|| code === "UND_ERR_CONNECT_TIMEOUT"
|
|
190
|
+
) {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return lower.includes("network")
|
|
195
|
+
|| lower.includes("fetch failed")
|
|
196
|
+
|| lower.includes("socket hang up")
|
|
197
|
+
|| lower.includes("econnreset")
|
|
198
|
+
|| lower.includes("econnrefused");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
class RecallTimeoutError extends Error {
|
|
202
|
+
constructor() {
|
|
203
|
+
super("message.recall timed out");
|
|
204
|
+
this.name = "RecallTimeoutError";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
209
|
+
return new Promise<T>((resolve, reject) => {
|
|
210
|
+
const timer = setTimeout(() => reject(new RecallTimeoutError()), ms);
|
|
211
|
+
promise.then(
|
|
212
|
+
(value) => {
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
resolve(value);
|
|
215
|
+
},
|
|
216
|
+
(error) => {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
reject(error);
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
}
|
package/src/dbk/send.ts
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { create } from "@bufbuild/protobuf";
|
|
2
|
+
import { h, type Bot, type Context } from "koishi";
|
|
3
|
+
import { BotStatus, ErrorCode, SendStatus } from "../gen/dbk/v1/common_pb";
|
|
4
|
+
import {
|
|
5
|
+
SendReceiptSchema,
|
|
6
|
+
SendResultSchema,
|
|
7
|
+
type ForwardNode,
|
|
8
|
+
type Segment,
|
|
9
|
+
type SendParams,
|
|
10
|
+
type SendReceipt,
|
|
11
|
+
type SendResult,
|
|
12
|
+
} from "../gen/dbk/v1/rpc_pb";
|
|
13
|
+
import { botFeatures, toDbkBotStatus } from "./bots";
|
|
14
|
+
import { DbkRpcError } from "./error";
|
|
15
|
+
|
|
16
|
+
const SEND_TIMEOUT_MS = 25_000;
|
|
17
|
+
const MENTION_ALL_FALLBACK = "@全体成员";
|
|
18
|
+
|
|
19
|
+
type El = ReturnType<typeof h>;
|
|
20
|
+
|
|
21
|
+
export interface SegmentRenderOptions {
|
|
22
|
+
/** Prepend `h.quote` unless a reply segment already uses this id. */
|
|
23
|
+
quoteMessageId?: string;
|
|
24
|
+
/**
|
|
25
|
+
* `mention_all` mapping:
|
|
26
|
+
* - true → Satori `<at type="all"/>` (`h("at", { type: "all" })`)
|
|
27
|
+
* - false → degrade to text `@全体成员` (not PARTIAL)
|
|
28
|
+
*/
|
|
29
|
+
mentionAll?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function sendMessage(ctx: Context, request: SendParams): Promise<SendResult> {
|
|
33
|
+
const bot = findBot(ctx, request.botKey);
|
|
34
|
+
if (!bot) {
|
|
35
|
+
throw new DbkRpcError(ErrorCode.NOT_FOUND, `bot not found: ${request.botKey.trim() || "(empty)"}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const status = toDbkBotStatus(bot.status);
|
|
39
|
+
if (status === BotStatus.CONNECTING) {
|
|
40
|
+
return failed("bot is connecting", true);
|
|
41
|
+
}
|
|
42
|
+
if (status !== BotStatus.READY) {
|
|
43
|
+
return failed("bot is unavailable", false);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const channelId = request.target?.id.trim() ?? "";
|
|
47
|
+
if (!channelId) {
|
|
48
|
+
return failed("target id is empty", false);
|
|
49
|
+
}
|
|
50
|
+
if (request.units.length === 0) {
|
|
51
|
+
return failed("units is empty", false);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const guildId = request.target?.guildId.trim() || undefined;
|
|
55
|
+
const features = botFeatures(bot);
|
|
56
|
+
const recallable = features.includes("message.recall") || typeof bot.deleteMessage === "function";
|
|
57
|
+
const mentionAll = features.includes("mention.all");
|
|
58
|
+
const receipts: SendReceipt[] = [];
|
|
59
|
+
const failures: string[] = [];
|
|
60
|
+
const unknownReasons: string[] = [];
|
|
61
|
+
let failuresRetryable = true;
|
|
62
|
+
let quoteUsed = false;
|
|
63
|
+
let aborted = false;
|
|
64
|
+
|
|
65
|
+
const nextQuoteId = (): string | undefined => {
|
|
66
|
+
if (quoteUsed) return undefined;
|
|
67
|
+
const id = request.replyToMessageId.trim();
|
|
68
|
+
return id || undefined;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const sendElements = async (elements: El[]): Promise<void> => {
|
|
72
|
+
if (aborted) return;
|
|
73
|
+
if (elements.length === 0) {
|
|
74
|
+
failuresRetryable = false;
|
|
75
|
+
failures.push("no sendable segments");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const outgoing = withQuote(elements, nextQuoteId());
|
|
79
|
+
try {
|
|
80
|
+
const ids = await bot.sendMessage(channelId, outgoing, guildId);
|
|
81
|
+
if (aborted) return;
|
|
82
|
+
quoteUsed = true;
|
|
83
|
+
const kept = normalizeMessageIds(ids);
|
|
84
|
+
if (kept.length === 0) {
|
|
85
|
+
unknownReasons.push("send returned no message id");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
for (const messageId of kept) {
|
|
89
|
+
receipts.push(create(SendReceiptSchema, { messageId, recallable }));
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (aborted) return;
|
|
93
|
+
if (isTimeoutError(error)) {
|
|
94
|
+
quoteUsed = true;
|
|
95
|
+
unknownReasons.push(errorMessage(error) || "send timed out");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const retryable = isRetryableSendError(error);
|
|
99
|
+
failuresRetryable = failuresRetryable && retryable;
|
|
100
|
+
failures.push(errorMessage(error) || "send failed");
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const work = (async (): Promise<SendResult> => {
|
|
105
|
+
for (const unit of request.units) {
|
|
106
|
+
if (aborted) break;
|
|
107
|
+
if (unit.body.case === "normal") {
|
|
108
|
+
await sendElements(segmentsToElements(unit.body.value.segments, { mentionAll }));
|
|
109
|
+
} else if (unit.body.case === "forward") {
|
|
110
|
+
// v1: never emit a single merged-forward receipt. Always one send per node.
|
|
111
|
+
const nodes = unit.body.value.nodes;
|
|
112
|
+
if (nodes.length === 0) {
|
|
113
|
+
failuresRetryable = false;
|
|
114
|
+
failures.push("forward has no nodes");
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
for (const node of nodes) {
|
|
118
|
+
if (aborted) break;
|
|
119
|
+
await sendElements(forwardNodeElements(node, { mentionAll }));
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
failuresRetryable = false;
|
|
123
|
+
failures.push("empty send unit");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return finalize(receipts, failures, unknownReasons, failuresRetryable);
|
|
127
|
+
})();
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
return await withTimeout(work, SEND_TIMEOUT_MS);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
aborted = true;
|
|
133
|
+
void work.catch(() => undefined);
|
|
134
|
+
if (error instanceof SendTimeoutError || isTimeoutError(error)) {
|
|
135
|
+
return create(SendResultSchema, {
|
|
136
|
+
status: SendStatus.UNKNOWN,
|
|
137
|
+
reason: error instanceof Error ? error.message : "send timed out",
|
|
138
|
+
retryable: false,
|
|
139
|
+
receipts,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return create(SendResultSchema, {
|
|
143
|
+
status: SendStatus.FAILED,
|
|
144
|
+
reason: errorMessage(error) || "message.send failed",
|
|
145
|
+
retryable: isRetryableSendError(error),
|
|
146
|
+
receipts,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function segmentsToElements(segments: Segment[], options?: SegmentRenderOptions): El[] {
|
|
152
|
+
const elements: El[] = [];
|
|
153
|
+
const quoted = new Set<string>();
|
|
154
|
+
|
|
155
|
+
const addQuote = (id: string) => {
|
|
156
|
+
const trimmed = id.trim();
|
|
157
|
+
if (!trimmed || quoted.has(trimmed)) return;
|
|
158
|
+
quoted.add(trimmed);
|
|
159
|
+
elements.push(h.quote(trimmed));
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (options?.quoteMessageId) addQuote(options.quoteMessageId);
|
|
163
|
+
|
|
164
|
+
for (const segment of segments) {
|
|
165
|
+
switch (segment.body.case) {
|
|
166
|
+
case "text": {
|
|
167
|
+
elements.push(h.text(segment.body.value.text));
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case "image": {
|
|
171
|
+
const uri = segment.body.value.uri.trim();
|
|
172
|
+
if (uri) elements.push(h.image(uri));
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case "video": {
|
|
176
|
+
const uri = segment.body.value.uri.trim();
|
|
177
|
+
if (uri) elements.push(h.video(uri));
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case "audio": {
|
|
181
|
+
const uri = segment.body.value.uri.trim();
|
|
182
|
+
if (uri) elements.push(h.audio(uri));
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case "mention": {
|
|
186
|
+
const id = segment.body.value.id.trim();
|
|
187
|
+
if (id) elements.push(h.at(id));
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case "mentionAll": {
|
|
191
|
+
if (options?.mentionAll === false) {
|
|
192
|
+
elements.push(h.text(MENTION_ALL_FALLBACK));
|
|
193
|
+
} else {
|
|
194
|
+
elements.push(h("at", { type: "all" }));
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
case "reply": {
|
|
199
|
+
addQuote(segment.body.value.messageId);
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
case "link": {
|
|
203
|
+
const url = segment.body.value.url.trim();
|
|
204
|
+
if (!url) break;
|
|
205
|
+
const title = segment.body.value.title.trim();
|
|
206
|
+
elements.push(h("a", { href: url }, title || url));
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case "unknown":
|
|
210
|
+
case undefined:
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return elements;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function forwardNodeElements(node: ForwardNode, options?: SegmentRenderOptions): El[] {
|
|
219
|
+
const body = segmentsToElements(node.segments, options);
|
|
220
|
+
const name = node.senderName.trim();
|
|
221
|
+
if (!name) return body;
|
|
222
|
+
return [h.text(`${name}\n`), ...body];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function withQuote(elements: El[], quoteId: string | undefined): El[] {
|
|
226
|
+
if (!quoteId) return elements;
|
|
227
|
+
if (hasQuote(elements, quoteId)) return elements;
|
|
228
|
+
return [h.quote(quoteId), ...elements];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function hasQuote(elements: El[], messageId: string): boolean {
|
|
232
|
+
return elements.some((el) => el.type === "quote" && String(el.attrs?.id ?? "") === messageId);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function normalizeMessageIds(raw: unknown): string[] {
|
|
236
|
+
if (raw == null) return [];
|
|
237
|
+
const list = Array.isArray(raw) ? raw : [raw];
|
|
238
|
+
return list.map((id) => String(id).trim()).filter(Boolean);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function findBot(ctx: Context, botKey: string): Bot | undefined {
|
|
242
|
+
const key = botKey.trim();
|
|
243
|
+
if (!key) return undefined;
|
|
244
|
+
return ctx.bots.find((bot) => !bot.hidden && botKeyOf(bot) === key);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function botKeyOf(bot: Bot): string {
|
|
248
|
+
const platform = bot.platform ?? "";
|
|
249
|
+
const selfId = bot.selfId ?? "";
|
|
250
|
+
return platform && selfId ? `${platform}:${selfId}` : "";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function finalize(
|
|
254
|
+
receipts: SendReceipt[],
|
|
255
|
+
failures: string[],
|
|
256
|
+
unknownReasons: string[],
|
|
257
|
+
failuresRetryable: boolean,
|
|
258
|
+
): SendResult {
|
|
259
|
+
if (unknownReasons.length > 0) {
|
|
260
|
+
return create(SendResultSchema, {
|
|
261
|
+
status: SendStatus.UNKNOWN,
|
|
262
|
+
reason: unknownReasons[0] ?? "send outcome unknown",
|
|
263
|
+
retryable: false,
|
|
264
|
+
receipts,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
if (failures.length === 0) {
|
|
268
|
+
return create(SendResultSchema, {
|
|
269
|
+
status: SendStatus.OK,
|
|
270
|
+
reason: "",
|
|
271
|
+
retryable: false,
|
|
272
|
+
receipts,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
if (receipts.length > 0) {
|
|
276
|
+
return create(SendResultSchema, {
|
|
277
|
+
status: SendStatus.PARTIAL,
|
|
278
|
+
reason: failures.join("; "),
|
|
279
|
+
retryable: false,
|
|
280
|
+
receipts,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return create(SendResultSchema, {
|
|
284
|
+
status: SendStatus.FAILED,
|
|
285
|
+
reason: failures.join("; "),
|
|
286
|
+
retryable: failuresRetryable,
|
|
287
|
+
receipts: [],
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function failed(reason: string, retryable: boolean): SendResult {
|
|
292
|
+
return create(SendResultSchema, {
|
|
293
|
+
status: SendStatus.FAILED,
|
|
294
|
+
reason,
|
|
295
|
+
retryable,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function isRetryableSendError(error: unknown): boolean {
|
|
300
|
+
const text = errorMessage(error);
|
|
301
|
+
const lower = text.toLowerCase();
|
|
302
|
+
const status = httpStatusOf(error);
|
|
303
|
+
|
|
304
|
+
if (status === 401 || status === 403 || isForbidden(lower)) return false;
|
|
305
|
+
if (status === 400 || status === 404) return false;
|
|
306
|
+
if (status === 429 || (status !== undefined && status >= 500)) return true;
|
|
307
|
+
if (isRetryableNetwork(error, lower)) return true;
|
|
308
|
+
if (/\brate.?limit\b|too many requests/.test(lower)) return true;
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isTimeoutError(error: unknown): boolean {
|
|
313
|
+
const text = errorMessage(error).toLowerCase();
|
|
314
|
+
const status = httpStatusOf(error);
|
|
315
|
+
if (error instanceof SendTimeoutError) return true;
|
|
316
|
+
if (status === 408 || status === 504) return true;
|
|
317
|
+
if (error && typeof error === "object") {
|
|
318
|
+
const name = (error as { name?: unknown }).name;
|
|
319
|
+
if (name === "TimeoutError" || name === "AbortError") return true;
|
|
320
|
+
const code = (error as { code?: unknown }).code;
|
|
321
|
+
if (code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR") return true;
|
|
322
|
+
}
|
|
323
|
+
return text.includes("timed out") || text.includes("timeout");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function httpStatusOf(error: unknown): number | undefined {
|
|
327
|
+
if (!error || typeof error !== "object") return undefined;
|
|
328
|
+
const record = error as Record<string, unknown>;
|
|
329
|
+
if (typeof record.status === "number") return record.status;
|
|
330
|
+
if (typeof record.statusCode === "number") return record.statusCode;
|
|
331
|
+
const response = record.response;
|
|
332
|
+
if (response && typeof response === "object") {
|
|
333
|
+
const nested = (response as Record<string, unknown>).status;
|
|
334
|
+
if (typeof nested === "number") return nested;
|
|
335
|
+
}
|
|
336
|
+
if (typeof record.code === "number" && record.code >= 400 && record.code < 600) return record.code;
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function errorMessage(error: unknown): string {
|
|
341
|
+
if (error instanceof Error) return error.message.trim();
|
|
342
|
+
if (typeof error === "string") return error.trim();
|
|
343
|
+
return String(error);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function isForbidden(lower: string): boolean {
|
|
347
|
+
return lower.includes("forbidden")
|
|
348
|
+
|| lower.includes("missing permission")
|
|
349
|
+
|| lower.includes("missing access")
|
|
350
|
+
|| lower.includes("not permitted")
|
|
351
|
+
|| lower.includes("unauthorized");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function isRetryableNetwork(error: unknown, lower: string): boolean {
|
|
355
|
+
if (error && typeof error === "object") {
|
|
356
|
+
const code = (error as { code?: unknown }).code;
|
|
357
|
+
if (
|
|
358
|
+
code === "ECONNRESET"
|
|
359
|
+
|| code === "ECONNREFUSED"
|
|
360
|
+
|| code === "ECONNABORTED"
|
|
361
|
+
|| code === "ENOTFOUND"
|
|
362
|
+
|| code === "EAI_AGAIN"
|
|
363
|
+
|| code === "EPIPE"
|
|
364
|
+
|| code === "UND_ERR_SOCKET"
|
|
365
|
+
|| code === "UND_ERR_CONNECT_TIMEOUT"
|
|
366
|
+
) {
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return lower.includes("network")
|
|
371
|
+
|| lower.includes("fetch failed")
|
|
372
|
+
|| lower.includes("socket hang up")
|
|
373
|
+
|| lower.includes("econnreset")
|
|
374
|
+
|| lower.includes("econnrefused");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
class SendTimeoutError extends Error {
|
|
378
|
+
constructor() {
|
|
379
|
+
super("send timed out");
|
|
380
|
+
this.name = "SendTimeoutError";
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
385
|
+
return new Promise<T>((resolve, reject) => {
|
|
386
|
+
const timer = setTimeout(() => reject(new SendTimeoutError()), ms);
|
|
387
|
+
promise.then(
|
|
388
|
+
(value) => {
|
|
389
|
+
clearTimeout(timer);
|
|
390
|
+
resolve(value);
|
|
391
|
+
},
|
|
392
|
+
(error) => {
|
|
393
|
+
clearTimeout(timer);
|
|
394
|
+
reject(error);
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
}
|