Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 76 additions & 37 deletions docs/adapters/desktop/discord.md
Original file line number Diff line number Diff line change
@@ -1,65 +1,104 @@
# Discord
# Discord 桌面端

Control the **Discord Desktop App** from the terminal via Chrome DevTools Protocol (CDP).
通过 Chrome DevTools Protocol(CDP)从终端控制已登录的 **Discord Desktop App**

## Prerequisites
## 前置条件

- 已安装 OpenCLI。
- 已安装 Discord 桌面端并完成登录。
- CDP 只监听本机 `127.0.0.1:9232`。

正常情况下无需预先设置 `OPENCLI_CDP_ENDPOINT`。直接运行:

Launch with remote debugging port:
```bash
/Applications/Discord.app/Contents/MacOS/Discord --remote-debugging-port=9232
opencli discord-app status -f json
```

## Setup
OpenCLI 会按以下顺序处理:

1. 如果 `9232` 已暴露 Discord 页面目标,直接连接。
2. macOS/Windows 下自动发现 Discord 安装路径。
3. 如果 Discord 正在运行但未开启 CDP,先请求确认,再重启应用;确认默认选择“否”,非交互环境不会自动重启。
4. 使用 `--remote-debugging-port=9232 --remote-allow-origins=*` 启动并等待就绪。

Windows 默认发现 `%LOCALAPPDATA%\Discord\app-*\Discord.exe`。找不到安装目录时,检查 Discord 是否采用了自定义安装路径。

Linux 暂不支持自动发现;请手动启动:

```bash
discord --remote-debugging-port=9232 --remote-allow-origins=*
```

只有使用自定义 HTTP(S) 端口或远程端点时才设置:

```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9232"
```

## Commands
PowerShell:

```powershell
$env:OPENCLI_CDP_ENDPOINT = 'http://127.0.0.1:9232'
```

CDP 没有应用层认证。不要把调试端点暴露到公网;远程场景应使用受信任的 SSH/VPN 隧道。

## 命令

| Command | Description |
|---------|-------------|
| `opencli discord-app status` | Check CDP connection |
| `opencli discord-app send "message"` | Send a message in the active channel |
| `opencli discord-app read` | Read recent messages |
| `opencli discord-app channels` | List channels in the current server, including `guild_id`, `channel_id`, and `url` |
| `opencli discord-app servers` | List visible joined servers, including `guild_id` and `url` |
| `opencli discord-app goto` | Open a channel by id/name/url without sending messages |
| `opencli discord-app threads` | List visible forum/thread posts in a channel |
| `opencli discord-app thread-read` | Read a forum/thread post by id or URL |
| `opencli discord-app search "query"` | Search messages (Cmd+F) |
| `opencli discord-app members` | List online members |
| `opencli discord-app delete MESSAGE_ID` | Delete a message by its ID |
| 命令 | 说明 |
|---|---|
| `opencli discord-app status` | 检查 CDP 连接 |
| `opencli discord-app send "message"` | 在活动频道发送消息 |
| `opencli discord-app read` | 读取近期消息 |
| `opencli discord-app channels` | 列出当前服务器的频道及稳定 ID/URL |
| `opencli discord-app servers` | 列出侧栏可见的已加入服务器 |
| `opencli discord-app goto` | 通过 ID、名称或 URL 打开频道,不发送消息 |
| `opencli discord-app threads` | 列出可见的论坛帖子或线程 |
| `opencli discord-app thread-read` | 读取指定论坛帖子或线程 |
| `opencli discord-app search "query"` | 使用 Discord UI 搜索消息 |
| `opencli discord-app members` | 列出当前频道可见的在线成员 |
| `opencli discord-app delete MESSAGE_ID` | 删除指定消息 |

## Read-only channel targeting
## 只读定向采集

优先使用数字 ID 或完整 URL;名称解析仅针对当前侧栏可见频道,属于尽力而为。

```bash
# Get stable channel ids/URLs from the currently open server.
# 获取当前服务器的稳定频道 ID/URL。
opencli discord-app channels -f json

# Open one channel without clicking the UI.
opencli discord-app goto --url https://discord.com/channels/<guild_id>/<channel_id>
opencli discord-app goto --guild <guild_id> --channel <channel_id>

# Read after navigating internally. Omitting the target keeps the old behavior:
# read the currently active channel.
# 定向读取频道。
opencli discord-app read --url https://discord.com/channels/<guild_id>/<channel_id> --count 20 -f json
opencli discord-app read --guild <guild_id> --channel <channel_id> --count 20 -f json
```

