Feature/support discord (#972)

* feat(discord): add channel setup and default all-channel access

* build(deps): require harness-gateway 0.9.9 for Discord
This commit is contained in:
薄生
2026-09-22 16:42:51 +08:00
committed by GitHub
parent f5a6404a83
commit 339c78deff
20 changed files with 571 additions and 24 deletions
+1
View File
@@ -371,6 +371,7 @@ OpenAI-compatible APIs, DashScope (Qwen), Ollama, and other presets — configur
| **DingTalk** | App Key, App Secret |
| **QQ** | Bot AppID, Token |
| **Telegram** | Bot Token |
| **Discord** | Bot Token; all accessible channels allowed by default, optional channel/DM allowlists; [setup and testing](docs/discord-channel.md) |
| **WeCom** | Corp ID, Agent Secret |
| **Web Dashboard** | Enabled by default |
+1
View File
@@ -372,6 +372,7 @@ OpenAI 兼容 API、DashScope(千问)、Ollama 等预设 — 在控制台或
| **钉钉** | App Key、App Secret |
| **QQ** | Bot AppID、Token |
| **Telegram** | Bot Token |
| **Discord** | Bot Token;默认允许所有可访问频道,可配置频道/私聊用户允许列表;[配置与验收](docs/discord-channel.md) |
| **企业微信** | Corp ID、Agent Secret |
| **Web 控制台** | 默认启用 |
+3
View File
@@ -5,8 +5,11 @@ export interface BaseChannelConfig {
export interface DiscordConfig extends BaseChannelConfig {
bot_token: string;
allow_all_channels?: boolean;
http_proxy: string;
http_proxy_auth: string;
allowed_channel_ids?: string[];
allowed_user_ids?: string[];
}
export interface DingTalkConfig extends BaseChannelConfig {
+7 -1
View File
@@ -2417,7 +2417,13 @@
"rawConfigTooltip": "Channel-specific config — see harness-gateway docs",
"getCredentials": "Get credentials",
"channelSettingsNamed": "{{kind}} channel settings",
"deleteConfirmTitle": "Delete channel \"{{name}}\"?"
"deleteConfirmTitle": "Delete channel \"{{name}}\"?",
"discordAllowedChannels": "Allowed channel IDs (comma or newline separated)",
"discordAllowedUsers": "Allowed DM user IDs (comma or newline separated)",
"discordInvalidIds": "Enter numeric IDs separated by commas or newlines.",
"discordAllowAllChannels": "Allow all accessible channels",
"discordAllowAllChannelsHelp": "On by default; no channel IDs needed. Turn off to restrict access to the channels listed below. Mention the bot to get a reply.",
"discordSetupHelp": "Enable Message Content Intent on the Discord Bot page and invite the bot to your server. All server text channels the bot can access are allowed by default; mention the bot to get a reply. DMs only accept the user IDs listed below; leave empty to disable DMs."
},
"tokenUsage": {
"title": "Token Usage Analytics",
+7 -1
View File
@@ -2417,7 +2417,13 @@
"rawConfigTooltip": "渠道特定配置 — 详见 harness-gateway 文档",
"getCredentials": "前往获取凭据",
"channelSettingsNamed": "{{kind}} 频道设置",
"deleteConfirmTitle": "删除频道「{{name}}」?"
"deleteConfirmTitle": "删除频道「{{name}}」?",
"discordAllowedChannels": "允许的频道 ID(逗号或换行分隔)",
"discordAllowedUsers": "允许私聊的用户 ID(逗号或换行分隔)",
"discordInvalidIds": "请输入数字 ID,用逗号或换行分隔。",
"discordAllowAllChannels": "允许所有可访问频道",
"discordAllowAllChannelsHelp": "默认开启,无需填写频道 ID。关闭后仅响应下方指定频道;频道内仍需 @机器人。",
"discordSetupHelp": "在 Discord Bot 页面开启 Message Content Intent,并邀请机器人加入服务器。默认允许机器人有权限访问的所有服务器文字频道,频道内需 @机器人。私聊仅允许下方填写的用户 ID,留空则不接收私聊。"
},
"tokenUsage": {
"title": "Token 消耗统计",
@@ -27,6 +27,7 @@ const api = vi.mocked(request, true);
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
// GET list -> empty; POST create -> server-echoed row with enabled=1
api.mockImplementation(async (_url: string, init?: RequestInit) => {
if (init?.method === "POST") {
@@ -37,6 +38,130 @@ beforeEach(() => {
});
describe("<ChannelsPanel /> create-flow default", () => {
it("defaults Discord to all channels and saves without channel IDs", async () => {
render(<ChannelsPanel agentId="ag1" />);
await userEvent.click(
await screen.findByRole("button", { name: /channels\.showMoreChannels/ }),
);
await userEvent.click(
(await screen.findAllByText("channels.label_discord"))[0],
);
expect(
await screen.findByLabelText("channels.discordAllowAllChannels"),
).toBeChecked();
expect(
screen.getByLabelText("channels.discordAllowedChannels"),
).toBeDisabled();
expect(screen.getByLabelText("channels.discordAllowedUsers")).toBeEnabled();
await userEvent.type(
screen.getByLabelText(/Bot Token/i),
"fake-discord-token",
);
await userEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => {
const post = api.mock.calls.find(([, init]) => init?.method === "POST");
expect(post).toBeDefined();
expect(JSON.parse(String(post![1]!.body)).config).toMatchObject({
bot_token: "fake-discord-token",
allow_all_channels: true,
});
});
});
it.each([undefined, true, false])(
"loads and saves Discord all-channels setting %s without losing boolean false",
async (allowAll) => {
const row = { id: "d1", kind: "discord", name: "discord", enabled: true };
api.mockImplementation(async (url, init) => {
if (init?.method === "PATCH") return row;
if (url.endsWith("/d1"))
return {
...row,
config: {
bot_token: "fake",
...(allowAll === undefined
? {}
: { allow_all_channels: allowAll }),
allowed_channel_ids: ["1234567890123456789"],
},
};
return [row];
});
render(<ChannelsPanel agentId="ag1" />);
await userEvent.click(
(await screen.findAllByText("channels.label_discord"))[0],
);
const toggle = await screen.findByLabelText(
"channels.discordAllowAllChannels",
);
expect(toggle.getAttribute("aria-checked")).toBe(
String(allowAll ?? true),
);
const ids = screen.getByLabelText("channels.discordAllowedChannels");
expect(ids).toHaveValue("1234567890123456789");
if (allowAll === false) expect(ids).toBeEnabled();
else expect(ids).toBeDisabled();
await userEvent.click(
screen.getByRole("button", { name: "common.save" }),
);
await waitFor(() => {
const patch = api.mock.calls.find(
([, init]) => init?.method === "PATCH",
);
expect(patch).toBeDefined();
expect(JSON.parse(String(patch![1]!.body)).config).toMatchObject({
allow_all_channels: allowAll ?? true,
allowed_channel_ids: ["1234567890123456789"],
});
});
},
);
it("opens Discord and saves the token with exact channel/user IDs", async () => {
render(<ChannelsPanel agentId="ag1" />);
await userEvent.click(
await screen.findByRole("button", { name: /channels\.showMoreChannels/ }),
);
await userEvent.click(
(await screen.findAllByText("channels.label_discord"))[0],
);
expect(
await screen.findByText("channels.discordSetupHelp"),
).toBeInTheDocument();
await userEvent.click(
screen.getByLabelText("channels.discordAllowAllChannels"),
);
expect(
screen.getByLabelText("channels.discordAllowedChannels"),
).toBeEnabled();
await userEvent.type(
await screen.findByLabelText(/Bot Token/i),
"fake-discord-token",
);
await userEvent.type(
screen.getByLabelText("channels.discordAllowedChannels"),
"1234567890123456789,2345678901234567890",
);
await userEvent.type(
screen.getByLabelText("channels.discordAllowedUsers"),
"3456789012345678901",
);
await userEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => {
const post = api.mock.calls.find(([, init]) => init?.method === "POST");
expect(post).toBeDefined();
expect(JSON.parse(String(post![1]!.body))).toMatchObject({
kind: "discord",
config: {
bot_token: "fake-discord-token",
allow_all_channels: false,
allowed_channel_ids: ["1234567890123456789", "2345678901234567890"],
allowed_user_ids: ["3456789012345678901"],
},
});
});
});
async function openTelegramCreateDrawer() {
render(<ChannelsPanel agentId="ag1" />);
// Telegram is collapsed behind "更多通道" until expanded.
@@ -163,6 +163,7 @@ export default function ChannelsPanel({ agentId }: ChannelsPanelProps) {
// signature and no start/stop churn happens behind the save.
enabled: true,
...DEFAULT_CHANNEL_DISPLAY_CONFIG,
...(kind === "discord" ? { allow_all_channels: true } : {}),
...(kind === "qq"
? { group_context: { ...DEFAULT_QQ_GROUP_CONTEXT_CONFIG } }
: {}),
@@ -202,7 +203,13 @@ export default function ChannelsPanel({ agentId }: ChannelsPanelProps) {
) {
continue;
}
if (row.kind === "qq" && k === "group_context") {
if (
row.kind === "discord" &&
(k === "allowed_channel_ids" || k === "allowed_user_ids") &&
Array.isArray(v)
) {
formCfg[k] = v.join("\n");
} else if (row.kind === "qq" && k === "group_context") {
formCfg[k] = normalizeQqGroupContextConfig(v);
} else if (typeof v === "string") formCfg[k] = v;
else if (typeof v === "number" || typeof v === "boolean")
@@ -232,6 +239,9 @@ export default function ChannelsPanel({ agentId }: ChannelsPanelProps) {
? cfg.show_tool_hints
: DEFAULT_CHANNEL_DISPLAY_CONFIG.show_tool_hints,
...formCfg,
...(row.kind === "discord"
? { allow_all_channels: cfg.allow_all_channels !== false }
: {}),
__raw_config: JSON.stringify(cfg, null, 2),
};
setDrawerInitialValues(next);
@@ -160,8 +160,29 @@ interface ChannelDrawerProps {
agentId: string;
}
function FormItemForField({ field }: { field: ChannelField }) {
function FormItemForField({
field,
disabled = false,
}: {
field: ChannelField;
disabled?: boolean;
}) {
const { t } = useTranslation();
const label = field.label.startsWith("channels.")
? t(field.label)
: field.label;
if (field.type === "switch") {
return (
<Form.Item
name={field.name}
label={label}
valuePropName="checked"
extra={field.helpKey ? t(field.helpKey) : undefined}
>
<Switch />
</Form.Item>
);
}
const Input1 =
field.type === "password"
? Input.Password
@@ -172,7 +193,7 @@ function FormItemForField({ field }: { field: ChannelField }) {
? [
{
required: true,
message: t("channels.fieldRequired", { label: field.label }),
message: t("channels.fieldRequired", { label }),
},
]
: [];
@@ -190,9 +211,24 @@ function FormItemForField({ field }: { field: ChannelField }) {
},
});
}
if (
field.name === "allowed_channel_ids" ||
field.name === "allowed_user_ids"
) {
rules.push({
validator: async (_: unknown, value: unknown) => {
try {
normalizeChannelFieldValue(field.name, value);
} catch {
throw new Error(t("channels.discordInvalidIds"));
}
},
});
}
return (
<Form.Item name={field.name} label={field.label} rules={rules}>
<Form.Item name={field.name} label={label} rules={rules}>
<Input1
disabled={disabled}
placeholder={field.placeholder}
{...(field.type === "textarea" || field.type === "json"
? { rows: 5 }
@@ -486,6 +522,8 @@ export function ChannelDrawer({
agentId,
}: ChannelDrawerProps) {
const { t } = useTranslation();
const allowAllDiscordChannels =
Form.useWatch("allow_all_channels", form) !== false;
const isEdit = editing !== null;
const [selectedKind, setSelectedKind] = useState<ChannelKey>(
initialValues?.kind ?? "feishu",
@@ -1841,8 +1879,26 @@ export function ChannelDrawer({
</Form.Item>
)}
{selectedKind === "discord" && (
<Alert
type="info"
showIcon
message={t("channels.discordSetupHelp")}
style={{ marginBottom: 16 }}
/>
)}
{hasSchema ? (
fields!.map((f) => <FormItemForField key={f.name} field={f} />)
fields!.map((f) => (
<FormItemForField
key={f.name}
field={f}
disabled={
selectedKind === "discord" &&
f.name === "allowed_channel_ids" &&
allowAllDiscordChannels
}
/>
))
) : (
<Form.Item
name="__raw_config"
@@ -6,8 +6,45 @@ import {
DEFAULT_QQ_GROUP_CONTEXT_CONFIG,
normalizeQqGroupContextConfig,
partitionChannelKeys,
CHANNEL_KEYS,
CHANNEL_FIELDS,
normalizeChannelFieldValue,
} from "./constants";
describe("Discord configuration", () => {
it("exposes Discord in more channels and keeps configured bots visible", () => {
expect(CHANNEL_KEYS).toContain("discord");
expect(partitionChannelKeys(["discord"], new Set()).more).toEqual([
"discord",
]);
expect(
partitionChannelKeys(["discord"], new Set(["discord"])).featured,
).toEqual(["discord"]);
expect(
CHANNEL_FIELDS.discord?.find((f) => f.name === "bot_token"),
).toMatchObject({ required: true, type: "password" });
expect(
CHANNEL_FIELDS.discord?.find((f) => f.name === "http_proxy_auth"),
).toMatchObject({ type: "password" });
});
it("preserves snowflake IDs exactly and validates user input", () => {
expect(
normalizeChannelFieldValue(
"allowed_channel_ids",
"1234567890123456789, 2345678901234567890\n1234567890123456789",
),
).toEqual(["1234567890123456789", "2345678901234567890"]);
expect(normalizeChannelFieldValue("allowed_user_ids", "")).toEqual([]);
expect(normalizeChannelFieldValue("allowed_user_ids", ["123"])).toEqual([
"123",
]);
expect(() =>
normalizeChannelFieldValue("allowed_channel_ids", "#general"),
).toThrow();
});
});
describe("partitionChannelKeys", () => {
it("hides telegram until expanded unless already configured", () => {
expect(
@@ -33,7 +33,7 @@ export type ChannelKey =
/**
* Channel kinds backed by Octop ``ChannelKind`` / harness-gateway ``BUILTIN_CHANNELS``.
* ``dashboard`` / ``agentchat`` / ``discord`` are intentionally omitted until implemented.
* ``dashboard`` / ``agentchat`` are intentionally omitted until implemented.
*/
export const CHANNEL_KEYS: ChannelKey[] = [
"weixin",
@@ -43,12 +43,13 @@ export const CHANNEL_KEYS: ChannelKey[] = [
"yuanbao",
"dingtalk",
"telegram",
"discord",
"xiaoyi",
"mqtt",
];
/** Overseas / less-common kinds hidden behind "更多通道" until expanded. */
const COLLAPSED_CHANNEL_KEYS = new Set<ChannelKey>(["telegram"]);
const COLLAPSED_CHANNEL_KEYS = new Set<ChannelKey>(["telegram", "discord"]);
export function isCollapsedChannelKey(key: ChannelKey): boolean {
return COLLAPSED_CHANNEL_KEYS.has(key);
@@ -167,11 +168,13 @@ export interface ChannelField {
/** Visible label (Chinese; falls back when no i18n key). */
label: string;
/** Antd input type. */
type?: "text" | "password" | "textarea" | "json";
type?: "text" | "password" | "textarea" | "json" | "switch";
/** Placeholder for the input. */
placeholder?: string;
/** True when the field is required at create time. */
required?: boolean;
/** Optional i18n key for help beneath the control. */
helpKey?: string;
}
export const QQ_GROUP_VISIBILITIES = [
@@ -315,6 +318,22 @@ export const CHANNEL_FIELDS: Partial<Record<ChannelKey, ChannelField[]>> = {
],
discord: [
{ name: "bot_token", label: "Bot Token", type: "password", required: true },
{
name: "allow_all_channels",
label: "channels.discordAllowAllChannels",
type: "switch",
helpKey: "channels.discordAllowAllChannelsHelp",
},
{
name: "allowed_channel_ids",
label: "channels.discordAllowedChannels",
type: "textarea",
},
{
name: "allowed_user_ids",
label: "channels.discordAllowedUsers",
type: "textarea",
},
{
name: "http_proxy",
label: "HTTP Proxy",
@@ -323,6 +342,7 @@ export const CHANNEL_FIELDS: Partial<Record<ChannelKey, ChannelField[]>> = {
{
name: "http_proxy_auth",
label: "HTTP Proxy Auth",
type: "password",
placeholder: "user:password",
},
],
@@ -422,6 +442,18 @@ export function normalizeChannelFieldValue(
fieldName: string,
value: unknown,
): unknown {
if (fieldName === "allowed_channel_ids" || fieldName === "allowed_user_ids") {
const entries = Array.isArray(value)
? value
: String(value ?? "")
.trim()
.split(/[,\s]+/)
.filter(Boolean);
if (entries.some((entry) => !/^\d+$/.test(String(entry)))) {
throw new Error("Invalid Discord ID");
}
return [...new Set(entries.map(String))];
}
if (fieldName !== "group_context") return value;
return normalizeQqGroupContextConfig(value);
}
+75
View File
@@ -0,0 +1,75 @@
# Discord 通道:本地验收与配置
支持私聊、服务器文字频道、已有线程、文本/图片/附件、正在输入提示、长回复自动分段。
## 1. 安装依赖并启动
Discord 适配器已随 `harness-gateway 0.9.9` 发布。Octop 要求 `harness-gateway>=0.9.9`,可直接使用发布包,无需克隆相邻网关仓库:
```sh
cd Octop
uv sync --locked --extra dev
uv run octop run
```
可在 `uv run octop run` 后追加原有启动参数。先停止占用同一服务端口的旧进程;不要同时启动两个使用同一 Bot Token 的实例。
仅在联调相邻 `harness-im-bridge` 源码时使用 `bash scripts/run-discord-local.sh`;该脚本会覆盖发布包为本地可编辑依赖。恢复发布包时重新运行 `uv sync --locked --extra dev`。
## 2. 填写位置
打开本地 Octop 网页,进入 **Agent → 通道 → 更多通道 → Discord**。
1. **Bot Token**:填写 Discord Developer Portal 的 Bot 页面生成的 Token,不需要 Public Key 或 Client Secret。
2. **允许所有可访问频道**:默认开启,无需填写频道 ID;机器人在所有有 Discord 访问权限的服务器文字频道和已有线程中可响应。关闭后,填写 **允许的频道 ID**,多个 ID 用逗号或换行分隔;留空则不接收服务器频道消息。已有线程继承父频道权限,也可单独填写线程 ID。旧配置缺少 `allow_all_channels` 时也默认开启;显式保存为 `false` 才限制到列表。
3. **允许私聊的用户 ID**:填写你的 Discord 用户 ID。私聊独立授权,空列表表示不接受任何私聊消息。
4. **HTTP Proxy / HTTP Proxy Auth**:网络需要代理时填写。认证格式为 `user:password`;代理应用到 Gateway、API 与附件下载。
5. 点击 **检查连接**,成功后 **保存**。连接检查验证机器人能够登录 Gateway,不证明某个频道具备发送权限;后者用下一节的真实消息验证。
在 Discord 用户设置开启开发者模式后,可右键频道/用户复制 ID。ID 必须保留完整数字,不是频道名,也不是 Application ID。
## 3. Discord 应用设置
在 Bot 页面开启 **Message Content Intent**。邀请机器人加入测试服务器,并给予 View Channels、Send Messages、Read Message History、Attach Files、Send Messages in Threads 权限。首版不需要 Server Members 或 Presence Intent。
服务器频道默认只在用户直接 `@机器人` 时启动 Agent;不把 `@everyone` 或角色提及当作直接触发。私聊允许列表中的用户无需 @。机器人与 webhook 消息被忽略。
## 4. 人工验收(约 10 分钟)
| 操作 | 预期 |
|---|---|
| 在允许频道发送 `@机器人 你好`,再发送一条不带 @ 的消息 | 前者回复,后者不单独触发 Agent |
| 在同一频道用两位用户提问,再去另一个允许频道/线程提问 | 同频道共享上下文,频道与线程分别隔离 |
| 用允许用户私聊,关闭允许所有频道后,再从未授权频道发消息 | 私聊有回复,未授权频道无回复 |
| 发图片/文件,要求生成长回复 | 附件进入现有媒体管线,长回复分段,代码块可读,无意外 @everyone |
| 停用/启用通道、重启服务,再次提问 | 正常恢复,没有重复回复;断线时重新获取通道状态可见重连提示 |
上传限制取决于 Discord 服务器和 Bot 权限;远程附件下载在适配器中限制为 25 MiB,超限/上传失败会走现有错误或附件降级处理。
暂不包含原生 Slash Command 注册、自动新建线程、语音房、编辑式流式回复。一个 Agent 建议先配置一个 Discord Bot;同一 Agent 多 Bot 共用同一频道的会话隔离不在首版范围。
本次自动化验收使用模拟 Gateway/REST,不需要真实 Token。真实网络、Discord 服务器权限与 LLM 回复由上述人工步骤验证。
## 5. 自动化验收结果(2026-09-21)
| 检查 | 结果 |
|---|---|
| harness-im-bridge `make all` | 格式、Lint、mypy 通过;458 项测试通过,13 项集成测试按默认命令排除 |
| Octop `make all` | 格式、Lint、mypy 通过;3587 项测试通过,17 项条件跳过 |
| Discord 最终相关后端复核 | 18 项通过,覆盖路由、实时状态、通道 CRUD 与配置探测 |
| 前端通道测试 | 12 项通过,包含 Discord 入口、Token 和长数字 ID 的完整保存流程 |
| `make build-frontend` / 启动脚本 | TypeScript 与 Vite 构建通过;`run-discord-local.sh --help` 通过;内置网页产物已生成 |
命令使用 `RUN='uv run --no-sync'` 保留未发布的本地网关;通道库复用 Octop 的开发环境(`UV_PROJECT_ENVIRONMENT=../Octop/.venv`)。Octop 完整测试需允许绑定本机随机端口,最初沙箱导致的 15 项端口权限失败已在允许本机端口后完整重跑通过。
没有使用真实 Bot Token,也没有发布包、提交或推送代码。人工测试请使用本文第一节的本地启动方式。
## 6. 全频道默认模式验收(2026-09-22)
新增「允许所有可访问频道」开关,前后端默认开启;缺少该字段的旧配置同样按开启处理。关闭并保存后保持指定频道模式;输入框保留原有 ID。私聊允许列表与直接 @触发规则不变。
- harness-im-bridge `make all`:460 passed,13 deselected;格式、Lint、mypy 通过。
- Octop `make all`:3591 passed,17 skipped;格式、Lint、mypy 通过。
- 前端通道测试:16 passed,覆盖默认开启、关闭后保存、旧配置默认值、重新编辑保留布尔值。
- 前端相关文件 ESLint 通过;TypeScript 和 Vite 构建通过,内置网页产物已更新。
- 重启本地 Octop 并刷新网页后生效;真实 Discord 联调仍由用户人工测试。
+1 -1
View File
@@ -24,7 +24,7 @@ dependencies = [
"orcakit-harness-agent[all]>=1.0.13",
"harness-memory>=0.9.11",
"lark-oapi>=1.7.3",
"harness-gateway>=0.9.8",
"harness-gateway>=0.9.9",
"cryptography>=41",
"scalar-fastapi>=1.0",
"edge-tts>=6.1",
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Run Octop against the sibling gateway source for local development.
set -euo pipefail
cd "$(dirname "$0")/.."
bridge_dir="$(cd ../harness-im-bridge && pwd)"
if [[ ! -x .venv/bin/python ]]; then
echo "Create the Octop development environment first: uv sync --extra dev" >&2
exit 1
fi
if ! .venv/bin/python - "$bridge_dir" <<'PY'
import sys
from pathlib import Path
try:
import harness_gateway
expected = Path(sys.argv[1]) / "src/harness_gateway/__init__.py"
sys.exit(Path(harness_gateway.__file__).resolve() != expected.resolve())
except ImportError:
sys.exit(1)
PY
then
uv pip install --python .venv/bin/python --no-deps --editable "$bridge_dir"
fi
# A normal uv run would replace the editable gateway with the locked PyPI release.
exec uv run --no-sync octop run "$@"
+7 -1
View File
@@ -602,7 +602,13 @@
"sk": "SK",
"agent_id": "Agent ID",
"host": "Broker Host"
}
},
"discord_invalid_token": "Discord rejected the Bot Token. Reset it on the Bot page and update the configuration.",
"discord_intents_required": "Enable Message Content Intent on the Discord application Bot page.",
"discord_connect_timeout": "Discord connection timed out. Check the network and HTTP proxy settings.",
"discord_connection_failed": "Discord connection failed. Check the network, proxy and bot configuration.",
"discord_disconnected": "Discord is disconnected or reconnecting.",
"discord_proxy_auth_invalid": "Proxy authentication must use user:password."
}
},
"threads": {
+7 -1
View File
@@ -602,7 +602,13 @@
"sk": "SK",
"agent_id": "Agent ID",
"host": "Broker Host"
}
},
"discord_invalid_token": "Discord Bot Token 无效,请在 Bot 页面重新生成并更新。",
"discord_intents_required": "请在 Discord 应用的 Bot 页面开启 Message Content Intent。",
"discord_connect_timeout": "Discord 连接超时,请检查网络与 HTTP 代理设置。",
"discord_connection_failed": "Discord 连接失败,请检查网络、代理和机器人配置。",
"discord_disconnected": "Discord 尚未连接或正在重连。",
"discord_proxy_auth_invalid": "代理认证格式应为 user:password。"
}
},
"threads": {
+26 -1
View File
@@ -180,6 +180,21 @@ class Gateway:
return self._thread_registry
def get_runtime_status(self, channel_id: str) -> ChannelRuntimeStatus | None:
status = self._runtime_status.get(channel_id)
if status is None or status.reason in ("disabled", "unregistered"):
return status
channel = self._channel_manager.get_channel(channel_id) if self._channel_manager else None
connected = getattr(channel, "is_connected", None)
if isinstance(connected, bool):
detail = getattr(channel, "runtime_error", None)
detail = detail if isinstance(detail, str) else None
if connected != status.connected or detail != status.detail:
self._set_runtime_status(
channel_id,
connected=connected,
reason=None if connected else "error",
detail=detail,
)
return self._runtime_status.get(channel_id)
def runtime_status_to_dict(
@@ -192,7 +207,8 @@ class Gateway:
if status.reason is not None:
error = channel_runtime_reason(status.reason, locale)
if status.detail:
error = f"{error}: {status.detail}"
detail = self._format_probe_error(RuntimeError(status.detail), locale)
error = f"{error}: {detail}"
return {
"connected": status.connected,
"error": error,
@@ -596,6 +612,15 @@ class Gateway:
@staticmethod
def _format_probe_error(exc: Exception, locale: Locale) -> str:
msg = str(exc)
if msg in {
"discord_invalid_token",
"discord_intents_required",
"discord_connect_timeout",
"discord_connection_failed",
"discord_disconnected",
"discord_proxy_auth_invalid",
}:
return tr(f"channel.probe.{msg}", locale)
lower = msg.lower()
if "invalid appid or secret" in lower or "100016" in msg:
return tr("channel.probe.invalid_credentials", locale)
@@ -13,7 +13,11 @@ async def env(env_alice_bob_agent):
yield env_alice_bob_agent
async def test_probe_draft_config(env: Any) -> None:
@pytest.mark.parametrize(
"kind,config",
[("qq", {"app_id": "1", "client_secret": "sec"}), ("discord", {"bot_token": "fake"})],
)
async def test_probe_draft_config(env: Any, kind: str, config: dict) -> None:
c, srv, alice_auth, _bob_auth, aid = env
with patch.object(
srv.app_runtime.gateway,
@@ -23,7 +27,7 @@ async def test_probe_draft_config(env: Any) -> None:
r = await c.post(
f"/api/agents/{aid}/channels/probe",
headers=alice_auth,
json={"kind": "qq", "config": {"app_id": "1", "client_secret": "sec"}},
json={"kind": kind, "config": config},
)
assert r.status_code == 200, r.text
assert r.json()["ok"] is True
+16 -4
View File
@@ -20,19 +20,31 @@ async def env(env_alice_bob_agent):
# --- CRUD cycle ---------------------------------------------------------------
async def test_create_lists_get_patch_delete_cycle(env: Any) -> None:
@pytest.mark.parametrize(
"kind,config",
[
("feishu", {"app_id": "x"}),
("discord", {"bot_token": "fake", "allowed_channel_ids": ["200"]}),
("discord", {"bot_token": "fake", "allow_all_channels": True}),
(
"discord",
{"bot_token": "fake", "allow_all_channels": False, "allowed_channel_ids": ["200"]},
),
],
)
async def test_create_lists_get_patch_delete_cycle(env: Any, kind: str, config: dict) -> None:
c, _srv, alice_auth, _bob_auth, aid = env
# CREATE
r = await c.post(
f"/api/agents/{aid}/channels",
headers=alice_auth,
json={"kind": "feishu", "name": "main", "config": {"app_id": "x"}},
json={"kind": kind, "name": "main", "config": config},
)
assert r.status_code == 201, r.text
body = r.json()
cid = body["id"]
assert body["kind"] == "feishu"
assert body["kind"] == kind
assert body["name"] == "main"
assert body["enabled"] is True
assert body["agent_id"] == aid
@@ -48,7 +60,7 @@ async def test_create_lists_get_patch_delete_cycle(env: Any) -> None:
assert r.status_code == 200
body = r.json()
assert body["id"] == cid
assert body["config"] == {"app_id": "x"}
assert body["config"] == config
# PATCH name + enabled
r = await c.patch(
+117
View File
@@ -0,0 +1,117 @@
"""Discord routing and diagnostics through Octop's real gateway contracts."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from harness_gateway.channels.discord import DiscordChannel, DiscordConfig
from harness_gateway.models import ChannelSubject, InboundMessage, MessageEvent
from tests.unit.gateway.test_gateway_runtime_status import _make_gateway
from octop.infra.gateway.gateway import ChannelCreateSpec, ChannelKind, Gateway
from octop.infra.gateway.process.message_keys import (
resolve_user_id_for_message,
sanitize_im_metadata,
session_key_from_message,
)
def test_discord_session_and_push_routing():
def inbound(subject: str, sender: str, chat_type: str = "group") -> InboundMessage:
return InboundMessage(
channel_id="bot-registration",
channel_type="discord",
tenant_id="agent1",
channel_subject=ChannelSubject(subject_id=subject, chat_type=chat_type),
content=[],
metadata={
"chat_type": chat_type,
"chat_id": subject,
"sender_id": sender,
"message_id": "ephemeral",
},
)
a, b = inbound("200", "10"), inbound("200", "11")
thread = inbound("300", "10")
dm = inbound("10", "10", "dm")
assert session_key_from_message(a, agent_id="agent1") == session_key_from_message(
b, agent_id="agent1"
)
assert len({session_key_from_message(m, agent_id="agent1") for m in [a, thread, dm]}) == 3
assert resolve_user_id_for_message(dm, agent_owner_id=7) == 7
metadata = sanitize_im_metadata(thread)
assert metadata["chat_id"] == "300"
assert "message_id" not in metadata
def test_live_discord_status_and_localized_diagnostics(tmp_path):
gateway = _make_gateway(tmp_path)
channel = SimpleNamespace(is_connected=True, runtime_error=None)
gateway._channel_manager = SimpleNamespace(get_channel=lambda _: channel)
gateway._set_runtime_status("discord1", connected=True)
channel.is_connected = False
channel.runtime_error = "discord_disconnected"
state = gateway.runtime_status_to_dict("discord1", locale="zh")
assert state["connected"] is False
assert "重连" in state["error"]
channel.is_connected = True
channel.runtime_error = None
state = gateway.runtime_status_to_dict("discord1", locale="zh")
assert state["connected"] is True
assert state["error"] is None
assert "Message Content Intent" in Gateway._format_probe_error(
RuntimeError("discord_intents_required"), "zh"
)
assert "Bot Token" in Gateway._format_probe_error(RuntimeError("discord_invalid_token"), "en")
@pytest.mark.asyncio
@pytest.mark.parametrize("allow_all", [None, True, False])
async def test_discord_registration_forwards_config_and_routing(tmp_path, monkeypatch, allow_all):
import json
from unittest.mock import AsyncMock, MagicMock
from harness_gateway.manager import ChannelManager
from harness_gateway.media import FileSystemMediaBackend
async def processor(msg):
yield MessageEvent.completed()
monkeypatch.setattr(DiscordChannel, "start", AsyncMock())
monkeypatch.setattr(DiscordChannel, "stop", AsyncMock())
gateway = _make_gateway(tmp_path)
manager = ChannelManager(
processor=processor, media_backend=FileSystemMediaBackend(str(tmp_path))
)
gateway._channel_manager = manager
gateway._processor = MagicMock()
# Use the same real row contract as registration, without invoking an LLM.
row = SimpleNamespace(
channel_id="discord1",
agent_id="agent1",
kind="discord",
config_json=json.dumps(
{
"bot_token": "fake",
"allowed_channel_ids": ["200"],
**({} if allow_all is None else {"allow_all_channels": allow_all}),
}
),
)
await manager.start()
try:
await gateway._register_channel(row)
registered = manager.get_channel("discord1")
assert isinstance(registered, DiscordChannel)
assert registered.tenant_id == "agent1"
assert registered._config.allowed_channel_ids == ["200"]
assert registered._config.allow_all_channels is (allow_all is not False)
assert isinstance(registered._config, DiscordConfig)
assert (
ChannelCreateSpec("discord1", "agent1", 1, ChannelKind.DISCORD, "Discord").kind
== "discord"
)
finally:
await manager.stop()
Generated
+4 -4
View File
@@ -1345,7 +1345,7 @@ wheels = [
[[package]]
name = "harness-gateway"
version = "0.9.8"
version = "0.9.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -1363,9 +1363,9 @@ dependencies = [
{ name = "websockets" },
{ name = "wecom-aibot-sdk" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0a/ae/693a178aaf7a4e923a4785e906a34d7606c15192627f0f00184b63f190e8/harness_gateway-0.9.8.tar.gz", hash = "sha256:4ea005ff8e5fd08dc73325dfc3cd63e3086fb41d2c6bf690400b37b84c646b02", size = 522880, upload-time = "2026-09-18T14:17:12.9Z" }
sdist = { url = "https://files.pythonhosted.org/packages/76/95/07ccd875cab4cb92c4fcf33948af3feb47ef552279a853e289b615d0fec3/harness_gateway-0.9.9.tar.gz", hash = "sha256:7bf412b3645fd07ee4b9646af2384e770062268426f49778c37dfde558a61788", size = 532057, upload-time = "2026-09-22T04:04:28.177Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/5b/4de90dc81fbc940c5dda0e823e3835127e00223de479d41e9cbc53cb784c/harness_gateway-0.9.8-py3-none-any.whl", hash = "sha256:966cae5627bc925f3c373fd4ca09b6e785611b6adbc1653d4495115ac7347c33", size = 152471, upload-time = "2026-09-18T14:17:09.542Z" },
{ url = "https://files.pythonhosted.org/packages/16/2f/6e38ab8fa06a8ac74b01374a6f79489d695ab42aab25de3e445d2f940e25/harness_gateway-0.9.9-py3-none-any.whl", hash = "sha256:e0021105cece57bce69fa966fea836be5219e124dc74a0c238221703cbb381de", size = 157953, upload-time = "2026-09-22T04:04:25.872Z" },
]
[[package]]
@@ -2599,7 +2599,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.110" },
{ name = "fastembed", marker = "extra == 'local-embedding'", specifier = ">=0.4" },
{ name = "harness-browser", specifier = ">=0.7.9" },
{ name = "harness-gateway", specifier = ">=0.9.8" },
{ name = "harness-gateway", specifier = ">=0.9.9" },
{ name = "harness-memory", specifier = ">=0.9.11" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
{ name = "huggingface-hub", marker = "extra == 'local-embedding'", specifier = ">=0.20" },