Merge branch 'main' into feat/deepinfra-integration
This commit is contained in:
commit
79a760e003
@ -16,12 +16,14 @@ This is the difference between telling your assistant "send the weekly report" e
|
||||
## Why Standing Orders?
|
||||
|
||||
**Without standing orders:**
|
||||
|
||||
- You must prompt the agent for every task
|
||||
- The agent sits idle between requests
|
||||
- Routine work gets forgotten or delayed
|
||||
- You become the bottleneck
|
||||
|
||||
**With standing orders:**
|
||||
|
||||
- The agent executes autonomously within defined boundaries
|
||||
- Routine work happens on schedule without prompting
|
||||
- You only get involved for exceptions and approvals
|
||||
@ -55,6 +57,7 @@ Put standing orders in `AGENTS.md` to guarantee they're loaded every session. Th
|
||||
**Escalation:** If data source is unavailable or metrics look unusual (>2σ from norm)
|
||||
|
||||
### Execution Steps
|
||||
|
||||
1. Pull metrics from configured sources
|
||||
2. Compare to prior week and targets
|
||||
3. Generate report in Reports/weekly/YYYY-MM-DD.md
|
||||
@ -62,6 +65,7 @@ Put standing orders in `AGENTS.md` to guarantee they're loaded every session. Th
|
||||
5. Log completion to Agent/Logs/
|
||||
|
||||
### What NOT to Do
|
||||
|
||||
- Do not send reports to external parties
|
||||
- Do not modify source data
|
||||
- Do not skip delivery if metrics look bad — report accurately
|
||||
@ -105,11 +109,13 @@ openclaw cron create \
|
||||
**Trigger:** Weekly cycle (Monday review → mid-week drafts → Friday brief)
|
||||
|
||||
### Weekly Cycle
|
||||
|
||||
- **Monday:** Review platform metrics and audience engagement
|
||||
- **Tuesday–Thursday:** Draft social posts, create blog content
|
||||
- **Friday:** Compile weekly marketing brief → deliver to owner
|
||||
|
||||
### Content Rules
|
||||
|
||||
- Voice must match the brand (see SOUL.md or brand voice guide)
|
||||
- Never identify as AI in public-facing content
|
||||
- Include metrics when available
|
||||
@ -126,6 +132,7 @@ openclaw cron create \
|
||||
**Trigger:** New data file detected OR scheduled monthly cycle
|
||||
|
||||
### When New Data Arrives
|
||||
|
||||
1. Detect new file in designated input directory
|
||||
2. Parse and categorize all transactions
|
||||
3. Compare against budget targets
|
||||
@ -134,6 +141,7 @@ openclaw cron create \
|
||||
6. Deliver summary to owner via configured channel
|
||||
|
||||
### Escalation Rules
|
||||
|
||||
- Single item > $500: immediate alert
|
||||
- Category > budget by 20%: flag in report
|
||||
- Unrecognizable transaction: ask owner for categorization
|
||||
@ -150,18 +158,20 @@ openclaw cron create \
|
||||
**Trigger:** Every heartbeat cycle
|
||||
|
||||
### Checks
|
||||
|
||||
- Service health endpoints responding
|
||||
- Disk space above threshold
|
||||
- Pending tasks not stale (>24 hours)
|
||||
- Delivery channels operational
|
||||
|
||||
### Response Matrix
|
||||
| Condition | Action | Escalate? |
|
||||
|-----------|--------|-----------|
|
||||
| Service down | Restart automatically | Only if restart fails 2x |
|
||||
| Disk space < 10% | Alert owner | Yes |
|
||||
| Stale task > 24h | Remind owner | No |
|
||||
| Channel offline | Log and retry next cycle | If offline > 2 hours |
|
||||
|
||||
| Condition | Action | Escalate? |
|
||||
| ---------------- | ------------------------ | ------------------------ |
|
||||
| Service down | Restart automatically | Only if restart fails 2x |
|
||||
| Disk space < 10% | Alert owner | Yes |
|
||||
| Stale task > 24h | Remind owner | No |
|
||||
| Channel offline | Log and retry next cycle | If offline > 2 hours |
|
||||
```
|
||||
|
||||
## The Execute-Verify-Report Pattern
|
||||
@ -174,6 +184,7 @@ Standing orders work best when combined with strict execution discipline. Every
|
||||
|
||||
```markdown
|
||||
### Execution Rules
|
||||
|
||||
- Every task follows Execute-Verify-Report. No exceptions.
|
||||
- "I'll do that" is not execution. Do it, then report.
|
||||
- "Done" without verification is not acceptable. Prove it.
|
||||
@ -192,20 +203,25 @@ For agents managing multiple concerns, organize standing orders as separate prog
|
||||
# Standing Orders
|
||||
|
||||
## Program 1: [Domain A] (Weekly)
|
||||
|
||||
...
|
||||
|
||||
## Program 2: [Domain B] (Monthly + On-Demand)
|
||||
|
||||
...
|
||||
|
||||
## Program 3: [Domain C] (As-Needed)
|
||||
|
||||
...
|
||||
|
||||
## Escalation Rules (All Programs)
|
||||
|
||||
- [Common escalation criteria]
|
||||
- [Approval gates that apply across programs]
|
||||
```
|
||||
|
||||
Each program should have:
|
||||
|
||||
- Its own **trigger cadence** (weekly, monthly, event-driven, continuous)
|
||||
- Its own **approval gates** (some programs need more oversight than others)
|
||||
- Clear **boundaries** (the agent should know where one program ends and another begins)
|
||||
@ -213,6 +229,7 @@ Each program should have:
|
||||
## Best Practices
|
||||
|
||||
### Do
|
||||
|
||||
- Start with narrow authority and expand as trust builds
|
||||
- Define explicit approval gates for high-risk actions
|
||||
- Include "What NOT to do" sections — boundaries matter as much as permissions
|
||||
@ -221,6 +238,7 @@ Each program should have:
|
||||
- Update standing orders as your needs evolve — they're living documents
|
||||
|
||||
### Don't
|
||||
|
||||
- Grant broad authority on day one ("do whatever you think is best")
|
||||
- Skip escalation rules — every program needs a "when to stop and ask" clause
|
||||
- Assume the agent will remember verbal instructions — put everything in the file
|
||||
|
||||
@ -974,6 +974,9 @@ Compatibility note:
|
||||
helper is only needed by a bundled extension, keep it behind the extension's
|
||||
local `api.js` or `runtime-api.js` seam instead of promoting it into
|
||||
`openclaw/plugin-sdk/<extension>`.
|
||||
- Channel-branded bundled bars such as `feishu`, `googlechat`, `irc`, `line`,
|
||||
`nostr`, `twitch`, and `zalo` stay private unless they are explicitly added
|
||||
back to the public contract.
|
||||
- Capability-specific subpaths such as `image-generation`,
|
||||
`media-understanding`, and `speech` exist because bundled/native plugins use
|
||||
them today. Their presence does not by itself mean every exported helper is a
|
||||
|
||||
@ -100,6 +100,7 @@ import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pair
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import { createOptionalChannelSetupSurface } from "openclaw/plugin-sdk/channel-setup";
|
||||
import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth";
|
||||
|
||||
// Wrong: monolithic root (lint will reject this)
|
||||
import { ... } from "openclaw/plugin-sdk";
|
||||
@ -120,6 +121,7 @@ Common subpaths:
|
||||
| `plugin-sdk/runtime-store` | Persistent plugin storage |
|
||||
| `plugin-sdk/allow-from` | Allowlist resolution |
|
||||
| `plugin-sdk/reply-payload` | Message reply types |
|
||||
| `plugin-sdk/provider-oauth` | OAuth login + PKCE helpers |
|
||||
| `plugin-sdk/provider-onboard` | Provider onboarding config patches |
|
||||
| `plugin-sdk/testing` | Test utilities |
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
export * from "openclaw/plugin-sdk/acpx";
|
||||
export * from "../../src/plugin-sdk/acpx.js";
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
buildOauthProviderAuthResult,
|
||||
createProviderApiKeyAuthMethod,
|
||||
resolveOAuthApiKeyMarker,
|
||||
type ProviderAuthContext,
|
||||
type ProviderAuthResult,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import { loginChutes } from "openclaw/plugin-sdk/provider-auth-login";
|
||||
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth";
|
||||
import {
|
||||
CHUTES_DEFAULT_MODEL_REF,
|
||||
applyChutesApiKeyConfig,
|
||||
|
||||
@ -1 +1,8 @@
|
||||
export * from "openclaw/plugin-sdk/device-pair";
|
||||
export {
|
||||
approveDevicePairing,
|
||||
issueDeviceBootstrapToken,
|
||||
listDevicePairing,
|
||||
} from "openclaw/plugin-sdk/device-bootstrap";
|
||||
export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
export { resolveGatewayBindUrl, resolveTailnetHostWithRunner } from "openclaw/plugin-sdk/core";
|
||||
export { runPluginCommandWithTimeout } from "openclaw/plugin-sdk/sandbox";
|
||||
|
||||
@ -1 +1,4 @@
|
||||
export * from "openclaw/plugin-sdk/feishu";
|
||||
// Private runtime barrel for the bundled Feishu extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/feishu.js";
|
||||
|
||||
@ -3,7 +3,7 @@ import type {
|
||||
ProviderAuthContext,
|
||||
ProviderFetchUsageSnapshotContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth";
|
||||
import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage";
|
||||
import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js";
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
export * from "openclaw/plugin-sdk/google";
|
||||
export * from "../../src/plugin-sdk/google.js";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Google Chat extension.
|
||||
// Keep this barrel thin and aligned with the curated plugin-sdk/googlechat surface.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "openclaw/plugin-sdk/googlechat";
|
||||
export * from "../../src/plugin-sdk/googlechat.js";
|
||||
|
||||
@ -1 +1,4 @@
|
||||
export * from "openclaw/plugin-sdk/irc";
|
||||
// Private runtime barrel for the bundled IRC extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../../src/plugin-sdk/irc.js";
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
export * from "openclaw/plugin-sdk/line";
|
||||
export * from "./runtime-api.js";
|
||||
export * from "./setup-api.js";
|
||||
|
||||
@ -1 +1,12 @@
|
||||
export * from "openclaw/plugin-sdk/line-core";
|
||||
// Private runtime barrel for the bundled LINE extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/line.js";
|
||||
export { resolveExactLineGroupConfigKey } from "../../src/plugin-sdk/line-core.js";
|
||||
export {
|
||||
formatDocsLink,
|
||||
setSetupChannelEnabled,
|
||||
splitSetupEntries,
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizard,
|
||||
} from "../../src/plugin-sdk/line-core.js";
|
||||
|
||||
@ -1 +1 @@
|
||||
export * from "openclaw/plugin-sdk/lobster";
|
||||
export * from "../../src/plugin-sdk/lobster.js";
|
||||
|
||||
@ -1 +1,4 @@
|
||||
export * from "openclaw/plugin-sdk/mattermost";
|
||||
// Private runtime barrel for the bundled Mattermost extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/mattermost.js";
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import {
|
||||
buildOauthProviderAuthResult,
|
||||
definePluginEntry,
|
||||
type ProviderAuthContext,
|
||||
type ProviderAuthResult,
|
||||
type ProviderCatalogContext,
|
||||
} from "openclaw/plugin-sdk/minimax-portal-auth";
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
MINIMAX_OAUTH_MARKER,
|
||||
createProviderApiKeyAuthMethod,
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth";
|
||||
import { fetchMinimaxUsage } from "openclaw/plugin-sdk/provider-usage";
|
||||
import {
|
||||
minimaxMediaUnderstandingProvider,
|
||||
|
||||
@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
|
||||
import {
|
||||
generatePkceVerifierChallenge,
|
||||
toFormUrlEncoded,
|
||||
} from "openclaw/plugin-sdk/minimax-portal-auth";
|
||||
} from "openclaw/plugin-sdk/provider-oauth";
|
||||
|
||||
export type MiniMaxRegion = "cn" | "global";
|
||||
|
||||
|
||||
@ -1 +1,4 @@
|
||||
export * from "openclaw/plugin-sdk/msteams";
|
||||
// Private runtime barrel for the bundled Microsoft Teams extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/msteams.js";
|
||||
|
||||
@ -9,6 +9,29 @@ import { msteamsPlugin } from "./channel.js";
|
||||
describe("msteams directory", () => {
|
||||
const runtimeEnv = createDirectoryTestRuntime() as RuntimeEnv;
|
||||
|
||||
describe("self()", () => {
|
||||
it("returns bot identity when credentials are configured", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
msteams: {
|
||||
appId: "test-app-id-1234",
|
||||
appPassword: "secret",
|
||||
tenantId: "tenant-id-5678",
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = await msteamsPlugin.directory?.self?.({ cfg, runtime: runtimeEnv });
|
||||
expect(result).toEqual({ kind: "user", id: "test-app-id-1234", name: "test-app-id-1234" });
|
||||
});
|
||||
|
||||
it("returns null when credentials are not configured", async () => {
|
||||
const cfg = { channels: {} } as unknown as OpenClawConfig;
|
||||
const result = await msteamsPlugin.directory?.self?.({ cfg, runtime: runtimeEnv });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("lists peers and groups from config", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@ -217,6 +217,13 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
},
|
||||
},
|
||||
directory: createChannelDirectoryAdapter({
|
||||
self: async ({ cfg }) => {
|
||||
const creds = resolveMSTeamsCredentials(cfg.channels?.msteams);
|
||||
if (!creds) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "user" as const, id: creds.appId, name: creds.appId };
|
||||
},
|
||||
listPeers: async ({ cfg, query, limit }) =>
|
||||
listDirectoryEntriesFromSources({
|
||||
kind: "user",
|
||||
|
||||
@ -25,6 +25,13 @@ export type StoredConversationReference = {
|
||||
serviceUrl?: string;
|
||||
/** Locale */
|
||||
locale?: string;
|
||||
/**
|
||||
* Cached Graph API chat ID (format: `19:xxx@thread.tacv2` or `19:xxx@unq.gbl.spaces`).
|
||||
* Bot Framework conversation IDs for personal DMs use a different format (`a:1xxx` or
|
||||
* `8:orgid:xxx`) that the Graph API does not accept. This field caches the resolved
|
||||
* Graph-native chat ID so we don't need to re-query the API on every send.
|
||||
*/
|
||||
graphChatId?: string;
|
||||
};
|
||||
|
||||
export type MSTeamsConversationStoreEntry = {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withFetchPreconnect } from "../../../test/helpers/extensions/fetch-mock.js";
|
||||
import { uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js";
|
||||
import { resolveGraphChatId, uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js";
|
||||
|
||||
describe("graph upload helpers", () => {
|
||||
const tokenProvider = {
|
||||
@ -100,3 +100,106 @@ describe("graph upload helpers", () => {
|
||||
).rejects.toThrow("SharePoint upload response missing required fields");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGraphChatId", () => {
|
||||
const tokenProvider = {
|
||||
getAccessToken: vi.fn(async () => "graph-token"),
|
||||
};
|
||||
|
||||
it("returns the ID directly when it already starts with 19:", async () => {
|
||||
const fetchFn = vi.fn();
|
||||
const result = await resolveGraphChatId({
|
||||
botFrameworkConversationId: "19:abc123@thread.tacv2",
|
||||
tokenProvider,
|
||||
fetchFn,
|
||||
});
|
||||
// Should short-circuit without making any API call
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
expect(result).toBe("19:abc123@thread.tacv2");
|
||||
});
|
||||
|
||||
it("resolves personal DM chat ID via Graph API using user AAD object ID", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ value: [{ id: "19:dm-chat-id@unq.gbl.spaces" }] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGraphChatId({
|
||||
botFrameworkConversationId: "a:1abc_bot_framework_dm_id",
|
||||
userAadObjectId: "user-aad-object-id-123",
|
||||
tokenProvider,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/me/chats"),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: "Bearer graph-token" }),
|
||||
}),
|
||||
);
|
||||
// Should filter by user AAD object ID
|
||||
const callUrl = (fetchFn.mock.calls[0] as [string, unknown])[0];
|
||||
expect(callUrl).toContain("user-aad-object-id-123");
|
||||
expect(result).toBe("19:dm-chat-id@unq.gbl.spaces");
|
||||
});
|
||||
|
||||
it("resolves personal DM chat ID without user AAD object ID (lists all 1:1 chats)", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ value: [{ id: "19:fallback-chat@unq.gbl.spaces" }] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGraphChatId({
|
||||
botFrameworkConversationId: "8:orgid:user-object-id",
|
||||
tokenProvider,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledOnce();
|
||||
expect(result).toBe("19:fallback-chat@unq.gbl.spaces");
|
||||
});
|
||||
|
||||
it("returns null when Graph API returns no chats", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ value: [] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGraphChatId({
|
||||
botFrameworkConversationId: "a:1unknown_dm",
|
||||
userAadObjectId: "some-user",
|
||||
tokenProvider,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when Graph API call fails", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async () =>
|
||||
new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: { "content-type": "text/plain" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGraphChatId({
|
||||
botFrameworkConversationId: "a:1some_dm_id",
|
||||
userAadObjectId: "some-user",
|
||||
tokenProvider,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -264,6 +264,82 @@ export async function getDriveItemProperties(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Graph API-native chat ID from a Bot Framework conversation ID.
|
||||
*
|
||||
* Bot Framework personal DM conversation IDs use formats like `a:1xxx@unq.gbl.spaces`
|
||||
* or `8:orgid:xxx` that the Graph API does not accept. Graph API requires the
|
||||
* `19:xxx@thread.tacv2` or `19:xxx@unq.gbl.spaces` format.
|
||||
*
|
||||
* This function looks up the matching Graph chat by querying the bot's chats filtered
|
||||
* by the target user's AAD object ID.
|
||||
*
|
||||
* Returns the Graph chat ID if found, or null if resolution fails.
|
||||
*/
|
||||
export async function resolveGraphChatId(params: {
|
||||
/** Bot Framework conversation ID (may be in non-Graph format for personal DMs) */
|
||||
botFrameworkConversationId: string;
|
||||
/** AAD object ID of the user in the conversation (used for filtering chats) */
|
||||
userAadObjectId?: string;
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<string | null> {
|
||||
const { botFrameworkConversationId, userAadObjectId, tokenProvider } = params;
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
|
||||
// If the conversation ID already looks like a valid Graph chat ID, return it directly.
|
||||
// Graph chat IDs start with "19:" — Bot Framework group chat IDs already use this format.
|
||||
if (botFrameworkConversationId.startsWith("19:")) {
|
||||
return botFrameworkConversationId;
|
||||
}
|
||||
|
||||
// For personal DMs with non-Graph conversation IDs (e.g. `a:1xxx` or `8:orgid:xxx`),
|
||||
// query the bot's chats to find the matching one.
|
||||
const token = await tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||
|
||||
// Build filter: if we have the user's AAD object ID, narrow the search to 1:1 chats
|
||||
// with that member. Otherwise, fall back to listing all 1:1 chats.
|
||||
let path: string;
|
||||
if (userAadObjectId) {
|
||||
const encoded = encodeURIComponent(
|
||||
`chatType eq 'oneOnOne' and members/any(m:m/microsoft.graph.aadUserConversationMember/userId eq '${userAadObjectId}')`,
|
||||
);
|
||||
path = `/me/chats?$filter=${encoded}&$select=id`;
|
||||
} else {
|
||||
// Fallback: list all 1:1 chats when no user ID is available.
|
||||
// Only safe when the bot has exactly one 1:1 chat; returns null otherwise to
|
||||
// avoid sending to the wrong person's chat.
|
||||
path = `/me/chats?$filter=${encodeURIComponent("chatType eq 'oneOnOne'")}&$select=id`;
|
||||
}
|
||||
|
||||
const res = await fetchFn(`${GRAPH_ROOT}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
value?: Array<{ id?: string }>;
|
||||
};
|
||||
|
||||
const chats = data.value ?? [];
|
||||
|
||||
// When filtered by userAadObjectId, any non-empty result is the right 1:1 chat.
|
||||
if (userAadObjectId && chats.length > 0 && chats[0]?.id) {
|
||||
return chats[0].id;
|
||||
}
|
||||
|
||||
// Without a user ID we can only be certain when exactly one chat is returned;
|
||||
// multiple results would be ambiguous and could route to the wrong person.
|
||||
if (!userAadObjectId && chats.length === 1 && chats[0]?.id) {
|
||||
return chats[0].id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get members of a Teams chat for per-user sharing.
|
||||
* Used to create sharing links scoped to only the chat participants.
|
||||
|
||||
@ -61,6 +61,8 @@ export type MSTeamsAdapter = {
|
||||
res: unknown,
|
||||
logic: (context: unknown) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
updateActivity: (context: unknown, activity: object) => Promise<void>;
|
||||
deleteActivity: (context: unknown, reference: { activityId?: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
export type MSTeamsReplyRenderOptions = {
|
||||
@ -319,8 +321,10 @@ async function buildActivity(
|
||||
|
||||
if (!isPersonal && !isImage && tokenProvider && sharePointSiteId) {
|
||||
// Non-image in group chat/channel with SharePoint site configured:
|
||||
// Upload to SharePoint and use native file card attachment
|
||||
const chatId = conversationRef.conversation?.id;
|
||||
// Upload to SharePoint and use native file card attachment.
|
||||
// Use the cached Graph-native chat ID when available — Bot Framework conversation IDs
|
||||
// for personal DMs use a format (e.g. `a:1xxx`) that Graph API rejects.
|
||||
const chatId = conversationRef.graphChatId ?? conversationRef.conversation?.id;
|
||||
|
||||
// Upload to SharePoint
|
||||
const uploaded = await uploadAndShareSharePoint({
|
||||
|
||||
@ -21,6 +21,12 @@ export type MSTeamsActivityHandler = {
|
||||
onMembersAdded: (
|
||||
handler: (context: unknown, next: () => Promise<void>) => Promise<void>,
|
||||
) => MSTeamsActivityHandler;
|
||||
onReactionsAdded: (
|
||||
handler: (context: unknown, next: () => Promise<void>) => Promise<void>,
|
||||
) => MSTeamsActivityHandler;
|
||||
onReactionsRemoved: (
|
||||
handler: (context: unknown, next: () => Promise<void>) => Promise<void>,
|
||||
) => MSTeamsActivityHandler;
|
||||
run?: (context: unknown) => Promise<void>;
|
||||
};
|
||||
|
||||
|
||||
@ -177,10 +177,17 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
channelName,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
|
||||
});
|
||||
const senderGroupPolicy = resolveSenderScopedGroupPolicy({
|
||||
groupPolicy,
|
||||
groupAllowFrom: effectiveGroupAllowFrom,
|
||||
});
|
||||
// When a route-level (team/channel) allowlist is configured but the sender allowlist is
|
||||
// empty, resolveSenderScopedGroupPolicy would otherwise downgrade the policy to "open",
|
||||
// allowing any sender. To close this bypass (GHSA-g7cr-9h7q-4qxq), treat an empty sender
|
||||
// allowlist as deny-all whenever the route allowlist is active.
|
||||
const senderGroupPolicy =
|
||||
channelGate.allowlistConfigured && effectiveGroupAllowFrom.length === 0
|
||||
? groupPolicy
|
||||
: resolveSenderScopedGroupPolicy({
|
||||
groupPolicy,
|
||||
groupAllowFrom: effectiveGroupAllowFrom,
|
||||
});
|
||||
const access = resolveDmGroupAccessWithLists({
|
||||
isGroup: !isDirectMessage,
|
||||
dmPolicy,
|
||||
|
||||
@ -9,6 +9,7 @@ import type {
|
||||
MSTeamsConversationStore,
|
||||
StoredConversationReference,
|
||||
} from "./conversation-store.js";
|
||||
import { resolveGraphChatId } from "./graph-upload.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
@ -30,6 +31,13 @@ export type MSTeamsProactiveContext = {
|
||||
sharePointSiteId?: string;
|
||||
/** Resolved media max bytes from config (default: 100MB) */
|
||||
mediaMaxBytes?: number;
|
||||
/**
|
||||
* Graph API-native chat ID for this conversation.
|
||||
* Bot Framework personal DM IDs (`a:1xxx` / `8:orgid:xxx`) cannot be used directly
|
||||
* with Graph chat endpoints. This field holds the resolved `19:xxx` format ID.
|
||||
* Null if resolution failed or not applicable.
|
||||
*/
|
||||
graphChatId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -150,6 +158,45 @@ export async function resolveMSTeamsSendContext(params: {
|
||||
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
|
||||
});
|
||||
|
||||
// Resolve Graph API-native chat ID if needed for SharePoint per-user sharing.
|
||||
// Bot Framework personal DM conversation IDs (e.g. `a:1xxx` or `8:orgid:xxx`) cannot
|
||||
// be used directly with Graph /chats/{chatId} endpoints — the Graph API requires the
|
||||
// `19:xxx@thread.tacv2` or `19:xxx@unq.gbl.spaces` format.
|
||||
// We check the cached value first, then resolve via Graph API and cache for future sends.
|
||||
let graphChatId: string | null | undefined = ref.graphChatId ?? undefined;
|
||||
if (graphChatId === undefined && sharePointSiteId) {
|
||||
// Only resolve when SharePoint is configured (the only place chatId matters currently)
|
||||
try {
|
||||
const resolved = await resolveGraphChatId({
|
||||
botFrameworkConversationId: conversationId,
|
||||
userAadObjectId: ref.user?.aadObjectId,
|
||||
tokenProvider,
|
||||
});
|
||||
graphChatId = resolved;
|
||||
|
||||
// Cache in the conversation store so subsequent sends skip the Graph lookup.
|
||||
// NOTE: We intentionally do NOT cache null results. Transient Graph API failures
|
||||
// (network, 401, rate limit) should be retried on subsequent sends rather than
|
||||
// permanently blocking file uploads for this conversation.
|
||||
if (resolved) {
|
||||
await store.upsert(conversationId, { ...ref, graphChatId: resolved });
|
||||
} else {
|
||||
log.warn?.("could not resolve Graph chat ID; file uploads may fail for this conversation", {
|
||||
conversationId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn?.(
|
||||
"failed to resolve Graph chat ID; file uploads may fall back to Bot Framework ID",
|
||||
{
|
||||
conversationId,
|
||||
error: String(err),
|
||||
},
|
||||
);
|
||||
graphChatId = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
appId: creds.appId,
|
||||
conversationId,
|
||||
@ -160,5 +207,6 @@ export async function resolveMSTeamsSendContext(params: {
|
||||
tokenProvider,
|
||||
sharePointSiteId,
|
||||
mediaMaxBytes,
|
||||
graphChatId,
|
||||
};
|
||||
}
|
||||
|
||||
@ -9,6 +9,9 @@ const mockState = vi.hoisted(() => ({
|
||||
prepareFileConsentActivity: vi.fn(),
|
||||
extractFilename: vi.fn(async () => "fallback.bin"),
|
||||
sendMSTeamsMessages: vi.fn(),
|
||||
uploadAndShareSharePoint: vi.fn(),
|
||||
getDriveItemProperties: vi.fn(),
|
||||
buildTeamsFileInfoCard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
@ -45,6 +48,16 @@ vi.mock("./runtime.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./graph-upload.js", () => ({
|
||||
uploadAndShareSharePoint: mockState.uploadAndShareSharePoint,
|
||||
getDriveItemProperties: mockState.getDriveItemProperties,
|
||||
uploadAndShareOneDrive: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./graph-chat.js", () => ({
|
||||
buildTeamsFileInfoCard: mockState.buildTeamsFileInfoCard,
|
||||
}));
|
||||
|
||||
describe("sendMessageMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
mockState.loadOutboundMediaFromUrl.mockReset();
|
||||
@ -53,6 +66,9 @@ describe("sendMessageMSTeams", () => {
|
||||
mockState.prepareFileConsentActivity.mockReset();
|
||||
mockState.extractFilename.mockReset();
|
||||
mockState.sendMSTeamsMessages.mockReset();
|
||||
mockState.uploadAndShareSharePoint.mockReset();
|
||||
mockState.getDriveItemProperties.mockReset();
|
||||
mockState.buildTeamsFileInfoCard.mockReset();
|
||||
|
||||
mockState.extractFilename.mockResolvedValue("fallback.bin");
|
||||
mockState.requiresFileConsent.mockReturnValue(false);
|
||||
@ -106,4 +122,139 @@ describe("sendMessageMSTeams", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses graphChatId instead of conversationId when uploading to SharePoint", async () => {
|
||||
// Simulates a group chat where Bot Framework conversationId is valid but we have
|
||||
// a resolved Graph chat ID cached from a prior send.
|
||||
const graphChatId = "19:graph-native-chat-id@thread.tacv2";
|
||||
const botFrameworkConversationId = "19:bot-framework-id@thread.tacv2";
|
||||
|
||||
mockState.resolveMSTeamsSendContext.mockResolvedValue({
|
||||
adapter: {
|
||||
continueConversation: vi.fn(
|
||||
async (
|
||||
_id: string,
|
||||
_ref: unknown,
|
||||
fn: (ctx: { sendActivity: () => { id: "msg-1" } }) => Promise<void>,
|
||||
) => fn({ sendActivity: () => ({ id: "msg-1" }) }),
|
||||
),
|
||||
},
|
||||
appId: "app-id",
|
||||
conversationId: botFrameworkConversationId,
|
||||
graphChatId,
|
||||
ref: {},
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
conversationType: "groupChat",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
mediaMaxBytes: 8 * 1024 * 1024,
|
||||
sharePointSiteId: "site-123",
|
||||
});
|
||||
|
||||
const pdfBuffer = Buffer.alloc(100, "pdf");
|
||||
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
||||
buffer: pdfBuffer,
|
||||
contentType: "application/pdf",
|
||||
fileName: "doc.pdf",
|
||||
kind: "file",
|
||||
});
|
||||
mockState.requiresFileConsent.mockReturnValue(false);
|
||||
mockState.uploadAndShareSharePoint.mockResolvedValue({
|
||||
itemId: "item-1",
|
||||
webUrl: "https://sp.example.com/doc.pdf",
|
||||
shareUrl: "https://sp.example.com/share/doc.pdf",
|
||||
name: "doc.pdf",
|
||||
});
|
||||
mockState.getDriveItemProperties.mockResolvedValue({
|
||||
eTag: '"{GUID-123},1"',
|
||||
webDavUrl: "https://sp.example.com/dav/doc.pdf",
|
||||
name: "doc.pdf",
|
||||
});
|
||||
mockState.buildTeamsFileInfoCard.mockReturnValue({
|
||||
contentType: "application/vnd.microsoft.teams.card.file.info",
|
||||
contentUrl: "https://sp.example.com/dav/doc.pdf",
|
||||
name: "doc.pdf",
|
||||
content: { uniqueId: "GUID-123", fileType: "pdf" },
|
||||
});
|
||||
|
||||
await sendMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "conversation:19:bot-framework-id@thread.tacv2",
|
||||
text: "here is a file",
|
||||
mediaUrl: "https://example.com/doc.pdf",
|
||||
});
|
||||
|
||||
// The Graph-native chatId must be passed to SharePoint upload, not the Bot Framework ID
|
||||
expect(mockState.uploadAndShareSharePoint).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatId: graphChatId,
|
||||
siteId: "site-123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to conversationId when graphChatId is not available", async () => {
|
||||
const botFrameworkConversationId = "19:fallback-id@thread.tacv2";
|
||||
|
||||
mockState.resolveMSTeamsSendContext.mockResolvedValue({
|
||||
adapter: {
|
||||
continueConversation: vi.fn(
|
||||
async (
|
||||
_id: string,
|
||||
_ref: unknown,
|
||||
fn: (ctx: { sendActivity: () => { id: "msg-1" } }) => Promise<void>,
|
||||
) => fn({ sendActivity: () => ({ id: "msg-1" }) }),
|
||||
),
|
||||
},
|
||||
appId: "app-id",
|
||||
conversationId: botFrameworkConversationId,
|
||||
graphChatId: null, // resolution failed — must fall back
|
||||
ref: {},
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
conversationType: "groupChat",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
mediaMaxBytes: 8 * 1024 * 1024,
|
||||
sharePointSiteId: "site-456",
|
||||
});
|
||||
|
||||
const pdfBuffer = Buffer.alloc(50, "pdf");
|
||||
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
||||
buffer: pdfBuffer,
|
||||
contentType: "application/pdf",
|
||||
fileName: "report.pdf",
|
||||
kind: "file",
|
||||
});
|
||||
mockState.requiresFileConsent.mockReturnValue(false);
|
||||
mockState.uploadAndShareSharePoint.mockResolvedValue({
|
||||
itemId: "item-2",
|
||||
webUrl: "https://sp.example.com/report.pdf",
|
||||
shareUrl: "https://sp.example.com/share/report.pdf",
|
||||
name: "report.pdf",
|
||||
});
|
||||
mockState.getDriveItemProperties.mockResolvedValue({
|
||||
eTag: '"{GUID-456},1"',
|
||||
webDavUrl: "https://sp.example.com/dav/report.pdf",
|
||||
name: "report.pdf",
|
||||
});
|
||||
mockState.buildTeamsFileInfoCard.mockReturnValue({
|
||||
contentType: "application/vnd.microsoft.teams.card.file.info",
|
||||
contentUrl: "https://sp.example.com/dav/report.pdf",
|
||||
name: "report.pdf",
|
||||
content: { uniqueId: "GUID-456", fileType: "pdf" },
|
||||
});
|
||||
|
||||
await sendMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "conversation:19:fallback-id@thread.tacv2",
|
||||
text: "report",
|
||||
mediaUrl: "https://example.com/report.pdf",
|
||||
});
|
||||
|
||||
// Falls back to conversationId when graphChatId is null
|
||||
expect(mockState.uploadAndShareSharePoint).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatId: botFrameworkConversationId,
|
||||
siteId: "site-456",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -206,7 +206,9 @@ export async function sendMessageMSTeams(
|
||||
contentType: media.contentType,
|
||||
tokenProvider,
|
||||
siteId: sharePointSiteId,
|
||||
chatId: conversationId,
|
||||
// Use the Graph-native chat ID (19:xxx format) — the Bot Framework conversationId
|
||||
// for personal DMs uses a different format that Graph API rejects.
|
||||
chatId: ctx.graphChatId ?? conversationId,
|
||||
usePerUserSharing: conversationType === "groupChat",
|
||||
});
|
||||
|
||||
|
||||
@ -1 +1,4 @@
|
||||
export * from "openclaw/plugin-sdk/nextcloud-talk";
|
||||
// Private runtime barrel for the bundled Nextcloud Talk extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/nextcloud-talk.js";
|
||||
|
||||
@ -1 +1 @@
|
||||
export * from "openclaw/plugin-sdk/nostr";
|
||||
export * from "./runtime-api.js";
|
||||
|
||||
@ -1 +1,4 @@
|
||||
export * from "openclaw/plugin-sdk/nostr";
|
||||
// Private runtime barrel for the bundled Nostr extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/nostr.js";
|
||||
|
||||
@ -3,7 +3,6 @@ import type {
|
||||
ProviderResolveDynamicModelContext,
|
||||
ProviderRuntimeModel,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
@ -17,6 +16,7 @@ import {
|
||||
normalizeProviderId,
|
||||
type ProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-models";
|
||||
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth";
|
||||
import { createOpenAIAttributionHeadersWrapper } from "openclaw/plugin-sdk/provider-stream";
|
||||
import { fetchCodexUsage } from "openclaw/plugin-sdk/provider-usage";
|
||||
import { buildOpenAICodexProvider } from "./openai-codex-catalog.js";
|
||||
|
||||
@ -2,6 +2,6 @@ export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
export type {
|
||||
OpenClawPluginApi,
|
||||
OpenClawPluginCommandDefinition,
|
||||
OpenClawPluginService,
|
||||
PluginCommandContext,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
OpenClawPluginService,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { QWEN_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { buildQwenPortalProvider, QWEN_PORTAL_BASE_URL } from "./provider-catalog.js";
|
||||
import {
|
||||
buildOauthProviderAuthResult,
|
||||
definePluginEntry,
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
QWEN_OAUTH_MARKER,
|
||||
refreshQwenPortalCredentials,
|
||||
type ProviderAuthContext,
|
||||
type ProviderCatalogContext,
|
||||
|
||||
135
extensions/qwen-portal-auth/refresh.test.ts
Normal file
135
extensions/qwen-portal-auth/refresh.test.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { refreshQwenPortalCredentials } from "./refresh.js";
|
||||
|
||||
function expiredCredentials() {
|
||||
return {
|
||||
type: "oauth" as const,
|
||||
provider: "qwen-portal",
|
||||
access: "expired-access",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() - 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
describe("refreshQwenPortalCredentials", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const runRefresh = async () => await refreshQwenPortalCredentials(expiredCredentials());
|
||||
|
||||
it("refreshes oauth credentials and preserves existing refresh token when absent", async () => {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-access",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await runRefresh();
|
||||
|
||||
expect(result.access).toBe("new-access");
|
||||
expect(result.refresh).toBe("refresh-token");
|
||||
expect(result.expires).toBeGreaterThan(Date.now());
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://chat.qwen.ai/api/v1/oauth2/token",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.any(URLSearchParams),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces the refresh token when the server rotates it", async () => {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-access",
|
||||
refresh_token: "rotated-refresh",
|
||||
expires_in: 1200,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await runRefresh();
|
||||
|
||||
expect(result.refresh).toBe("rotated-refresh");
|
||||
});
|
||||
|
||||
it("rejects invalid expires_in payloads", async () => {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-access",
|
||||
expires_in: 0,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
await expect(runRefresh()).rejects.toThrow(
|
||||
"Qwen OAuth refresh response missing or invalid expires_in",
|
||||
);
|
||||
});
|
||||
|
||||
it("turns 400 responses into a re-authenticate hint", async () => {
|
||||
globalThis.fetch = vi.fn(
|
||||
async () => new Response("bad refresh", { status: 400 }),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh token expired or invalid");
|
||||
});
|
||||
|
||||
it("requires a refresh token", async () => {
|
||||
await expect(
|
||||
refreshQwenPortalCredentials({
|
||||
type: "oauth",
|
||||
provider: "qwen-portal",
|
||||
access: "expired-access",
|
||||
refresh: "",
|
||||
expires: Date.now() - 60_000,
|
||||
}),
|
||||
).rejects.toThrow("Qwen OAuth refresh token missing");
|
||||
});
|
||||
|
||||
it("rejects missing access tokens", async () => {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh response missing access token");
|
||||
});
|
||||
|
||||
it("surfaces non-400 refresh failures", async () => {
|
||||
globalThis.fetch = vi.fn(
|
||||
async () => new Response("gateway down", { status: 502 }),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh failed: gateway down");
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { formatCliCommand } from "openclaw/plugin-sdk/setup-tools";
|
||||
|
||||
const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai";
|
||||
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
|
||||
@ -54,9 +54,9 @@ export async function refreshQwenPortalCredentials(
|
||||
|
||||
return {
|
||||
...credentials,
|
||||
access: accessToken,
|
||||
// RFC 6749 section 6: new refresh token is optional; if present, replace old.
|
||||
refresh: newRefreshToken || refreshToken,
|
||||
access: accessToken,
|
||||
expires: Date.now() + expiresIn * 1000,
|
||||
};
|
||||
}
|
||||
@ -1 +1,10 @@
|
||||
export * from "openclaw/plugin-sdk/qwen-portal-auth";
|
||||
export { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth";
|
||||
export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
export type { ProviderAuthContext, ProviderCatalogContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
export { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/provider-auth";
|
||||
export { QWEN_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime";
|
||||
export {
|
||||
generatePkceVerifierChallenge,
|
||||
toFormUrlEncoded,
|
||||
} from "openclaw/plugin-sdk/provider-oauth";
|
||||
export { refreshQwenPortalCredentials } from "./refresh.js";
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
resolveAccountEntry,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { SignalAccountConfig } from "openclaw/plugin-sdk/signal-core";
|
||||
import type { SignalAccountConfig } from "./runtime-api.js";
|
||||
|
||||
export type ResolvedSignalAccount = {
|
||||
accountId: string;
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
import { buildChannelConfigSchema, SignalConfigSchema } from "openclaw/plugin-sdk/signal-core";
|
||||
import { buildChannelConfigSchema, SignalConfigSchema } from "./runtime-api.js";
|
||||
|
||||
export const SignalChannelConfigSchema = buildChannelConfigSchema(SignalConfigSchema);
|
||||
|
||||
@ -1 +1,4 @@
|
||||
export * from "openclaw/plugin-sdk/signal";
|
||||
// Private runtime barrel for the bundled Signal extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../../src/plugin-sdk/signal.js";
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "openclaw/plugin-sdk/synology-chat";
|
||||
export * from "./setup-api.js";
|
||||
@ -27,20 +27,37 @@ async function readRequestBodyWithLimitForTest(req: IncomingMessage): Promise<st
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("../api.js", () => ({
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
setAccountEnabledInConfigSection: vi.fn((_opts: unknown) => ({})),
|
||||
registerPluginHttpRoute: registerPluginHttpRouteMock,
|
||||
buildChannelConfigSchema: vi.fn((schema: unknown) => ({ schema })),
|
||||
readRequestBodyWithLimit: vi.fn(readRequestBodyWithLimitForTest),
|
||||
isRequestBodyLimitError: vi.fn(() => false),
|
||||
requestBodyErrorToText: vi.fn(() => "Request body too large"),
|
||||
createFixedWindowRateLimiter: vi.fn(() => ({
|
||||
isRateLimited: vi.fn(() => false),
|
||||
size: vi.fn(() => 0),
|
||||
clear: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/setup", async () => {
|
||||
const actual = await vi.importActual<object>("openclaw/plugin-sdk/setup");
|
||||
return {
|
||||
...actual,
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/channel-config-schema", async () => {
|
||||
const actual = await vi.importActual<object>("openclaw/plugin-sdk/channel-config-schema");
|
||||
return {
|
||||
...actual,
|
||||
buildChannelConfigSchema: vi.fn((schema: unknown) => ({ schema })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/webhook-ingress", async () => {
|
||||
const actual = await vi.importActual<object>("openclaw/plugin-sdk/webhook-ingress");
|
||||
return {
|
||||
...actual,
|
||||
registerPluginHttpRoute: registerPluginHttpRouteMock,
|
||||
readRequestBodyWithLimit: vi.fn(readRequestBodyWithLimitForTest),
|
||||
isRequestBodyLimitError: vi.fn(() => false),
|
||||
requestBodyErrorToText: vi.fn(() => "Request body too large"),
|
||||
createFixedWindowRateLimiter: vi.fn(() => ({
|
||||
isRateLimited: vi.fn(() => false),
|
||||
size: vi.fn(() => 0),
|
||||
clear: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
sendMessage: vi.fn().mockResolvedValue(true),
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
createHybridChannelConfigAdapter,
|
||||
createScopedDmSecurityResolver,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import {
|
||||
createConditionalWarningCollector,
|
||||
projectWarningCollector,
|
||||
@ -17,8 +18,9 @@ import {
|
||||
createEmptyChannelDirectoryAdapter,
|
||||
createTextPairingAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
||||
import { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { z } from "zod";
|
||||
import { DEFAULT_ACCOUNT_ID, registerPluginHttpRoute, buildChannelConfigSchema } from "../api.js";
|
||||
import { listAccountIds, resolveAccount } from "./accounts.js";
|
||||
import { sendMessage, sendFileUrl } from "./client.js";
|
||||
import { getSynologyRuntime } from "./runtime.js";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { z } from "zod";
|
||||
import { buildChannelConfigSchema } from "../api.js";
|
||||
|
||||
export const SynologyChatChannelConfigSchema = buildChannelConfigSchema(z.object({}).passthrough());
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "../api.js";
|
||||
|
||||
const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>(
|
||||
|
||||
@ -3,7 +3,10 @@
|
||||
*/
|
||||
|
||||
import * as crypto from "node:crypto";
|
||||
import { createFixedWindowRateLimiter, type FixedWindowRateLimiter } from "../api.js";
|
||||
import {
|
||||
createFixedWindowRateLimiter,
|
||||
type FixedWindowRateLimiter,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
|
||||
export type DmAuthorizationResult =
|
||||
| { allowed: true }
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "../api.js";
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { sendMessage, resolveChatUserId } from "./client.js";
|
||||
import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js";
|
||||
import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js";
|
||||
|
||||
@ -1 +1 @@
|
||||
export * from "openclaw/plugin-sdk/tlon";
|
||||
export * from "./runtime-api.js";
|
||||
|
||||
4
extensions/tlon/runtime-api.ts
Normal file
4
extensions/tlon/runtime-api.ts
Normal file
@ -0,0 +1,4 @@
|
||||
// Private runtime barrel for the bundled Tlon extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/tlon.js";
|
||||
@ -1 +1 @@
|
||||
export * from "openclaw/plugin-sdk/twitch";
|
||||
export * from "./runtime-api.js";
|
||||
|
||||
@ -1 +1,4 @@
|
||||
export * from "openclaw/plugin-sdk/twitch";
|
||||
// Private runtime barrel for the bundled Twitch extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/twitch.js";
|
||||
|
||||
@ -1 +1 @@
|
||||
export * from "openclaw/plugin-sdk/voice-call";
|
||||
export * from "./runtime-api.js";
|
||||
|
||||
4
extensions/voice-call/runtime-api.ts
Normal file
4
extensions/voice-call/runtime-api.ts
Normal file
@ -0,0 +1,4 @@
|
||||
// Private runtime barrel for the bundled Voice Call extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/voice-call.js";
|
||||
@ -1 +1 @@
|
||||
export * from "openclaw/plugin-sdk/zai";
|
||||
export * from "../../src/plugin-sdk/zai.js";
|
||||
|
||||
@ -1 +1,4 @@
|
||||
export * from "openclaw/plugin-sdk/zalo";
|
||||
// Private runtime barrel for the bundled Zalo extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/zalo.js";
|
||||
|
||||
@ -1 +1,4 @@
|
||||
export * from "openclaw/plugin-sdk/zalouser";
|
||||
// Private runtime barrel for the bundled Zalo Personal extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/zalouser.js";
|
||||
|
||||
102
package.json
102
package.json
@ -173,10 +173,6 @@
|
||||
"types": "./dist/plugin-sdk/acp-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/acp-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/acpx": {
|
||||
"types": "./dist/plugin-sdk/acpx.d.ts",
|
||||
"default": "./dist/plugin-sdk/acpx.js"
|
||||
},
|
||||
"./plugin-sdk/telegram": {
|
||||
"types": "./dist/plugin-sdk/telegram.d.ts",
|
||||
"default": "./dist/plugin-sdk/telegram.js"
|
||||
@ -193,46 +189,10 @@
|
||||
"types": "./dist/plugin-sdk/discord-core.d.ts",
|
||||
"default": "./dist/plugin-sdk/discord-core.js"
|
||||
},
|
||||
"./plugin-sdk/feishu": {
|
||||
"types": "./dist/plugin-sdk/feishu.d.ts",
|
||||
"default": "./dist/plugin-sdk/feishu.js"
|
||||
},
|
||||
"./plugin-sdk/google": {
|
||||
"types": "./dist/plugin-sdk/google.d.ts",
|
||||
"default": "./dist/plugin-sdk/google.js"
|
||||
},
|
||||
"./plugin-sdk/googlechat": {
|
||||
"types": "./dist/plugin-sdk/googlechat.d.ts",
|
||||
"default": "./dist/plugin-sdk/googlechat.js"
|
||||
},
|
||||
"./plugin-sdk/irc": {
|
||||
"types": "./dist/plugin-sdk/irc.d.ts",
|
||||
"default": "./dist/plugin-sdk/irc.js"
|
||||
},
|
||||
"./plugin-sdk/line-core": {
|
||||
"types": "./dist/plugin-sdk/line-core.d.ts",
|
||||
"default": "./dist/plugin-sdk/line-core.js"
|
||||
},
|
||||
"./plugin-sdk/lobster": {
|
||||
"types": "./dist/plugin-sdk/lobster.d.ts",
|
||||
"default": "./dist/plugin-sdk/lobster.js"
|
||||
},
|
||||
"./plugin-sdk/matrix": {
|
||||
"types": "./dist/plugin-sdk/matrix.d.ts",
|
||||
"default": "./dist/plugin-sdk/matrix.js"
|
||||
},
|
||||
"./plugin-sdk/mattermost": {
|
||||
"types": "./dist/plugin-sdk/mattermost.d.ts",
|
||||
"default": "./dist/plugin-sdk/mattermost.js"
|
||||
},
|
||||
"./plugin-sdk/msteams": {
|
||||
"types": "./dist/plugin-sdk/msteams.d.ts",
|
||||
"default": "./dist/plugin-sdk/msteams.js"
|
||||
},
|
||||
"./plugin-sdk/nextcloud-talk": {
|
||||
"types": "./dist/plugin-sdk/nextcloud-talk.d.ts",
|
||||
"default": "./dist/plugin-sdk/nextcloud-talk.js"
|
||||
},
|
||||
"./plugin-sdk/slack": {
|
||||
"types": "./dist/plugin-sdk/slack.d.ts",
|
||||
"default": "./dist/plugin-sdk/slack.js"
|
||||
@ -249,10 +209,6 @@
|
||||
"types": "./dist/plugin-sdk/imessage-core.d.ts",
|
||||
"default": "./dist/plugin-sdk/imessage-core.js"
|
||||
},
|
||||
"./plugin-sdk/signal": {
|
||||
"types": "./dist/plugin-sdk/signal.d.ts",
|
||||
"default": "./dist/plugin-sdk/signal.js"
|
||||
},
|
||||
"./plugin-sdk/whatsapp": {
|
||||
"types": "./dist/plugin-sdk/whatsapp.d.ts",
|
||||
"default": "./dist/plugin-sdk/whatsapp.js"
|
||||
@ -313,9 +269,9 @@
|
||||
"types": "./dist/plugin-sdk/boolean-param.d.ts",
|
||||
"default": "./dist/plugin-sdk/boolean-param.js"
|
||||
},
|
||||
"./plugin-sdk/device-pair": {
|
||||
"types": "./dist/plugin-sdk/device-pair.d.ts",
|
||||
"default": "./dist/plugin-sdk/device-pair.js"
|
||||
"./plugin-sdk/device-bootstrap": {
|
||||
"types": "./dist/plugin-sdk/device-bootstrap.d.ts",
|
||||
"default": "./dist/plugin-sdk/device-bootstrap.js"
|
||||
},
|
||||
"./plugin-sdk/diagnostics-otel": {
|
||||
"types": "./dist/plugin-sdk/diagnostics-otel.d.ts",
|
||||
@ -369,10 +325,6 @@
|
||||
"types": "./dist/plugin-sdk/keyed-async-queue.d.ts",
|
||||
"default": "./dist/plugin-sdk/keyed-async-queue.js"
|
||||
},
|
||||
"./plugin-sdk/line": {
|
||||
"types": "./dist/plugin-sdk/line.d.ts",
|
||||
"default": "./dist/plugin-sdk/line.js"
|
||||
},
|
||||
"./plugin-sdk/llm-task": {
|
||||
"types": "./dist/plugin-sdk/llm-task.d.ts",
|
||||
"default": "./dist/plugin-sdk/llm-task.js"
|
||||
@ -381,14 +333,14 @@
|
||||
"types": "./dist/plugin-sdk/memory-lancedb.d.ts",
|
||||
"default": "./dist/plugin-sdk/memory-lancedb.js"
|
||||
},
|
||||
"./plugin-sdk/minimax-portal-auth": {
|
||||
"types": "./dist/plugin-sdk/minimax-portal-auth.d.ts",
|
||||
"default": "./dist/plugin-sdk/minimax-portal-auth.js"
|
||||
},
|
||||
"./plugin-sdk/provider-auth": {
|
||||
"types": "./dist/plugin-sdk/provider-auth.d.ts",
|
||||
"default": "./dist/plugin-sdk/provider-auth.js"
|
||||
},
|
||||
"./plugin-sdk/provider-oauth": {
|
||||
"types": "./dist/plugin-sdk/provider-oauth.d.ts",
|
||||
"default": "./dist/plugin-sdk/provider-oauth.js"
|
||||
},
|
||||
"./plugin-sdk/provider-auth-api-key": {
|
||||
"types": "./dist/plugin-sdk/provider-auth-api-key.d.ts",
|
||||
"default": "./dist/plugin-sdk/provider-auth-api-key.js"
|
||||
@ -429,10 +381,6 @@
|
||||
"types": "./dist/plugin-sdk/image-generation.d.ts",
|
||||
"default": "./dist/plugin-sdk/image-generation.js"
|
||||
},
|
||||
"./plugin-sdk/nostr": {
|
||||
"types": "./dist/plugin-sdk/nostr.d.ts",
|
||||
"default": "./dist/plugin-sdk/nostr.js"
|
||||
},
|
||||
"./plugin-sdk/reply-history": {
|
||||
"types": "./dist/plugin-sdk/reply-history.d.ts",
|
||||
"default": "./dist/plugin-sdk/reply-history.js"
|
||||
@ -453,10 +401,6 @@
|
||||
"types": "./dist/plugin-sdk/request-url.d.ts",
|
||||
"default": "./dist/plugin-sdk/request-url.js"
|
||||
},
|
||||
"./plugin-sdk/qwen-portal-auth": {
|
||||
"types": "./dist/plugin-sdk/qwen-portal-auth.d.ts",
|
||||
"default": "./dist/plugin-sdk/qwen-portal-auth.js"
|
||||
},
|
||||
"./plugin-sdk/webhook-ingress": {
|
||||
"types": "./dist/plugin-sdk/webhook-ingress.d.ts",
|
||||
"default": "./dist/plugin-sdk/webhook-ingress.js"
|
||||
@ -473,46 +417,14 @@
|
||||
"types": "./dist/plugin-sdk/secret-input.d.ts",
|
||||
"default": "./dist/plugin-sdk/secret-input.js"
|
||||
},
|
||||
"./plugin-sdk/signal-core": {
|
||||
"types": "./dist/plugin-sdk/signal-core.d.ts",
|
||||
"default": "./dist/plugin-sdk/signal-core.js"
|
||||
},
|
||||
"./plugin-sdk/synology-chat": {
|
||||
"types": "./dist/plugin-sdk/synology-chat.d.ts",
|
||||
"default": "./dist/plugin-sdk/synology-chat.js"
|
||||
},
|
||||
"./plugin-sdk/thread-ownership": {
|
||||
"types": "./dist/plugin-sdk/thread-ownership.d.ts",
|
||||
"default": "./dist/plugin-sdk/thread-ownership.js"
|
||||
},
|
||||
"./plugin-sdk/tlon": {
|
||||
"types": "./dist/plugin-sdk/tlon.d.ts",
|
||||
"default": "./dist/plugin-sdk/tlon.js"
|
||||
},
|
||||
"./plugin-sdk/twitch": {
|
||||
"types": "./dist/plugin-sdk/twitch.d.ts",
|
||||
"default": "./dist/plugin-sdk/twitch.js"
|
||||
},
|
||||
"./plugin-sdk/voice-call": {
|
||||
"types": "./dist/plugin-sdk/voice-call.d.ts",
|
||||
"default": "./dist/plugin-sdk/voice-call.js"
|
||||
},
|
||||
"./plugin-sdk/web-media": {
|
||||
"types": "./dist/plugin-sdk/web-media.d.ts",
|
||||
"default": "./dist/plugin-sdk/web-media.js"
|
||||
},
|
||||
"./plugin-sdk/zai": {
|
||||
"types": "./dist/plugin-sdk/zai.d.ts",
|
||||
"default": "./dist/plugin-sdk/zai.js"
|
||||
},
|
||||
"./plugin-sdk/zalo": {
|
||||
"types": "./dist/plugin-sdk/zalo.d.ts",
|
||||
"default": "./dist/plugin-sdk/zalo.js"
|
||||
},
|
||||
"./plugin-sdk/zalouser": {
|
||||
"types": "./dist/plugin-sdk/zalouser.d.ts",
|
||||
"default": "./dist/plugin-sdk/zalouser.js"
|
||||
},
|
||||
"./plugin-sdk/speech": {
|
||||
"types": "./dist/plugin-sdk/speech.d.ts",
|
||||
"default": "./dist/plugin-sdk/speech.js"
|
||||
|
||||
@ -33,26 +33,15 @@
|
||||
"hook-runtime",
|
||||
"process-runtime",
|
||||
"acp-runtime",
|
||||
"acpx",
|
||||
"telegram",
|
||||
"telegram-core",
|
||||
"discord",
|
||||
"discord-core",
|
||||
"feishu",
|
||||
"google",
|
||||
"googlechat",
|
||||
"irc",
|
||||
"line-core",
|
||||
"lobster",
|
||||
"matrix",
|
||||
"mattermost",
|
||||
"msteams",
|
||||
"nextcloud-talk",
|
||||
"slack",
|
||||
"slack-core",
|
||||
"imessage",
|
||||
"imessage-core",
|
||||
"signal",
|
||||
"whatsapp",
|
||||
"whatsapp-shared",
|
||||
"whatsapp-action-runtime",
|
||||
@ -68,7 +57,7 @@
|
||||
"allowlist-resolution",
|
||||
"allowlist-config-edit",
|
||||
"boolean-param",
|
||||
"device-pair",
|
||||
"device-bootstrap",
|
||||
"diagnostics-otel",
|
||||
"diffs",
|
||||
"extension-shared",
|
||||
@ -82,11 +71,10 @@
|
||||
"directory-runtime",
|
||||
"json-store",
|
||||
"keyed-async-queue",
|
||||
"line",
|
||||
"llm-task",
|
||||
"memory-lancedb",
|
||||
"minimax-portal-auth",
|
||||
"provider-auth",
|
||||
"provider-oauth",
|
||||
"provider-auth-api-key",
|
||||
"provider-auth-login",
|
||||
"plugin-entry",
|
||||
@ -97,27 +85,17 @@
|
||||
"provider-usage",
|
||||
"provider-web-search",
|
||||
"image-generation",
|
||||
"nostr",
|
||||
"reply-history",
|
||||
"media-understanding",
|
||||
"secret-input-runtime",
|
||||
"secret-input-schema",
|
||||
"request-url",
|
||||
"qwen-portal-auth",
|
||||
"webhook-ingress",
|
||||
"webhook-path",
|
||||
"runtime-store",
|
||||
"secret-input",
|
||||
"signal-core",
|
||||
"synology-chat",
|
||||
"thread-ownership",
|
||||
"tlon",
|
||||
"twitch",
|
||||
"voice-call",
|
||||
"web-media",
|
||||
"zai",
|
||||
"zalo",
|
||||
"zalouser",
|
||||
"speech",
|
||||
"state-paths",
|
||||
"tool-send"
|
||||
|
||||
105
src/cli/directory-cli.test.ts
Normal file
105
src/cli/directory-cli.test.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerDirectoryCli } from "./directory-cli.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(),
|
||||
writeConfigFile: vi.fn(),
|
||||
resolveInstallableChannelPlugin: vi.fn(),
|
||||
resolveMessageChannelSelection: vi.fn(),
|
||||
getChannelPlugin: vi.fn(),
|
||||
resolveChannelDefaultAccountId: vi.fn(),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: mocks.loadConfig,
|
||||
writeConfigFile: mocks.writeConfigFile,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/channel-setup/channel-plugin-resolution.js", () => ({
|
||||
resolveInstallableChannelPlugin: mocks.resolveInstallableChannelPlugin,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/outbound/channel-selection.js", () => ({
|
||||
resolveMessageChannelSelection: mocks.resolveMessageChannelSelection,
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
getChannelPlugin: mocks.getChannelPlugin,
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/helpers.js", () => ({
|
||||
resolveChannelDefaultAccountId: mocks.resolveChannelDefaultAccountId,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime: {
|
||||
log: (...args: unknown[]) => mocks.log(...args),
|
||||
error: (...args: unknown[]) => mocks.error(...args),
|
||||
exit: (...args: unknown[]) => mocks.exit(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("registerDirectoryCli", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.loadConfig.mockReturnValue({ channels: {} });
|
||||
mocks.writeConfigFile.mockResolvedValue(undefined);
|
||||
mocks.resolveChannelDefaultAccountId.mockReturnValue("default");
|
||||
mocks.resolveMessageChannelSelection.mockResolvedValue({
|
||||
channel: "slack",
|
||||
configured: ["slack"],
|
||||
source: "explicit",
|
||||
});
|
||||
mocks.exit.mockImplementation((code?: number) => {
|
||||
throw new Error(`exit:${code ?? 0}`);
|
||||
});
|
||||
});
|
||||
|
||||
it("installs an explicit optional directory channel on demand", async () => {
|
||||
const self = vi.fn().mockResolvedValue({ id: "self-1", name: "Family Phone" });
|
||||
mocks.resolveInstallableChannelPlugin.mockResolvedValue({
|
||||
cfg: {
|
||||
channels: {},
|
||||
plugins: { entries: { whatsapp: { enabled: true } } },
|
||||
},
|
||||
channelId: "whatsapp",
|
||||
plugin: {
|
||||
id: "whatsapp",
|
||||
directory: { self },
|
||||
},
|
||||
configChanged: true,
|
||||
});
|
||||
|
||||
const program = new Command().name("openclaw");
|
||||
registerDirectoryCli(program);
|
||||
|
||||
await program.parseAsync(["directory", "self", "--channel", "whatsapp", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(mocks.resolveInstallableChannelPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rawChannel: "whatsapp",
|
||||
allowInstall: true,
|
||||
}),
|
||||
);
|
||||
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
plugins: { entries: { whatsapp: { enabled: true } } },
|
||||
}),
|
||||
);
|
||||
expect(self).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
expect(mocks.log).toHaveBeenCalledWith(
|
||||
JSON.stringify({ id: "self-1", name: "Family Phone" }, null, 2),
|
||||
);
|
||||
expect(mocks.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,7 +1,8 @@
|
||||
import type { Command } from "commander";
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { getChannelPlugin } from "../channels/plugins/index.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveInstallableChannelPlugin } from "../commands/channel-setup/channel-plugin-resolution.js";
|
||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@ -96,13 +97,32 @@ export function registerDirectoryCli(program: Command) {
|
||||
.option("--json", "Output JSON", false);
|
||||
|
||||
const resolve = async (opts: { channel?: string; account?: string }) => {
|
||||
const cfg = loadConfig();
|
||||
const selection = await resolveMessageChannelSelection({
|
||||
cfg,
|
||||
channel: opts.channel ?? null,
|
||||
});
|
||||
let cfg = loadConfig();
|
||||
const explicitChannel = opts.channel?.trim();
|
||||
const resolvedExplicit = explicitChannel
|
||||
? await resolveInstallableChannelPlugin({
|
||||
cfg,
|
||||
runtime: defaultRuntime,
|
||||
rawChannel: explicitChannel,
|
||||
allowInstall: true,
|
||||
supports: (plugin) => Boolean(plugin.directory),
|
||||
})
|
||||
: null;
|
||||
if (resolvedExplicit?.configChanged) {
|
||||
cfg = resolvedExplicit.cfg;
|
||||
await writeConfigFile(cfg);
|
||||
}
|
||||
const selection = explicitChannel
|
||||
? {
|
||||
channel: resolvedExplicit?.channelId,
|
||||
}
|
||||
: await resolveMessageChannelSelection({
|
||||
cfg,
|
||||
channel: opts.channel ?? null,
|
||||
});
|
||||
const channelId = selection.channel;
|
||||
const plugin = getChannelPlugin(channelId);
|
||||
const plugin =
|
||||
resolvedExplicit?.plugin ?? (channelId ? getChannelPlugin(channelId) : undefined);
|
||||
if (!plugin) {
|
||||
throw new Error(`Unsupported channel: ${String(channelId)}`);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Public ACPX runtime backend helpers.
|
||||
// Private ACPX runtime backend helpers for bundled extensions.
|
||||
// Keep this surface narrow and limited to the ACP runtime/backend contract.
|
||||
|
||||
export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js";
|
||||
|
||||
@ -91,7 +91,6 @@ export {
|
||||
parseOptionalDelimitedEntries,
|
||||
} from "../channels/plugins/helpers.js";
|
||||
export { getChatChannelMeta } from "../channels/registry.js";
|
||||
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
||||
export {
|
||||
channelTargetSchema,
|
||||
channelTargetsSchema,
|
||||
|
||||
4
src/plugin-sdk/device-bootstrap.ts
Normal file
4
src/plugin-sdk/device-bootstrap.ts
Normal file
@ -0,0 +1,4 @@
|
||||
// Shared bootstrap/pairing helpers for plugins that provision remote devices.
|
||||
|
||||
export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js";
|
||||
export { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
|
||||
@ -1,10 +0,0 @@
|
||||
// Narrow plugin-sdk surface for the bundled device-pair plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/device-pair.
|
||||
|
||||
export { definePluginEntry } from "./core.js";
|
||||
export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js";
|
||||
export { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
|
||||
export type { OpenClawPluginApi } from "../plugins/types.js";
|
||||
export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
|
||||
export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js";
|
||||
export { runPluginCommandWithTimeout } from "./run-command.js";
|
||||
@ -1,4 +1,4 @@
|
||||
// Narrow plugin-sdk surface for the bundled feishu plugin.
|
||||
// Private helper surface for the bundled feishu plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/feishu.
|
||||
|
||||
export type { HistoryEntry } from "../auto-reply/reply/history.js";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Public Google-specific helpers used by bundled Google plugins.
|
||||
// Private Google-specific helpers used by bundled Google plugins.
|
||||
|
||||
export { normalizeGoogleModelId } from "../agents/model-id-normalization.js";
|
||||
export { parseGeminiAuth } from "../infra/gemini-auth.js";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Narrow plugin-sdk surface for the bundled googlechat plugin.
|
||||
// Private helper surface for the bundled googlechat plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/googlechat.
|
||||
|
||||
import { resolveChannelGroupRequireMention } from "./channel-policy.js";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Narrow plugin-sdk surface for the bundled irc plugin.
|
||||
// Private helper surface for the bundled irc plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/irc.
|
||||
|
||||
export { resolveControlCommandGate } from "../channels/command-gating.js";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Public Lobster plugin helpers.
|
||||
// Private Lobster plugin helpers for bundled extensions.
|
||||
// Keep this surface narrow and limited to the Lobster workflow/tool contract.
|
||||
|
||||
export { definePluginEntry } from "./core.js";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Narrow plugin-sdk surface for the bundled mattermost plugin.
|
||||
// Private helper surface for the bundled mattermost plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/mattermost.
|
||||
|
||||
export { formatInboundFromLabel } from "../auto-reply/envelope.js";
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
// Narrow plugin-sdk surface for MiniMax OAuth helpers used by the bundled minimax plugin.
|
||||
// Keep this list additive and scoped to MiniMax OAuth support code.
|
||||
|
||||
export { definePluginEntry } from "./core.js";
|
||||
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
||||
export type {
|
||||
OpenClawPluginApi,
|
||||
ProviderAuthContext,
|
||||
ProviderCatalogContext,
|
||||
ProviderAuthResult,
|
||||
} from "../plugins/types.js";
|
||||
export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js";
|
||||
@ -1,4 +1,4 @@
|
||||
// Narrow plugin-sdk surface for the bundled msteams plugin.
|
||||
// Private helper surface for the bundled msteams plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/msteams.
|
||||
|
||||
import { createOptionalChannelSetupSurface } from "./channel-setup.js";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Narrow plugin-sdk surface for the bundled nextcloud-talk plugin.
|
||||
// Private helper surface for the bundled nextcloud-talk plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/nextcloud-talk.
|
||||
|
||||
export { logInboundDrop } from "../channels/logging.js";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Narrow plugin-sdk surface for the bundled nostr plugin.
|
||||
// Private helper surface for the bundled nostr plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/nostr.
|
||||
|
||||
import { createOptionalChannelSetupSurface } from "./channel-setup.js";
|
||||
|
||||
@ -11,6 +11,7 @@ export type {
|
||||
AnyAgentTool,
|
||||
MediaUnderstandingProviderPlugin,
|
||||
OpenClawPluginApi,
|
||||
PluginCommandContext,
|
||||
OpenClawPluginConfigSchema,
|
||||
ProviderDiscoveryContext,
|
||||
ProviderCatalogContext,
|
||||
|
||||
@ -5,7 +5,6 @@ export type { SecretInput } from "../config/types.secrets.js";
|
||||
export type { ProviderAuthResult } from "../plugins/types.js";
|
||||
export type { ProviderAuthContext } from "../plugins/types.js";
|
||||
export type { AuthProfileStore, OAuthCredential } from "../agents/auth-profiles/types.js";
|
||||
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
||||
|
||||
export {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
|
||||
4
src/plugin-sdk/provider-oauth.ts
Normal file
4
src/plugin-sdk/provider-oauth.ts
Normal file
@ -0,0 +1,4 @@
|
||||
// Focused OAuth helpers for provider plugins.
|
||||
|
||||
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
||||
export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js";
|
||||
@ -1,14 +0,0 @@
|
||||
// Narrow plugin-sdk surface for the bundled qwen-portal-auth plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/qwen-portal-auth.
|
||||
|
||||
export { definePluginEntry } from "./core.js";
|
||||
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
||||
export type {
|
||||
OpenClawPluginApi,
|
||||
ProviderAuthContext,
|
||||
ProviderCatalogContext,
|
||||
} from "../plugins/types.js";
|
||||
export { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js";
|
||||
export { QWEN_OAUTH_MARKER } from "../agents/model-auth-markers.js";
|
||||
export { refreshQwenPortalCredentials } from "../providers/qwen-portal-oauth.js";
|
||||
export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js";
|
||||
@ -34,13 +34,13 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
|
||||
'export { probeIMessage } from "./src/probe.js";',
|
||||
'export { sendMessageIMessage } from "./src/send.js";',
|
||||
],
|
||||
"extensions/googlechat/runtime-api.ts": ['export * from "openclaw/plugin-sdk/googlechat";'],
|
||||
"extensions/googlechat/runtime-api.ts": ['export * from "../../src/plugin-sdk/googlechat.js";'],
|
||||
"extensions/matrix/runtime-api.ts": [
|
||||
'export * from "./src/auth-precedence.js";',
|
||||
'export * from "./helper-api.js";',
|
||||
],
|
||||
"extensions/nextcloud-talk/runtime-api.ts": [
|
||||
'export * from "openclaw/plugin-sdk/nextcloud-talk";',
|
||||
'export * from "../../src/plugin-sdk/nextcloud-talk.js";',
|
||||
],
|
||||
"extensions/signal/runtime-api.ts": ['export * from "./src/runtime-api.js";'],
|
||||
"extensions/slack/runtime-api.ts": [
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
// Private helper surface for the bundled signal plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/signal.
|
||||
|
||||
export type { SignalAccountConfig } from "../config/types.js";
|
||||
export type { ChannelPlugin } from "./channel-plugin-common.js";
|
||||
export {
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
// Private helper surface for the bundled signal plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/signal.
|
||||
|
||||
export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js";
|
||||
export type { OpenClawConfig } from "../config/config.js";
|
||||
export type { SignalAccountConfig } from "../config/types.js";
|
||||
|
||||
@ -16,7 +16,9 @@ import * as imessageSdk from "openclaw/plugin-sdk/imessage";
|
||||
import * as imessageCoreSdk from "openclaw/plugin-sdk/imessage-core";
|
||||
import * as lazyRuntimeSdk from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup";
|
||||
import * as providerAuthSdk from "openclaw/plugin-sdk/provider-auth";
|
||||
import * as providerModelsSdk from "openclaw/plugin-sdk/provider-models";
|
||||
import * as providerOauthSdk from "openclaw/plugin-sdk/provider-oauth";
|
||||
import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup";
|
||||
import * as replyPayloadSdk from "openclaw/plugin-sdk/reply-payload";
|
||||
import * as routingSdk from "openclaw/plugin-sdk/routing";
|
||||
@ -56,10 +58,33 @@ const allowlistEditSdk = await import("openclaw/plugin-sdk/allowlist-config-edit
|
||||
|
||||
describe("plugin-sdk subpath exports", () => {
|
||||
it("keeps the curated public list free of internal implementation subpaths", () => {
|
||||
expect(pluginSdkSubpaths).not.toContain("acpx");
|
||||
expect(pluginSdkSubpaths).not.toContain("compat");
|
||||
expect(pluginSdkSubpaths).not.toContain("device-pair");
|
||||
expect(pluginSdkSubpaths).not.toContain("feishu");
|
||||
expect(pluginSdkSubpaths).not.toContain("google");
|
||||
expect(pluginSdkSubpaths).not.toContain("googlechat");
|
||||
expect(pluginSdkSubpaths).not.toContain("irc");
|
||||
expect(pluginSdkSubpaths).not.toContain("line");
|
||||
expect(pluginSdkSubpaths).not.toContain("line-core");
|
||||
expect(pluginSdkSubpaths).not.toContain("lobster");
|
||||
expect(pluginSdkSubpaths).not.toContain("mattermost");
|
||||
expect(pluginSdkSubpaths).not.toContain("msteams");
|
||||
expect(pluginSdkSubpaths).not.toContain("nextcloud-talk");
|
||||
expect(pluginSdkSubpaths).not.toContain("nostr");
|
||||
expect(pluginSdkSubpaths).not.toContain("pairing-access");
|
||||
expect(pluginSdkSubpaths).not.toContain("qwen-portal-auth");
|
||||
expect(pluginSdkSubpaths).not.toContain("reply-prefix");
|
||||
expect(pluginSdkSubpaths).not.toContain("signal");
|
||||
expect(pluginSdkSubpaths).not.toContain("signal-core");
|
||||
expect(pluginSdkSubpaths).not.toContain("synology-chat");
|
||||
expect(pluginSdkSubpaths).not.toContain("tlon");
|
||||
expect(pluginSdkSubpaths).not.toContain("twitch");
|
||||
expect(pluginSdkSubpaths).not.toContain("typing");
|
||||
expect(pluginSdkSubpaths).not.toContain("voice-call");
|
||||
expect(pluginSdkSubpaths).not.toContain("zalo");
|
||||
expect(pluginSdkSubpaths).not.toContain("zai");
|
||||
expect(pluginSdkSubpaths).not.toContain("zalouser");
|
||||
expect(pluginSdkSubpaths).not.toContain("provider-model-definitions");
|
||||
});
|
||||
|
||||
@ -91,6 +116,13 @@ describe("plugin-sdk subpath exports", () => {
|
||||
expect(typeof accountHelpersSdk.createAccountListHelpers).toBe("function");
|
||||
});
|
||||
|
||||
it("exports device bootstrap helpers from the dedicated subpath", async () => {
|
||||
const deviceBootstrapSdk = await import("openclaw/plugin-sdk/device-bootstrap");
|
||||
expect(typeof deviceBootstrapSdk.approveDevicePairing).toBe("function");
|
||||
expect(typeof deviceBootstrapSdk.issueDeviceBootstrapToken).toBe("function");
|
||||
expect(typeof deviceBootstrapSdk.listDevicePairing).toBe("function");
|
||||
});
|
||||
|
||||
it("exports allowlist edit helpers from the dedicated subpath", () => {
|
||||
expect(typeof allowlistEditSdk.buildDmGroupAccountAllowlistAdapter).toBe("function");
|
||||
expect(typeof allowlistEditSdk.createNestedAllowlistOverrideResolver).toBe("function");
|
||||
@ -139,6 +171,14 @@ describe("plugin-sdk subpath exports", () => {
|
||||
expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function");
|
||||
});
|
||||
|
||||
it("exports oauth helpers from the dedicated provider oauth subpath", () => {
|
||||
expect(typeof providerOauthSdk.buildOauthProviderAuthResult).toBe("function");
|
||||
expect(typeof providerOauthSdk.generatePkceVerifierChallenge).toBe("function");
|
||||
expect(typeof providerOauthSdk.toFormUrlEncoded).toBe("function");
|
||||
expect("buildOauthProviderAuthResult" in asExports(coreSdk)).toBe(false);
|
||||
expect("buildOauthProviderAuthResult" in asExports(providerAuthSdk)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps provider models focused on shared provider primitives", () => {
|
||||
expect(typeof providerModelsSdk.applyOpenAIConfig).toBe("function");
|
||||
expect(typeof providerModelsSdk.buildKilocodeModelDefinition).toBe("function");
|
||||
@ -187,8 +227,11 @@ describe("plugin-sdk subpath exports", () => {
|
||||
});
|
||||
|
||||
it("exports webhook ingress helpers from the dedicated subpath", () => {
|
||||
expect(typeof webhookIngressSdk.registerPluginHttpRoute).toBe("function");
|
||||
expect(typeof webhookIngressSdk.resolveWebhookPath).toBe("function");
|
||||
expect(typeof webhookIngressSdk.readRequestBodyWithLimit).toBe("function");
|
||||
expect(typeof webhookIngressSdk.readJsonWebhookBodyOrReject).toBe("function");
|
||||
expect(typeof webhookIngressSdk.requestBodyErrorToText).toBe("function");
|
||||
expect(typeof webhookIngressSdk.withResolvedWebhookRequestPipeline).toBe("function");
|
||||
});
|
||||
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
// Narrow plugin-sdk surface for the bundled synology-chat plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/synology-chat.
|
||||
|
||||
export { setAccountEnabledInConfigSection } from "../channels/plugins/config-helpers.js";
|
||||
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
||||
export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js";
|
||||
export {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "../infra/http-body.js";
|
||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||
export { registerPluginHttpRoute } from "../plugins/http-registry.js";
|
||||
export type { OpenClawConfig } from "../config/config.js";
|
||||
export type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
export type { OpenClawPluginApi } from "../plugins/types.js";
|
||||
export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||
export type { FixedWindowRateLimiter } from "./webhook-memory-guards.js";
|
||||
export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js";
|
||||
export {
|
||||
synologyChatSetupAdapter,
|
||||
synologyChatSetupWizard,
|
||||
} from "../../extensions/synology-chat/setup-api.js";
|
||||
@ -1,4 +1,4 @@
|
||||
// Narrow plugin-sdk surface for the bundled tlon plugin.
|
||||
// Private helper surface for the bundled tlon plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/tlon.
|
||||
|
||||
import { createOptionalChannelSetupSurface } from "./channel-setup.js";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Narrow plugin-sdk surface for the bundled twitch plugin.
|
||||
// Private helper surface for the bundled twitch plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/twitch.
|
||||
|
||||
import { createOptionalChannelSetupSurface } from "./channel-setup.js";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Public Voice Call plugin helpers.
|
||||
// Private helper surface for the bundled voice-call plugin.
|
||||
// Keep this surface narrow and limited to the voice-call feature contract.
|
||||
|
||||
export { definePluginEntry } from "./core.js";
|
||||
|
||||
@ -14,14 +14,18 @@ export {
|
||||
beginWebhookRequestPipelineOrReject,
|
||||
createWebhookInFlightLimiter,
|
||||
isJsonContentType,
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
readJsonWebhookBodyOrReject,
|
||||
readWebhookBodyOrReject,
|
||||
requestBodyErrorToText,
|
||||
WEBHOOK_BODY_READ_DEFAULTS,
|
||||
WEBHOOK_IN_FLIGHT_DEFAULTS,
|
||||
type WebhookBodyReadProfile,
|
||||
type WebhookInFlightLimiter,
|
||||
} from "./webhook-request-guards.js";
|
||||
export {
|
||||
registerPluginHttpRoute,
|
||||
registerWebhookTarget,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
resolveSingleWebhookTarget,
|
||||
|
||||
@ -10,6 +10,12 @@ import type { FixedWindowRateLimiter } from "./webhook-memory-guards.js";
|
||||
|
||||
export type WebhookBodyReadProfile = "pre-auth" | "post-auth";
|
||||
|
||||
export {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "../infra/http-body.js";
|
||||
|
||||
export const WEBHOOK_BODY_READ_DEFAULTS = Object.freeze({
|
||||
preAuth: {
|
||||
maxBytes: 64 * 1024,
|
||||
|
||||
@ -19,6 +19,8 @@ export type RegisterWebhookTargetOptions<T extends { path: string }> = {
|
||||
|
||||
type RegisterPluginHttpRouteParams = Parameters<typeof registerPluginHttpRoute>[0];
|
||||
|
||||
export { registerPluginHttpRoute };
|
||||
|
||||
export type RegisterWebhookPluginRouteOptions = Omit<
|
||||
RegisterPluginHttpRouteParams,
|
||||
"path" | "fallbackPath"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Public Z.ai helpers for provider plugins that need endpoint detection.
|
||||
// Private Z.ai helpers for bundled provider plugins that need endpoint detection.
|
||||
|
||||
export {
|
||||
detectZaiEndpoint,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Narrow plugin-sdk surface for the bundled zalo plugin.
|
||||
// Private helper surface for the bundled zalo plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/zalo.
|
||||
|
||||
export { jsonResult, readStringParam } from "../agents/tools/common.js";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Narrow plugin-sdk surface for the bundled zalouser plugin.
|
||||
// Private helper surface for the bundled zalouser plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/zalouser.
|
||||
|
||||
import { createOptionalChannelSetupSurface } from "./channel-setup.js";
|
||||
|
||||
@ -23,8 +23,8 @@ vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../plugin-sdk/qwen-portal-auth.js", async () => {
|
||||
const actual = await vi.importActual<object>("../../plugin-sdk/qwen-portal-auth.js");
|
||||
vi.mock("../../../extensions/qwen-portal-auth/refresh.js", async () => {
|
||||
const actual = await vi.importActual<object>("../../../extensions/qwen-portal-auth/refresh.js");
|
||||
return {
|
||||
...actual,
|
||||
refreshQwenPortalCredentials: refreshQwenPortalCredentialsMock,
|
||||
|
||||
@ -1,140 +0,0 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { refreshQwenPortalCredentials } from "./qwen-portal-oauth.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("refreshQwenPortalCredentials", () => {
|
||||
const expiredCredentials = () => ({
|
||||
access: "old-access",
|
||||
refresh: "old-refresh",
|
||||
expires: Date.now() - 1000,
|
||||
});
|
||||
|
||||
const runRefresh = async () => await refreshQwenPortalCredentials(expiredCredentials());
|
||||
|
||||
const stubFetchResponse = (response: unknown) => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(response);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
return fetchSpy;
|
||||
};
|
||||
|
||||
it("refreshes tokens with a new access token", async () => {
|
||||
const fetchSpy = stubFetchResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: "new-access",
|
||||
refresh_token: "new-refresh",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runRefresh();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://chat.qwen.ai/api/v1/oauth2/token",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
expect(result.access).toBe("new-access");
|
||||
expect(result.refresh).toBe("new-refresh");
|
||||
expect(result.expires).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("keeps refresh token when refresh response omits it", async () => {
|
||||
stubFetchResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: "new-access",
|
||||
expires_in: 1800,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runRefresh();
|
||||
|
||||
expect(result.refresh).toBe("old-refresh");
|
||||
});
|
||||
|
||||
it("keeps refresh token when response sends an empty refresh token", async () => {
|
||||
stubFetchResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: "new-access",
|
||||
refresh_token: "",
|
||||
expires_in: 1800,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runRefresh();
|
||||
|
||||
expect(result.refresh).toBe("old-refresh");
|
||||
});
|
||||
|
||||
it("errors when refresh response has invalid expires_in", async () => {
|
||||
stubFetchResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: "new-access",
|
||||
refresh_token: "new-refresh",
|
||||
expires_in: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(runRefresh()).rejects.toThrow(
|
||||
"Qwen OAuth refresh response missing or invalid expires_in",
|
||||
);
|
||||
});
|
||||
|
||||
it("errors when refresh token is invalid", async () => {
|
||||
stubFetchResponse({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => "invalid_grant",
|
||||
});
|
||||
|
||||
await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh token expired or invalid");
|
||||
});
|
||||
|
||||
it("errors when refresh token is missing before any request", async () => {
|
||||
await expect(
|
||||
refreshQwenPortalCredentials({
|
||||
access: "old-access",
|
||||
refresh: " ",
|
||||
expires: Date.now() - 1000,
|
||||
}),
|
||||
).rejects.toThrow("Qwen OAuth refresh token missing");
|
||||
});
|
||||
|
||||
it("errors when refresh response omits access token", async () => {
|
||||
stubFetchResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
refresh_token: "new-refresh",
|
||||
expires_in: 1800,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh response missing access token");
|
||||
});
|
||||
|
||||
it("errors with server payload text for non-400 status", async () => {
|
||||
stubFetchResponse({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Server Error",
|
||||
text: async () => "gateway down",
|
||||
});
|
||||
|
||||
await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh failed: gateway down");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user