ework-web 0.10.65 → 0.10.67
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.
- package/package.json +1 -1
- package/src/index.ts +3 -1
- package/src/render/layout.ts +6 -3
- package/src/views/issueThread.ts +3 -1
- package/src/webhooks.ts +22 -52
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -2189,7 +2189,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
2189
2189
|
const project = await getProject(owner, repo);
|
|
2190
2190
|
if (!project) return html(errorPage("404", "项目不存在"), 404);
|
|
2191
2191
|
if (!(await canReadProject(project.id, ctx.user))) return html(errorPage("404", "项目不存在"), 404);
|
|
2192
|
-
const
|
|
2192
|
+
const dispatchCfg = await getConfigAll();
|
|
2193
|
+
const projectDispatchOff = dispatchCfg[`dispatchOff:${owner}/${repo}`] === "1";
|
|
2194
|
+
const { html: body } = await buildIssueThread(cfg, owner, repo, number, ctx.user?.login, projectDispatchOff);
|
|
2193
2195
|
return html(body);
|
|
2194
2196
|
} catch (e) {
|
|
2195
2197
|
const status = e instanceof StoreError ? e.status : 500;
|
package/src/render/layout.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface LayoutProps {
|
|
|
18
18
|
labels?: { id: number; name: string; color: string }[];
|
|
19
19
|
canEditLabels?: boolean;
|
|
20
20
|
aiStatus?: string;
|
|
21
|
+
projectDispatchOff?: boolean;
|
|
21
22
|
viewerLogin?: string;
|
|
22
23
|
viewerIsAdmin?: boolean;
|
|
23
24
|
customActions?: IssueAction[];
|
|
@@ -192,9 +193,11 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
192
193
|
: `<button type="button" class="action-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/halt" data-action-confirm="确认停止 AI 处理?" title="停止 AI 处理">⏹ 停止</button>`
|
|
193
194
|
: "";
|
|
194
195
|
const dispatchBtnHtml = showActions
|
|
195
|
-
? props.
|
|
196
|
-
? `<
|
|
197
|
-
:
|
|
196
|
+
? props.projectDispatchOff
|
|
197
|
+
? `<span class="action-btn dispatch-btn" style="opacity:.5;cursor:not-allowed" title="项目已关闭自动接单">🔕 项目不接单</span>`
|
|
198
|
+
: props.aiStatus === "dispatch_off"
|
|
199
|
+
? `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-on" title="允许自动接单">🔔 接单</button>`
|
|
200
|
+
: `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-off" data-action-confirm="设为不自动接单?" title="设为不自动接单">🔕 不接单</button>`
|
|
198
201
|
: "";
|
|
199
202
|
return `<!doctype html>
|
|
200
203
|
<html lang="zh">
|
package/src/views/issueThread.ts
CHANGED
|
@@ -91,7 +91,8 @@ export async function buildIssueThread(
|
|
|
91
91
|
owner: string,
|
|
92
92
|
repo: string,
|
|
93
93
|
number: number,
|
|
94
|
-
viewerLogin?: string
|
|
94
|
+
viewerLogin?: string,
|
|
95
|
+
projectDispatchOff?: boolean
|
|
95
96
|
): Promise<{ html: string }> {
|
|
96
97
|
const project = await getProject(owner, repo);
|
|
97
98
|
if (!project) throw new StoreError(404, `项目 ${owner}/${repo} 不存在`);
|
|
@@ -150,6 +151,7 @@ export async function buildIssueThread(
|
|
|
150
151
|
labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })),
|
|
151
152
|
canEditLabels: cfg.writesEnabled !== false,
|
|
152
153
|
aiStatus: issue.ai_status ?? "",
|
|
154
|
+
projectDispatchOff: projectDispatchOff ?? false,
|
|
153
155
|
viewerLogin,
|
|
154
156
|
viewerIsAdmin,
|
|
155
157
|
customActions,
|
package/src/webhooks.ts
CHANGED
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
resolveModel,
|
|
29
29
|
listLabelsForIssue,
|
|
30
30
|
getUserByLogin,
|
|
31
|
-
canWriteProject,
|
|
32
31
|
type IssueRow,
|
|
33
32
|
type ProjectRow,
|
|
34
33
|
type CommentRow,
|
|
@@ -301,6 +300,7 @@ interface PayloadComment {
|
|
|
301
300
|
created_at: string;
|
|
302
301
|
updated_at: string;
|
|
303
302
|
user: PayloadUser;
|
|
303
|
+
author_kind?: string;
|
|
304
304
|
}
|
|
305
305
|
|
|
306
306
|
interface IssueEventPayload {
|
|
@@ -312,6 +312,7 @@ interface IssueEventPayload {
|
|
|
312
312
|
commit_id?: string;
|
|
313
313
|
commit_url?: string;
|
|
314
314
|
url?: string;
|
|
315
|
+
dispatch_off?: boolean;
|
|
315
316
|
}
|
|
316
317
|
|
|
317
318
|
interface CommentEventPayload {
|
|
@@ -636,23 +637,23 @@ export async function emitIssueEvent(
|
|
|
636
637
|
const project = await getProjectById(projectId);
|
|
637
638
|
if (!project) return;
|
|
638
639
|
const issue = await getIssueById(issueId);
|
|
639
|
-
if (!issue) return;
|
|
640
|
+
if (!issue) { log.warn(`emitIssueEvent: issue ${issueId} not found — silent skip prevented`); return; }
|
|
641
|
+
|
|
642
|
+
// Web is a dumb message bus for comments — always fan out.
|
|
643
|
+
// BUT for issue_opened, dispatch state is a subscription-level switch:
|
|
644
|
+
// if dispatch is off (global/project/issue), don't emit at all.
|
|
645
|
+
// This is the "webhook switch" — downstream never sees the event.
|
|
646
|
+
// We still include dispatch_off in payload for defense-in-depth.
|
|
647
|
+
const cfg = await getConfigAll();
|
|
648
|
+
const scopeKey = `${project.owner}/${project.name}`;
|
|
649
|
+
const globalOff = cfg["dispatchEnabled"] === "false";
|
|
650
|
+
const projectOff = cfg[`dispatchOff:${scopeKey}`] === "1";
|
|
651
|
+
const issueOff = issue.ai_status === "dispatch_off" || issue.ai_status === "halted";
|
|
652
|
+
const dispatchOff = globalOff || projectOff || issueOff;
|
|
640
653
|
|
|
641
|
-
if (action === "opened") {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
if (cfg["dispatchEnabled"] === "false") {
|
|
645
|
-
log.info(`webhook: global dispatch disabled — skipping opened for ${scopeKey}#${issue.number}`);
|
|
646
|
-
return;
|
|
647
|
-
}
|
|
648
|
-
if (cfg[`dispatchOff:${scopeKey}`] === "1") {
|
|
649
|
-
log.info(`webhook: project dispatch disabled — skipping opened for ${scopeKey}#${issue.number}`);
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
if ((issue as IssueRow).ai_status === "dispatch_off") {
|
|
653
|
-
log.info(`webhook: issue dispatch disabled — skipping opened for ${scopeKey}#${issue.number}`);
|
|
654
|
-
return;
|
|
655
|
-
}
|
|
654
|
+
if (action === "opened" && dispatchOff) {
|
|
655
|
+
log.info(`webhook: issue_opened suppressed (dispatch off: global=${globalOff} project=${projectOff} issue=${issueOff}) for ${scopeKey}#${issue.number}`);
|
|
656
|
+
return;
|
|
656
657
|
}
|
|
657
658
|
|
|
658
659
|
const commentCount = await countCommentsSafe(issueId);
|
|
@@ -664,6 +665,7 @@ export async function emitIssueEvent(
|
|
|
664
665
|
const labels = await toPayloadLabels(issueId);
|
|
665
666
|
const payload = buildIssuePayload(issue, project, commentCount, action, origin, model, labels);
|
|
666
667
|
(payload as IssueEventPayload).event_id = randomUUID();
|
|
668
|
+
(payload as IssueEventPayload).dispatch_off = dispatchOff;
|
|
667
669
|
const rawBody = JSON.stringify(payload);
|
|
668
670
|
await fanOut(projectId, "issues", rawBody);
|
|
669
671
|
} catch (e) {
|
|
@@ -689,48 +691,16 @@ export async function emitCommentEvent(
|
|
|
689
691
|
if (!issue) return;
|
|
690
692
|
const comment = await getCommentByIdSafe(commentId);
|
|
691
693
|
if (!comment) return;
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
// Issue-level dispatch_off is a per-issue explicit close — block comments entirely (same as halted).
|
|
695
|
-
if ((issue as IssueRow).ai_status === "dispatch_off") {
|
|
696
|
-
log.info(`webhook: issue dispatch disabled — skipping comment_created (author=${comment.author}) for ${scopeKey}#${issue.number}`);
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
694
|
+
// Web is a dumb message bus — always fan out. Wake policy is enforced
|
|
695
|
+
// downstream (daemon handleEvent checks author_kind, ai_status, env config).
|
|
699
696
|
const authorUser = await getUserByLogin(comment.author);
|
|
700
|
-
const dispatchOff = cfg["dispatchEnabled"] === "false" || cfg[`dispatchOff:${scopeKey}`] === "1";
|
|
701
|
-
if (dispatchOff && authorUser && authorUser.kind !== "human") {
|
|
702
|
-
log.info(`webhook: dispatch disabled — non-human comment blocked (author=${comment.author} kind=${authorUser.kind}) for ${scopeKey}#${issue.number}`);
|
|
703
|
-
return;
|
|
704
|
-
}
|
|
705
|
-
if (dispatchOff) {
|
|
706
|
-
log.info(`webhook: dispatch disabled but human comment waking AI (author=${comment.author}) for ${scopeKey}#${issue.number}`);
|
|
707
|
-
}
|
|
708
|
-
const appCfg = await loadConfig();
|
|
709
|
-
const cfgList = (k: string, d: string) =>
|
|
710
|
-
(cfg[k] ?? d).split(",").map((s) => s.trim()).filter(Boolean);
|
|
711
|
-
const noWake = cfgList("noWakeLogins", appCfg.daemonBotLogin);
|
|
712
|
-
const okLogins = cfgList("wakeLogins", "");
|
|
713
|
-
const okKinds = cfgList("wakeKinds", "human");
|
|
714
|
-
const author = comment.author;
|
|
715
|
-
const denied =
|
|
716
|
-
noWake.includes(author) ? "deny-list" :
|
|
717
|
-
okLogins.includes(author) ? null :
|
|
718
|
-
(authorUser && !okKinds.includes(authorUser.kind)) ? `kind=${authorUser.kind}` :
|
|
719
|
-
null;
|
|
720
|
-
if (denied) {
|
|
721
|
-
log.info(`webhook: comment wake denied (author=${author} reason=${denied}) for ${scopeKey}#${issue.number}`);
|
|
722
|
-
return;
|
|
723
|
-
}
|
|
724
|
-
if (authorUser && !(await canWriteProject(projectId, { login: comment.author, is_admin: authorUser.is_admin }))) {
|
|
725
|
-
log.info(`webhook: comment wake denied (author=${comment.author} not project member) for ${scopeKey}#${issue.number}`);
|
|
726
|
-
return;
|
|
727
|
-
}
|
|
728
697
|
const commentCount = await countCommentsSafe(issueId);
|
|
729
698
|
const globalDefault = (await loadConfig()).defaultModel;
|
|
730
699
|
const model = resolveModel(project.model, globalDefault);
|
|
731
700
|
const labels = await toPayloadLabels(issueId);
|
|
732
701
|
const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model, labels);
|
|
733
702
|
(payload as CommentEventPayload).event_id = randomUUID();
|
|
703
|
+
(payload as CommentEventPayload).comment.author_kind = authorUser?.kind ?? "human";
|
|
734
704
|
const rawBody = JSON.stringify(payload);
|
|
735
705
|
await fanOut(projectId, "issue_comment", rawBody);
|
|
736
706
|
} catch (e) {
|