feat(qq): add QQ Bot channel plugin (Official Bot API)
Some checks failed
CI / install-check (push) Has been cancelled
CI / checks (bunx tsc -p tsconfig.json, bun, build) (push) Has been cancelled
CI / checks (bunx vitest run, bun, test) (push) Has been cancelled
CI / checks (pnpm build, node, build) (push) Has been cancelled
CI / checks (pnpm format, node, format) (push) Has been cancelled
CI / checks (pnpm lint, node, lint) (push) Has been cancelled
CI / checks (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / checks (pnpm test, node, test) (push) Has been cancelled
CI / secrets (push) Has been cancelled
CI / checks-windows (pnpm build, node, build) (push) Has been cancelled
CI / checks-windows (pnpm lint, node, lint) (push) Has been cancelled
CI / checks-windows (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / checks-windows (pnpm test, node, test) (push) Has been cancelled
CI / checks-macos (pnpm test, test) (push) Has been cancelled
CI / macos-app (set -euo pipefail
for attempt in 1 2 3; do
if swift build --package-path apps/macos --configuration release; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
, build) (push) Has been cancelled
CI / macos-app (set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt *… (push) Has been cancelled
CI / macos-app (swiftlint --config .swiftlint.yml
swiftformat --lint apps/macos/Sources --config .swiftformat
, lint) (push) Has been cancelled
CI / ios (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:assembleDebug, build) (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:testDebugUnitTest, test) (push) Has been cancelled
Docker Release / build-amd64 (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
Install Smoke / install-smoke (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Docker Release / create-manifest (push) Has been cancelled
Some checks failed
CI / install-check (push) Has been cancelled
CI / checks (bunx tsc -p tsconfig.json, bun, build) (push) Has been cancelled
CI / checks (bunx vitest run, bun, test) (push) Has been cancelled
CI / checks (pnpm build, node, build) (push) Has been cancelled
CI / checks (pnpm format, node, format) (push) Has been cancelled
CI / checks (pnpm lint, node, lint) (push) Has been cancelled
CI / checks (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / checks (pnpm test, node, test) (push) Has been cancelled
CI / secrets (push) Has been cancelled
CI / checks-windows (pnpm build, node, build) (push) Has been cancelled
CI / checks-windows (pnpm lint, node, lint) (push) Has been cancelled
CI / checks-windows (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / checks-windows (pnpm test, node, test) (push) Has been cancelled
CI / checks-macos (pnpm test, test) (push) Has been cancelled
CI / macos-app (set -euo pipefail
for attempt in 1 2 3; do
if swift build --package-path apps/macos --configuration release; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
, build) (push) Has been cancelled
CI / macos-app (set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt *… (push) Has been cancelled
CI / macos-app (swiftlint --config .swiftlint.yml
swiftformat --lint apps/macos/Sources --config .swiftformat
, lint) (push) Has been cancelled
CI / ios (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:assembleDebug, build) (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:testDebugUnitTest, test) (push) Has been cancelled
Docker Release / build-amd64 (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
Install Smoke / install-smoke (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Docker Release / create-manifest (push) Has been cancelled
- Implement QQ Bot API client with token caching - Add WebSocket monitor for event handling - Support C2C (single chat) and group messages - Include pairing mechanism for DM authorization Also fix memory-core peerDependencies to use workspace:*
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
"./index.ts"
|
"./index.ts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"dependencies": {
|
||||||
"moltbot": ">=2026.1.26"
|
"moltbot": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
38
extensions/qq/README.md
Normal file
38
extensions/qq/README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Moltbot QQ Channel Plugin
|
||||||
|
|
||||||
|
QQ 机器人官方 API 渠道插件,支持:
|
||||||
|
- 单聊 (C2C) 消息
|
||||||
|
- 群聊 @机器人 消息
|
||||||
|
- 频道消息 (可选)
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
在 `~/.clawdbot/clawdbot.json` 中添加:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
qq: {
|
||||||
|
appId: "YOUR_APP_ID",
|
||||||
|
appSecret: "YOUR_APP_SECRET",
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
或使用环境变量:
|
||||||
|
```bash
|
||||||
|
export QQ_APP_ID=your_app_id
|
||||||
|
export QQ_APP_SECRET=your_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
## 获取凭据
|
||||||
|
|
||||||
|
1. 访问 [QQ 开放平台](https://q.qq.com/)
|
||||||
|
2. 创建机器人应用
|
||||||
|
3. 获取 AppID 和 AppSecret
|
||||||
|
|
||||||
|
## 事件订阅
|
||||||
|
|
||||||
|
需要在 QQ 开放平台配置 WebSocket 事件订阅 Intents。
|
||||||
11
extensions/qq/clawdbot.plugin.json
Normal file
11
extensions/qq/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "qq",
|
||||||
|
"channels": [
|
||||||
|
"qq"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
extensions/qq/index.ts
Normal file
22
extensions/qq/index.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* QQ Bot Plugin Entry
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { qqDock, qqPlugin } from "./src/channel.js";
|
||||||
|
import { setQQRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "qq",
|
||||||
|
name: "QQ",
|
||||||
|
description: "QQ channel plugin (Official Bot API)",
|
||||||
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
register(api: MoltbotPluginApi) {
|
||||||
|
setQQRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: qqPlugin, dock: qqDock });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
33
extensions/qq/package.json
Normal file
33
extensions/qq/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@moltbot/qq",
|
||||||
|
"version": "2026.1.27",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Moltbot QQ channel plugin (Official Bot API)",
|
||||||
|
"moltbot": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
],
|
||||||
|
"channel": {
|
||||||
|
"id": "qq",
|
||||||
|
"label": "QQ",
|
||||||
|
"selectionLabel": "QQ (Official Bot API)",
|
||||||
|
"docsPath": "/channels/qq",
|
||||||
|
"docsLabel": "qq",
|
||||||
|
"blurb": "QQ 机器人官方 API 渠道插件",
|
||||||
|
"aliases": [
|
||||||
|
"qq"
|
||||||
|
],
|
||||||
|
"order": 85,
|
||||||
|
"quickstartAllowFrom": true
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"npmSpec": "@moltbot/qq",
|
||||||
|
"localPath": "extensions/qq",
|
||||||
|
"defaultChoice": "npm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"moltbot": "workspace:*",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
114
extensions/qq/src/accounts.ts
Normal file
114
extensions/qq/src/accounts.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* QQ Bot Account Management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MoltbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
|
||||||
|
import type { QQAccountConfig, QQConfig } from "./config-schema.js";
|
||||||
|
|
||||||
|
export interface ResolvedQQAccount {
|
||||||
|
accountId: string;
|
||||||
|
name?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
appId?: string;
|
||||||
|
appSecret?: string;
|
||||||
|
tokenSource: "config" | "env" | "none";
|
||||||
|
config: QQAccountConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all configured QQ account IDs
|
||||||
|
*/
|
||||||
|
export function listQQAccountIds(cfg: MoltbotConfig): string[] {
|
||||||
|
const qqConfig = cfg.channels?.qq as QQConfig | undefined;
|
||||||
|
if (!qqConfig) return [];
|
||||||
|
|
||||||
|
const ids = new Set<string>();
|
||||||
|
|
||||||
|
// Check base level config
|
||||||
|
if (qqConfig.appId || qqConfig.appSecret || process.env.QQ_APP_ID) {
|
||||||
|
ids.add(DEFAULT_ACCOUNT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check named accounts
|
||||||
|
if (qqConfig.accounts) {
|
||||||
|
for (const accountId of Object.keys(qqConfig.accounts)) {
|
||||||
|
ids.add(accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve default account ID
|
||||||
|
*/
|
||||||
|
export function resolveDefaultQQAccountId(cfg: MoltbotConfig): string {
|
||||||
|
const ids = listQQAccountIds(cfg);
|
||||||
|
return ids.includes(DEFAULT_ACCOUNT_ID)
|
||||||
|
? DEFAULT_ACCOUNT_ID
|
||||||
|
: ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a specific QQ account configuration
|
||||||
|
*/
|
||||||
|
export function resolveQQAccount(params: {
|
||||||
|
cfg: MoltbotConfig;
|
||||||
|
accountId?: string;
|
||||||
|
}): ResolvedQQAccount {
|
||||||
|
const { cfg, accountId: rawAccountId } = params;
|
||||||
|
const accountId = rawAccountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
|
||||||
|
const qqConfig = cfg.channels?.qq as QQConfig | undefined;
|
||||||
|
const accountConfig =
|
||||||
|
accountId !== DEFAULT_ACCOUNT_ID
|
||||||
|
? qqConfig?.accounts?.[accountId]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const baseConfig: QQAccountConfig = {
|
||||||
|
enabled: qqConfig?.enabled,
|
||||||
|
appId: qqConfig?.appId,
|
||||||
|
appSecret: qqConfig?.appSecret,
|
||||||
|
name: qqConfig?.name,
|
||||||
|
allowFrom: qqConfig?.allowFrom,
|
||||||
|
dmPolicy: qqConfig?.dmPolicy,
|
||||||
|
intents: qqConfig?.intents,
|
||||||
|
sandbox: qqConfig?.sandbox,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergedConfig: QQAccountConfig =
|
||||||
|
accountId !== DEFAULT_ACCOUNT_ID && accountConfig
|
||||||
|
? { ...baseConfig, ...accountConfig }
|
||||||
|
: baseConfig;
|
||||||
|
|
||||||
|
// Resolve credentials
|
||||||
|
let appId = mergedConfig.appId;
|
||||||
|
let appSecret = mergedConfig.appSecret;
|
||||||
|
let tokenSource: ResolvedQQAccount["tokenSource"] = "none";
|
||||||
|
|
||||||
|
if (appId && appSecret) {
|
||||||
|
tokenSource = "config";
|
||||||
|
} else if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
// Fall back to environment variables for default account
|
||||||
|
const envAppId = process.env.QQ_APP_ID;
|
||||||
|
const envAppSecret = process.env.QQ_APP_SECRET;
|
||||||
|
if (envAppId && envAppSecret) {
|
||||||
|
appId = envAppId;
|
||||||
|
appSecret = envAppSecret;
|
||||||
|
tokenSource = "env";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
name:
|
||||||
|
mergedConfig.name ??
|
||||||
|
(accountId === DEFAULT_ACCOUNT_ID ? "QQ" : accountId),
|
||||||
|
enabled: mergedConfig.enabled ?? false,
|
||||||
|
appId,
|
||||||
|
appSecret,
|
||||||
|
tokenSource,
|
||||||
|
config: mergedConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
200
extensions/qq/src/api.ts
Normal file
200
extensions/qq/src/api.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* QQ Bot API Client
|
||||||
|
*
|
||||||
|
* Handles authentication and API calls to QQ Bot platform.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AccessTokenResponse,
|
||||||
|
GatewayResponse,
|
||||||
|
SendMessageRequest,
|
||||||
|
SendMessageResponse,
|
||||||
|
QQApiError,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
const API_BASE = "https://api.sgroup.qq.com";
|
||||||
|
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
||||||
|
|
||||||
|
export type QQFetch = typeof fetch;
|
||||||
|
|
||||||
|
// Token cache
|
||||||
|
interface TokenCache {
|
||||||
|
token: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenCacheMap = new Map<string, TokenCache>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Access Token with caching and auto-refresh
|
||||||
|
*/
|
||||||
|
export async function getAccessToken(
|
||||||
|
appId: string,
|
||||||
|
appSecret: string,
|
||||||
|
fetcher: QQFetch = fetch,
|
||||||
|
): Promise<string> {
|
||||||
|
const cacheKey = appId;
|
||||||
|
const cached = tokenCacheMap.get(cacheKey);
|
||||||
|
|
||||||
|
// Return cached token if still valid (with 60s buffer)
|
||||||
|
if (cached && cached.expiresAt > Date.now() + 60_000) {
|
||||||
|
return cached.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetcher(TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ appId, clientSecret: appSecret }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new QQApiException(`Failed to get access token: ${response.status} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as AccessTokenResponse;
|
||||||
|
const expiresIn = parseInt(data.expires_in, 10) * 1000;
|
||||||
|
|
||||||
|
tokenCacheMap.set(cacheKey, {
|
||||||
|
token: data.access_token,
|
||||||
|
expiresAt: Date.now() + expiresIn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear token cache for an app
|
||||||
|
*/
|
||||||
|
export function clearTokenCache(appId: string): void {
|
||||||
|
tokenCacheMap.delete(appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get WebSocket Gateway URL
|
||||||
|
*/
|
||||||
|
export async function getGatewayUrl(
|
||||||
|
token: string,
|
||||||
|
fetcher: QQFetch = fetch,
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await fetcher(`${API_BASE}/gateway`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `QQBot ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new QQApiException(`Failed to get gateway URL: ${response.status} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as GatewayResponse;
|
||||||
|
return data.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send message to C2C (single chat)
|
||||||
|
*/
|
||||||
|
export async function sendC2CMessage(
|
||||||
|
token: string,
|
||||||
|
openId: string,
|
||||||
|
request: SendMessageRequest,
|
||||||
|
fetcher: QQFetch = fetch,
|
||||||
|
): Promise<SendMessageResult> {
|
||||||
|
return sendMessage(token, `/v2/users/${openId}/messages`, request, fetcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send message to group
|
||||||
|
*/
|
||||||
|
export async function sendGroupMessage(
|
||||||
|
token: string,
|
||||||
|
groupOpenId: string,
|
||||||
|
request: SendMessageRequest,
|
||||||
|
fetcher: QQFetch = fetch,
|
||||||
|
): Promise<SendMessageResult> {
|
||||||
|
return sendMessage(token, `/v2/groups/${groupOpenId}/messages`, request, fetcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send message to channel
|
||||||
|
*/
|
||||||
|
export async function sendChannelMessage(
|
||||||
|
token: string,
|
||||||
|
channelId: string,
|
||||||
|
request: SendMessageRequest,
|
||||||
|
fetcher: QQFetch = fetch,
|
||||||
|
): Promise<SendMessageResult> {
|
||||||
|
return sendMessage(token, `/channels/${channelId}/messages`, request, fetcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send message to DMS (频道私信)
|
||||||
|
*/
|
||||||
|
export async function sendDmsMessage(
|
||||||
|
token: string,
|
||||||
|
guildId: string,
|
||||||
|
request: SendMessageRequest,
|
||||||
|
fetcher: QQFetch = fetch,
|
||||||
|
): Promise<SendMessageResult> {
|
||||||
|
return sendMessage(token, `/dms/${guildId}/messages`, request, fetcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageResult {
|
||||||
|
ok: boolean;
|
||||||
|
messageId?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage(
|
||||||
|
token: string,
|
||||||
|
path: string,
|
||||||
|
request: SendMessageRequest,
|
||||||
|
fetcher: QQFetch,
|
||||||
|
): Promise<SendMessageResult> {
|
||||||
|
try {
|
||||||
|
const response = await fetcher(`${API_BASE}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `QQBot ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = (await response.json().catch(() => ({}))) as QQApiError;
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: errorData.message || `HTTP ${response.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as SendMessageResponse;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
messageId: data.id,
|
||||||
|
timestamp: data.timestamp,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QQ API Exception
|
||||||
|
*/
|
||||||
|
export class QQApiException extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public code?: number,
|
||||||
|
public data?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "QQApiException";
|
||||||
|
}
|
||||||
|
}
|
||||||
416
extensions/qq/src/channel.ts
Normal file
416
extensions/qq/src/channel.ts
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
/**
|
||||||
|
* QQ Bot Channel Plugin
|
||||||
|
*
|
||||||
|
* Main channel plugin implementation for QQ Bot.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ChannelAccountSnapshot,
|
||||||
|
ChannelDock,
|
||||||
|
ChannelPlugin,
|
||||||
|
MoltbotConfig,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
import {
|
||||||
|
applyAccountNameToChannelSection,
|
||||||
|
buildChannelConfigSchema,
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
deleteAccountFromConfigSection,
|
||||||
|
formatPairingApproveHint,
|
||||||
|
migrateBaseNameToDefaultAccount,
|
||||||
|
normalizeAccountId,
|
||||||
|
PAIRING_APPROVED_MESSAGE,
|
||||||
|
setAccountEnabledInConfigSection,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import {
|
||||||
|
listQQAccountIds,
|
||||||
|
resolveDefaultQQAccountId,
|
||||||
|
resolveQQAccount,
|
||||||
|
type ResolvedQQAccount,
|
||||||
|
} from "./accounts.js";
|
||||||
|
import { QQConfigSchema } from "./config-schema.js";
|
||||||
|
import { probeQQ } from "./probe.js";
|
||||||
|
import { sendMessageQQ } from "./send.js";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
id: "qq",
|
||||||
|
label: "QQ",
|
||||||
|
selectionLabel: "QQ (Official Bot API)",
|
||||||
|
docsPath: "/channels/qq",
|
||||||
|
docsLabel: "qq",
|
||||||
|
blurb: "QQ 机器人官方 API(支持单聊、群聊)",
|
||||||
|
aliases: ["qq"],
|
||||||
|
order: 85,
|
||||||
|
quickstartAllowFrom: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeQQMessagingTarget(raw: string): string | undefined {
|
||||||
|
const trimmed = raw?.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
// Remove qq: or group: prefix
|
||||||
|
return trimmed.replace(/^(qq|group):/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const qqDock: ChannelDock = {
|
||||||
|
id: "qq",
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct", "group"],
|
||||||
|
media: true,
|
||||||
|
blockStreaming: true,
|
||||||
|
},
|
||||||
|
outbound: { textChunkLimit: 2000 },
|
||||||
|
config: {
|
||||||
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
|
(
|
||||||
|
resolveQQAccount({ cfg: cfg as MoltbotConfig, accountId }).config
|
||||||
|
.allowFrom ?? []
|
||||||
|
).map((entry) => String(entry)),
|
||||||
|
formatAllowFrom: ({ allowFrom }) =>
|
||||||
|
allowFrom
|
||||||
|
.map((entry) => String(entry).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((entry) => entry.replace(/^(qq|group):/i, ""))
|
||||||
|
.map((entry) => entry.toLowerCase()),
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
resolveRequireMention: () => true,
|
||||||
|
},
|
||||||
|
threading: {
|
||||||
|
resolveReplyToMode: () => "off",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const qqPlugin: ChannelPlugin<ResolvedQQAccount> = {
|
||||||
|
id: "qq",
|
||||||
|
meta,
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct", "group"],
|
||||||
|
media: true,
|
||||||
|
reactions: false,
|
||||||
|
threads: false,
|
||||||
|
polls: false,
|
||||||
|
nativeCommands: false,
|
||||||
|
blockStreaming: true,
|
||||||
|
},
|
||||||
|
reload: { configPrefixes: ["channels.qq"] },
|
||||||
|
configSchema: buildChannelConfigSchema(QQConfigSchema),
|
||||||
|
config: {
|
||||||
|
listAccountIds: (cfg) => listQQAccountIds(cfg as MoltbotConfig),
|
||||||
|
resolveAccount: (cfg, accountId) =>
|
||||||
|
resolveQQAccount({ cfg: cfg as MoltbotConfig, accountId }),
|
||||||
|
defaultAccountId: (cfg) =>
|
||||||
|
resolveDefaultQQAccountId(cfg as MoltbotConfig),
|
||||||
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||||
|
setAccountEnabledInConfigSection({
|
||||||
|
cfg: cfg as MoltbotConfig,
|
||||||
|
sectionKey: "qq",
|
||||||
|
accountId,
|
||||||
|
enabled,
|
||||||
|
allowTopLevel: true,
|
||||||
|
}),
|
||||||
|
deleteAccount: ({ cfg, accountId }) =>
|
||||||
|
deleteAccountFromConfigSection({
|
||||||
|
cfg: cfg as MoltbotConfig,
|
||||||
|
sectionKey: "qq",
|
||||||
|
accountId,
|
||||||
|
clearBaseFields: ["appId", "appSecret", "name"],
|
||||||
|
}),
|
||||||
|
isConfigured: (account) =>
|
||||||
|
Boolean(account.appId?.trim() && account.appSecret?.trim()),
|
||||||
|
describeAccount: (account): ChannelAccountSnapshot => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: Boolean(account.appId?.trim() && account.appSecret?.trim()),
|
||||||
|
tokenSource: account.tokenSource,
|
||||||
|
}),
|
||||||
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
|
(
|
||||||
|
resolveQQAccount({ cfg: cfg as MoltbotConfig, accountId }).config
|
||||||
|
.allowFrom ?? []
|
||||||
|
).map((entry) => String(entry)),
|
||||||
|
formatAllowFrom: ({ allowFrom }) =>
|
||||||
|
allowFrom
|
||||||
|
.map((entry) => String(entry).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((entry) => entry.replace(/^(qq|group):/i, ""))
|
||||||
|
.map((entry) => entry.toLowerCase()),
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||||
|
const resolvedAccountId =
|
||||||
|
accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
const useAccountPath = Boolean(
|
||||||
|
(cfg as MoltbotConfig).channels?.qq?.accounts?.[resolvedAccountId],
|
||||||
|
);
|
||||||
|
const basePath = useAccountPath
|
||||||
|
? `channels.qq.accounts.${resolvedAccountId}.`
|
||||||
|
: "channels.qq.";
|
||||||
|
return {
|
||||||
|
policy: account.config.dmPolicy ?? "open",
|
||||||
|
allowFrom: account.config.allowFrom ?? [],
|
||||||
|
policyPath: `${basePath}dmPolicy`,
|
||||||
|
allowFromPath: basePath,
|
||||||
|
approveHint: formatPairingApproveHint("qq"),
|
||||||
|
normalizeEntry: (raw) => raw.replace(/^(qq|group):/i, ""),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
resolveRequireMention: () => true,
|
||||||
|
},
|
||||||
|
threading: {
|
||||||
|
resolveReplyToMode: () => "off",
|
||||||
|
},
|
||||||
|
messaging: {
|
||||||
|
normalizeTarget: normalizeQQMessagingTarget,
|
||||||
|
targetResolver: {
|
||||||
|
looksLikeId: (raw) => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
// QQ OpenIDs are typically hex strings
|
||||||
|
return /^[A-F0-9]{32}$/i.test(trimmed) || /^\d{5,}$/.test(trimmed);
|
||||||
|
},
|
||||||
|
hint: "<openId>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: {
|
||||||
|
self: async () => null,
|
||||||
|
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||||
|
const account = resolveQQAccount({
|
||||||
|
cfg: cfg as MoltbotConfig,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
const q = query?.trim().toLowerCase() || "";
|
||||||
|
const peers = Array.from(
|
||||||
|
new Set(
|
||||||
|
(account.config.allowFrom ?? [])
|
||||||
|
.map((entry) => String(entry).trim())
|
||||||
|
.filter((entry) => Boolean(entry) && entry !== "*")
|
||||||
|
.map((entry) => entry.replace(/^(qq|group):/i, "")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||||
|
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||||
|
.map((id) => ({ kind: "user", id }) as const);
|
||||||
|
return peers;
|
||||||
|
},
|
||||||
|
listGroups: async () => [],
|
||||||
|
},
|
||||||
|
setup: {
|
||||||
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||||
|
applyAccountName: ({ cfg, accountId, name }) =>
|
||||||
|
applyAccountNameToChannelSection({
|
||||||
|
cfg: cfg as MoltbotConfig,
|
||||||
|
channelKey: "qq",
|
||||||
|
accountId,
|
||||||
|
name,
|
||||||
|
}),
|
||||||
|
validateInput: ({ accountId, input }) => {
|
||||||
|
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||||
|
return "QQ_APP_ID/QQ_APP_SECRET can only be used for the default account.";
|
||||||
|
}
|
||||||
|
if (!input.useEnv && (!input.appId || !input.appSecret)) {
|
||||||
|
return "QQ requires appId and appSecret (or --use-env).";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||||
|
const namedConfig = applyAccountNameToChannelSection({
|
||||||
|
cfg: cfg as MoltbotConfig,
|
||||||
|
channelKey: "qq",
|
||||||
|
accountId,
|
||||||
|
name: input.name,
|
||||||
|
});
|
||||||
|
const next =
|
||||||
|
accountId !== DEFAULT_ACCOUNT_ID
|
||||||
|
? migrateBaseNameToDefaultAccount({
|
||||||
|
cfg: namedConfig,
|
||||||
|
channelKey: "qq",
|
||||||
|
})
|
||||||
|
: namedConfig;
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
channels: {
|
||||||
|
...next.channels,
|
||||||
|
qq: {
|
||||||
|
...next.channels?.qq,
|
||||||
|
enabled: true,
|
||||||
|
...(input.useEnv
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
appId: input.appId,
|
||||||
|
appSecret: input.appSecret,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as MoltbotConfig;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
channels: {
|
||||||
|
...next.channels,
|
||||||
|
qq: {
|
||||||
|
...next.channels?.qq,
|
||||||
|
enabled: true,
|
||||||
|
accounts: {
|
||||||
|
...(next.channels?.qq?.accounts ?? {}),
|
||||||
|
[accountId]: {
|
||||||
|
...(next.channels?.qq?.accounts?.[accountId] ?? {}),
|
||||||
|
enabled: true,
|
||||||
|
appId: input.appId,
|
||||||
|
appSecret: input.appSecret,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as MoltbotConfig;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pairing: {
|
||||||
|
idLabel: "qqOpenId",
|
||||||
|
normalizeAllowEntry: (entry) => entry.replace(/^(qq|group):/i, ""),
|
||||||
|
notifyApproval: async ({ cfg, id }) => {
|
||||||
|
const account = resolveQQAccount({ cfg: cfg as MoltbotConfig });
|
||||||
|
if (!account.appId || !account.appSecret) {
|
||||||
|
throw new Error("QQ appId/appSecret not configured");
|
||||||
|
}
|
||||||
|
await sendMessageQQ("c2c", id, PAIRING_APPROVED_MESSAGE, {
|
||||||
|
appId: account.appId,
|
||||||
|
appSecret: account.appSecret,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
chunker: (text, limit) => {
|
||||||
|
if (!text) return [];
|
||||||
|
if (limit <= 0 || text.length <= limit) return [text];
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let remaining = text;
|
||||||
|
while (remaining.length > limit) {
|
||||||
|
const window = remaining.slice(0, limit);
|
||||||
|
const lastNewline = window.lastIndexOf("\n");
|
||||||
|
const lastSpace = window.lastIndexOf(" ");
|
||||||
|
let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
|
||||||
|
if (breakIdx <= 0) breakIdx = limit;
|
||||||
|
const rawChunk = remaining.slice(0, breakIdx);
|
||||||
|
const chunk = rawChunk.trimEnd();
|
||||||
|
if (chunk.length > 0) chunks.push(chunk);
|
||||||
|
const brokeOnSeparator =
|
||||||
|
breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
||||||
|
const nextStart = Math.min(
|
||||||
|
remaining.length,
|
||||||
|
breakIdx + (brokeOnSeparator ? 1 : 0),
|
||||||
|
);
|
||||||
|
remaining = remaining.slice(nextStart).trimStart();
|
||||||
|
}
|
||||||
|
if (remaining.length) chunks.push(remaining);
|
||||||
|
return chunks;
|
||||||
|
},
|
||||||
|
chunkerMode: "text",
|
||||||
|
textChunkLimit: 2000,
|
||||||
|
sendText: async ({ to, text, accountId, cfg }) => {
|
||||||
|
// Determine chat type from target format
|
||||||
|
const isGroup = to.startsWith("group:");
|
||||||
|
const targetId = to.replace(/^(qq:|group:)/i, "");
|
||||||
|
const chatType = isGroup ? "group" : "c2c";
|
||||||
|
|
||||||
|
const result = await sendMessageQQ(chatType, targetId, text, {
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
cfg: cfg as MoltbotConfig,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
channel: "qq",
|
||||||
|
ok: result.ok,
|
||||||
|
messageId: result.messageId ?? "",
|
||||||
|
error: result.error ? new Error(result.error) : undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
|
||||||
|
// TODO: Implement media sending via QQ rich media API
|
||||||
|
// For now, just send text with media URL
|
||||||
|
const isGroup = to.startsWith("group:");
|
||||||
|
const targetId = to.replace(/^(qq:|group:)/i, "");
|
||||||
|
const chatType = isGroup ? "group" : "c2c";
|
||||||
|
|
||||||
|
const messageText = mediaUrl ? `${text}\n${mediaUrl}` : text;
|
||||||
|
const result = await sendMessageQQ(chatType, targetId, messageText, {
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
cfg: cfg as MoltbotConfig,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
channel: "qq",
|
||||||
|
ok: result.ok,
|
||||||
|
messageId: result.messageId ?? "",
|
||||||
|
error: result.error ? new Error(result.error) : undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
defaultRuntime: {
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
running: false,
|
||||||
|
lastStartAt: null,
|
||||||
|
lastStopAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
collectStatusIssues: async () => [],
|
||||||
|
buildChannelSummary: ({ snapshot }) => ({
|
||||||
|
configured: snapshot.configured ?? false,
|
||||||
|
tokenSource: snapshot.tokenSource ?? "none",
|
||||||
|
running: snapshot.running ?? false,
|
||||||
|
mode: "websocket",
|
||||||
|
lastStartAt: snapshot.lastStartAt ?? null,
|
||||||
|
lastStopAt: snapshot.lastStopAt ?? null,
|
||||||
|
lastError: snapshot.lastError ?? null,
|
||||||
|
probe: snapshot.probe,
|
||||||
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
|
}),
|
||||||
|
probeAccount: async ({ account, timeoutMs }) =>
|
||||||
|
account.appId && account.appSecret
|
||||||
|
? probeQQ(account.appId, account.appSecret, timeoutMs)
|
||||||
|
: { ok: false, error: "appId/appSecret not configured" },
|
||||||
|
buildAccountSnapshot: ({ account, runtime }) => {
|
||||||
|
const configured = Boolean(
|
||||||
|
account.appId?.trim() && account.appSecret?.trim(),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured,
|
||||||
|
tokenSource: account.tokenSource,
|
||||||
|
running: runtime?.running ?? false,
|
||||||
|
lastStartAt: runtime?.lastStartAt ?? null,
|
||||||
|
lastStopAt: runtime?.lastStopAt ?? null,
|
||||||
|
lastError: runtime?.lastError ?? null,
|
||||||
|
mode: "websocket",
|
||||||
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||||
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||||
|
dmPolicy: account.config.dmPolicy ?? "open",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
startAccount: async (ctx) => {
|
||||||
|
const account = ctx.account;
|
||||||
|
if (!account.appId?.trim() || !account.appSecret?.trim()) {
|
||||||
|
throw new Error("QQ appId and appSecret are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.log?.info(`[${account.accountId}] Starting QQ provider`);
|
||||||
|
|
||||||
|
const { monitorQQProvider } = await import("./monitor.js");
|
||||||
|
return monitorQQProvider({
|
||||||
|
account,
|
||||||
|
config: ctx.cfg as MoltbotConfig,
|
||||||
|
abortSignal: ctx.abortSignal,
|
||||||
|
statusSink: (patch) =>
|
||||||
|
ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||||
|
log: ctx.log,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
41
extensions/qq/src/config-schema.ts
Normal file
41
extensions/qq/src/config-schema.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* QQ Bot Configuration Schema
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
const StringEnum = <T extends string[]>(values: [...T]) =>
|
||||||
|
Type.Unsafe<T[number]>({ type: "string", enum: values });
|
||||||
|
|
||||||
|
export const QQAccountConfigSchema = Type.Object({
|
||||||
|
enabled: Type.Optional(Type.Boolean()),
|
||||||
|
appId: Type.Optional(Type.String()),
|
||||||
|
appSecret: Type.Optional(Type.String()),
|
||||||
|
name: Type.Optional(Type.String()),
|
||||||
|
allowFrom: Type.Optional(Type.Array(Type.String())),
|
||||||
|
dmPolicy: Type.Optional(StringEnum(["open", "pairing", "disabled"])),
|
||||||
|
intents: Type.Optional(Type.Number()),
|
||||||
|
sandbox: Type.Optional(Type.Boolean()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const QQConfigSchema = Type.Object({
|
||||||
|
...QQAccountConfigSchema.properties,
|
||||||
|
accounts: Type.Optional(
|
||||||
|
Type.Record(Type.String(), QQAccountConfigSchema),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type QQAccountConfig = {
|
||||||
|
enabled?: boolean;
|
||||||
|
appId?: string;
|
||||||
|
appSecret?: string;
|
||||||
|
name?: string;
|
||||||
|
allowFrom?: string[];
|
||||||
|
dmPolicy?: "open" | "pairing" | "disabled";
|
||||||
|
intents?: number;
|
||||||
|
sandbox?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QQConfig = QQAccountConfig & {
|
||||||
|
accounts?: Record<string, QQAccountConfig>;
|
||||||
|
};
|
||||||
534
extensions/qq/src/monitor.ts
Normal file
534
extensions/qq/src/monitor.ts
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
/**
|
||||||
|
* QQ Bot WebSocket Monitor
|
||||||
|
*
|
||||||
|
* Handles WebSocket connection to QQ Bot Gateway for receiving events.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import WebSocket from "ws";
|
||||||
|
import type { MoltbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import type { ResolvedQQAccount } from "./accounts.js";
|
||||||
|
import {
|
||||||
|
getAccessToken,
|
||||||
|
getGatewayUrl,
|
||||||
|
sendC2CMessage,
|
||||||
|
sendGroupMessage,
|
||||||
|
} from "./api.js";
|
||||||
|
import { getQQRuntime } from "./runtime.js";
|
||||||
|
import { chunkQQText } from "./send.js";
|
||||||
|
import {
|
||||||
|
OpCode,
|
||||||
|
EventType,
|
||||||
|
DEFAULT_INTENTS,
|
||||||
|
type GatewayPayload,
|
||||||
|
type HelloData,
|
||||||
|
type ReadyData,
|
||||||
|
type QQMessageEvent,
|
||||||
|
type IdentifyData,
|
||||||
|
type ResumeData,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export interface QQMonitorOptions {
|
||||||
|
account: ResolvedQQAccount;
|
||||||
|
config: MoltbotConfig;
|
||||||
|
abortSignal: AbortSignal;
|
||||||
|
statusSink?: (patch: {
|
||||||
|
lastInboundAt?: number;
|
||||||
|
lastOutboundAt?: number;
|
||||||
|
sessionId?: string;
|
||||||
|
}) => void;
|
||||||
|
log?: {
|
||||||
|
info: (msg: string) => void;
|
||||||
|
error: (msg: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QQMonitorResult {
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QQ_TEXT_LIMIT = 2000;
|
||||||
|
const DEFAULT_MEDIA_MAX_MB = 5;
|
||||||
|
|
||||||
|
type QQCoreRuntime = ReturnType<typeof getQQRuntime>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start QQ Bot WebSocket monitor
|
||||||
|
*/
|
||||||
|
export async function monitorQQProvider(
|
||||||
|
options: QQMonitorOptions,
|
||||||
|
): Promise<QQMonitorResult> {
|
||||||
|
const { account, config, abortSignal, statusSink, log } = options;
|
||||||
|
|
||||||
|
if (!account.appId || !account.appSecret) {
|
||||||
|
throw new Error("QQ appId and appSecret are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const core = getQQRuntime();
|
||||||
|
let stopped = false;
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let sessionId: string | null = null;
|
||||||
|
let lastSeq: number | null = null;
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
const maxReconnectAttempts = 10;
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
stopped = true;
|
||||||
|
if (heartbeatInterval) {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = null;
|
||||||
|
}
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
abortSignal.addEventListener("abort", stop, { once: true });
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
if (stopped || abortSignal.aborted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await getAccessToken(account.appId!, account.appSecret!);
|
||||||
|
const gatewayUrl = await getGatewayUrl(token);
|
||||||
|
|
||||||
|
log?.info(`[${account.accountId}] Connecting to gateway: ${gatewayUrl}`);
|
||||||
|
|
||||||
|
ws = new WebSocket(gatewayUrl);
|
||||||
|
|
||||||
|
ws.on("open", () => {
|
||||||
|
log?.info(`[${account.accountId}] WebSocket connected`);
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("message", async (data) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data.toString()) as GatewayPayload;
|
||||||
|
await handlePayload(payload, token);
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(
|
||||||
|
`[${account.accountId}] Failed to parse message: ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", (code, reason) => {
|
||||||
|
log?.info(
|
||||||
|
`[${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`,
|
||||||
|
);
|
||||||
|
if (heartbeatInterval) {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = null;
|
||||||
|
}
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", (err) => {
|
||||||
|
log?.error(`[${account.accountId}] WebSocket error: ${String(err)}`);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(`[${account.accountId}] Failed to connect: ${String(err)}`);
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (stopped || abortSignal.aborted) return;
|
||||||
|
if (reconnectAttempts >= maxReconnectAttempts) {
|
||||||
|
log?.error(`[${account.accountId}] Max reconnect attempts reached`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectAttempts++;
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
||||||
|
log?.info(
|
||||||
|
`[${account.accountId}] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`,
|
||||||
|
);
|
||||||
|
setTimeout(connect, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePayload = async (
|
||||||
|
payload: GatewayPayload,
|
||||||
|
token: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
// Update sequence number
|
||||||
|
if (payload.s !== undefined) {
|
||||||
|
lastSeq = payload.s;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (payload.op) {
|
||||||
|
case OpCode.Hello: {
|
||||||
|
const hello = payload.d as HelloData;
|
||||||
|
log?.info(
|
||||||
|
`[${account.accountId}] Received Hello, heartbeat interval: ${hello.heartbeat_interval}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start heartbeat
|
||||||
|
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = setInterval(() => {
|
||||||
|
sendHeartbeat();
|
||||||
|
}, hello.heartbeat_interval);
|
||||||
|
|
||||||
|
// Send Identify or Resume
|
||||||
|
if (sessionId && lastSeq !== null) {
|
||||||
|
sendResume(token);
|
||||||
|
} else {
|
||||||
|
sendIdentify(token);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case OpCode.HeartbeatAck:
|
||||||
|
// Heartbeat acknowledged
|
||||||
|
break;
|
||||||
|
|
||||||
|
case OpCode.Dispatch:
|
||||||
|
statusSink?.({ lastInboundAt: Date.now() });
|
||||||
|
await handleEvent(payload.t!, payload.d, token);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case OpCode.Reconnect:
|
||||||
|
log?.info(`[${account.accountId}] Received Reconnect, reconnecting...`);
|
||||||
|
ws?.close();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case OpCode.InvalidSession: {
|
||||||
|
const resumable = payload.d as boolean;
|
||||||
|
log?.info(
|
||||||
|
`[${account.accountId}] Invalid session, resumable: ${resumable}`,
|
||||||
|
);
|
||||||
|
if (!resumable) {
|
||||||
|
sessionId = null;
|
||||||
|
lastSeq = null;
|
||||||
|
}
|
||||||
|
// Wait a bit before reconnecting
|
||||||
|
setTimeout(() => {
|
||||||
|
if (resumable && sessionId) {
|
||||||
|
sendResume(token);
|
||||||
|
} else {
|
||||||
|
sendIdentify(token);
|
||||||
|
}
|
||||||
|
}, 1000 + Math.random() * 4000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendHeartbeat = () => {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const payload: GatewayPayload<number | null> = {
|
||||||
|
op: OpCode.Heartbeat,
|
||||||
|
d: lastSeq,
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendIdentify = async (token: string) => {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const intents = account.config.intents ?? DEFAULT_INTENTS;
|
||||||
|
|
||||||
|
const payload: GatewayPayload<IdentifyData> = {
|
||||||
|
op: OpCode.Identify,
|
||||||
|
d: {
|
||||||
|
token: `QQBot ${token}`,
|
||||||
|
intents,
|
||||||
|
properties: {
|
||||||
|
$os: "linux",
|
||||||
|
$browser: "moltbot",
|
||||||
|
$device: "moltbot",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
log?.info(`[${account.accountId}] Sending Identify with intents: ${intents}`);
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendResume = (token: string) => {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN || !sessionId) return;
|
||||||
|
|
||||||
|
const payload: GatewayPayload<ResumeData> = {
|
||||||
|
op: OpCode.Resume,
|
||||||
|
d: {
|
||||||
|
token: `QQBot ${token}`,
|
||||||
|
session_id: sessionId,
|
||||||
|
seq: lastSeq ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
log?.info(`[${account.accountId}] Sending Resume for session: ${sessionId}`);
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEvent = async (
|
||||||
|
eventType: string,
|
||||||
|
data: unknown,
|
||||||
|
token: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
switch (eventType) {
|
||||||
|
case EventType.READY: {
|
||||||
|
const ready = data as ReadyData;
|
||||||
|
sessionId = ready.session_id;
|
||||||
|
statusSink?.({ sessionId });
|
||||||
|
log?.info(
|
||||||
|
`[${account.accountId}] Ready! Bot: ${ready.user.username} (${ready.user.id})`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventType.RESUMED:
|
||||||
|
log?.info(`[${account.accountId}] Session resumed`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EventType.C2C_MESSAGE_CREATE:
|
||||||
|
await handleC2CMessage(data as QQMessageEvent, token);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EventType.GROUP_AT_MESSAGE_CREATE:
|
||||||
|
await handleGroupMessage(data as QQMessageEvent, token);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EventType.DIRECT_MESSAGE_CREATE:
|
||||||
|
case EventType.AT_MESSAGE_CREATE:
|
||||||
|
case EventType.MESSAGE_CREATE:
|
||||||
|
// TODO: Implement channel message handling
|
||||||
|
log?.info(`[${account.accountId}] Received channel event: ${eventType}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
log?.info(`[${account.accountId}] Unhandled event: ${eventType}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleC2CMessage = async (
|
||||||
|
message: QQMessageEvent,
|
||||||
|
token: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const senderId = message.author.user_openid;
|
||||||
|
if (!senderId) return;
|
||||||
|
|
||||||
|
log?.info(
|
||||||
|
`[${account.accountId}] C2C message from ${senderId}: ${message.content?.slice(0, 50)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await processMessageWithPipeline({
|
||||||
|
message,
|
||||||
|
token,
|
||||||
|
chatType: "c2c",
|
||||||
|
chatId: senderId,
|
||||||
|
senderId,
|
||||||
|
isGroup: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGroupMessage = async (
|
||||||
|
message: QQMessageEvent,
|
||||||
|
token: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const groupId = message.group_openid;
|
||||||
|
const senderId = message.author.member_openid;
|
||||||
|
if (!groupId || !senderId) return;
|
||||||
|
|
||||||
|
log?.info(
|
||||||
|
`[${account.accountId}] Group message in ${groupId} from ${senderId}: ${message.content?.slice(0, 50)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await processMessageWithPipeline({
|
||||||
|
message,
|
||||||
|
token,
|
||||||
|
chatType: "group",
|
||||||
|
chatId: groupId,
|
||||||
|
senderId,
|
||||||
|
isGroup: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const processMessageWithPipeline = async (params: {
|
||||||
|
message: QQMessageEvent;
|
||||||
|
token: string;
|
||||||
|
chatType: "c2c" | "group";
|
||||||
|
chatId: string;
|
||||||
|
senderId: string;
|
||||||
|
isGroup: boolean;
|
||||||
|
}): Promise<void> => {
|
||||||
|
const { message, token, chatType, chatId, senderId, isGroup } = params;
|
||||||
|
|
||||||
|
const rawBody = message.content?.trim() || "";
|
||||||
|
if (!rawBody) return;
|
||||||
|
|
||||||
|
const dmPolicy = account.config.dmPolicy ?? "open";
|
||||||
|
const configAllowFrom = (account.config.allowFrom ?? []).map((v) =>
|
||||||
|
String(v),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check authorization for DMs
|
||||||
|
if (!isGroup && dmPolicy !== "open") {
|
||||||
|
const allowed =
|
||||||
|
configAllowFrom.includes("*") ||
|
||||||
|
configAllowFrom.some(
|
||||||
|
(entry) => entry.toLowerCase() === senderId.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
if (dmPolicy === "pairing") {
|
||||||
|
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||||
|
channel: "qq",
|
||||||
|
id: senderId,
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
log?.info(`[${account.accountId}] Pairing request from ${senderId}`);
|
||||||
|
const replyText = core.channel.pairing.buildPairingReply({
|
||||||
|
channel: "qq",
|
||||||
|
idLine: `Your QQ OpenID: ${senderId}`,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendC2CMessage(token, senderId, {
|
||||||
|
content: replyText,
|
||||||
|
msg_type: 0,
|
||||||
|
msg_id: message.id,
|
||||||
|
});
|
||||||
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve agent route
|
||||||
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
|
cfg: config,
|
||||||
|
channel: "qq",
|
||||||
|
accountId: account.accountId,
|
||||||
|
peer: {
|
||||||
|
kind: isGroup ? "group" : "dm",
|
||||||
|
id: chatId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromLabel = isGroup ? `group:${chatId}` : `user:${senderId}`;
|
||||||
|
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||||
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
|
channel: "QQ",
|
||||||
|
from: fromLabel,
|
||||||
|
timestamp: new Date(message.timestamp).getTime(),
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
|
body: rawBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
|
Body: body,
|
||||||
|
RawBody: rawBody,
|
||||||
|
CommandBody: rawBody,
|
||||||
|
From: isGroup ? `qq:group:${chatId}` : `qq:${senderId}`,
|
||||||
|
To: `qq:${chatId}`,
|
||||||
|
SessionKey: route.sessionKey,
|
||||||
|
AccountId: route.accountId,
|
||||||
|
ChatType: isGroup ? "group" : "direct",
|
||||||
|
ConversationLabel: fromLabel,
|
||||||
|
SenderId: senderId,
|
||||||
|
Provider: "qq",
|
||||||
|
Surface: "qq",
|
||||||
|
MessageSid: message.id,
|
||||||
|
OriginatingChannel: "qq",
|
||||||
|
OriginatingTo: `qq:${chatId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await core.channel.session.recordInboundSession({
|
||||||
|
storePath,
|
||||||
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
|
ctx: ctxPayload,
|
||||||
|
onRecordError: (err) => {
|
||||||
|
log?.error(`[${account.accountId}] Failed updating session: ${String(err)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg: config,
|
||||||
|
channel: "qq",
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg: config,
|
||||||
|
dispatcherOptions: {
|
||||||
|
deliver: async (payload) => {
|
||||||
|
await deliverQQReply({
|
||||||
|
payload,
|
||||||
|
token,
|
||||||
|
chatType,
|
||||||
|
chatId,
|
||||||
|
msgId: message.id,
|
||||||
|
tableMode,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
log?.error(
|
||||||
|
`[${account.accountId}] QQ ${info.kind} reply failed: ${String(err)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deliverQQReply = async (params: {
|
||||||
|
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
|
||||||
|
token: string;
|
||||||
|
chatType: "c2c" | "group";
|
||||||
|
chatId: string;
|
||||||
|
msgId?: string;
|
||||||
|
tableMode?: MarkdownTableMode;
|
||||||
|
}): Promise<void> => {
|
||||||
|
const { payload, token, chatType, chatId, msgId } = params;
|
||||||
|
const tableMode = params.tableMode ?? "code";
|
||||||
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||||
|
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const chunkMode = core.channel.text.resolveChunkMode(config, "qq", account.accountId);
|
||||||
|
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
||||||
|
text,
|
||||||
|
QQ_TEXT_LIMIT,
|
||||||
|
chunkMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendFn = chatType === "c2c" ? sendC2CMessage : sendGroupMessage;
|
||||||
|
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
const chunk = chunks[i];
|
||||||
|
try {
|
||||||
|
await sendFn(token, chatId, {
|
||||||
|
content: chunk,
|
||||||
|
msg_type: 0,
|
||||||
|
msg_id: i === 0 ? msgId : undefined, // Only use msg_id for first chunk
|
||||||
|
msg_seq: i + 1,
|
||||||
|
});
|
||||||
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(`[${account.accountId}] QQ message send failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start initial connection
|
||||||
|
await connect();
|
||||||
|
|
||||||
|
return { stop };
|
||||||
|
}
|
||||||
46
extensions/qq/src/probe.ts
Normal file
46
extensions/qq/src/probe.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* QQ Bot Probe - Verify token validity
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getAccessToken, getGatewayUrl, type QQFetch } from "./api.js";
|
||||||
|
|
||||||
|
export interface ProbeResult {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
gatewayUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe QQ Bot connection by verifying token and getting gateway URL
|
||||||
|
*/
|
||||||
|
export async function probeQQ(
|
||||||
|
appId: string,
|
||||||
|
appSecret: string,
|
||||||
|
timeoutMs = 5000,
|
||||||
|
fetcher?: QQFetch,
|
||||||
|
): Promise<ProbeResult> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
const fetchWithTimeout: QQFetch = (input, init) =>
|
||||||
|
(fetcher ?? fetch)(input, { ...init, signal: controller.signal });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await getAccessToken(appId, appSecret, fetchWithTimeout);
|
||||||
|
const gatewayUrl = await getGatewayUrl(token, fetchWithTimeout);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
gatewayUrl,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
18
extensions/qq/src/runtime.ts
Normal file
18
extensions/qq/src/runtime.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* QQ Bot Runtime Context
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MoltbotCoreRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let qqRuntime: MoltbotCoreRuntime | null = null;
|
||||||
|
|
||||||
|
export function setQQRuntime(runtime: MoltbotCoreRuntime): void {
|
||||||
|
qqRuntime = runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQQRuntime(): MoltbotCoreRuntime {
|
||||||
|
if (!qqRuntime) {
|
||||||
|
throw new Error("QQ runtime not initialized");
|
||||||
|
}
|
||||||
|
return qqRuntime;
|
||||||
|
}
|
||||||
117
extensions/qq/src/send.ts
Normal file
117
extensions/qq/src/send.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* QQ Bot Message Sending
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MoltbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import {
|
||||||
|
getAccessToken,
|
||||||
|
sendC2CMessage,
|
||||||
|
sendGroupMessage,
|
||||||
|
sendChannelMessage,
|
||||||
|
sendDmsMessage,
|
||||||
|
type SendMessageResult,
|
||||||
|
} from "./api.js";
|
||||||
|
import { resolveQQAccount } from "./accounts.js";
|
||||||
|
|
||||||
|
const QQ_TEXT_LIMIT = 2000;
|
||||||
|
|
||||||
|
export interface SendQQMessageOptions {
|
||||||
|
accountId?: string;
|
||||||
|
cfg?: MoltbotConfig;
|
||||||
|
appId?: string;
|
||||||
|
appSecret?: string;
|
||||||
|
msgId?: string;
|
||||||
|
msgSeq?: number;
|
||||||
|
mediaUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatType = "c2c" | "group" | "channel" | "dms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message via QQ Bot API
|
||||||
|
*/
|
||||||
|
export async function sendMessageQQ(
|
||||||
|
chatType: ChatType,
|
||||||
|
targetId: string,
|
||||||
|
text: string | undefined,
|
||||||
|
options: SendQQMessageOptions,
|
||||||
|
): Promise<SendMessageResult> {
|
||||||
|
const { accountId, cfg, msgId, msgSeq } = options;
|
||||||
|
|
||||||
|
let appId = options.appId;
|
||||||
|
let appSecret = options.appSecret;
|
||||||
|
|
||||||
|
// Resolve from config if not provided directly
|
||||||
|
if ((!appId || !appSecret) && cfg) {
|
||||||
|
const account = resolveQQAccount({ cfg, accountId });
|
||||||
|
appId = account.appId;
|
||||||
|
appSecret = account.appSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appId || !appSecret) {
|
||||||
|
return { ok: false, error: "QQ appId or appSecret not configured" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await getAccessToken(appId, appSecret);
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
content: text,
|
||||||
|
msg_type: 0, // text message
|
||||||
|
msg_id: msgId,
|
||||||
|
msg_seq: msgSeq,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (chatType) {
|
||||||
|
case "c2c":
|
||||||
|
return sendC2CMessage(token, targetId, request);
|
||||||
|
case "group":
|
||||||
|
return sendGroupMessage(token, targetId, request);
|
||||||
|
case "channel":
|
||||||
|
return sendChannelMessage(token, targetId, request);
|
||||||
|
case "dms":
|
||||||
|
return sendDmsMessage(token, targetId, request);
|
||||||
|
default:
|
||||||
|
return { ok: false, error: `Unknown chat type: ${chatType}` };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunk text for QQ message limit
|
||||||
|
*/
|
||||||
|
export function chunkQQText(text: string, limit = QQ_TEXT_LIMIT): string[] {
|
||||||
|
if (!text) return [];
|
||||||
|
if (text.length <= limit) return [text];
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let remaining = text;
|
||||||
|
|
||||||
|
while (remaining.length > limit) {
|
||||||
|
const window = remaining.slice(0, limit);
|
||||||
|
const lastNewline = window.lastIndexOf("\n");
|
||||||
|
const lastSpace = window.lastIndexOf(" ");
|
||||||
|
let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
|
||||||
|
if (breakIdx <= 0) breakIdx = limit;
|
||||||
|
|
||||||
|
const rawChunk = remaining.slice(0, breakIdx);
|
||||||
|
const chunk = rawChunk.trimEnd();
|
||||||
|
if (chunk.length > 0) chunks.push(chunk);
|
||||||
|
|
||||||
|
const brokeOnSeparator =
|
||||||
|
breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
||||||
|
const nextStart = Math.min(
|
||||||
|
remaining.length,
|
||||||
|
breakIdx + (brokeOnSeparator ? 1 : 0),
|
||||||
|
);
|
||||||
|
remaining = remaining.slice(nextStart).trimStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining.length) chunks.push(remaining);
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
193
extensions/qq/src/types.ts
Normal file
193
extensions/qq/src/types.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* QQ Bot API Types
|
||||||
|
*/
|
||||||
|
|
||||||
|
// WebSocket OpCodes
|
||||||
|
export const OpCode = {
|
||||||
|
/** Server push event */
|
||||||
|
Dispatch: 0,
|
||||||
|
/** Client/Server heartbeat */
|
||||||
|
Heartbeat: 1,
|
||||||
|
/** Client identify */
|
||||||
|
Identify: 2,
|
||||||
|
/** Client resume */
|
||||||
|
Resume: 6,
|
||||||
|
/** Server reconnect hint */
|
||||||
|
Reconnect: 7,
|
||||||
|
/** Server invalid session */
|
||||||
|
InvalidSession: 9,
|
||||||
|
/** Server hello */
|
||||||
|
Hello: 10,
|
||||||
|
/** Server heartbeat ack */
|
||||||
|
HeartbeatAck: 11,
|
||||||
|
/** Server HTTP callback ack */
|
||||||
|
HTTPCallbackAck: 12,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type OpCodeType = (typeof OpCode)[keyof typeof OpCode];
|
||||||
|
|
||||||
|
// Gateway Payload
|
||||||
|
export interface GatewayPayload<T = unknown> {
|
||||||
|
op: OpCodeType;
|
||||||
|
d?: T;
|
||||||
|
s?: number;
|
||||||
|
t?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hello Data
|
||||||
|
export interface HelloData {
|
||||||
|
heartbeat_interval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready Data
|
||||||
|
export interface ReadyData {
|
||||||
|
version: number;
|
||||||
|
session_id: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
bot: boolean;
|
||||||
|
};
|
||||||
|
shard: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify Payload
|
||||||
|
export interface IdentifyData {
|
||||||
|
token: string;
|
||||||
|
intents: number;
|
||||||
|
shard?: [number, number];
|
||||||
|
properties?: {
|
||||||
|
$os?: string;
|
||||||
|
$browser?: string;
|
||||||
|
$device?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume Payload
|
||||||
|
export interface ResumeData {
|
||||||
|
token: string;
|
||||||
|
session_id: string;
|
||||||
|
seq: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message Author
|
||||||
|
export interface MessageAuthor {
|
||||||
|
user_openid?: string;
|
||||||
|
member_openid?: string;
|
||||||
|
id?: string;
|
||||||
|
username?: string;
|
||||||
|
bot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message Attachment
|
||||||
|
export interface MessageAttachment {
|
||||||
|
content_type: string;
|
||||||
|
filename?: string;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
size?: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inbound Message Event
|
||||||
|
export interface QQMessageEvent {
|
||||||
|
id: string;
|
||||||
|
author: MessageAuthor;
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
group_openid?: string;
|
||||||
|
guild_id?: string;
|
||||||
|
channel_id?: string;
|
||||||
|
attachments?: MessageAttachment[];
|
||||||
|
msg_seq?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access Token Response
|
||||||
|
export interface AccessTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway URL Response
|
||||||
|
export interface GatewayResponse {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Message Request
|
||||||
|
export interface SendMessageRequest {
|
||||||
|
content?: string;
|
||||||
|
msg_type: number; // 0=text, 2=markdown, 3=ark, 4=embed, 7=media
|
||||||
|
msg_id?: string;
|
||||||
|
msg_seq?: number;
|
||||||
|
event_id?: string;
|
||||||
|
markdown?: MarkdownPayload;
|
||||||
|
keyboard?: unknown;
|
||||||
|
ark?: unknown;
|
||||||
|
media?: MediaPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownPayload {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaPayload {
|
||||||
|
file_info: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Message Response
|
||||||
|
export interface SendMessageResponse {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Error Response
|
||||||
|
export interface QQApiError {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intents Flags
|
||||||
|
export const Intents = {
|
||||||
|
/** 频道相关 */
|
||||||
|
GUILDS: 1 << 0,
|
||||||
|
GUILD_MEMBERS: 1 << 1,
|
||||||
|
GUILD_MESSAGES: 1 << 9,
|
||||||
|
GUILD_MESSAGE_REACTIONS: 1 << 10,
|
||||||
|
DIRECT_MESSAGE: 1 << 12,
|
||||||
|
/** 群聊 @ 机器人 */
|
||||||
|
GROUP_AT_MESSAGE_CREATE: 1 << 25,
|
||||||
|
/** 单聊消息 */
|
||||||
|
C2C_MESSAGE_CREATE: 1 << 25,
|
||||||
|
/** 公域消息 (频道 @ 机器人) */
|
||||||
|
PUBLIC_GUILD_MESSAGES: 1 << 30,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Common intents for bot
|
||||||
|
export const DEFAULT_INTENTS =
|
||||||
|
Intents.GUILDS |
|
||||||
|
Intents.GUILD_MEMBERS |
|
||||||
|
Intents.DIRECT_MESSAGE |
|
||||||
|
Intents.GROUP_AT_MESSAGE_CREATE |
|
||||||
|
Intents.C2C_MESSAGE_CREATE;
|
||||||
|
|
||||||
|
// Event Types
|
||||||
|
export const EventType = {
|
||||||
|
// 单聊
|
||||||
|
C2C_MESSAGE_CREATE: "C2C_MESSAGE_CREATE",
|
||||||
|
// 群聊 @ 机器人
|
||||||
|
GROUP_AT_MESSAGE_CREATE: "GROUP_AT_MESSAGE_CREATE",
|
||||||
|
// 频道私信
|
||||||
|
DIRECT_MESSAGE_CREATE: "DIRECT_MESSAGE_CREATE",
|
||||||
|
// 频道 @ 机器人
|
||||||
|
AT_MESSAGE_CREATE: "AT_MESSAGE_CREATE",
|
||||||
|
// 频道全量消息 (私域)
|
||||||
|
MESSAGE_CREATE: "MESSAGE_CREATE",
|
||||||
|
// 连接就绪
|
||||||
|
READY: "READY",
|
||||||
|
// 连接恢复
|
||||||
|
RESUMED: "RESUMED",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type EventTypeType = (typeof EventType)[keyof typeof EventType];
|
||||||
Reference in New Issue
Block a user