@capybara-hq/plugin-sdk 0.0.0-stage → 0.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +27 -2
  4. package/dist/agent-template.d.ts +47 -0
  5. package/dist/agent-template.js +123 -0
  6. package/dist/bootstrap.d.ts +67 -0
  7. package/dist/bootstrap.js +109 -0
  8. package/dist/cards.d.ts +64 -0
  9. package/dist/cards.js +217 -0
  10. package/dist/channel.d.ts +230 -0
  11. package/dist/channel.js +26 -0
  12. package/dist/connector-auth.d.ts +64 -0
  13. package/dist/connector-auth.js +96 -0
  14. package/dist/connector.d.ts +154 -0
  15. package/dist/connector.js +1 -0
  16. package/dist/ctx.d.ts +176 -0
  17. package/dist/ctx.js +1 -0
  18. package/dist/define-extension.d.ts +41 -0
  19. package/dist/define-extension.js +85 -0
  20. package/dist/directory.d.ts +29 -0
  21. package/dist/directory.js +1 -0
  22. package/dist/extension-point.d.ts +29 -0
  23. package/dist/extension-point.js +36 -0
  24. package/dist/file-preview.d.ts +64 -0
  25. package/dist/file-preview.js +72 -0
  26. package/dist/frontmatter.d.ts +12 -0
  27. package/dist/frontmatter.js +36 -0
  28. package/dist/hooks.d.ts +194 -0
  29. package/dist/hooks.js +14 -0
  30. package/dist/identity.d.ts +44 -0
  31. package/dist/identity.js +1 -0
  32. package/dist/index.d.ts +27 -0
  33. package/dist/index.js +27 -0
  34. package/dist/marketplace.d.ts +99 -0
  35. package/dist/marketplace.js +256 -0
  36. package/dist/model-params.d.ts +82 -0
  37. package/dist/model-params.js +222 -0
  38. package/dist/model.d.ts +227 -0
  39. package/dist/model.js +152 -0
  40. package/dist/permissions.d.ts +67 -0
  41. package/dist/permissions.js +271 -0
  42. package/dist/plugin-manifest.d.ts +141 -0
  43. package/dist/plugin-manifest.js +322 -0
  44. package/dist/plugin-package.d.ts +139 -0
  45. package/dist/plugin-package.js +448 -0
  46. package/dist/sandbox.d.ts +152 -0
  47. package/dist/sandbox.js +96 -0
  48. package/dist/secret.d.ts +5 -0
  49. package/dist/secret.js +8 -0
  50. package/dist/semver-range.d.ts +6 -0
  51. package/dist/semver-range.js +12 -0
  52. package/dist/skill-invocation.d.ts +83 -0
  53. package/dist/skill-invocation.js +201 -0
  54. package/dist/skill-keys.d.ts +9 -0
  55. package/dist/skill-keys.js +16 -0
  56. package/dist/skill.d.ts +77 -0
  57. package/dist/skill.js +244 -0
  58. package/dist/storage.d.ts +96 -0
  59. package/dist/storage.js +9 -0
  60. package/dist/tool.d.ts +154 -0
  61. package/dist/tool.js +218 -0
  62. package/dist/ui.d.ts +265 -0
  63. package/dist/ui.js +358 -0
  64. package/dist/url.d.ts +2 -0
  65. package/dist/url.js +12 -0
  66. package/package.json +40 -3
  67. package/schemas/bootstrap.v1.schema.json +125 -0
  68. package/schemas/connector-template.v1.schema.json +116 -0
  69. package/schemas/marketplace.v1.schema.json +270 -0
  70. package/schemas/plugin.v1.schema.json +330 -0
