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.
@@ -0,0 +1,155 @@
1
+ import { create } from "@bufbuild/protobuf";
2
+ import type { Context } from "koishi";
3
+ import { BotChangeType } from "../gen/dbk/v1/common_pb";
4
+ import { BotChangedEventSchema } from "../gen/dbk/v1/rpc_pb";
5
+ import { toDbkBot } from "./bots";
6
+ import { DbkEvent, type DbkEncoding, type DbkEventMap, type DbkGatewayHandlers } from "./protocol";
7
+ import { DbkGatewaySession } from "./session";
8
+ import { attachSocket, sendSocket, type DbkSocket } from "./socket";
9
+
10
+ const RECONNECT_BACKOFF_MS = [
11
+ 5_000,
12
+ 10_000,
13
+ 30_000,
14
+ 60_000,
15
+ 5 * 60_000,
16
+ 10 * 60_000,
17
+ 30 * 60_000,
18
+ 60 * 60_000,
19
+ ];
20
+
21
+ export interface DbkGatewayConfig {
22
+ encoding: DbkEncoding;
23
+ accessToken: string;
24
+ path: string;
25
+ host: string;
26
+ port: number;
27
+ reconnect: boolean;
28
+ }
29
+
30
+ export class DbkGateway {
31
+ private session: DbkGatewaySession | undefined;
32
+ private socket: DbkSocket | undefined;
33
+ private closing = false;
34
+ private reconnectAttempts = 0;
35
+ private reconnectTimer: (() => void) | undefined;
36
+ private reconnectSuspended = false;
37
+
38
+ constructor(
39
+ private readonly ctx: Context,
40
+ private readonly config: DbkGatewayConfig,
41
+ private readonly handlers: DbkGatewayHandlers,
42
+ ) {}
43
+
44
+ get isHandshook(): boolean {
45
+ return this.session?.isHandshook ?? false;
46
+ }
47
+
48
+ emit<K extends keyof DbkEventMap>(method: K, event: DbkEventMap[K]): void {
49
+ this.session?.emit(method, event);
50
+ }
51
+
52
+ startForward(): void {
53
+ const path = normalizePath(this.config.path);
54
+ this.ctx.logger.info("DBK forward WebSocket mounted on Koishi HTTP at %s", path);
55
+ this.ctx.server.ws(path, (socket) => {
56
+ this.attach("replaced", socket as DbkSocket);
57
+ });
58
+ }
59
+
60
+ startReverse(): void {
61
+ this.connectReverse();
62
+ }
63
+
64
+ stop(): void {
65
+ this.closing = true;
66
+ this.reconnectTimer?.();
67
+ this.reconnectTimer = undefined;
68
+ this.session?.close("plugin stop");
69
+ this.session = undefined;
70
+ this.socket?.close(1000, "plugin stop");
71
+ this.socket = undefined;
72
+ }
73
+
74
+ private connectReverse(): void {
75
+ if (this.closing) return;
76
+ const url = `ws://${this.config.host}:${this.config.port}`;
77
+ this.ctx.logger.info("DBK reverse WebSocket connecting to %s", url);
78
+ const socket = this.ctx.http.ws(url) as DbkSocket;
79
+ const onOpen = () => {
80
+ this.reconnectAttempts = 0;
81
+ this.attach("ws close", socket);
82
+ };
83
+ if (typeof socket.on === "function") {
84
+ socket.on("open", onOpen);
85
+ socket.on("error", (error) => this.ctx.logger.warn("DBK reverse WebSocket error: %s", error));
86
+ socket.on("close", () => this.onReverseClosed());
87
+ } else {
88
+ socket.addEventListener?.("open", onOpen);
89
+ socket.addEventListener?.("close", () => this.onReverseClosed());
90
+ }
91
+ }
92
+
93
+ private attach(replaceReason: string, socket: DbkSocket): void {
94
+ if (this.socket && this.socket !== socket) {
95
+ this.ctx.logger.warn("replacing existing DBK connection");
96
+ this.session?.close(replaceReason);
97
+ this.socket.close(1000, replaceReason);
98
+ }
99
+ this.socket = socket;
100
+ const session = new DbkGatewaySession({
101
+ encoding: this.config.encoding,
102
+ accessToken: this.config.accessToken,
103
+ handlers: this.handlers,
104
+ logger: this.ctx.logger,
105
+ send: (data) => sendSocket(socket, data),
106
+ onDead: (reason) => {
107
+ socket.close(1000, reason.slice(0, 123));
108
+ },
109
+ onUnauthorized: () => {
110
+ this.reconnectSuspended = true;
111
+ this.ctx.logger.warn("DBK unauthorized, automatic reconnect paused");
112
+ },
113
+ });
114
+ this.session = session;
115
+ attachSocket(socket, session, this.config.encoding);
116
+ }
117
+
118
+ private onReverseClosed(): void {
119
+ this.session?.close("ws close");
120
+ if (this.socket) this.socket = undefined;
121
+ this.session = undefined;
122
+ this.scheduleReconnect();
123
+ }
124
+
125
+ private scheduleReconnect(): void {
126
+ if (this.closing || !this.config.reconnect || this.reconnectSuspended) return;
127
+ this.reconnectTimer?.();
128
+ const attempt = this.reconnectAttempts + 1;
129
+ this.reconnectAttempts = attempt;
130
+ const delay = RECONNECT_BACKOFF_MS[Math.min(attempt, RECONNECT_BACKOFF_MS.length) - 1];
131
+ this.ctx.logger.warn("DBK reverse disconnected, reconnecting in %dms (attempt=%d)", delay, attempt);
132
+ this.reconnectTimer = this.ctx.setTimeout(() => {
133
+ this.reconnectTimer = undefined;
134
+ this.connectReverse();
135
+ }, delay);
136
+ }
137
+ }
138
+
139
+ export function watchBots(ctx: Context, gateway: DbkGateway): void {
140
+ const emit = (type: BotChangeType, bot: Parameters<typeof toDbkBot>[0]) => {
141
+ if (bot.hidden && type !== BotChangeType.REMOVED) return;
142
+ gateway.emit(DbkEvent.BOT_CHANGED, create(BotChangedEventSchema, {
143
+ type,
144
+ bot: toDbkBot(bot),
145
+ }));
146
+ };
147
+ ctx.on("bot-added", (bot) => emit(BotChangeType.ADDED, bot));
148
+ ctx.on("bot-removed", (bot) => emit(BotChangeType.REMOVED, bot));
149
+ ctx.on("bot-status-updated", (bot) => emit(BotChangeType.UPDATED, bot));
150
+ }
151
+
152
+ function normalizePath(path: string): string {
153
+ const trimmed = path.trim() || "/dbk";
154
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
155
+ }
@@ -0,0 +1,275 @@
1
+ import { create } from "@bufbuild/protobuf";
2
+ import type { Bot, Context } from "koishi";
3
+ import { ErrorCode, TargetKind, TargetSchema, type Target } from "../gen/dbk/v1/common_pb";
4
+ import {
5
+ GetTargetResponseSchema,
6
+ TargetInfoSchema,
7
+ type GetTargetRequest,
8
+ type GetTargetResponse,
9
+ type TargetInfo,
10
+ } from "../gen/dbk/v1/rpc_pb";
11
+ import { pickAvatar } from "./avatar";
12
+ import { hasNestedChannels } from "./bots";
13
+ import { DbkRpcError } from "./error";
14
+
15
+ /** Satori `Universal.Channel.Type` (Koishi 4.18). TEXT=0 DIRECT=1 CATEGORY=2 VOICE=3. */
16
+ const CHANNEL_TEXT = 0;
17
+ const CHANNEL_DIRECT = 1;
18
+ const CHANNEL_CATEGORY = 2;
19
+ const CHANNEL_VOICE = 3;
20
+ /** Discord native thread types if an adapter leaks them instead of Satori TEXT. */
21
+ const DISCORD_THREAD_TYPES = new Set([10, 11, 12]);
22
+
23
+ interface ChannelLike {
24
+ id?: string | number;
25
+ type?: number;
26
+ name?: string;
27
+ parentId?: string;
28
+ parent_id?: string;
29
+ guildId?: string;
30
+ avatar?: string;
31
+ }
32
+
33
+ interface GuildLike {
34
+ id?: string | number;
35
+ name?: string;
36
+ title?: string;
37
+ first_name?: string;
38
+ type?: string;
39
+ guildId?: string;
40
+ guildName?: string;
41
+ avatar?: string;
42
+ }
43
+
44
+ interface UserLike {
45
+ id?: string | number;
46
+ name?: string;
47
+ nick?: string;
48
+ username?: string;
49
+ avatar?: string;
50
+ }
51
+
52
+ export async function getTarget(ctx: Context, request: GetTargetRequest): Promise<GetTargetResponse> {
53
+ const botKey = request.botKey.trim();
54
+ const target = request.target;
55
+
56
+ if (botKey) {
57
+ const bot = findBot(ctx, botKey);
58
+ if (!bot) {
59
+ throw new DbkRpcError(ErrorCode.NOT_FOUND, `bot not found: ${botKey}`);
60
+ }
61
+ const info = await resolveOnBot(bot, target);
62
+ return info ? resolved(info) : unresolved(target);
63
+ }
64
+
65
+ // Empty bot_key: search non-hidden, non-qq bots in ctx.bots order; first successful resolve wins.
66
+ for (const bot of listSearchableBots(ctx)) {
67
+ const info = await resolveOnBot(bot, target);
68
+ if (info) return resolved(info);
69
+ }
70
+ return unresolved(target);
71
+ }
72
+
73
+ function findBot(ctx: Context, botKey: string): Bot | undefined {
74
+ return ctx.bots.find((bot) => !bot.hidden && botKeyOf(bot) === botKey);
75
+ }
76
+
77
+ function listSearchableBots(ctx: Context): Bot[] {
78
+ return ctx.bots.filter((bot) => !bot.hidden && (bot.platform ?? "").toLowerCase() !== "qq");
79
+ }
80
+
81
+ function botKeyOf(bot: Bot): string {
82
+ const platform = bot.platform ?? "";
83
+ const selfId = bot.selfId ?? "";
84
+ return platform && selfId ? `${platform}:${selfId}` : "";
85
+ }
86
+
87
+ async function resolveOnBot(bot: Bot, target: Target | undefined): Promise<TargetInfo | undefined> {
88
+ const id = target?.id.trim() ?? "";
89
+ if (!id) return undefined;
90
+ const guildId = target?.guildId.trim() ?? "";
91
+ const kind = target?.kind ?? TargetKind.UNSPECIFIED;
92
+
93
+ try {
94
+ switch (kind) {
95
+ case TargetKind.USER:
96
+ return await resolveUser(bot, id, guildId);
97
+ case TargetKind.GROUP:
98
+ return await resolveGroup(bot, id, guildId);
99
+ case TargetKind.CHANNEL:
100
+ return await resolveChannelKind(bot, id, guildId, TargetKind.CHANNEL);
101
+ case TargetKind.THREAD:
102
+ return await resolveChannelKind(bot, id, guildId, TargetKind.THREAD);
103
+ default:
104
+ return (
105
+ (await resolveChannelKind(bot, id, guildId, TargetKind.UNSPECIFIED)) ??
106
+ (await resolveGroup(bot, id, guildId)) ??
107
+ (await resolveUser(bot, id, guildId))
108
+ );
109
+ }
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ async function resolveUser(bot: Bot, id: string, guildId: string): Promise<TargetInfo | undefined> {
116
+ const user = await callBot(bot, "getUser", () => bot.getUser(id, guildId || undefined));
117
+ if (user) return toUserInfo(bot, user, guildId);
118
+
119
+ const friend = await callFriend(bot, id);
120
+ if (friend) return toUserInfo(bot, friend, guildId);
121
+
122
+ const channel = await callBot(bot, "getChannel", () => bot.getChannel(id, guildId || undefined));
123
+ if (channel && channelKind(bot, channel, TargetKind.USER) === TargetKind.USER) {
124
+ return toChannelInfo(bot, channel, TargetKind.USER, guildId);
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ async function resolveGroup(bot: Bot, id: string, guildId: string): Promise<TargetInfo | undefined> {
130
+ if (!hasNestedChannels(bot)) {
131
+ const guild = await callBot(bot, "getGuild", () => bot.getGuild(id));
132
+ if (guild) {
133
+ const kind = kindFromGuild(bot, guild);
134
+ if (kind) return toGuildInfo(bot, guild, kind);
135
+ }
136
+ }
137
+ return resolveChannelKind(bot, id, guildId, TargetKind.GROUP);
138
+ }
139
+
140
+ async function resolveChannelKind(
141
+ bot: Bot,
142
+ id: string,
143
+ guildId: string,
144
+ requested: TargetKind,
145
+ ): Promise<TargetInfo | undefined> {
146
+ const channel = await callBot(bot, "getChannel", () => bot.getChannel(id, guildId || undefined));
147
+ if (!channel) return undefined;
148
+ const kind = channelKind(bot, channel, requested);
149
+ if (!kind) return undefined;
150
+ return toChannelInfo(bot, channel, kind, guildId);
151
+ }
152
+
153
+ function channelKind(bot: Bot, channel: ChannelLike, requested: TargetKind): TargetKind | undefined {
154
+ const nested = hasNestedChannels(bot);
155
+ const type = channel.type ?? CHANNEL_TEXT;
156
+ if (type === CHANNEL_DIRECT) return TargetKind.USER;
157
+ if (type === CHANNEL_CATEGORY || type === CHANNEL_VOICE) return undefined;
158
+ if (DISCORD_THREAD_TYPES.has(type) || requested === TargetKind.THREAD) {
159
+ if (requested === TargetKind.USER) return undefined;
160
+ if (requested === TargetKind.GROUP && nested) return undefined;
161
+ return TargetKind.THREAD;
162
+ }
163
+ if (requested === TargetKind.USER) return undefined;
164
+ if (!nested) {
165
+ if (requested === TargetKind.CHANNEL) return TargetKind.CHANNEL;
166
+ return TargetKind.GROUP;
167
+ }
168
+ if (requested === TargetKind.GROUP) return undefined;
169
+ return TargetKind.CHANNEL;
170
+ }
171
+
172
+ function kindFromGuild(bot: Bot, guild: GuildLike): TargetKind | undefined {
173
+ const chatType = guild.type;
174
+ if (chatType === "private") return TargetKind.USER;
175
+ if (chatType === "channel") return TargetKind.CHANNEL;
176
+ if (chatType === "group" || chatType === "supergroup") return TargetKind.GROUP;
177
+ if (hasNestedChannels(bot)) return undefined;
178
+ return TargetKind.GROUP;
179
+ }
180
+
181
+ async function toChannelInfo(
182
+ bot: Bot,
183
+ channel: ChannelLike,
184
+ kind: TargetKind,
185
+ requestGuildId: string,
186
+ ): Promise<TargetInfo> {
187
+ const id = stringifyId(channel.id);
188
+ const guildId = requestGuildId || stringifyId(channel.guildId);
189
+ const guild = guildId ? await lookupGuild(bot, guildId) : undefined;
190
+ return create(TargetInfoSchema, {
191
+ target: create(TargetSchema, { kind, id, guildId }),
192
+ name: channel.name?.trim() || id,
193
+ guildId: stringifyId(guild?.id) || guildId,
194
+ guildName: guildDisplayName(guild),
195
+ avatar: pickAvatar(channel, guild),
196
+ botKeys: [botKeyOf(bot)],
197
+ });
198
+ }
199
+
200
+ function toGuildInfo(bot: Bot, guild: GuildLike, kind: TargetKind): TargetInfo {
201
+ const id = stringifyId(guild.id) || stringifyId(guild.guildId);
202
+ const guildId = kind === TargetKind.USER ? "" : id;
203
+ return create(TargetInfoSchema, {
204
+ target: create(TargetSchema, { kind, id, guildId }),
205
+ name: guildDisplayName(guild) || id,
206
+ guildId,
207
+ guildName: kind === TargetKind.USER ? "" : guildDisplayName(guild),
208
+ avatar: pickAvatar(guild),
209
+ botKeys: [botKeyOf(bot)],
210
+ });
211
+ }
212
+
213
+ function toUserInfo(bot: Bot, user: UserLike, guildId: string): TargetInfo {
214
+ const id = stringifyId(user.id);
215
+ return create(TargetInfoSchema, {
216
+ target: create(TargetSchema, { kind: TargetKind.USER, id, guildId }),
217
+ name: user.nick?.trim() || user.name?.trim() || user.username?.trim() || id,
218
+ guildId,
219
+ guildName: "",
220
+ avatar: pickAvatar(user),
221
+ botKeys: [botKeyOf(bot)],
222
+ });
223
+ }
224
+
225
+ async function lookupGuild(bot: Bot, guildId: string): Promise<GuildLike | undefined> {
226
+ return callBot(bot, "getGuild", () => bot.getGuild(guildId));
227
+ }
228
+
229
+ function guildDisplayName(guild: GuildLike | undefined): string {
230
+ if (!guild) return "";
231
+ return guild.name?.trim() || guild.guildName?.trim() || guild.title?.trim() || guild.first_name?.trim() || "";
232
+ }
233
+
234
+ function stringifyId(value: string | number | undefined): string {
235
+ if (value === undefined || value === null) return "";
236
+ return String(value).trim();
237
+ }
238
+
239
+ function resolved(target: TargetInfo): GetTargetResponse {
240
+ return create(GetTargetResponseSchema, { target, unresolved: false });
241
+ }
242
+
243
+ function unresolved(requestTarget: Target | undefined): GetTargetResponse {
244
+ return create(GetTargetResponseSchema, {
245
+ unresolved: true,
246
+ target: requestTarget
247
+ ? create(TargetInfoSchema, {
248
+ target: requestTarget,
249
+ })
250
+ : undefined,
251
+ });
252
+ }
253
+
254
+ async function callBot<K extends string, T>(
255
+ bot: Bot,
256
+ method: K,
257
+ fn: () => Promise<T>,
258
+ ): Promise<T | undefined> {
259
+ if (typeof (bot as unknown as Record<string, unknown>)[method] !== "function") return undefined;
260
+ try {
261
+ return await fn();
262
+ } catch {
263
+ return undefined;
264
+ }
265
+ }
266
+
267
+ async function callFriend(bot: Bot, id: string): Promise<UserLike | undefined> {
268
+ const getFriend = (bot as Bot & { getFriend?: (userId: string) => Promise<UserLike> }).getFriend;
269
+ if (typeof getFriend !== "function") return undefined;
270
+ try {
271
+ return await getFriend.call(bot, id);
272
+ } catch {
273
+ return undefined;
274
+ }
275
+ }
@@ -0,0 +1,29 @@
1
+ import { create } from "@bufbuild/protobuf";
2
+ import type { Context } from "koishi";
3
+ import {
4
+ HelloResponseSchema,
5
+ ListBotsResponseSchema,
6
+ } from "../gen/dbk/v1/rpc_pb";
7
+ import { listBots } from "./bots";
8
+ import { getTarget } from "./get-target";
9
+ import { listTargets } from "./list-targets";
10
+ import { DbkMethod, GATEWAY_VERSION, PROTOCOL_VERSION, type DbkGatewayHandlers } from "./protocol";
11
+ import { recallMessage } from "./recall";
12
+ import { sendMessage } from "./send";
13
+
14
+ export function createGatewayHandlers(ctx: Context): DbkGatewayHandlers {
15
+ const snapshot = () => create(ListBotsResponseSchema, { bots: listBots(ctx) });
16
+
17
+ return {
18
+ [DbkMethod.SESSION_HELLO]: () => create(HelloResponseSchema, {
19
+ protocolVersion: PROTOCOL_VERSION,
20
+ gatewayVersion: GATEWAY_VERSION,
21
+ bots: snapshot().bots,
22
+ }),
23
+ [DbkMethod.BOTS_LIST]: snapshot,
24
+ [DbkMethod.TARGETS_LIST]: (request) => listTargets(ctx, request),
25
+ [DbkMethod.TARGETS_GET]: (request) => getTarget(ctx, request),
26
+ [DbkMethod.MESSAGE_SEND]: (request) => sendMessage(ctx, request),
27
+ [DbkMethod.MESSAGE_RECALL]: (request) => recallMessage(ctx, request),
28
+ };
29
+ }