mirror of
https://github.com/different-ai/openwork
synced 2026-05-14 02:56:24 +02:00
feat(owpenbot): scope workspace agent and split identities advanced tab
This commit is contained in:
@@ -214,6 +214,12 @@ export type OpenworkOwpenbotHealthSnapshot = {
|
||||
lastOutboundAt?: number;
|
||||
lastMessageAt?: number;
|
||||
};
|
||||
agent?: {
|
||||
scope: "workspace";
|
||||
path: string;
|
||||
loaded: boolean;
|
||||
selected?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type OpenworkOwpenbotBindingItem = {
|
||||
@@ -238,6 +244,7 @@ export type OpenworkOwpenbotSendResult = {
|
||||
channel: string;
|
||||
identityId?: string;
|
||||
directory: string;
|
||||
peerId?: string;
|
||||
attempted: number;
|
||||
sent: number;
|
||||
failures?: Array<{ identityId: string; peerId: string; error: string }>;
|
||||
@@ -1011,7 +1018,14 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
),
|
||||
sendOwpenbotMessage: (
|
||||
workspaceId: string,
|
||||
input: { channel: "telegram" | "slack"; text: string; identityId?: string; directory?: string },
|
||||
input: {
|
||||
channel: "telegram" | "slack";
|
||||
text: string;
|
||||
identityId?: string;
|
||||
directory?: string;
|
||||
peerId?: string;
|
||||
autoBind?: boolean;
|
||||
},
|
||||
options?: { healthPort?: number | null },
|
||||
) => {
|
||||
const payload = {
|
||||
@@ -1019,6 +1033,8 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
text: input.text,
|
||||
...(input.identityId?.trim() ? { identityId: input.identityId.trim() } : {}),
|
||||
...(input.directory?.trim() ? { directory: input.directory.trim() } : {}),
|
||||
...(input.peerId?.trim() ? { peerId: input.peerId.trim() } : {}),
|
||||
...(input.autoBind === true ? { autoBind: true } : {}),
|
||||
healthPort: options?.healthPort ?? null,
|
||||
};
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
const [slackError, setSlackError] = createSignal<string | null>(null);
|
||||
|
||||
const [expandedChannel, setExpandedChannel] = createSignal<string | null>(null);
|
||||
const [activeTab, setActiveTab] = createSignal<"general" | "advanced">("general");
|
||||
|
||||
const [agentLoading, setAgentLoading] = createSignal(false);
|
||||
const [agentSaving, setAgentSaving] = createSignal(false);
|
||||
@@ -150,6 +151,8 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
|
||||
const [sendChannel, setSendChannel] = createSignal<"telegram" | "slack">("telegram");
|
||||
const [sendDirectory, setSendDirectory] = createSignal("");
|
||||
const [sendPeerId, setSendPeerId] = createSignal("");
|
||||
const [sendAutoBind, setSendAutoBind] = createSignal(true);
|
||||
const [sendText, setSendText] = createSignal("");
|
||||
const [sendBusy, setSendBusy] = createSignal(false);
|
||||
const [sendStatus, setSendStatus] = createSignal<string | null>(null);
|
||||
@@ -228,6 +231,16 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
return `${days}d ago`;
|
||||
});
|
||||
|
||||
const workspaceAgentStatus = createMemo(() => {
|
||||
const agent = health()?.agent;
|
||||
if (!agent) return null;
|
||||
return {
|
||||
path: agent.path,
|
||||
loaded: agent.loaded,
|
||||
selected: agent.selected ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
const resetAgentState = () => {
|
||||
setAgentLoading(false);
|
||||
setAgentSaving(false);
|
||||
@@ -353,9 +366,12 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
channel: sendChannel(),
|
||||
text,
|
||||
...(sendDirectory().trim() ? { directory: sendDirectory().trim() } : {}),
|
||||
...(sendPeerId().trim() ? { peerId: sendPeerId().trim() } : {}),
|
||||
...(sendAutoBind() ? { autoBind: true } : {}),
|
||||
});
|
||||
setSendResult(result);
|
||||
setSendStatus(`Dispatched ${result.sent}/${result.attempted} messages.`);
|
||||
const base = `Dispatched ${result.sent}/${result.attempted} messages.`;
|
||||
setSendStatus(result.reason?.trim() ? `${base} ${result.reason.trim()}` : base);
|
||||
} catch (error) {
|
||||
setSendError(formatRequestError(error));
|
||||
} finally {
|
||||
@@ -607,6 +623,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
setSendResult(null);
|
||||
setReconnectStatus(null);
|
||||
setReconnectError(null);
|
||||
setActiveTab("general");
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
@@ -679,6 +696,31 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center gap-2 rounded-xl border border-gray-4 bg-gray-1 p-1">
|
||||
<button
|
||||
class={`flex-1 rounded-lg px-3 py-2 text-xs font-semibold transition-colors ${
|
||||
activeTab() === "general"
|
||||
? "bg-gray-12 text-gray-1"
|
||||
: "text-gray-10 hover:bg-gray-2"
|
||||
}`}
|
||||
onClick={() => setActiveTab("general")}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
class={`flex-1 rounded-lg px-3 py-2 text-xs font-semibold transition-colors ${
|
||||
activeTab() === "advanced"
|
||||
? "bg-gray-12 text-gray-1"
|
||||
: "text-gray-10 hover:bg-gray-2"
|
||||
}`}
|
||||
onClick={() => setActiveTab("advanced")}
|
||||
>
|
||||
Advanced
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={activeTab() === "general"}>
|
||||
|
||||
{/* ---- Worker status card ---- */}
|
||||
<div class="rounded-xl border border-gray-4 bg-gray-1 p-4 space-y-3.5">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -1101,6 +1143,10 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "advanced"}>
|
||||
|
||||
{/* ---- Message routing ---- */}
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold text-gray-9 uppercase tracking-wider mb-2">
|
||||
@@ -1128,7 +1174,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-10 mt-2.5">
|
||||
Advanced: reply with <code class="text-[11px] font-mono bg-gray-3 px-1 py-0.5 rounded">/dir <path></code> in Slack/Telegram to override the directory for a specific chat.
|
||||
Advanced: reply with <code class="text-[11px] font-mono bg-gray-3 px-1 py-0.5 rounded">/dir <path></code> in Slack/Telegram to override the directory for a specific chat (limited to this workspace root).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1138,7 +1184,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
<div>
|
||||
<div class="text-[13px] font-semibold text-gray-12">Messaging agent behavior</div>
|
||||
<div class="text-[12px] text-gray-9 mt-0.5">
|
||||
Edit the workspace instructions used before each inbound Telegram/Slack message.
|
||||
One file per workspace. Add optional first line <code class="font-mono">@agent <id></code> to route via a specific OpenCode agent.
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded-md border border-gray-4 bg-gray-2/50 px-2 py-1 text-[11px] font-mono text-gray-10">
|
||||
@@ -1146,6 +1192,14 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={workspaceAgentStatus()}>
|
||||
{(value) => (
|
||||
<div class="rounded-lg border border-gray-4 bg-gray-2/40 px-3 py-2 text-[11px] text-gray-10">
|
||||
Active scope: workspace · status: {value().loaded ? "loaded" : "missing"} · selected agent: {value().selected || "(none)"}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={agentLoading()}>
|
||||
<div class="text-[11px] text-gray-9">Loading agent file…</div>
|
||||
</Show>
|
||||
@@ -1208,7 +1262,7 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
<div>
|
||||
<div class="text-[13px] font-semibold text-gray-12">Send test message</div>
|
||||
<div class="text-[12px] text-gray-9 mt-0.5">
|
||||
Dispatch an outbound message via the workspace send route to validate bindings and channel wiring.
|
||||
Validate outbound wiring. Use a peer ID for direct send, or leave peer ID empty to fan out by bindings in a directory.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1224,6 +1278,18 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
<option value="slack">Slack</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[12px] text-gray-9 block mb-1">Peer ID (optional)</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-gray-4 bg-gray-1 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-8"
|
||||
placeholder={sendChannel() === "telegram" ? "Telegram chat id (e.g. 123456789)" : "Slack peer id (e.g. D12345678|thread_ts)"}
|
||||
value={sendPeerId()}
|
||||
onInput={(e) => setSendPeerId(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-[12px] text-gray-9 block mb-1">Directory (optional)</label>
|
||||
<input
|
||||
@@ -1233,6 +1299,16 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
onInput={(e) => setSendDirectory(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end pb-1">
|
||||
<label class="flex items-center gap-2 text-xs text-gray-11">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sendAutoBind()}
|
||||
onChange={(e) => setSendAutoBind(e.currentTarget.checked)}
|
||||
/>
|
||||
Auto-bind peer to directory on direct send
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1277,6 +1353,8 @@ export default function IdentitiesView(props: IdentitiesViewProps) {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
</Show>
|
||||
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
|
||||
import type { Logger } from "pino";
|
||||
|
||||
@@ -139,6 +139,13 @@ const TYPING_INTERVAL_MS = 6000;
|
||||
const OWPENBOT_AGENT_FILE_RELATIVE_PATH = ".opencode/agents/owpenbot.md";
|
||||
const OWPENBOT_AGENT_MAX_CHARS = 16_000;
|
||||
|
||||
type MessagingAgentConfig = {
|
||||
filePath: string;
|
||||
loaded: boolean;
|
||||
selectedAgent?: string;
|
||||
instructions: string;
|
||||
};
|
||||
|
||||
// Model presets for quick switching
|
||||
const MODEL_PRESETS: Record<string, ModelRef> = {
|
||||
opus: { providerID: "anthropic", modelID: "claude-opus-4-5-20251101" },
|
||||
@@ -182,7 +189,83 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
|
||||
const reportStatus = reporter?.onStatus;
|
||||
const clients = new Map<string, ReturnType<typeof createClient>>();
|
||||
const defaultDirectory = config.opencodeDirectory;
|
||||
const agentPromptCache = new Map<string, { mtimeMs: number; content: string }>();
|
||||
const workspaceRoot = resolve(defaultDirectory || process.cwd());
|
||||
const workspaceAgentFilePath = join(workspaceRoot, OWPENBOT_AGENT_FILE_RELATIVE_PATH);
|
||||
const agentPromptCache = new Map<string, { mtimeMs: number; config: MessagingAgentConfig }>();
|
||||
let latestAgentConfig: MessagingAgentConfig = {
|
||||
filePath: workspaceAgentFilePath,
|
||||
loaded: false,
|
||||
instructions: "",
|
||||
};
|
||||
|
||||
const parseMessagingAgentFile = (content: string): { selectedAgent?: string; instructions: string } => {
|
||||
const lines = content.split(/\r?\n/);
|
||||
let start = 0;
|
||||
while (start < lines.length && !lines[start]?.trim()) {
|
||||
start += 1;
|
||||
}
|
||||
|
||||
let selectedAgent: string | undefined;
|
||||
if (start < lines.length) {
|
||||
const first = lines[start]?.trim() ?? "";
|
||||
const match = first.match(/^@agent\s+([A-Za-z0-9_.:/-]+)$/);
|
||||
if (match?.[1]) {
|
||||
selectedAgent = match[1];
|
||||
lines.splice(start, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const instructions = lines.join("\n").trim();
|
||||
return { ...(selectedAgent ? { selectedAgent } : {}), instructions };
|
||||
};
|
||||
|
||||
const loadMessagingAgentConfig = async (): Promise<MessagingAgentConfig> => {
|
||||
const filePath = workspaceAgentFilePath;
|
||||
try {
|
||||
const info = await stat(filePath);
|
||||
if (!info.isFile()) {
|
||||
agentPromptCache.delete(filePath);
|
||||
latestAgentConfig = { filePath, loaded: false, instructions: "" };
|
||||
return latestAgentConfig;
|
||||
}
|
||||
|
||||
const cached = agentPromptCache.get(filePath);
|
||||
if (cached && cached.mtimeMs === info.mtimeMs) {
|
||||
latestAgentConfig = cached.config;
|
||||
return latestAgentConfig;
|
||||
}
|
||||
|
||||
const raw = (await readFile(filePath, "utf8")).trim();
|
||||
if (!raw) {
|
||||
const next: MessagingAgentConfig = { filePath, loaded: false, instructions: "" };
|
||||
agentPromptCache.set(filePath, { mtimeMs: info.mtimeMs, config: next });
|
||||
latestAgentConfig = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
const truncated = raw.length > OWPENBOT_AGENT_MAX_CHARS ? raw.slice(0, OWPENBOT_AGENT_MAX_CHARS) : raw;
|
||||
const parsed = parseMessagingAgentFile(truncated);
|
||||
const next: MessagingAgentConfig = {
|
||||
filePath,
|
||||
loaded: Boolean(parsed.instructions || parsed.selectedAgent),
|
||||
...(parsed.selectedAgent ? { selectedAgent: parsed.selectedAgent } : {}),
|
||||
instructions: parsed.instructions,
|
||||
};
|
||||
agentPromptCache.set(filePath, { mtimeMs: info.mtimeMs, config: next });
|
||||
latestAgentConfig = next;
|
||||
return next;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
if (code === "ENOENT") {
|
||||
agentPromptCache.delete(filePath);
|
||||
latestAgentConfig = { filePath, loaded: false, instructions: "" };
|
||||
return latestAgentConfig;
|
||||
}
|
||||
logger.warn({ error, filePath }, "failed to load owpenbot agent file");
|
||||
latestAgentConfig = { filePath, loaded: false, instructions: "" };
|
||||
return latestAgentConfig;
|
||||
}
|
||||
};
|
||||
|
||||
const isDangerousRootDirectory = (dir: string) => {
|
||||
const normalized = dir.trim();
|
||||
@@ -217,46 +300,6 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
|
||||
return next;
|
||||
};
|
||||
|
||||
const loadMessagingAgentPrompt = async (directory: string): Promise<string> => {
|
||||
const base = directory.trim() || defaultDirectory;
|
||||
if (!base) return "";
|
||||
|
||||
const filePath = join(base, OWPENBOT_AGENT_FILE_RELATIVE_PATH);
|
||||
try {
|
||||
const info = await stat(filePath);
|
||||
if (!info.isFile()) {
|
||||
agentPromptCache.delete(filePath);
|
||||
return "";
|
||||
}
|
||||
|
||||
const cached = agentPromptCache.get(filePath);
|
||||
if (cached && cached.mtimeMs === info.mtimeMs) {
|
||||
return cached.content;
|
||||
}
|
||||
|
||||
const content = (await readFile(filePath, "utf8")).trim();
|
||||
if (!content) {
|
||||
agentPromptCache.set(filePath, { mtimeMs: info.mtimeMs, content: "" });
|
||||
return "";
|
||||
}
|
||||
|
||||
const next =
|
||||
content.length > OWPENBOT_AGENT_MAX_CHARS
|
||||
? content.slice(0, OWPENBOT_AGENT_MAX_CHARS)
|
||||
: content;
|
||||
agentPromptCache.set(filePath, { mtimeMs: info.mtimeMs, content: next });
|
||||
return next;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
if (code === "ENOENT") {
|
||||
agentPromptCache.delete(filePath);
|
||||
return "";
|
||||
}
|
||||
logger.warn({ error, filePath }, "failed to load owpenbot agent file");
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const rootClient = getClient(defaultDirectory);
|
||||
const store = deps.store ?? new BridgeStore(config.dbPath);
|
||||
|
||||
@@ -321,6 +364,31 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
||||
};
|
||||
|
||||
const workspaceRootNormalized = normalizeDirectory(workspaceRoot);
|
||||
|
||||
const isWithinWorkspaceRoot = (candidate: string) => {
|
||||
const resolved = resolve(candidate || workspaceRoot);
|
||||
const relativePath = relative(workspaceRoot, resolved);
|
||||
if (!relativePath) return true;
|
||||
if (relativePath === ".") return true;
|
||||
if (relativePath.startsWith("..") || isAbsolute(relativePath)) return false;
|
||||
const boundary = workspaceRoot.endsWith(sep) ? workspaceRoot : `${workspaceRoot}${sep}`;
|
||||
return resolved === workspaceRoot || resolved.startsWith(boundary);
|
||||
};
|
||||
|
||||
const resolveScopedDirectory = (input: string): { ok: true; directory: string } | { ok: false; error: string } => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { ok: false, error: "Directory is required." };
|
||||
const resolved = resolve(isAbsolute(trimmed) ? trimmed : join(workspaceRoot, trimmed));
|
||||
if (!isWithinWorkspaceRoot(resolved)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Directory must stay within workspace root: ${workspaceRootNormalized}`,
|
||||
};
|
||||
}
|
||||
return { ok: true, directory: normalizeDirectory(resolved) };
|
||||
};
|
||||
|
||||
const formatModelLabel = (model?: ModelRef) =>
|
||||
model ? `${model.providerID}/${model.modelID}` : null;
|
||||
|
||||
@@ -443,6 +511,8 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
|
||||
lastOutboundAt = now;
|
||||
};
|
||||
|
||||
await loadMessagingAgentConfig();
|
||||
|
||||
let stopHealthServer: (() => void) | null = null;
|
||||
if (!deps.disableHealthServer && config.healthPort) {
|
||||
stopHealthServer = startHealthServer(
|
||||
@@ -473,6 +543,12 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
|
||||
? { lastMessageAt: Math.max(lastInboundAt ?? 0, lastOutboundAt ?? 0) }
|
||||
: {}),
|
||||
},
|
||||
agent: {
|
||||
scope: "workspace",
|
||||
path: latestAgentConfig.filePath,
|
||||
loaded: latestAgentConfig.loaded,
|
||||
...(latestAgentConfig.selectedAgent ? { selected: latestAgentConfig.selectedAgent } : {}),
|
||||
},
|
||||
}),
|
||||
logger,
|
||||
{
|
||||
@@ -855,7 +931,13 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
|
||||
if (!peerKey || !directory) {
|
||||
throw new Error("peerId and directory are required");
|
||||
}
|
||||
const normalizedDir = normalizeDirectory(directory);
|
||||
const scoped = resolveScopedDirectory(directory);
|
||||
if (!scoped.ok) {
|
||||
const error = new Error(scoped.error) as Error & { status?: number };
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
const normalizedDir = scoped.directory;
|
||||
store.upsertBinding(channel as ChannelName, identityId, peerKey, normalizedDir);
|
||||
store.deleteSession(channel as ChannelName, identityId, peerKey);
|
||||
ensureEventSubscription(normalizedDir);
|
||||
@@ -874,23 +956,107 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
|
||||
store.deleteSession(channel as ChannelName, identityId, peerKey);
|
||||
},
|
||||
|
||||
sendMessage: async (input: { channel: string; identityId?: string; directory: string; text: string }) => {
|
||||
sendMessage: async (input: {
|
||||
channel: string;
|
||||
identityId?: string;
|
||||
directory?: string;
|
||||
peerId?: string;
|
||||
text: string;
|
||||
autoBind?: boolean;
|
||||
}) => {
|
||||
const channelRaw = input.channel.trim().toLowerCase();
|
||||
if (channelRaw !== "telegram" && channelRaw !== "slack") {
|
||||
throw new Error("Invalid channel");
|
||||
}
|
||||
const channel = channelRaw as ChannelName;
|
||||
const identityId = input.identityId?.trim() ? normalizeIdentityId(input.identityId) : undefined;
|
||||
const directory = input.directory.trim();
|
||||
const directoryInput = (input.directory ?? "").trim();
|
||||
const peerId = (input.peerId ?? "").trim();
|
||||
const autoBind = input.autoBind === true;
|
||||
const text = input.text ?? "";
|
||||
if (!directory) {
|
||||
throw new Error("directory is required");
|
||||
}
|
||||
if (!text.trim()) {
|
||||
throw new Error("text is required");
|
||||
}
|
||||
|
||||
const normalizedDir = normalizeDirectory(directory);
|
||||
if (!directoryInput && !peerId) {
|
||||
throw new Error("directory or peerId is required");
|
||||
}
|
||||
|
||||
const normalizedDir = directoryInput ? (() => {
|
||||
const scoped = resolveScopedDirectory(directoryInput);
|
||||
if (!scoped.ok) {
|
||||
const error = new Error(scoped.error) as Error & { status?: number };
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
return scoped.directory;
|
||||
})() : "";
|
||||
|
||||
const resolveSendIdentityId = () => {
|
||||
if (identityId) return identityId;
|
||||
const active = Array.from(adapters.values()).find((adapter) => adapter.name === channel);
|
||||
return active?.identityId;
|
||||
};
|
||||
|
||||
const targetIdentityId = resolveSendIdentityId();
|
||||
if (peerId && !targetIdentityId) {
|
||||
return {
|
||||
channel,
|
||||
directory: normalizedDir || workspaceRootNormalized,
|
||||
peerId,
|
||||
attempted: 0,
|
||||
sent: 0,
|
||||
reason: `No ${channel} adapter is running for direct send`,
|
||||
};
|
||||
}
|
||||
|
||||
if (peerId && targetIdentityId) {
|
||||
const adapter = adapters.get(adapterKey(channel, targetIdentityId));
|
||||
if (!adapter) {
|
||||
return {
|
||||
channel,
|
||||
directory: normalizedDir || workspaceRootNormalized,
|
||||
identityId: targetIdentityId,
|
||||
peerId,
|
||||
attempted: 1,
|
||||
sent: 0,
|
||||
failures: [{ identityId: targetIdentityId, peerId, error: "Adapter not running" }],
|
||||
};
|
||||
}
|
||||
|
||||
if (autoBind && normalizedDir) {
|
||||
store.upsertBinding(channel, targetIdentityId, peerId, normalizedDir);
|
||||
store.deleteSession(channel, targetIdentityId, peerId);
|
||||
ensureEventSubscription(normalizedDir);
|
||||
}
|
||||
|
||||
try {
|
||||
await sendText(channel, targetIdentityId, peerId, text, { kind: "system", display: false });
|
||||
return {
|
||||
channel,
|
||||
directory: normalizedDir || workspaceRootNormalized,
|
||||
identityId: targetIdentityId,
|
||||
peerId,
|
||||
attempted: 1,
|
||||
sent: 1,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
channel,
|
||||
directory: normalizedDir || workspaceRootNormalized,
|
||||
identityId: targetIdentityId,
|
||||
peerId,
|
||||
attempted: 1,
|
||||
sent: 0,
|
||||
failures: [{
|
||||
identityId: targetIdentityId,
|
||||
peerId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const bindings = store.listBindings({
|
||||
channel,
|
||||
...(identityId ? { identityId } : {}),
|
||||
@@ -1140,11 +1306,11 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
|
||||
|
||||
const identityDirectory = resolveIdentityDirectory(inbound.channel, inbound.identityId);
|
||||
|
||||
const boundDirectory =
|
||||
const boundDirectoryCandidate =
|
||||
binding?.directory?.trim() || session?.directory?.trim() || identityDirectory || defaultDirectory;
|
||||
|
||||
const hasExplicitBinding = Boolean(binding?.directory?.trim() || session?.directory?.trim() || identityDirectory);
|
||||
if (!boundDirectory || (!hasExplicitBinding && isDangerousRootDirectory(boundDirectory))) {
|
||||
if (!boundDirectoryCandidate || (!hasExplicitBinding && isDangerousRootDirectory(boundDirectoryCandidate))) {
|
||||
await sendText(
|
||||
inbound.channel,
|
||||
inbound.identityId,
|
||||
@@ -1155,6 +1321,13 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
|
||||
return;
|
||||
}
|
||||
|
||||
const scopedBound = resolveScopedDirectory(boundDirectoryCandidate);
|
||||
if (!scopedBound.ok) {
|
||||
await sendText(inbound.channel, inbound.identityId, inbound.peerId, scopedBound.error, { kind: "system" });
|
||||
return;
|
||||
}
|
||||
const boundDirectory = scopedBound.directory;
|
||||
|
||||
if (!binding?.directory?.trim()) {
|
||||
store.upsertBinding(inbound.channel, inbound.identityId, peerKey, boundDirectory);
|
||||
}
|
||||
@@ -1200,22 +1373,33 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
|
||||
startTyping(runState);
|
||||
try {
|
||||
const effectiveModel = getUserModel(inbound.channel, inbound.identityId, peerKey, config.model);
|
||||
const messagingAgentPrompt = await loadMessagingAgentPrompt(boundDirectory);
|
||||
const promptText = messagingAgentPrompt
|
||||
const messagingAgent = await loadMessagingAgentConfig();
|
||||
const promptText = messagingAgent.instructions
|
||||
? [
|
||||
"You are handling a Slack/Telegram message via OpenWork.",
|
||||
"Follow this workspace messaging agent file:",
|
||||
messagingAgentPrompt,
|
||||
`Workspace agent file: ${messagingAgent.filePath}`,
|
||||
...(messagingAgent.selectedAgent ? [`Selected OpenCode agent: ${messagingAgent.selectedAgent}`] : []),
|
||||
"Follow these workspace messaging instructions:",
|
||||
messagingAgent.instructions,
|
||||
"",
|
||||
"Incoming user message:",
|
||||
inbound.text,
|
||||
].join("\n")
|
||||
: inbound.text;
|
||||
logger.debug({ sessionID, length: inbound.text.length, model: effectiveModel }, "prompt start");
|
||||
logger.debug(
|
||||
{
|
||||
sessionID,
|
||||
length: inbound.text.length,
|
||||
model: effectiveModel,
|
||||
agent: messagingAgent.selectedAgent,
|
||||
},
|
||||
"prompt start",
|
||||
);
|
||||
const response = await getClient(boundDirectory).session.prompt({
|
||||
sessionID,
|
||||
parts: [{ type: "text", text: promptText }],
|
||||
...(effectiveModel ? { model: effectiveModel } : {}),
|
||||
...(messagingAgent.selectedAgent ? { agent: messagingAgent.selectedAgent } : {}),
|
||||
});
|
||||
const parts = (response as { parts?: Array<{ type?: string; text?: string; ignored?: boolean }> }).parts ?? [];
|
||||
const textParts = parts.filter((part) => part.type === "text" && !part.ignored);
|
||||
@@ -1341,7 +1525,12 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
|
||||
await sendText(channel, identityId, peerId, `Current directory: ${current || "(none)"}`, { kind: "system" });
|
||||
return true;
|
||||
}
|
||||
const normalized = normalizeDirectory(next);
|
||||
const scoped = resolveScopedDirectory(next);
|
||||
if (!scoped.ok) {
|
||||
await sendText(channel, identityId, peerId, scoped.error, { kind: "system" });
|
||||
return true;
|
||||
}
|
||||
const normalized = scoped.directory;
|
||||
store.upsertBinding(channel, identityId, peerKey, normalized);
|
||||
store.deleteSession(channel, identityId, peerKey);
|
||||
ensureEventSubscription(normalized);
|
||||
@@ -1350,17 +1539,17 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
|
||||
}
|
||||
|
||||
if (command === "agent") {
|
||||
const binding = store.getBinding(channel, identityId, peerKey);
|
||||
const current =
|
||||
binding?.directory?.trim() || store.getSession(channel, identityId, peerKey)?.directory?.trim() || defaultDirectory;
|
||||
const resolved = current.trim() || defaultDirectory;
|
||||
const filePath = join(resolved, OWPENBOT_AGENT_FILE_RELATIVE_PATH);
|
||||
const loaded = await loadMessagingAgentPrompt(resolved);
|
||||
const config = await loadMessagingAgentConfig();
|
||||
await sendText(
|
||||
channel,
|
||||
identityId,
|
||||
peerId,
|
||||
`Agent file: ${filePath}\nStatus: ${loaded ? "loaded" : "missing or empty"}`,
|
||||
[
|
||||
`Scope: workspace`,
|
||||
`Agent file: ${config.filePath}`,
|
||||
`OpenCode agent: ${config.selectedAgent ?? "(none)"}`,
|
||||
`Status: ${config.loaded ? "loaded" : "missing or empty"}`,
|
||||
].join("\n"),
|
||||
{ kind: "system" },
|
||||
);
|
||||
return true;
|
||||
@@ -1368,7 +1557,7 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri
|
||||
|
||||
// /help command
|
||||
if (command === "help") {
|
||||
const helpText = `/opus - Claude Opus 4.5\n/codex - GPT 5.2 Codex\n/dir <path> - bind this chat to a directory\n/dir - show current directory\n/agent - show workspace agent file path\n/model - show current\n/reset - start fresh\n/help - this`;
|
||||
const helpText = `/opus - Claude Opus 4.5\n/codex - GPT 5.2 Codex\n/dir <path> - bind this chat to a workspace directory\n/dir - show current directory\n/agent - show workspace agent scope/path\n/model - show current\n/reset - start fresh\n/help - this`;
|
||||
await sendText(channel, identityId, peerId, helpText, { kind: "system" });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ export type HealthSnapshot = {
|
||||
lastOutboundAt?: number;
|
||||
lastMessageAt?: number;
|
||||
};
|
||||
agent?: {
|
||||
scope: "workspace";
|
||||
path: string;
|
||||
loaded: boolean;
|
||||
selected?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type GroupsConfigResult = {
|
||||
@@ -97,7 +103,9 @@ export type BindingsListResult = {
|
||||
export type SendMessageInput = {
|
||||
channel: string;
|
||||
identityId?: string;
|
||||
directory: string;
|
||||
directory?: string;
|
||||
peerId?: string;
|
||||
autoBind?: boolean;
|
||||
text: string;
|
||||
};
|
||||
|
||||
@@ -105,6 +113,7 @@ export type SendMessageResult = {
|
||||
channel: string;
|
||||
directory: string;
|
||||
identityId?: string;
|
||||
peerId?: string;
|
||||
attempted: number;
|
||||
sent: number;
|
||||
failures?: Array<{ identityId: string; peerId: string; error: string }>;
|
||||
@@ -582,17 +591,21 @@ export function startHealthServer(
|
||||
const channel = typeof payload.channel === "string" ? payload.channel.trim() : "";
|
||||
const identityId = typeof payload.identityId === "string" ? payload.identityId.trim() : "";
|
||||
const directory = typeof payload.directory === "string" ? payload.directory.trim() : "";
|
||||
const peerId = typeof payload.peerId === "string" ? payload.peerId.trim() : "";
|
||||
const autoBind = payload.autoBind === true;
|
||||
const text = typeof payload.text === "string" ? payload.text : "";
|
||||
if (!channel || !directory || !text.trim()) {
|
||||
if (!channel || !text.trim() || (!directory && !peerId)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: "channel, directory, and text are required" }));
|
||||
res.end(JSON.stringify({ ok: false, error: "channel, text, and either directory or peerId are required" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await handlers.sendMessage({
|
||||
channel,
|
||||
...(identityId ? { identityId } : {}),
|
||||
directory,
|
||||
...(directory ? { directory } : {}),
|
||||
...(peerId ? { peerId } : {}),
|
||||
...(autoBind ? { autoBind: true } : {}),
|
||||
text,
|
||||
});
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
|
||||
@@ -19,10 +19,10 @@ function createLoggerStub() {
|
||||
return base;
|
||||
}
|
||||
|
||||
test("bridge: routes sessions per peer directory binding", async () => {
|
||||
test("bridge: routes sessions per peer directory binding within workspace", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-multiws-"));
|
||||
const wsA = path.join(dir, "ws-a");
|
||||
const wsB = path.join(dir, "ws-b");
|
||||
const wsB = path.join(wsA, "project-b");
|
||||
fs.mkdirSync(wsA, { recursive: true });
|
||||
fs.mkdirSync(wsB, { recursive: true });
|
||||
|
||||
@@ -98,14 +98,85 @@ test("bridge: routes sessions per peer directory binding", async () => {
|
||||
await bridge.dispatchInbound({ channel: "slack", identityId: "default", peerId: "D-B", text: `/dir ${wsB}`, raw: {} });
|
||||
await bridge.dispatchInbound({ channel: "slack", identityId: "default", peerId: "D-B", text: "ping", raw: {} });
|
||||
|
||||
// Ensure prompts were routed to different directories
|
||||
// Ensure prompts were routed to different workspace subdirectories
|
||||
assert.ok(prompted.includes(wsA));
|
||||
assert.ok(prompted.includes(wsB));
|
||||
|
||||
// Ensure output includes per-directory pong
|
||||
const output = sent.map((m) => `${m.peerId}:${m.text}`).join("\n");
|
||||
assert.ok(output.includes("D-A:pong:ws-a"));
|
||||
assert.ok(output.includes("D-B:pong:ws-b"));
|
||||
assert.ok(output.includes("D-B:pong:project-b"));
|
||||
|
||||
await bridge.stop();
|
||||
});
|
||||
|
||||
test("bridge: rejects /dir outside workspace root", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-multiws-"));
|
||||
const wsA = path.join(dir, "ws-a");
|
||||
const outside = path.join(dir, "outside");
|
||||
fs.mkdirSync(wsA, { recursive: true });
|
||||
fs.mkdirSync(outside, { recursive: true });
|
||||
|
||||
const dbPath = path.join(dir, "owpenbot.db");
|
||||
const prompted = [];
|
||||
const sent = [];
|
||||
|
||||
const slackAdapter = {
|
||||
key: "slack:default",
|
||||
name: "slack",
|
||||
identityId: "default",
|
||||
maxTextLength: 39_000,
|
||||
async start() {},
|
||||
async stop() {},
|
||||
async sendText(peerId, text) {
|
||||
sent.push({ peerId, text });
|
||||
},
|
||||
};
|
||||
|
||||
const clientFactory = (directory) => ({
|
||||
global: { health: async () => ({ healthy: true, version: "test" }) },
|
||||
session: {
|
||||
create: async () => ({ id: "session-1" }),
|
||||
prompt: async () => {
|
||||
prompted.push(directory);
|
||||
return { parts: [{ type: "text", text: "pong" }] };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const bridge = await startBridge(
|
||||
{
|
||||
configPath: path.join(dir, "owpenbot.json"),
|
||||
configFile: { version: 1 },
|
||||
opencodeUrl: "http://127.0.0.1:4096",
|
||||
opencodeDirectory: wsA,
|
||||
telegramBots: [],
|
||||
slackApps: [],
|
||||
dataDir: dir,
|
||||
dbPath,
|
||||
logFile: path.join(dir, "owpenbot.log"),
|
||||
toolUpdatesEnabled: false,
|
||||
groupsEnabled: false,
|
||||
permissionMode: "allow",
|
||||
toolOutputLimit: 1200,
|
||||
healthPort: undefined,
|
||||
logLevel: "silent",
|
||||
},
|
||||
createLoggerStub(),
|
||||
undefined,
|
||||
{
|
||||
clientFactory,
|
||||
adapters: new Map([["slack:default", slackAdapter]]),
|
||||
disableEventStream: true,
|
||||
disableHealthServer: true,
|
||||
},
|
||||
);
|
||||
|
||||
await bridge.dispatchInbound({ channel: "slack", identityId: "default", peerId: "D-OUT", text: `/dir ${outside}`, raw: {} });
|
||||
await bridge.dispatchInbound({ channel: "slack", identityId: "default", peerId: "D-OUT", text: "ping", raw: {} });
|
||||
|
||||
assert.ok(sent.some((m) => m.text.includes("Directory must stay within workspace root")));
|
||||
assert.ok(prompted.every((dirPath) => dirPath === wsA));
|
||||
|
||||
await bridge.stop();
|
||||
});
|
||||
|
||||
@@ -100,7 +100,7 @@ test("health /send delivers to directory bindings", async () => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
test("health /send returns 404 when no bindings exist", async () => {
|
||||
test("health /send reports no-op when no bindings exist", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-health-send-"));
|
||||
const dbPath = path.join(dir, "owpenbot.db");
|
||||
const store = new BridgeStore(dbPath);
|
||||
@@ -153,3 +153,72 @@ test("health /send returns 404 when no bindings exist", async () => {
|
||||
await bridge.stop();
|
||||
store.close();
|
||||
});
|
||||
|
||||
test("health /send can deliver directly with peerId", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "owpenbot-health-send-"));
|
||||
const dbPath = path.join(dir, "owpenbot.db");
|
||||
const store = new BridgeStore(dbPath);
|
||||
const healthPort = await freePort();
|
||||
|
||||
const sent = [];
|
||||
const slackAdapter = {
|
||||
key: "slack:default",
|
||||
name: "slack",
|
||||
identityId: "default",
|
||||
maxTextLength: 39_000,
|
||||
async start() {},
|
||||
async stop() {},
|
||||
async sendText(peerId, text) {
|
||||
sent.push({ peerId, text });
|
||||
},
|
||||
};
|
||||
|
||||
const bridge = await startBridge(
|
||||
{
|
||||
configPath: path.join(dir, "owpenbot.json"),
|
||||
configFile: { version: 1 },
|
||||
opencodeUrl: "http://127.0.0.1:4096",
|
||||
opencodeDirectory: dir,
|
||||
telegramBots: [],
|
||||
slackApps: [],
|
||||
dataDir: dir,
|
||||
dbPath,
|
||||
logFile: path.join(dir, "owpenbot.log"),
|
||||
toolUpdatesEnabled: false,
|
||||
groupsEnabled: false,
|
||||
permissionMode: "allow",
|
||||
toolOutputLimit: 1200,
|
||||
healthPort,
|
||||
logLevel: "silent",
|
||||
},
|
||||
createLoggerStub(),
|
||||
undefined,
|
||||
{
|
||||
client: {
|
||||
global: {
|
||||
health: async () => ({ healthy: true, version: "test" }),
|
||||
},
|
||||
},
|
||||
store,
|
||||
adapters: new Map([["slack:default", slackAdapter]]),
|
||||
disableEventStream: true,
|
||||
},
|
||||
);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${healthPort}/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ channel: "slack", peerId: "D555", text: "hello-direct" }),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const json = await response.json();
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.peerId, "D555");
|
||||
assert.equal(json.sent, 1);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].peerId, "D555");
|
||||
assert.equal(sent[0].text, "hello-direct");
|
||||
|
||||
await bridge.stop();
|
||||
store.close();
|
||||
});
|
||||
|
||||
@@ -2062,6 +2062,8 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const channel = typeof body.channel === "string" ? body.channel.trim().toLowerCase() : "";
|
||||
const text = typeof body.text === "string" ? body.text : "";
|
||||
const peerId = typeof body.peerId === "string" ? body.peerId.trim() : "";
|
||||
const autoBind = body.autoBind === true || body.autoBind === "true";
|
||||
const directoryInput = typeof body.directory === "string" ? body.directory.trim() : "";
|
||||
const directory = directoryInput || workspace.path;
|
||||
const healthPort = normalizeHealthPort(body.healthPort);
|
||||
@@ -2082,8 +2084,8 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
|
||||
if (channel !== "telegram" && channel !== "slack") {
|
||||
throw new ApiError(400, "invalid_channel", "channel must be 'telegram' or 'slack'");
|
||||
}
|
||||
if (!directory.trim()) {
|
||||
throw new ApiError(400, "directory_required", "directory is required");
|
||||
if (!directory.trim() && !peerId) {
|
||||
throw new ApiError(400, "directory_required", "directory is required when peerId is not provided");
|
||||
}
|
||||
if (!text.trim()) {
|
||||
throw new ApiError(400, "text_required", "text is required");
|
||||
@@ -2095,7 +2097,9 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
|
||||
{
|
||||
channel,
|
||||
identityId,
|
||||
directory,
|
||||
...(directory.trim() ? { directory } : {}),
|
||||
...(peerId ? { peerId } : {}),
|
||||
...(autoBind ? { autoBind: true } : {}),
|
||||
text,
|
||||
},
|
||||
{ port, requestHost, timeoutMs: 5_000 },
|
||||
@@ -2115,7 +2119,7 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "owpenbot.send",
|
||||
target: `owpenbot.${channel}`,
|
||||
summary: `Sent outbound ${channel} message for ${identityId}`,
|
||||
summary: `Sent outbound ${channel} message for ${identityId}${peerId ? ` to ${peerId}` : ""}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user