package/dist/cards.js ADDED
@@ -0,0 +1,217 @@
1
+ import { validateInputForm } from "./tool.js";
2
+ import { isHttpUrl } from "./url.js";
3
+ const MAX_INLINE_SINGLE = 5;
4
+ const MAX_INLINE_MULTI = 10;
5
+ /**
6
+ * 提问能否在 IM 内直接作答:仅当表单恰有一个枚举字段(单选 ≤ 5 项 / 多选 ≤ 10 项)且不允许「其他」时为真;
7
+ * 否则卡片只给网页链接。
8
+ */
9
+ export function questionRenderableInline(form) {
10
+ if (form.fields.length !== 1)
11
+ return false;
12
+ const f = form.fields[0];
13
+ if (!f || f.allowOther)
14
+ return false;
15
+ if (f.schema.type !== "string" || !("enum" in f.schema))
16
+ return false;
17
+ const n = f.schema.enum.length;
18
+ return f.multiSelect ? n <= MAX_INLINE_MULTI : n <= MAX_INLINE_SINGLE;
19
+ }
20
+ const KINDS = ["approval", "question", "connect", "bind"];
21
+ const SCOPES = ["once", "session", "always"];
22
+ const APPROVAL_STATES = ["pending", "approved", "denied", "expired", "cancelled"];
23
+ const QUESTION_STATES = ["pending", "answered", "expired", "cancelled"];
24
+ const ISO_8601 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
25
+ const list = (xs) => xs.map((x) => JSON.stringify(x)).join(", ");
26
+ /** 校验卡片结构,返回问题列表(空 = 合法)。 */
27
+ export function validateChannelCard(card) {
28
+ if (typeof card !== "object" || card === null || Array.isArray(card))
29
+ return ["card must be an object"];
30
+ const c = card;
31
+ const out = [];
32
+ const nonEmpty = (k) => {
33
+ if (typeof c[k] !== "string" || c[k] === "")
34
+ out.push(`${k} must be a non-empty string`);
35
+ };
36
+ const optNonEmpty = (k) => {
37
+ if (c[k] !== undefined)
38
+ nonEmpty(k);
39
+ };
40
+ const oneOf = (k, xs) => {
41
+ if (typeof c[k] !== "string" || !xs.includes(c[k]))
42
+ out.push(`${k} must be one of ${list(xs)}`);
43
+ };
44
+ const url = (k) => {
45
+ if (!isHttpUrl(c[k]))
46
+ out.push(`${k} must be an absolute http(s) URL of at most 2000 characters`);
47
+ };
48
+ switch (c.kind) {
49
+ case "approval": {
50
+ for (const k of ["id", "title", "toolName", "agentName"])
51
+ nonEmpty(k);
52
+ optNonEmpty("appName");
53
+ if (typeof c.inputPreview !== "string")
54
+ out.push("inputPreview must be a string");
55
+ const s = c.scopes;
56
+ if (!Array.isArray(s) ||
57
+ s.length === 0 ||
58
+ new Set(s).size !== s.length ||
59
+ s.some((x) => !SCOPES.includes(x)))
60
+ out.push(`scopes must be a non-empty array of ${list(SCOPES)} without duplicates`);
61
+ if (typeof c.expiresAt !== "string" ||
62
+ !ISO_8601.test(c.expiresAt) ||
63
+ Number.isNaN(Date.parse(c.expiresAt)))
64
+ out.push("expiresAt must be an ISO 8601 timestamp");
65
+ oneOf("state", APPROVAL_STATES);
66
+ optNonEmpty("decidedBy");
67
+ optNonEmpty("denyReason");
68
+ url("webUrl");
69
+ return out;
70
+ }
71
+ case "question": {
72
+ nonEmpty("id");
73
+ oneOf("state", QUESTION_STATES);
74
+ url("webUrl");
75
+ if (c.answerSummary !== undefined && typeof c.answerSummary !== "string")
76
+ out.push("answerSummary must be a string");
77
+ // 不能在 IM 内作答的表单(见 questionRenderableInline)同样合法:渠道只渲染网页链接。
78
+ for (const p of validateInputForm(c.form))
79
+ out.push(`form: ${p}`);
80
+ return out;
81
+ }
82
+ case "connect":
83
+ nonEmpty("appName");
84
+ url("url");
85
+ return out;
86
+ case "bind":
87
+ url("url");
88
+ nonEmpty("text");
89
+ return out;
90
+ default:
91
+ return [`kind must be one of ${list(KINDS)}`];
92
+ }
93
+ }
94
+ /**
95
+ * 把模型可控的值(审批标题、输入预览、提问标题、回答摘要)放进代码里,退回文本里它们不会变成链接、强调或 HTML:
96
+ * 单行用行内代码,多行用围栏代码块(以换行开头),分隔符都比值里最长的反引号串更长(CommonMark 6.1 / 4.5)。
97
+ * 选代码而不是给标点加反斜杠:代码是 CommonMark / GFM 里唯一保证内容按原样显示的写法(反斜杠挡不住 GFM 的裸网址
98
+ * 自动链接,各渠道转换器对反斜杠的处理也不一致)。
99
+ */
100
+ function asCode(s) {
101
+ const v = s.replace(/\r\n?/g, "\n");
102
+ const longest = Math.max(0, ...[...v.matchAll(/`+/g)].map((m) => m[0].length));
103
+ if (v.includes("\n")) {
104
+ const fence = "`".repeat(Math.max(3, longest + 1));
105
+ return `\n${fence}\n${v}\n${fence}`;
106
+ }
107
+ const d = "`".repeat(longest + 1);
108
+ // 值以反引号开头 / 结尾、或首尾都是空格时补一个空格(渲染时去掉),否则会与分隔符粘连或被吃掉空格
109
+ const pad = /^`|`$/.test(v) || (v.startsWith(" ") && v.endsWith(" ")) ? " " : "";
110
+ return `${d}${pad}${v}${pad}${d}`;
111
+ }
112
+ /** 前缀 + 代码:代码是围栏块(以换行开头)时去掉前缀末尾的空格。 */
113
+ const withCode = (prefix, code) => code.startsWith("\n") ? `${prefix.trimEnd()}${code}` : `${prefix}${code}`;
114
+ const TEXT = {
115
+ en: {
116
+ sep: ": ",
117
+ approval: "[Approval] ",
118
+ question: "[Question] ",
119
+ connect: (app) => `[Connect] ${app} needs to be connected.`,
120
+ tool: "Tool",
121
+ agent: "Agent",
122
+ input: "Input",
123
+ status: "Status",
124
+ reason: "Reason",
125
+ expires: "Expires",
126
+ answer: "Answer",
127
+ approveOnWeb: "Cards are not supported here. Approve or deny on the web",
128
+ answerOnWeb: "Answer on the web",
129
+ details: "Details",
130
+ connectLink: "Connect",
131
+ approvalStates: {
132
+ pending: "Pending",
133
+ approved: "Approved",
134
+ denied: "Denied",
135
+ expired: "Expired",
136
+ cancelled: "Cancelled",
137
+ },
138
+ questionStates: {
139
+ pending: "Pending",
140
+ answered: "Answered",
141
+ expired: "Expired",
142
+ cancelled: "Cancelled",
143
+ },
144
+ },
145
+ zh: {
146
+ sep: ":",
147
+ approval: "【审批】",
148
+ question: "【提问】",
149
+ connect: (app) => `【连接】需要连接 ${app}。`,
150
+ tool: "工具",
151
+ agent: "Agent",
152
+ input: "输入",
153
+ status: "状态",
154
+ reason: "原因",
155
+ expires: "过期时间",
156
+ answer: "回答",
157
+ approveOnWeb: "此处不支持卡片,请到网页上批准或拒绝",
158
+ answerOnWeb: "到网页上回答",
159
+ details: "详情",
160
+ connectLink: "连接",
161
+ approvalStates: {
162
+ pending: "待审批",
163
+ approved: "已批准",
164
+ denied: "已拒绝",
165
+ expired: "已过期",
166
+ cancelled: "已取消",
167
+ },
168
+ questionStates: {
169
+ pending: "待回答",
170
+ answered: "已回答",
171
+ expired: "已过期",
172
+ cancelled: "已取消",
173
+ },
174
+ },
175
+ };
176
+ /**
177
+ * 不支持卡片的渠道的统一退回文本(含链接);固定话术按 locale 取 zh / en。结果是 Markdown(放进
178
+ * `OutboundMessage.text`,由渠道插件转换):模型可控的字段放在代码里(见 `asCode`),不会产生链接。
179
+ */
180
+ export function renderCardAsText(card, locale) {
181
+ const t = TEXT[locale] ?? TEXT.en;
182
+ const line = (label, value) => withCode(`${label}${t.sep}`, value);
183
+ switch (card.kind) {
184
+ case "approval": {
185
+ const state = t.approvalStates[card.state];
186
+ const lines = [
187
+ withCode(t.approval, asCode(card.title)),
188
+ line(t.tool, card.appName ? `${card.appName} / ${card.toolName}` : card.toolName),
189
+ line(t.agent, card.agentName),
190
+ line(t.input, asCode(card.inputPreview)),
191
+ line(t.status, card.decidedBy ? `${state} (${card.decidedBy})` : state),
192
+ ];
193
+ if (card.state === "denied" && card.denyReason)
194
+ lines.push(line(t.reason, asCode(card.denyReason)));
195
+ if (card.state === "pending")
196
+ lines.push(line(t.expires, card.expiresAt), line(t.approveOnWeb, card.webUrl));
197
+ else
198
+ lines.push(line(t.details, card.webUrl));
199
+ return lines.join("\n");
200
+ }
201
+ case "question": {
202
+ const title = typeof card.form.title === "string" ? card.form.title : card.form.title[locale];
203
+ const lines = [
204
+ withCode(t.question, asCode(title)),
205
+ line(t.status, t.questionStates[card.state]),
206
+ ];
207
+ if (card.answerSummary)
208
+ lines.push(line(t.answer, asCode(card.answerSummary)));
209
+ lines.push(line(card.state === "pending" ? t.answerOnWeb : t.details, card.webUrl));
210
+ return lines.join("\n");
211
+ }
212
+ case "connect":
213
+ return `${t.connect(card.appName)}\n${line(t.connectLink, card.url)}`;
214
+ case "bind":
215
+ return `${card.text}\n${card.url}`;
216
+ }
217
+ }
@@ -0,0 +1,230 @@
1
+ import type { ZodTypeAny } from "zod";
2
+ import type { CardAction, Locale, OutboundMessage } from "./cards.js";
3
+ /**
4
+ * 消息的投递目标。群聊里的 `threadId` 是话题**根消息的 `messageId`**(内核用它把同一话题归到一个会话,
5
+ * 回复也投到这个话题):Slack 为插件所定义的根消息 `messageId`(如 `<频道>:<ts>`,ts 即回复的
6
+ * `thread_ts`);飞书为根消息的 `message_id`(`root_id`,`om_…`),**不是** `thread_id`(`omt_…`)。私聊不带。
7
+ */
8
+ export interface ChannelTarget {
9
+ chatId: string;
10
+ threadId?: string;
11
+ chatType: "dm" | "group";
12
+ }
13
+ /**
14
+ * 入站事件的发言人 / 点击者。`tenantId` 为发言人所属企业:Slack 为 team id(Enterprise Grid 下是 team,
15
+ * 不是 enterprise),飞书为 `tenant_key`;与连接的租户(`verify` 返回的 `tenantId`,同一口径)不同的人
16
+ * (外部联系人、共享频道里的其他企业)内核只允许绑定,不按匿名访客处理。`displayName`、`avatarUrl` 为平台上的
17
+ * 显示名与头像(拿得到就带):内核把显示名用在绑定确认页(没有时回落到 externalId)。
18
+ */
19
+ export interface InboundSender {
20
+ externalId: string;
21
+ tenantId: string;
22
+ displayName?: string;
23
+ avatarUrl?: string;
24
+ }
25
+ /**
26
+ * 渠道交给内核的入站事件。插件的约定:
27
+ * - `message.messageId` 在本连接内唯一(内核按它去重);
28
+ * - M4 只处理文本:附件、图片、文件等忽略(消息只有附件时不发事件);
29
+ * - 用户编辑、撤回 / 删除消息不发事件(忽略);
30
+ * - `text` 里去掉 @ 机器人的部分(`mentionsBot` 另行说明是否 @ 了机器人);
31
+ * - `card_action`:`messageId` 为卡片所在消息的 id(同一张卡片的多次点击相同);`eventId` 为平台给这次点击的
32
+ * 唯一 id(拿得到就带,本连接内唯一:内核按它去重平台重投);群聊里 `target.threadId` 拿得到就带,
33
+ * 拿不到时内核取发出该卡片时记下的话题。
34
+ */
35
+ export type InboundEvent = {
36
+ type: "message";
37
+ messageId: string;
38
+ target: ChannelTarget;
39
+ sender: InboundSender;
40
+ text: string;
41
+ mentionsBot: boolean;
42
+ } | {
43
+ type: "card_action";
44
+ target: ChannelTarget;
45
+ messageId: string;
46
+ /** 平台给这次点击的唯一 id(如飞书回调的 event_id、Slack 的 trigger / action 时间戳组合)。 */
47
+ eventId?: string;
48
+ sender: InboundSender;
49
+ action: CardAction;
50
+ }
51
+ /** 平台推送人员变动的提示,内核据此触发通讯录同步。 */
52
+ | {
53
+ type: "directory_changed";
54
+ tenantId: string;
55
+ };
56
+ export interface ChannelConnectArgs {
57
+ connectionId: string;
58
+ /** 已按 connectionSchema 校验,含密钥。 */
59
+ config: unknown;
60
+ /**
61
+ * 把入站事件交给内核。内核只做去重并把事件持久地排进队列(很快,不在这里处理消息),resolve 即表示内核已经
62
+ * 可靠地收下了这条事件:插件**在它 resolve 之后**才向平台确认(ack);reject 时**不要**确认,让平台重投;
63
+ * 插件自己不必重试 `onEvent`。去重范围:`message` 按 `messageId`、带 `eventId` 的 `card_action` 按 `eventId`
64
+ * (各保留 24 小时)——重投只处理一次;不带 `eventId` 的 `card_action` 不去重(每次都处理,重复的审批 /
65
+ * 回答由内核回「已处理」);`directory_changed` 不去重(同步任务本身按分钟合并)。
66
+ */
67
+ onEvent(e: InboundEvent): Promise<void>;
68
+ signal: AbortSignal;
69
+ }
70
+ export interface ChannelSendArgs {
71
+ config: unknown;
72
+ target: ChannelTarget;
73
+ /**
74
+ * 消息内容。`text` 超过平台单条长度上限时由插件自行拆成多条按序发送,返回**最后一条**的消息 id
75
+ * (内核之后编辑的是这条)。
76
+ */
77
+ message: OutboundMessage;
78
+ signal: AbortSignal;
79
+ /** 连接所属组织的界面语言:插件自己渲染的文案(卡片按钮、降级文本里的固定词)按它选择。 */
80
+ locale: Locale;
81
+ /**
82
+ * 幂等键(可选):不超过 40 个 `[A-Za-z0-9_-]` 字符,同一条逻辑消息的每次重试(崩溃后其他副本续投、
83
+ * 可重试失败后重发)都相同——内核由「会话 + 事件 seq」或「卡片 + 状态」稳定哈希得到。平台支持幂等发送
84
+ * (如飞书的 `uuid`)时插件应原样透传或按平台要求映射,以免重复消息;不支持的平台忽略即可。
85
+ */
86
+ idempotencyKey?: string;
87
+ }
88
+ /**
89
+ * An inbound webhook request as the kernel received it on `POST /channels/webhook/<connectionId>`.
90
+ * `headers` names are lowercase (repeated headers joined with `, `; cookies are dropped); `body` is
91
+ * the raw request bytes, unparsed, so the plugin can verify signatures over them; `receivedAt` is the
92
+ * kernel's receive time in epoch milliseconds (use it for timestamp replay checks).
93
+ */
94
+ export interface ChannelWebhookRequest {
95
+ method: "POST";
96
+ headers: Readonly<Record<string, string>>;
97
+ body: Uint8Array;
98
+ receivedAt: number;
99
+ }
100
+ /** An HTTP response the kernel writes back to the platform as is. */
101
+ export interface ChannelWebhookResponse {
102
+ status: number;
103
+ headers?: Record<string, string>;
104
+ body?: string;
105
+ }
106
+ /**
107
+ * What the plugin made of a webhook request:
108
+ * - `respond`: reply directly without any event (URL verification challenge and the like);
109
+ * - `events`: hand these events to the kernel, which dedupes and enqueues them like the long
110
+ * connection's `onEvent` and replies `ack` only after every one resolved; if any rejects, the
111
+ * kernel replies 503 so the platform redelivers (dedupe makes the redelivery safe);
112
+ * an empty list just replies `ack` (ignored events are still acknowledged);
113
+ * - `reject`: the request is not genuine (bad signature, stale timestamp, malformed): reply this
114
+ * status and enqueue nothing.
115
+ */
116
+ export type ChannelWebhookResult = {
117
+ kind: "respond";
118
+ response: ChannelWebhookResponse;
119
+ } | {
120
+ kind: "events";
121
+ events: InboundEvent[];
122
+ ack: ChannelWebhookResponse;
123
+ } | {
124
+ kind: "reject";
125
+ status: 400 | 401 | 403;
126
+ };
127
+ /**
128
+ * Optional webhook inbound mode (`Channel.webhook`). Signature verification, decryption and
129
+ * timestamp replay protection all happen here: the kernel knows no platform formats, applies no
130
+ * auth on the route, and only calls this for connections in webhook mode. `config` is the
131
+ * connection config (validated against `connectionSchema`, secrets included).
132
+ *
133
+ * Timing: one request has a 10 second kernel deadline covering `handle` and handing its events to
134
+ * the kernel. `signal` aborts at the deadline or when the platform hangs up; past the deadline the
135
+ * kernel answers 503 whether or not `handle` stops (late work is not cancelled). Platforms' own ack
136
+ * deadlines are a few seconds, so keep it quick.
137
+ *
138
+ * Responses the kernel writes without asking the plugin:
139
+ * - 404 `channel.webhook_not_found`, the same body for an unknown connection, one not in webhook
140
+ * mode, a disabled one, or one whose plugin is off for its org;
141
+ * - 503 `extension.unavailable` when the plugin is not loaded on the replica that got the request (a
142
+ * rolling upgrade; the platform retries and reaches one that has it) or the stored config no
143
+ * longer matches `connectionSchema`; only reachable with an existing webhook connection's id;
144
+ * - 413 `payload_too_large` above `channels.webhookMaxBodyBytes` (default 1 MiB), and 415 for a
145
+ * malformed `Content-Type` header, both before `handle` is called (any well-formed content type,
146
+ * or none, reaches the plugin as raw bytes);
147
+ * - after `handle`: 503 `channel.webhook_retry` when the deadline passes or any `onEvent` rejects,
148
+ * 500 `internal` when `handle` throws or returns something else.
149
+ * Every response carries `x-content-type-options: nosniff` and
150
+ * `content-security-policy: default-src 'none'`, which the plugin cannot override; plugin-set
151
+ * `set-cookie`, `content-length`, `transfer-encoding`, `connection` and `keep-alive` are dropped.
152
+ */
153
+ export interface ChannelWebhook {
154
+ handle(args: {
155
+ connectionId: string;
156
+ config: unknown;
157
+ request: ChannelWebhookRequest;
158
+ signal: AbortSignal;
159
+ }): Promise<ChannelWebhookResult>;
160
+ }
161
+ /** 渠道实现(bot 用渠道连接配置)。 */
162
+ export interface Channel {
163
+ /** 渠道连接配置的 schema;密钥字段用 secret()。 */
164
+ connectionSchema: ZodTypeAny;
165
+ /**
166
+ * `minEditIntervalMs`:平台允许的同一条消息两次编辑的最小间隔;内核流式草稿的更新间隔取
167
+ * `max(1000, minEditIntervalMs)`。
168
+ */
169
+ features: {
170
+ cards: boolean;
171
+ edit: boolean;
172
+ threads: boolean;
173
+ minEditIntervalMs?: number;
174
+ };
175
+ verify(args: {
176
+ config: unknown;
177
+ signal: AbortSignal;
178
+ }): Promise<{
179
+ botUserId: string;
180
+ botName: string;
181
+ tenantId: string;
182
+ }>;
183
+ /** 长连接循环:signal 中止时返回;断线自行重连。 */
184
+ connect(args: ChannelConnectArgs): Promise<void>;
185
+ /** 失败时抛 {@link ChannelSendError} 说明能否重试;其他异常内核按可重试处理(次数有限)。 */
186
+ send(args: ChannelSendArgs): Promise<{
187
+ messageId: string;
188
+ }>;
189
+ /**
190
+ * features.edit 为 false 的渠道不会被调用。失败语义同 send。新文本超过平台单条上限、无法改成一条时
191
+ * 抛不可重试的 {@link ChannelSendError}(`reason: "too_large"`):内核改为把完整文本作为新消息 `send`
192
+ * (由插件拆分)。
193
+ */
194
+ update(args: ChannelSendArgs & {
195
+ messageId: string;
196
+ }): Promise<void>;
197
+ /**
198
+ * Optional webhook inbound (see {@link ChannelWebhook}). Only connections of plugins that
199
+ * implement it can be switched from the default long connection to webhook mode; a connection in
200
+ * webhook mode gets no `connect` call.
201
+ */
202
+ webhook?: ChannelWebhook;
203
+ }
204
+ /**
205
+ * 不可重试失败的原因(`ChannelSendError.reason`),内核据此处理:
206
+ * - `auth`:凭据失效 / 权限被收回 → 记连接 `last_error`,停止该会话本轮的投递;
207
+ * - `target_gone`:机器人被移出群、会话不存在 → 停止该会话本轮的投递,不计入毒事件;
208
+ * - `rejected`(内容被平台拒绝)、`too_large`(拆分后仍超限,如卡片过大)→ 按同一事件失败计数,满 3 次跳过它。
209
+ * 未给出原因的按 `rejected` 处理。
210
+ */
211
+ export type ChannelSendFailure = "auth" | "target_gone" | "rejected" | "too_large";
212
+ /**
213
+ * 渠道发送 / 更新消息失败的分类:`retryable` 为 true(限流、平台 5xx、网络)时内核退避后重试,
214
+ * `retryAfterMs` 为平台给出的建议等待时长;为 false 时按 `reason`(见 {@link ChannelSendFailure})处理。
215
+ * 编辑流式草稿不可重试地失败(`auth` / `target_gone` 除外)时,内核改为把完整文本作为新消息发出。
216
+ * 消息里不要带凭据。
217
+ */
218
+ export declare class ChannelSendError extends Error {
219
+ readonly retryable: boolean;
220
+ readonly retryAfterMs?: number;
221
+ readonly reason?: ChannelSendFailure;
222
+ constructor(message: string, opts: {
223
+ retryable: boolean;
224
+ retryAfterMs?: number;
225
+ reason?: ChannelSendFailure;
226
+ cause?: unknown;
227
+ });
228
+ }
229
+ /** 鸭子类型判断(不用 instanceof),兼容插件自带另一份 plugin-sdk 副本的情况。 */
230
+ export declare function isChannelSendError(e: unknown): e is ChannelSendError;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 渠道发送 / 更新消息失败的分类:`retryable` 为 true(限流、平台 5xx、网络)时内核退避后重试,
3
+ * `retryAfterMs` 为平台给出的建议等待时长;为 false 时按 `reason`(见 {@link ChannelSendFailure})处理。
4
+ * 编辑流式草稿不可重试地失败(`auth` / `target_gone` 除外)时,内核改为把完整文本作为新消息发出。
5
+ * 消息里不要带凭据。
6
+ */
7
+ export class ChannelSendError extends Error {
8
+ retryable;
9
+ retryAfterMs;
10
+ reason;
11
+ constructor(message, opts) {
12
+ super(message, "cause" in opts ? { cause: opts.cause } : undefined);
13
+ this.name = "ChannelSendError";
14
+ this.retryable = opts.retryable;
15
+ if (opts.retryAfterMs !== undefined)
16
+ this.retryAfterMs = opts.retryAfterMs;
17
+ if (opts.reason !== undefined)
18
+ this.reason = opts.reason;
19
+ }
20
+ }
21
+ /** 鸭子类型判断(不用 instanceof),兼容插件自带另一份 plugin-sdk 副本的情况。 */
22
+ export function isChannelSendError(e) {
23
+ return (e instanceof Error &&
24
+ e.name === "ChannelSendError" &&
25
+ typeof e.retryable === "boolean");
26
+ }
@@ -0,0 +1,64 @@
1
+ import { z } from "zod";
2
+ /** 认证方式:只有类型与非密钥字段;凭据值走内核的 `credential` / 凭据接口。 */
3
+ export declare const ConnectorAuthSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
4
+ type: z.ZodLiteral<"none">;
5
+ }, z.core.$strict>, z.ZodObject<{
6
+ type: z.ZodLiteral<"bearer">;
7
+ ownership: z.ZodEnum<{
8
+ org: "org";
9
+ personal: "personal";
10
+ }>;
11
+ }, z.core.$strict>, z.ZodObject<{
12
+ type: z.ZodLiteral<"api_key">;
13
+ ownership: z.ZodEnum<{
14
+ org: "org";
15
+ personal: "personal";
16
+ }>;
17
+ in: z.ZodEnum<{
18
+ header: "header";
19
+ query: "query";
20
+ }>;
21
+ name: z.ZodString;
22
+ }, z.core.$strict>, z.ZodObject<{
23
+ type: z.ZodLiteral<"basic">;
24
+ ownership: z.ZodEnum<{
25
+ org: "org";
26
+ personal: "personal";
27
+ }>;
28
+ }, z.core.$strict>, z.ZodObject<{
29
+ type: z.ZodLiteral<"headers">;
30
+ ownership: z.ZodEnum<{
31
+ org: "org";
32
+ personal: "personal";
33
+ }>;
34
+ names: z.ZodArray<z.ZodString>;
35
+ }, z.core.$strict>, z.ZodObject<{
36
+ type: z.ZodLiteral<"oauth_code">;
37
+ ownership: z.ZodLiteral<"personal">;
38
+ discovery: z.ZodEnum<{
39
+ manual: "manual";
40
+ mcp: "mcp";
41
+ }>;
42
+ authorizationUrl: z.ZodOptional<z.ZodURL>;
43
+ tokenUrl: z.ZodOptional<z.ZodURL>;
44
+ scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
45
+ client: z.ZodEnum<{
46
+ dcr: "dcr";
47
+ manual: "manual";
48
+ }>;
49
+ tokenAuthMethod: z.ZodOptional<z.ZodEnum<{
50
+ client_secret_basic: "client_secret_basic";
51
+ client_secret_post: "client_secret_post";
52
+ }>>;
53
+ }, z.core.$strict>, z.ZodObject<{
54
+ type: z.ZodLiteral<"oauth_client">;
55
+ ownership: z.ZodLiteral<"org">;
56
+ tokenUrl: z.ZodURL;
57
+ scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
58
+ tokenAuthMethod: z.ZodOptional<z.ZodEnum<{
59
+ client_secret_basic: "client_secret_basic";
60
+ client_secret_post: "client_secret_post";
61
+ }>>;
62
+ }, z.core.$strict>], "type">;
63
+ /** 认证预设({@link ConnectorAuthSchema} 的输出)。 */
64
+ export type ConnectorAuthPreset = z.infer<typeof ConnectorAuthSchema>;
@@ -0,0 +1,96 @@
1
+ import { z } from "zod";
2
+ /*
3
+ * 连接器的认证方式(只有类型与非密钥字段,凭据值不在这里):内核的连接器安装接口与企业目录里的连接器模板
4
+ * (`ConnectorTemplateSpec.auth`)共用这一个 schema。各分支都是 strict:多出的字段(如 `token`)一律拒绝,
5
+ * 所以合法的认证预设里不可能夹带凭据。OAuth 端点是否必须 https 由内核按部署配置再校验。
6
+ */
7
+ /** HTTP 头名(RFC 9110 token)。 */
8
+ const HeaderName = z
9
+ .string()
10
+ .min(1)
11
+ .max(100)
12
+ .regex(/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/, "Invalid header name");
13
+ const Ownership = z.enum(["org", "personal"]);
14
+ /** OAuth 端点(https 等由内核按部署配置再校验)。 */
15
+ const OAuthUrl = z.url().max(2000);
16
+ /** 令牌端点的客户端认证方式(有客户端密钥时)。 */
17
+ const TokenAuthMethod = z.enum(["client_secret_basic", "client_secret_post"]);
18
+ /** OAuth scope(RFC 6749 §3.3 scope-token)。 */
19
+ const Scopes = z
20
+ .array(z
21
+ .string()
22
+ .min(1)
23
+ .max(200)
24
+ .regex(/^[\x21\x23-\x5B\x5D-\x7E]+$/, "Invalid scope"))
25
+ .max(50);
26
+ /** 认证方式:只有类型与非密钥字段;凭据值走内核的 `credential` / 凭据接口。 */
27
+ export const ConnectorAuthSchema = z.discriminatedUnion("type", [
28
+ z.object({ type: z.literal("none") }).strict(),
29
+ z.object({ type: z.literal("bearer"), ownership: Ownership }).strict(),
30
+ z
31
+ .object({
32
+ type: z.literal("api_key"),
33
+ ownership: Ownership,
34
+ in: z.enum(["header", "query"]),
35
+ name: z.string().min(1).max(100),
36
+ })
37
+ .strict()
38
+ .refine((a) => a.in === "query" || HeaderName.safeParse(a.name).success, {
39
+ path: ["name"],
40
+ message: "Invalid header name",
41
+ }),
42
+ z.object({ type: z.literal("basic"), ownership: Ownership }).strict(),
43
+ z
44
+ .object({
45
+ type: z.literal("headers"),
46
+ ownership: Ownership,
47
+ names: z.array(HeaderName).min(1).max(20),
48
+ })
49
+ .strict(),
50
+ z
51
+ .object({
52
+ type: z.literal("oauth_code"),
53
+ ownership: z.literal("personal"),
54
+ discovery: z.enum(["mcp", "manual"]),
55
+ authorizationUrl: OAuthUrl.optional(),
56
+ tokenUrl: OAuthUrl.optional(),
57
+ scopes: Scopes.optional(),
58
+ client: z.enum(["dcr", "manual"]),
59
+ tokenAuthMethod: TokenAuthMethod.optional(),
60
+ })
61
+ .strict()
62
+ .superRefine((a, ctx) => {
63
+ if (a.discovery === "manual") {
64
+ for (const k of ["authorizationUrl", "tokenUrl"]) {
65
+ if (a[k] === undefined)
66
+ ctx.addIssue({ code: "custom", path: [k], message: "Required with discovery manual" });
67
+ }
68
+ }
69
+ else {
70
+ for (const k of ["authorizationUrl", "tokenUrl"]) {
71
+ if (a[k] !== undefined)
72
+ ctx.addIssue({
73
+ code: "custom",
74
+ path: [k],
75
+ message: "Endpoints are discovered with discovery mcp; omit this field",
76
+ });
77
+ }
78
+ }
79
+ if (a.client === "dcr" && a.discovery !== "mcp") {
80
+ ctx.addIssue({
81
+ code: "custom",
82
+ path: ["client"],
83
+ message: "Dynamic client registration requires discovery mcp",
84
+ });
85
+ }
86
+ }),
87
+ z
88
+ .object({
89
+ type: z.literal("oauth_client"),
90
+ ownership: z.literal("org"),
91
+ tokenUrl: OAuthUrl,
92
+ scopes: Scopes.optional(),
93
+ tokenAuthMethod: TokenAuthMethod.optional(),
94
+ })
95
+ .strict(),
96
+ ]);