Skip to content

Commit 02c846f

Browse files
author
linyuan.yang
committed
支持 onebot
1 parent 09f4089 commit 02c846f

14 files changed

Lines changed: 654 additions & 10 deletions

File tree

packages/admin/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,23 @@
77
"build": "vite build"
88
},
99
"dependencies": {
10+
"@sbot/chat-ui": "workspace:*",
1011
"@tiptap/extension-dropcursor": "^3.22.4",
1112
"@tiptap/extension-image": "^3.22.4",
1213
"@tiptap/extension-placeholder": "^3.22.4",
1314
"@tiptap/starter-kit": "^3.22.4",
1415
"@tiptap/vue-3": "^3.22.4",
1516
"axios": "catalog:",
1617
"marked": "^18.0.2",
17-
"@sbot/chat-ui": "workspace:*",
18+
"qrcode": "^1.5.4",
1819
"sbot.commons": "workspace:*",
1920
"vue": "^3.5.33",
2021
"vue-i18n": "^11.3.2",
2122
"vue-router": "^5.0.5"
2223
},
2324
"devDependencies": {
2425
"@types/node": "catalog:",
26+
"@types/qrcode": "^1.5.6",
2527
"@vitejs/plugin-vue": "^6.0.6",
2628
"typescript": "catalog:",
2729
"vite": "^8.0.9",

packages/admin/src/views/ChannelsView.vue

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useResponsive } from '../composables/useResponsive'
55
import { apiFetch } from '@/api'
66
import { store } from '@/store'
77
import { useToast } from '@/composables/useToast'
8+
import QRCode from 'qrcode'
89
import type { ChannelConfig } from '@/types'
910
import SaverViewModal from './modals/SaverViewModal.vue'
1011
import PathPickerModal from './modals/PathPickerModal.vue'
@@ -229,7 +230,7 @@ function formatUserInfo(raw: string) {
229230
const passwordVisible = ref<Record<string, boolean>>({})
230231
231232
// --- Action field support (QR login etc.) ---
232-
const actionState = ref<Record<string, { loading: boolean; qrUrl?: string; qrType?: 'image' | 'link'; status?: string; error?: string }>>({})
233+
const actionState = ref<Record<string, { loading: boolean; qrUrl?: string; qrLink?: string; qrType?: 'image' | 'link'; status?: string; error?: string }>>({})
233234
234235
function clearActionState() {
235236
actionState.value = {}
@@ -248,7 +249,14 @@ async function triggerAction(key: string) {
248249
const res = await apiFetch(url, 'POST', form.value.config)
249250
const data = res.data
250251
if (data?.url) {
251-
actionState.value[key] = { loading: false, qrUrl: data.url, qrType: data.type || 'link', status: 'wait' }
252+
let qrUrl = data.url
253+
let qrLink: string | undefined
254+
const qrType = data.type || 'link'
255+
if (qrType === 'link') {
256+
qrLink = data.url
257+
qrUrl = await QRCode.toDataURL(data.url, { width: 200, margin: 2 })
258+
}
259+
actionState.value[key] = { loading: false, qrUrl, qrLink, qrType: 'image', status: 'wait' }
252260
await waitForQRConfirm(key, channelId, type)
253261
} else {
254262
actionState.value[key] = { loading: false, status: 'done' }
@@ -624,8 +632,8 @@ async function refresh() {
624632
<button class="btn-outline" style="align-self:flex-start" :disabled="actionState[key]?.loading" @click="triggerAction(key as string)">
625633
{{ actionState[key]?.loading ? '...' : field.label }}
626634
</button>
627-
<img v-if="actionState[key]?.qrUrl && actionState[key]?.qrType === 'image'" :src="actionState[key]!.qrUrl" style="width:200px;height:200px;border:1px solid #e8e6e3;border-radius:8px" />
628-
<a v-else-if="actionState[key]?.qrUrl && actionState[key]?.qrType === 'link'" :href="actionState[key]!.qrUrl" target="_blank" class="btn-outline" style="align-self:flex-start;text-align:center">打开二维码链接</a>
635+
<img v-if="actionState[key]?.qrUrl" :src="actionState[key]!.qrUrl" style="width:200px;height:200px;border:1px solid #e8e6e3;border-radius:8px" />
636+
<a v-if="actionState[key]?.qrLink" :href="actionState[key]!.qrLink" target="_blank" style="font-size:11px;color:#888;align-self:flex-start">打开二维码链接</a>
629637
<span v-if="actionState[key]?.status === 'scaned'" style="font-size:12px;color:#e6a700">已扫码,请在手机上确认...</span>
630638
<span v-if="actionState[key]?.status === 'wait' && actionState[key]?.qrUrl" style="font-size:12px;color:#888">请扫描二维码</span>
631639
<span v-if="actionState[key]?.status === 'confirmed'" style="font-size:12px;color:#16a34a">登录成功</span>

packages/channel.onebot/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/node_modules
2+
/dist
3+
/package-lock.json
4+
/tsconfig.tsbuildinfo
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "channel.onebot",
3+
"version": "0.0.1",
4+
"description": "OneBot v11 reverse WebSocket channel integration",
5+
"main": "dist/index.js",
6+
"types": "dist/index.d.ts",
7+
"scripts": {
8+
"build": "rimraf dist && tsc -b --force"
9+
},
10+
"dependencies": {
11+
"channel.base": "workspace:*",
12+
"ws": "catalog:"
13+
},
14+
"private": true,
15+
"devDependencies": {
16+
"@types/node": "catalog:",
17+
"@types/ws": "catalog:",
18+
"rimraf": "catalog:",
19+
"typescript": "catalog:"
20+
}
21+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { AbstractChatProvider, parseMessages2Text, GlobalLoggerService } from 'channel.base';
2+
import type { OnebotService } from './OnebotService';
3+
4+
const getLogger = () => GlobalLoggerService.getLogger('OnebotChatProvider.ts');
5+
6+
export class OnebotChatProvider extends AbstractChatProvider {
7+
constructor(
8+
private service: OnebotService,
9+
private target: { userId?: number; groupId?: number },
10+
) {
11+
super();
12+
}
13+
14+
protected async onMessagesUpdated(): Promise<void> {}
15+
16+
async finish(): Promise<void> {
17+
const text = parseMessages2Text(this.messages);
18+
if (!text) return;
19+
try {
20+
await this.service.sendTextMessage(this.target, text);
21+
} catch (e: any) {
22+
getLogger()?.error(`finish error: ${e.message}`, e.stack);
23+
}
24+
}
25+
}
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import os from 'node:os';
2+
import path from 'node:path';
3+
import fs from 'node:fs/promises';
4+
import { WebSocketServer, WebSocket } from 'ws';
5+
import type { IncomingMessage } from 'node:http';
6+
import {
7+
IChannelService, ChannelSessionHandler, SessionService,
8+
readImageAsDataUrl, isEmptyContent,
9+
type ChannelMessageArgs, type ILogger, type MessageContent,
10+
} from 'channel.base';
11+
import { OnebotSessionHandler } from './OnebotSessionHandler';
12+
13+
export interface OnebotMessageArgs extends ChannelMessageArgs {
14+
messageType: 'private' | 'group';
15+
userId: number;
16+
groupId?: number;
17+
messageId: number;
18+
nickname: string;
19+
}
20+
21+
export interface OnebotServiceOptions {
22+
wsHost: string;
23+
wsPort: number;
24+
accessToken?: string;
25+
requireMention: boolean;
26+
logger?: ILogger;
27+
filterEvent: (eventId: string) => Promise<boolean>;
28+
onReceiveMessage: (userId: string, args: OnebotMessageArgs, query: MessageContent) => Promise<void>;
29+
}
30+
31+
interface PendingCall {
32+
resolve: (data: any) => void;
33+
reject: (err: Error) => void;
34+
}
35+
36+
export class OnebotService implements IChannelService {
37+
private wss: WebSocketServer | null = null;
38+
private connections = new Set<WebSocket>();
39+
private pendingCalls = new Map<string, PendingCall>();
40+
private selfId: string = '';
41+
private logger?: ILogger;
42+
private options: OnebotServiceOptions;
43+
44+
constructor(options: OnebotServiceOptions) {
45+
this.options = options;
46+
this.logger = options.logger;
47+
}
48+
49+
createSessionHandler(session: SessionService): ChannelSessionHandler {
50+
return new OnebotSessionHandler(session, this);
51+
}
52+
53+
dispose() {
54+
for (const ws of this.connections) {
55+
try { ws.close(); } catch (_) {}
56+
}
57+
this.connections.clear();
58+
if (this.wss) {
59+
this.wss.close();
60+
this.wss = null;
61+
}
62+
}
63+
64+
start() {
65+
const { wsHost, wsPort, accessToken } = this.options;
66+
this.wss = new WebSocketServer({ host: wsHost, port: wsPort });
67+
this.logger?.info(`OneBot WS server listening on ${wsHost}:${wsPort}`);
68+
69+
this.wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
70+
if (accessToken && !this.verifyToken(req, accessToken)) {
71+
this.logger?.warn('OneBot WS connection rejected: invalid token');
72+
ws.close(4001, 'Unauthorized');
73+
return;
74+
}
75+
76+
this.logger?.info('OneBot WS client connected');
77+
this.connections.add(ws);
78+
79+
ws.on('message', (raw: Buffer) => {
80+
try {
81+
const data = JSON.parse(raw.toString());
82+
if (data.echo) {
83+
this.handleEchoResponse(data);
84+
} else {
85+
this.handleEvent(data);
86+
}
87+
} catch (e: any) {
88+
this.logger?.error(`OneBot WS message parse error: ${e.message}`);
89+
}
90+
});
91+
92+
ws.on('close', () => {
93+
this.connections.delete(ws);
94+
this.logger?.info('OneBot WS client disconnected');
95+
});
96+
97+
ws.on('error', (err) => {
98+
this.logger?.error(`OneBot WS error: ${err.message}`);
99+
});
100+
});
101+
102+
this.wss.on('error', (err) => {
103+
this.logger?.error(`OneBot WS server error: ${err.message}`);
104+
});
105+
}
106+
107+
private verifyToken(req: IncomingMessage, token: string): boolean {
108+
const auth = req.headers['authorization'] ?? '';
109+
if (auth === `Bearer ${token}` || auth === `Token ${token}`) return true;
110+
const url = new URL(req.url ?? '', `http://${req.headers.host}`);
111+
return url.searchParams.get('access_token') === token;
112+
}
113+
114+
private handleEchoResponse(data: any) {
115+
const pending = this.pendingCalls.get(data.echo);
116+
if (pending) {
117+
this.pendingCalls.delete(data.echo);
118+
if (data.retcode === 0) {
119+
pending.resolve(data.data);
120+
} else {
121+
pending.reject(new Error(`OneBot API error: ${data.msg ?? data.wording ?? 'unknown'} (retcode=${data.retcode})`));
122+
}
123+
}
124+
}
125+
126+
private handleEvent(data: any) {
127+
if (data.post_type === 'meta_event') {
128+
this.handleMetaEvent(data);
129+
} else if (data.post_type === 'message') {
130+
this.handleMessageEvent(data).catch((e: any) => {
131+
this.logger?.error(`handleMessageEvent error: ${e.stack}`);
132+
});
133+
}
134+
}
135+
136+
private handleMetaEvent(data: any) {
137+
if (data.meta_event_type === 'lifecycle' && data.sub_type === 'connect') {
138+
this.selfId = String(data.self_id ?? '');
139+
this.logger?.info(`OneBot connected, self_id=${this.selfId}`);
140+
}
141+
}
142+
143+
private async handleMessageEvent(data: any) {
144+
const messageType: 'private' | 'group' = data.message_type;
145+
const userId: number = data.user_id;
146+
const groupId: number | undefined = data.group_id;
147+
const messageId: number = data.message_id;
148+
const nickname: string = data.sender?.card || data.sender?.nickname || String(userId);
149+
const segments: any[] = Array.isArray(data.message) ? data.message : [];
150+
151+
if (!await this.options.filterEvent(`onebot_message_${messageId}`)) return;
152+
153+
// Check @bot mention in group
154+
if (messageType === 'group' && this.options.requireMention) {
155+
const mentioned = segments.some(
156+
seg => seg.type === 'at' && String(seg.data?.qq) === this.selfId
157+
);
158+
if (!mentioned) return;
159+
}
160+
161+
// Parse segments into content
162+
const query = await this.parseSegments(segments, messageId);
163+
if (isEmptyContent(query)) return;
164+
165+
const sessionId = messageType === 'private'
166+
? `onebot:private:${userId}`
167+
: `onebot:group:${groupId}:${userId}`;
168+
169+
await this.options.onReceiveMessage(String(userId), {
170+
sessionId,
171+
messageType,
172+
userId,
173+
groupId,
174+
messageId,
175+
nickname,
176+
}, query);
177+
}
178+
179+
private async parseSegments(segments: any[], messageId: number): Promise<MessageContent> {
180+
const parts: Array<{ type: string; text?: string; image_url?: { url: string } }> = [];
181+
let hasImage = false;
182+
183+
for (const seg of segments) {
184+
if (seg.type === 'text' && seg.data?.text) {
185+
const text = seg.data.text.trim();
186+
if (text) parts.push({ type: 'text', text });
187+
} else if (seg.type === 'image' && seg.data?.url) {
188+
try {
189+
const filePath = await this.downloadFile(seg.data.url, messageId, '.png');
190+
const dataUrl = await readImageAsDataUrl(filePath);
191+
parts.push({ type: 'image_url', image_url: { url: dataUrl } });
192+
hasImage = true;
193+
} catch (e: any) {
194+
this.logger?.error(`Failed to download image: ${e.message}`);
195+
}
196+
}
197+
// at, reply, face, forward etc. are ignored
198+
}
199+
200+
if (parts.length === 0) return '';
201+
if (!hasImage) return parts.map(p => p.text ?? '').join(' ').trim();
202+
return parts as any;
203+
}
204+
205+
private async downloadFile(url: string, messageId: number, ext: string): Promise<string> {
206+
const resp = await fetch(url);
207+
if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
208+
const buffer = Buffer.from(await resp.arrayBuffer());
209+
const filePath = path.join(os.tmpdir(), `onebot_${messageId}_${Date.now()}${ext}`);
210+
await fs.writeFile(filePath, buffer);
211+
return filePath;
212+
}
213+
214+
// --- Public API methods ---
215+
216+
async callApi(action: string, params: Record<string, any> = {}): Promise<any> {
217+
const ws = this.getActiveConnection();
218+
if (!ws) throw new Error('No active OneBot WebSocket connection');
219+
220+
const echo = `${action}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
221+
const payload = JSON.stringify({ action, params, echo });
222+
223+
return new Promise((resolve, reject) => {
224+
const timer = setTimeout(() => {
225+
this.pendingCalls.delete(echo);
226+
reject(new Error(`OneBot API call timeout: ${action}`));
227+
}, 15_000);
228+
229+
this.pendingCalls.set(echo, {
230+
resolve: (data) => { clearTimeout(timer); resolve(data); },
231+
reject: (err) => { clearTimeout(timer); reject(err); },
232+
});
233+
234+
ws.send(payload);
235+
});
236+
}
237+
238+
async sendTextMessage(target: { userId?: number; groupId?: number }, text: string): Promise<void> {
239+
const message = [{ type: 'text', data: { text } }];
240+
if (target.groupId) {
241+
await this.callApi('send_group_msg', { group_id: target.groupId, message });
242+
} else if (target.userId) {
243+
await this.callApi('send_private_msg', { user_id: target.userId, message });
244+
}
245+
}
246+
247+
async sendImageMessage(target: { userId?: number; groupId?: number }, fileUrl: string): Promise<void> {
248+
const message = [{ type: 'image', data: { file: fileUrl } }];
249+
if (target.groupId) {
250+
await this.callApi('send_group_msg', { group_id: target.groupId, message });
251+
} else if (target.userId) {
252+
await this.callApi('send_private_msg', { user_id: target.userId, message });
253+
}
254+
}
255+
256+
async sendFileMessage(target: { userId?: number; groupId?: number }, filePath: string, fileName: string): Promise<void> {
257+
if (target.groupId) {
258+
await this.callApi('upload_group_file', { group_id: target.groupId, file: filePath, name: fileName });
259+
} else if (target.userId) {
260+
await this.callApi('upload_private_file', { user_id: target.userId, file: filePath, name: fileName });
261+
}
262+
}
263+
264+
private getActiveConnection(): WebSocket | null {
265+
for (const ws of this.connections) {
266+
if (ws.readyState === WebSocket.OPEN) return ws;
267+
}
268+
return null;
269+
}
270+
}

0 commit comments

Comments
 (0)