koishi-plugin-minecraft-adapter 1.0.0 → 1.0.2

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/lib/index.d.ts CHANGED
@@ -27,10 +27,32 @@ export interface QueqiaoPlayer {
27
27
  health?: number;
28
28
  max_health?: number;
29
29
  experience_level?: number;
30
+ experience_progress?: number;
31
+ total_experience?: number;
32
+ walk_speed?: number;
30
33
  x?: number;
31
34
  y?: number;
32
35
  z?: number;
33
36
  }
37
+ /**
38
+ * 鹊桥 V2 Death 对象
39
+ */
40
+ export interface QueqiaoDeath {
41
+ key?: string;
42
+ args?: string;
43
+ text?: string;
44
+ }
45
+ /**
46
+ * 鹊桥 V2 Achievement 对象
47
+ */
48
+ export interface QueqiaoAchievement {
49
+ display?: {
50
+ title?: string;
51
+ description?: string;
52
+ frame?: string;
53
+ };
54
+ text?: string;
55
+ }
34
56
  /**
35
57
  * 鹊桥 V2 事件基础结构
36
58
  */
@@ -49,7 +71,7 @@ export interface PlayerChatEvent extends QueqiaoEventBase {
49
71
  post_type: 'message';
50
72
  event_name: 'PlayerChatEvent';
51
73
  message: string;
52
- raw_message: string;
74
+ rawMessage?: string;
53
75
  message_id?: string;
54
76
  player: QueqiaoPlayer;
55
77
  }
@@ -60,7 +82,7 @@ export interface PlayerCommandEvent extends QueqiaoEventBase {
60
82
  post_type: 'message';
61
83
  event_name: 'PlayerCommandEvent';
62
84
  command: string;
63
- raw_message: string;
85
+ rawMessage?: string;
64
86
  message_id?: string;
65
87
  player: QueqiaoPlayer;
66
88
  }
@@ -89,7 +111,7 @@ export interface PlayerDeathEvent extends QueqiaoEventBase {
89
111
  post_type: 'notice';
90
112
  event_name: 'PlayerDeathEvent';
91
113
  sub_type: 'player_death';
92
- death_message?: string;
114
+ death?: QueqiaoDeath;
93
115
  player: QueqiaoPlayer;
94
116
  }
95
117
  /**
@@ -99,7 +121,7 @@ export interface PlayerAchievementEvent extends QueqiaoEventBase {
99
121
  post_type: 'notice';
100
122
  event_name: 'PlayerAchievementEvent';
101
123
  sub_type: 'player_achievement';
102
- achievement?: string;
124
+ achievement?: QueqiaoAchievement;
103
125
  player: QueqiaoPlayer;
104
126
  }
105
127
  export type QueqiaoEvent = PlayerChatEvent | PlayerCommandEvent | PlayerJoinEvent | PlayerQuitEvent | PlayerDeathEvent | PlayerAchievementEvent;
@@ -156,6 +178,12 @@ export declare class MinecraftBot<C extends Context = Context> extends Bot<C, Mi
156
178
  */
157
179
  executeCommand(command: string): Promise<string>;
158
180
  }