Passing numeric IDs or a channel URL is the most stable mode. Name lookup is
best-effort for channels visible in the current Discord sidebar.
# 仅导航,不读取或发送消息。
opencli discord-app goto --url https://discord.com/channels/<guild_id>/<channel_id>
```

Opening a channel through Discord's own route can update Discord's normal
client-side read/unread state, just like manually opening the channel.
通过 Discord 自身路由打开频道,会像人工点击一样改变正常的已读/未读状态。

## Forum and thread-style channels
## 论坛和线程

```bash
# List visible forum/thread post cards from the active or targeted channel.
opencli discord-app threads --url https://discord.com/channels/<guild_id>/<forum_channel_id> -f json

# Read a selected post/thread.
opencli discord-app thread-read --url https://discord.com/channels/<guild_id>/<forum_channel_id>/<thread_id> --count 20 -f json
opencli discord-app thread-read --guild <guild_id> --channel <forum_channel_id> --thread <thread_id> --count 20 -f json
```

## 数据边界

- `read` 只提取当前 DOM 已加载的消息,不提供完整历史分页。
- 消息正文最多保留约 300 字符。
- `search` 结果正文最多保留约 200 字符,通常不包含稳定消息 ID。
- `members` 是当前 UI 可见的在线成员,不是完整成员目录。
- `threads` 只列出当前 UI 已渲染的帖子卡片。

需要历史同步、SQLite 检索或批量导出时,应使用独立的 `discord-cli` 路径,并明确评估 user-token 自动化的账号风险。

## 故障排查

- `CDP port 9232 is active but does not belong to Discord`:端口被其他 CDP 程序占用;关闭冲突程序后重试。
- Discord 已运行但命令要求重启:确认后 OpenCLI 才会结束匹配安装目录的 Discord 进程。
- 找不到 Discord:确认安装位置;自定义 Electron 应用可在 `~/.opencli/apps.yaml` 中登记 `windowsInstallDirs`。
- Chromium 142+ WebSocket 返回 `403`:确保启动参数包含 `--remote-allow-origins=*`。
- 选择器漂移:使用 `--trace retain-on-failure`,但 trace 可能包含私密消息,必须按敏感数据处理。
11 changes: 11 additions & 0 deletions src/electron-apps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ describe('electron-apps registry', () => {
expect(app!.executableNames).toEqual(['ChatGPT', 'Codex']);
});

it('registers Discord Windows discovery and CDP identity metadata', () => {
const app = getElectronApp('discord-app');

expect(app).toMatchObject({
port: 9232,
processName: 'Discord',
windowsInstallDirs: ['%LOCALAPPDATA%\\Discord'],
cdpHosts: ['discord.com', 'canary.discord.com', 'ptb.discord.com'],
});
});

it('keeps builtin Electron app CDP ports unique and off the browser-bridge port', () => {
const ports = Object.values(builtinApps).map((app) => app.port);

Expand Down
17 changes: 14 additions & 3 deletions src/electron-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,20 @@ import yaml from 'js-yaml';
export interface ElectronAppEntry {
/** CDP debug port (unique per app) */
port: number;
/** macOS process name for detection via pgrep */
/** Process or executable name used for platform-specific detection */
processName: string;
/** Candidate executable names inside Contents/MacOS/, tried in order */
/** Candidate executable names, tried in order */
executableNames?: string[];
/** macOS bundle ID for path discovery */
bundleId?: string;
/** Human-readable name for prompts */
displayName?: string;
/** Additional launch args beyond --remote-debugging-port */
extraArgs?: string[];
/** Windows installation roots. Supports %ENV_VAR% placeholders. */
windowsInstallDirs?: string[];
/** Expected page hosts exposed by the app's CDP target list. */
cdpHosts?: string[];
}

export const builtinApps: Record<string, ElectronAppEntry> = {
Expand All @@ -35,7 +39,14 @@ export const builtinApps: Record<string, ElectronAppEntry> = {
displayName: 'Codex',
},
chatwise: { port: 9228, processName: 'ChatWise', bundleId: 'com.chatwise.app', displayName: 'ChatWise' },
'discord-app': { port: 9232, processName: 'Discord', bundleId: 'com.discord.app', displayName: 'Discord' },
'discord-app': {
port: 9232,
processName: 'Discord',
bundleId: 'com.discord.app',
displayName: 'Discord',
windowsInstallDirs: ['%LOCALAPPDATA%\\Discord'],
cdpHosts: ['discord.com', 'canary.discord.com', 'ptb.discord.com'],
},
'doubao-app': { port: 9225, processName: 'Doubao', bundleId: 'com.volcengine.doubao', displayName: 'Doubao' },
antigravity: {
port: 9234,
Expand Down
64 changes: 49 additions & 15 deletions src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,24 +552,58 @@ describe('executeCommand', () => {
});

it('uses launcher for registered Electron apps (chatwise)', async () => {
// Mock the launcher to return a fake endpoint (avoids real HTTP/process calls)
vi.stubEnv('OPENCLI_CDP_ENDPOINT', '');
const launcher = await import('./launcher.js');
const spy = vi.spyOn(launcher, 'resolveElectronEndpoint')
const { CDPBridge } = await import('./browser/cdp.js');
const launcherSpy = vi.spyOn(launcher, 'resolveElectronEndpoint')
.mockResolvedValue('http://127.0.0.1:9228');
const connectSpy = vi.spyOn(CDPBridge.prototype, 'connect')
.mockRejectedValue(new Error('expected test CDP failure'));

const cmd = cli({
site: 'chatwise',
name: 'status', access: 'read',
description: 'chatwise status',
browser: true,
strategy: Strategy.PUBLIC,
func: async () => [{ ok: true }],
});
try {
const cmd = cli({
site: 'chatwise',
name: 'status', access: 'read',
description: 'chatwise status',
browser: true,
strategy: Strategy.PUBLIC,
func: async () => [{ ok: true }],
});

// CDPBridge.connect() will fail (no actual CDP server), but the launcher
// should have been called with 'chatwise'.
await expect(executeCommand(cmd, {})).rejects.toThrow();
expect(spy).toHaveBeenCalledWith('chatwise');
spy.mockRestore();
await expect(executeCommand(cmd, {})).rejects.toThrow('expected test CDP failure');
expect(launcherSpy).toHaveBeenCalledWith('chatwise');
expect(connectSpy).toHaveBeenCalledWith(expect.objectContaining({
cdpEndpoint: 'http://127.0.0.1:9228',
}));
} finally {
connectSpy.mockRestore();
launcherSpy.mockRestore();
vi.unstubAllEnvs();
}
});

it('preserves direct WebSocket overrides for Electron apps without identity metadata', async () => {
const endpoint = 'ws://127.0.0.1:9228/devtools/page/test';
vi.stubEnv('OPENCLI_CDP_ENDPOINT', endpoint);
const { CDPBridge } = await import('./browser/cdp.js');
const connectSpy = vi.spyOn(CDPBridge.prototype, 'connect')
.mockRejectedValue(new Error('expected direct WebSocket failure'));

try {
const cmd = cli({
site: 'chatwise',
name: 'ws-status-test', access: 'read',
description: 'chatwise direct websocket status',
browser: true,
strategy: Strategy.PUBLIC,
func: async () => [{ ok: true }],
});

await expect(executeCommand(cmd, {})).rejects.toThrow('expected direct WebSocket failure');
expect(connectSpy).toHaveBeenCalledWith(expect.objectContaining({ cdpEndpoint: endpoint }));
} finally {
connectSpy.mockRestore();
vi.unstubAllEnvs();
}
});
});
19 changes: 14 additions & 5 deletions src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ import { profileRouteParams, resolveProfileSelection } from './browser/profile.j
import { clearDaemonRunContext, generateRunId, isUnknownOutcomeError, releaseSiteSessionLease, setDaemonCommandTimeoutSeconds, setDaemonRunContext } from './browser/daemon-client.js';
import { emitHook, type HookContext } from './hooks.js';
import { log } from './logger.js';
import { isElectronApp } from './electron-apps.js';
import { probeCDP, resolveElectronEndpoint } from './launcher.js';
import { getElectronApp, isElectronApp } from './electron-apps.js';
import { probeCDPEndpoint, resolveElectronEndpoint } from './launcher.js';
import { ObservationSession, exportObservationSession, type ObservationExportResult, type ObservationExportStatus } from './observation/index.js';
import { resolveAdapterSourcePath } from './adapter-source.js';

Expand Down Expand Up @@ -241,11 +241,20 @@ export async function executeCommand(
// Electron apps: respect manual endpoint override, then try auto-detect
const manualEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
if (manualEndpoint) {
const port = Number(new URL(manualEndpoint).port);
if (!await probeCDP(port)) {
const app = getElectronApp(cmd.site);
let protocol: string | undefined;
try {
protocol = new URL(manualEndpoint).protocol;
} catch {
// The endpoint probe below returns a controlled error for malformed URLs.
}
const directWebSocket = protocol === 'ws:' || protocol === 'wss:';
const identityRequired = Boolean(app?.cdpHosts?.length);
if ((!directWebSocket || identityRequired)
&& !await probeCDPEndpoint(manualEndpoint, undefined, app?.cdpHosts)) {
throw new CommandExecutionError(
`CDP not reachable at ${manualEndpoint}`,
'Check that the app is running with --remote-debugging-port and the endpoint is correct.',
'Check that the expected app is running with --remote-debugging-port and the endpoint is correct.',
);
}
cdpEndpoint = manualEndpoint;
Expand Down
Loading