181
+ export interface ChatImageConfig {
182
+ /** 是否启用 ChatImage CICode 生成(出站方向),默认关闭 */
183
+ enabled?: boolean;
184
+ /** 图片在聊天栏中的默认显示名称 */
185
+ defaultImageName?: string;
186
+ }
159
187
  export interface MinecraftAdapterConfig {
160
188
  bots: MinecraftBotConfig[];
161
189
  debug?: boolean;
@@ -165,8 +193,11 @@ export interface MinecraftAdapterConfig {
165
193
  maxReconnectAttempts?: number;
166
194
  /** 是否在消息前添加默认前缀 [鹊桥],默认不添加(由服务端配置) */
167
195
  useMessagePrefix?: boolean;
196
+ /** ChatImage 集成配置 */
197
+ chatImage?: ChatImageConfig;
168
198
  }
169
199
  export declare class MinecraftAdapter<C extends Context = Context> extends Adapter<C, MinecraftBot<C>> {
200
+ static reusable: boolean;
170
201
  private rconConnections;
171
202
  private wsConnections;
172
203
  private reconnectAttempts;
@@ -178,15 +209,25 @@ export declare class MinecraftAdapter<C extends Context = Context> extends Adapt
178
209
  private reconnectInterval;
179
210
  private maxReconnectAttempts;
180
211
  private useMessagePrefix;
212
+ private chatImageEnabled;
213
+ private chatImageDefaultName;
181
214
  constructor(ctx: C, config: MinecraftAdapterConfig);
182
215
  /**
183
216
  * 生成唯一的请求 ID
184
217
  */
185
218
  private generateEcho;
219
+ private toTextComponent;
220
+ private extractRawText;
186
221
  /**
187
- * 将消息转换为 Minecraft 文本组件格式
222
+ * 生成 ChatImage CICode: [[CICode,url=<url>,name=<name>]]
188
223
  */
189
- private toTextComponent;
224
+ private buildCICode;
225
+ /**
226
+ * 解析出站消息中的 Koishi 元素标签 (<img src="..."/>, <image url="..."/>)
227
+ */
228
+ private parseOutboundMessage;
229
+ private extractAttr;
230
+ private decodeHtmlEntities;
190
231
  /**
191
232
  * 发送 WebSocket API 请求并等待响应
192
233
  */
@@ -198,14 +239,18 @@ export declare class MinecraftAdapter<C extends Context = Context> extends Adapt
198
239
  private getWebSocketCloseCode;
199
240
  private getWebSocketStateString;
200
241
  private connectWebSocket;
242
+ private sessionCounter;
201
243
  /**
202
244
  * 根据鹊桥 V2 事件创建 Koishi Session
203
245
  */
204
246
  private createSession;
205
247
  /**
206
248
  * 解析消息文本为 Koishi 元素数组
249
+ * 入站方向始终解析 CICode 和裸图片 URL(不受 chatImage.enabled 控制)
207
250
  */
208
251
  private parseMessageToElements;
252
+ private extractCICodeParam;
253
+ private addTextElements;
209
254
  /**
210
255
  * 发送私聊消息 (send_private_msg)
211
256
  */
package/lib/index.js CHANGED
@@ -14,6 +14,7 @@ class MinecraftBot extends koishi_1.Bot {
14
14
  constructor(ctx, config) {
15
15
  super(ctx, config, 'minecraft');
16
16
  this.selfId = config.selfId;
17
+ this.platform = 'minecraft';
17
18
  }
18
19
  /**
19
20
  * 发送消息到频道或私聊
@@ -56,6 +57,7 @@ class MinecraftBot extends koishi_1.Bot {
56
57
  }
57
58
  exports.MinecraftBot = MinecraftBot;
58
59
  class MinecraftAdapter extends koishi_1.Adapter {
60
+ static reusable = true;
59
61
  rconConnections = new Map();
60
62
  wsConnections = new Map();
61
63
  reconnectAttempts = new Map();
@@ -67,6 +69,8 @@ class MinecraftAdapter extends koishi_1.Adapter {
67
69
  reconnectInterval;
68
70
  maxReconnectAttempts;
69
71
  useMessagePrefix;
72
+ chatImageEnabled;
73
+ chatImageDefaultName;
70
74
  constructor(ctx, config) {
71
75
  super(ctx);
72
76
  try {
@@ -76,6 +80,8 @@ class MinecraftAdapter extends koishi_1.Adapter {
76
80
  this.reconnectInterval = config.reconnectInterval ?? 5000;
77
81
  this.maxReconnectAttempts = config.maxReconnectAttempts ?? 10;
78
82
  this.useMessagePrefix = config.useMessagePrefix ?? false;
83
+ this.chatImageEnabled = config.chatImage?.enabled ?? false;
84
+ this.chatImageDefaultName = config.chatImage?.defaultImageName ?? '图片';
79
85
  if (this.debug) {
80
86
  logger.info(`[DEBUG] MinecraftAdapter initialized with config:`, {
81
87
  debug: this.debug,
@@ -97,7 +103,7 @@ class MinecraftAdapter extends koishi_1.Adapter {
97
103
  bot.adapter = this;
98
104
  this.bots.push(bot);
99
105
  // 初始化 RCON 连接
100
- if (botConfig.rcon) {
106
+ if (botConfig.rcon && botConfig.rcon.host && botConfig.rcon.port && botConfig.rcon.password) {
101
107
  try {
102
108
  if (this.debug) {
103
109
  logger.info(`[DEBUG] Connecting RCON for bot ${botConfig.selfId} to ${botConfig.rcon.host}:${botConfig.rcon.port}`);
@@ -121,7 +127,12 @@ class MinecraftAdapter extends koishi_1.Adapter {
121
127
  }
122
128
  else {
123
129
  if (this.debug) {
124
- logger.info(`[DEBUG] No RCON config for bot ${botConfig.selfId}`);
130
+ if (botConfig.rcon) {
131
+ logger.info(`[DEBUG] RCON config incomplete for bot ${botConfig.selfId}, skipping (need host, port, password)`);
132
+ }
133
+ else {
134
+ logger.info(`[DEBUG] No RCON config for bot ${botConfig.selfId}`);
135
+ }
125
136
  }
126
137
  }
127
138
  // 初始化 WebSocket 连接
@@ -150,53 +161,115 @@ class MinecraftAdapter extends koishi_1.Adapter {
150
161
  generateEcho() {
151
162
  return `koishi_${Date.now()}_${++this.requestCounter}`;
152
163
  }
153
- /**
154
- * 将消息转换为 Minecraft 文本组件格式
155
- */
156
164
  toTextComponent(message) {
157
- const extractText = (item) => {
158
- if (item == null)
159
- return '';
160
- if (typeof item === 'string')
161
- return item;
162
- if (typeof item === 'number' || typeof item === 'boolean')
163
- return String(item);
164
- if (Array.isArray(item))
165
- return item.map(extractText).join('');
166
- if (typeof item === 'object') {
167
- if (item.attrs && typeof item.attrs.content === 'string')
168
- return item.attrs.content;
169
- if (typeof item.content === 'string')
170
- return item.content;
171
- if (typeof item.text === 'string')
172
- return item.text;
173
- if (item.children)
174
- return extractText(item.children);
165
+ const raw = this.extractRawText(message);
166
+ if (!raw)
167
+ return [{ text: '' }];
168
+ const segments = this.parseOutboundMessage(raw);
169
+ if (segments.length === 0)
170
+ return [{ text: '' }];
171
+ const fullText = segments.map(seg => {
172
+ if (seg.type === 'image') {
173
+ if (this.chatImageEnabled) {
174
+ return this.buildCICode(seg.url, seg.name);
175
+ }
176
+ return seg.url;
177
+ }
178
+ return seg.text;
179
+ }).join('');
180
+ return [{ text: fullText }];
181
+ }
182
+ extractRawText(message) {
183
+ if (message == null)
184
+ return '';
185
+ if (typeof message === 'string')
186
+ return message;
187
+ if (typeof message === 'number' || typeof message === 'boolean')
188
+ return String(message);
189
+ if (Array.isArray(message))
190
+ return message.map(item => this.extractRawText(item)).join('');
191
+ if (typeof message === 'object') {
192
+ if (message.attrs && typeof message.attrs.content === 'string')
193
+ return message.attrs.content;
194
+ if (typeof message.content === 'string')
195
+ return message.content;
196
+ if (typeof message.text === 'string')
197
+ return message.text;
198
+ if (message.children)
199
+ return this.extractRawText(message.children);
200
+ try {
201
+ if (typeof message.toString === 'function' && message.toString !== Object.prototype.toString) {
202
+ const s = message.toString();
203
+ if (typeof s === 'string' && s !== '[object Object]')
204
+ return s;
205
+ }
206
+ }
207
+ catch (e) {
208
+ // ignore
209
+ }
210
+ let acc = '';
211
+ for (const key in message) {
175
212
  try {
176
- if (typeof item.toString === 'function' && item.toString !== Object.prototype.toString) {
177
- const s = item.toString();
178
- if (typeof s === 'string' && s !== '[object Object]')
179
- return s;
180
- }
213
+ acc += this.extractRawText(message[key]);
181
214
  }
182
215
  catch (e) {
183
216
  // ignore
184
217
  }
185
- let acc = '';
186
- for (const key in item) {
187
- try {
188
- acc += extractText(item[key]);
189
- }
190
- catch (e) {
191
- // ignore
192
- }
193
- }
194
- return acc;
195
218
  }
196
- return String(item);
197
- };
198
- const text = extractText(message);
199
- return { text };
219
+ return acc;
220
+ }
221
+ return String(message);
222
+ }
223
+ /**
224
+ * 生成 ChatImage CICode: [[CICode,url=<url>,name=<name>]]
225
+ */
226
+ buildCICode(url, name) {
227
+ const displayName = name || this.chatImageDefaultName;
228
+ return `[[CICode,url=${url},name=${displayName}]]`;
229
+ }
230
+ /**
231
+ * 解析出站消息中的 Koishi 元素标签 (<img src="..."/>, <image url="..."/>)
232
+ */
233
+ parseOutboundMessage(content) {
234
+ const segments = [];
235
+ const imgTagRegex = /<(?:img|image)\s+([^>]*?)\/?>(?:<\/(?:img|image)>)?/gi;
236
+ let lastIndex = 0;
237
+ let match;
238
+ while ((match = imgTagRegex.exec(content)) !== null) {
239
+ if (match.index > lastIndex) {
240
+ segments.push({ type: 'text', text: content.slice(lastIndex, match.index) });
241
+ }
242
+ const attrs = match[1];
243
+ const rawUrl = this.extractAttr(attrs, 'src') || this.extractAttr(attrs, 'url');
244
+ if (rawUrl) {
245
+ const url = this.decodeHtmlEntities(rawUrl);
246
+ const name = this.extractAttr(attrs, 'alt') || this.extractAttr(attrs, 'name') || this.extractAttr(attrs, 'summary');
247
+ segments.push({ type: 'image', url, name: name || undefined });
248
+ }
249
+ lastIndex = match.index + match[0].length;
250
+ }
251
+ if (lastIndex < content.length) {
252
+ segments.push({ type: 'text', text: content.slice(lastIndex) });
253
+ }
254
+ return segments;
255
+ }
256
+ // 从 HTML 属性字符串中提取指定属性值: name="val" | name='val' | name=val
257
+ extractAttr(attrs, name) {
258
+ const regex = new RegExp(`${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|(\\S+))`, 'i');
259
+ const match = regex.exec(attrs);
260
+ if (!match)
261
+ return null;
262
+ return match[1] ?? match[2] ?? match[3] ?? null;
263
+ }
264
+ decodeHtmlEntities(str) {
265
+ return str
266
+ .replace(/&amp;/g, '&')
267
+ .replace(/&lt;/g, '<')
268
+ .replace(/&gt;/g, '>')
269
+ .replace(/&quot;/g, '"')
270
+ .replace(/&#39;/g, "'")
271
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
272
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10)));
200
273
  }
201
274
  /**
202
275
  * 发送 WebSocket API 请求并等待响应
@@ -393,6 +466,7 @@ class MinecraftAdapter extends koishi_1.Adapter {
393
466
  }
394
467
  });
395
468
  }
469
+ sessionCounter = 0;
396
470
  /**
397
471
  * 根据鹊桥 V2 事件创建 Koishi Session
398
472
  */
@@ -401,10 +475,10 @@ class MinecraftAdapter extends koishi_1.Adapter {
401
475
  logger.info(`[DEBUG] Creating session for event: ${payload.event_name}, payload:`, payload);
402
476
  }
403
477
  const event = {
404
- sn: Date.now(),
478
+ sn: ++this.sessionCounter,
405
479
  login: {
406
480
  sn: bot.sn,
407
- adapter: 'minecraft-adapter',
481
+ adapter: bot.adapterName,
408
482
  user: bot.user || { id: bot.selfId, name: bot.selfId },
409
483
  platform: 'minecraft',
410
484
  selfId: bot.selfId,
@@ -551,11 +625,12 @@ class MinecraftAdapter extends koishi_1.Adapter {
551
625
  id: deathEvent.server_name || 'minecraft',
552
626
  name: deathEvent.server_name || 'Minecraft Server',
553
627
  };
554
- // 附加死亡消息
555
- if (deathEvent.death_message) {
628
+ // 从 death 对象提取死亡消息
629
+ const deathText = deathEvent.death?.text || '';
630
+ if (deathText) {
556
631
  event.message = {
557
632
  id: Date.now().toString(),
558
- content: deathEvent.death_message,
633
+ content: deathText,
559
634
  timestamp: payload.timestamp * 1000,
560
635
  };
561
636
  }
@@ -578,11 +653,14 @@ class MinecraftAdapter extends koishi_1.Adapter {
578
653
  id: achieveEvent.server_name || 'minecraft',
579
654
  name: achieveEvent.server_name || 'Minecraft Server',
580
655
  };
581
- // 附加成就信息
582
- if (achieveEvent.achievement) {
656
+ // 从 achievement 对象提取成就信息
657
+ const achievementText = achieveEvent.achievement?.display?.title
658
+ || achieveEvent.achievement?.text
659
+ || '';
660
+ if (achievementText) {
583
661
  event.message = {
584
662
  id: Date.now().toString(),
585
- content: achieveEvent.achievement,
663
+ content: achievementText,
586
664
  timestamp: payload.timestamp * 1000,
587
665
  };
588
666
  }
@@ -612,20 +690,68 @@ class MinecraftAdapter extends koishi_1.Adapter {
612
690
  }
613
691
  /**
614
692
  * 解析消息文本为 Koishi 元素数组
693
+ * 入站方向始终解析 CICode 和裸图片 URL(不受 chatImage.enabled 控制)
615
694
  */
616
695
  parseMessageToElements(messageText) {
617
696
  if (!messageText)
618
697
  return [];
698
+ const elements = [];
699
+ // CICode: [[CICode,url=<url>(,name=<name>)(,nsfw=<bool>)(,pre=<p>)(,suf=<s>)]]
700
+ // 裸图片 URL: https?://....(png|jpg|jpeg|gif|bmp|ico|jfif|webp)
701
+ const ciCodePattern = /\[\[CICode,([^\]]*)\]\]/g;
702
+ const imageUrlPattern = /https?:\/\/\S+\.(?:png|jpe?g|gif|bmp|ico|jfif|webp)(?:\?[^\s]*)?/gi;
703
+ const combinedPattern = new RegExp(`(${ciCodePattern.source})|(${imageUrlPattern.source})`, 'gi');
704
+ let lastIndex = 0;
705
+ let match;
706
+ while ((match = combinedPattern.exec(messageText)) !== null) {
707
+ if (match.index > lastIndex) {
708
+ const textBefore = messageText.slice(lastIndex, match.index);
709
+ this.addTextElements(elements, textBefore);
710
+ }
711
+ if (match[1]) {
712
+ const params = match[2];
713
+ const url = this.extractCICodeParam(params, 'url');
714
+ if (url) {
715
+ const el = { type: 'img', attrs: { src: url } };
716
+ const name = this.extractCICodeParam(params, 'name');
717
+ if (name)
718
+ el.attrs.alt = name;
719
+ el.toString = function () { return `[${this.attrs.alt || '图片'}]`; };
720
+ elements.push(el);
721
+ }
722
+ }
723
+ else {
724
+ const url = match[0];
725
+ const el = { type: 'img', attrs: { src: url } };
726
+ el.toString = function () { return `[图片]`; };
727
+ elements.push(el);
728
+ }
729
+ lastIndex = match.index + match[0].length;
730
+ }
731
+ if (lastIndex < messageText.length) {
732
+ const textAfter = messageText.slice(lastIndex);
733
+ this.addTextElements(elements, textAfter);
734
+ }
735
+ if (elements.length === 0) {
736
+ this.addTextElements(elements, messageText);
737
+ }
738
+ return elements;
739
+ }
740
+ // 提取 CICode 参数: "url=xxx,name=yyy" => { url: "xxx", name: "yyy" }
741
+ extractCICodeParam(params, key) {
742
+ const regex = new RegExp(`(?:^|,)${key}=([^,]*)`, 'i');
743
+ const match = regex.exec(params);
744
+ return match ? match[1] : null;
745
+ }
746
+ addTextElements(elements, text) {
619
747
  const tokens = this.tokenizeMode === 'none'
620
- ? [messageText]
621
- : messageText.split(/(\s+)/).filter((s) => s.length > 0);
622
- return tokens.map((token) => {
748
+ ? [text]
749
+ : text.split(/(\s+)/).filter((s) => s.length > 0);
750
+ for (const token of tokens) {
623
751
  const el = { type: 'text', attrs: { content: token } };
624
- el.toString = function () {
625
- return this.attrs?.content ?? '';
626
- };
627
- return el;
628
- });
752
+ el.toString = function () { return this.attrs?.content ?? ''; };
753
+ elements.push(el);
754
+ }
629
755
  }
630
756
  /**
631
757
  * 发送私聊消息 (send_private_msg)
@@ -768,7 +894,7 @@ class MinecraftAdapter extends koishi_1.Adapter {
768
894
  data.subtitle = subtitleComponent;
769
895
  if (player)
770
896
  data.nickname = player;
771
- await this.sendApiRequest(ws, 'title', data);
897
+ await this.sendApiRequest(ws, 'send_title', data);
772
898
  return;
773
899
  }
774
900
  catch (error) {
@@ -789,7 +915,7 @@ class MinecraftAdapter extends koishi_1.Adapter {
789
915
  const data = { message: messageComponent };
790
916
  if (player)
791
917
  data.nickname = player;
792
- await this.sendApiRequest(ws, 'action_bar', data);
918
+ await this.sendApiRequest(ws, 'send_actionbar', data);
793
919
  return;
794
920
  }
795
921
  catch (error) {
@@ -844,6 +970,10 @@ exports.MinecraftAdapter = MinecraftAdapter;
844
970
  reconnectInterval: koishi_1.Schema.number().description('重连间隔时间(ms)').default(5000),
845
971
  maxReconnectAttempts: koishi_1.Schema.number().description('最大重连尝试次数').default(10),
846
972
  useMessagePrefix: koishi_1.Schema.boolean().description('是否在消息前添加默认前缀(由服务端配置)').default(false),
973
+ chatImage: koishi_1.Schema.object({
974
+ enabled: koishi_1.Schema.boolean().description('启用 ChatImage CICode 图片发送(需客户端安装 ChatImage Mod)').default(false),
975
+ defaultImageName: koishi_1.Schema.string().description('图片在聊天栏中的默认显示名称').default('图片'),
976
+ }).description('ChatImage 图片显示集成配置'),
847
977
  bots: koishi_1.Schema.array(koishi_1.Schema.object({
848
978
  selfId: koishi_1.Schema.string().description('机器人 ID(唯一标识)').required(),
849
979
  serverName: koishi_1.Schema.string().description('服务器名称(需与鹊桥 config.yml 中的 server_name 一致)'),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-minecraft-adapter",
3
3
  "description": "Minecraft adapter for Koishi based on QueQiao V2 protocol",
4
- "version": "1.0.0",
4
+ "version": "1.0.2",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [