openclaw-linear 0.1.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.
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/dist/event-router.d.ts +21 -0
- package/dist/event-router.d.ts.map +1 -0
- package/dist/event-router.js +162 -0
- package/dist/event-router.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +153 -0
- package/dist/index.js.map +1 -0
- package/dist/webhook-handler.d.ts +18 -0
- package/dist/webhook-handler.d.ts.map +1 -0
- package/dist/webhook-handler.js +118 -0
- package/dist/webhook-handler.js.map +1 -0
- package/openclaw.plugin.json +36 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stepan Arsentjev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# openclaw-linear
|
|
2
|
+
|
|
3
|
+
Linear webhook integration for OpenClaw. Receives Linear events, filters and routes them, and dispatches consolidated notifications to agents.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Webhook handler** — receives Linear webhook events with HMAC signature verification (timing-safe), duplicate delivery detection, and body size limits
|
|
8
|
+
- **Event router** — filters by team and event type, routes issue assignments and comment mentions to the configured agent
|
|
9
|
+
- **Debounced dispatch** — batches events within a configurable window into a single consolidated message so the agent can triage before acting
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
openclaw plugins install openclaw-linear
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
Add the plugin to your OpenClaw config. Each OpenClaw instance runs one agent — configure a separate instance per agent.
|
|
20
|
+
|
|
21
|
+
```yaml
|
|
22
|
+
plugins:
|
|
23
|
+
linear:
|
|
24
|
+
webhookSecret: "your-webhook-signing-secret"
|
|
25
|
+
agentMapping: # Filter: only handle events for these Linear users
|
|
26
|
+
"linear-user-uuid": "titus"
|
|
27
|
+
teamIds: ["ENG", "OPS"] # Optional: filter to specific teams (empty = all)
|
|
28
|
+
eventFilter: ["Issue", "Comment"] # Optional: filter event types (empty = all)
|
|
29
|
+
debounceMs: 30000 # Optional: batch window in ms (default: 30000)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Config Fields
|
|
33
|
+
|
|
34
|
+
| Field | Type | Required | Description |
|
|
35
|
+
|-------|------|----------|-------------|
|
|
36
|
+
| `webhookSecret` | string | **Yes** | Shared secret for HMAC webhook signature verification. |
|
|
37
|
+
| `agentMapping` | object | No | Maps Linear user UUIDs to agent IDs. Acts as a filter — events for unmapped users are ignored. Since each instance runs one agent, this typically has one entry. |
|
|
38
|
+
| `teamIds` | string[] | No | Team keys to scope webhook processing. Empty = all teams. |
|
|
39
|
+
| `eventFilter` | string[] | No | Event types to handle (`Issue`, `Comment`). Empty = all. |
|
|
40
|
+
| `debounceMs` | integer | No | Debounce window in milliseconds. Events arriving within this window are batched into a single message. Must be positive. Default: `30000` (30s). |
|
|
41
|
+
|
|
42
|
+
## Webhook Setup
|
|
43
|
+
|
|
44
|
+
1. **Make your endpoint publicly accessible.** The plugin registers at `/hooks/linear`:
|
|
45
|
+
```bash
|
|
46
|
+
# Example with Tailscale Funnel
|
|
47
|
+
tailscale funnel --bg 3000
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
2. **Register the webhook in Linear:**
|
|
51
|
+
- Go to **Settings > API > Webhooks**
|
|
52
|
+
- Set the URL to `https://your-host/hooks/linear`
|
|
53
|
+
- Set the secret to match your `webhookSecret`
|
|
54
|
+
- Select event types: Issues, Comments
|
|
55
|
+
- Save
|
|
56
|
+
|
|
57
|
+
3. **Verify:** Assign a Linear issue to a mapped user — the agent should receive a notification.
|
|
58
|
+
|
|
59
|
+
## Routed Events
|
|
60
|
+
|
|
61
|
+
| Linear Event | Router Action | Agent Event |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| Issue assigned to mapped user | `wake` | `issue.assigned` |
|
|
64
|
+
| Issue unassigned from mapped user | `notify` | `issue.unassigned` |
|
|
65
|
+
| Issue reassigned away from mapped user | `notify` | `issue.reassigned` |
|
|
66
|
+
| @mention in comment (mapped user) | `wake` | `comment.mention` |
|
|
67
|
+
|
|
68
|
+
`wake` events are enqueued into the debouncer and dispatched to the agent. `notify` events are logged only.
|
|
69
|
+
|
|
70
|
+
## How Dispatch Works
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
Linear webhook POST
|
|
74
|
+
→ HMAC signature verified (timing-safe)
|
|
75
|
+
→ Duplicate delivery check (10-min TTL, 10k cap)
|
|
76
|
+
→ Event router filters by team/type, matches user via agentMapping
|
|
77
|
+
→ wake actions enqueued into debouncer (keyed by agent ID)
|
|
78
|
+
→ After debounce window expires, consolidated message dispatched to agent
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
When multiple events arrive within the debounce window, the agent receives a single numbered message:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
You have 3 new Linear notifications:
|
|
85
|
+
|
|
86
|
+
1. [Assigned] ENG-42: Fix login bug
|
|
87
|
+
2. [Assigned] ENG-43: Update API docs
|
|
88
|
+
3. [Mentioned] ENG-40: Auth flow: "Can you review this?"
|
|
89
|
+
|
|
90
|
+
Review and prioritize before starting work.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Single events are passed through as-is (no numbered wrapper).
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
npm install
|
|
99
|
+
npm run build
|
|
100
|
+
|
|
101
|
+
# Type-check without emitting
|
|
102
|
+
npx tsc --noEmit
|
|
103
|
+
|
|
104
|
+
# Run tests
|
|
105
|
+
npm test
|
|
106
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { LinearWebhookPayload } from "./webhook-handler.js";
|
|
2
|
+
export type RouterAction = {
|
|
3
|
+
type: "wake" | "notify";
|
|
4
|
+
agentId: string;
|
|
5
|
+
event: string;
|
|
6
|
+
detail: string;
|
|
7
|
+
issueId: string;
|
|
8
|
+
issueLabel: string;
|
|
9
|
+
linearUserId: string;
|
|
10
|
+
};
|
|
11
|
+
export type EventRouterConfig = {
|
|
12
|
+
agentMapping: Record<string, string>;
|
|
13
|
+
logger: {
|
|
14
|
+
info: (message: string) => void;
|
|
15
|
+
error: (message: string) => void;
|
|
16
|
+
};
|
|
17
|
+
eventFilter?: string[];
|
|
18
|
+
teamIds?: string[];
|
|
19
|
+
};
|
|
20
|
+
export declare function createEventRouter(config: EventRouterConfig): (event: LinearWebhookPayload) => RouterAction[];
|
|
21
|
+
//# sourceMappingURL=event-router.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"event-router.d.ts","sourceRoot":"","sources":["../src/event-router.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAEjE,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,MAAM,GAAG,QAAQ,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,MAAM,EAAE;QACN,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;QAChC,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;KAClC,CAAC;IACF,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB,CAAC;AAyKF,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,iBAAiB,IACnC,OAAO,oBAAoB,KAAG,YAAY,EAAE,CAiCnE"}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract mention user IDs from ProseMirror bodyData JSON.
|
|
3
|
+
* Traverses the document tree looking for "mention" nodes with an `attrs.id`.
|
|
4
|
+
*/
|
|
5
|
+
function extractMentionsFromProseMirror(node) {
|
|
6
|
+
if (!node || typeof node !== "object")
|
|
7
|
+
return [];
|
|
8
|
+
const n = node;
|
|
9
|
+
const ids = [];
|
|
10
|
+
if (n.type === "mention") {
|
|
11
|
+
const attrs = n.attrs;
|
|
12
|
+
const id = attrs?.id;
|
|
13
|
+
if (typeof id === "string" && id) {
|
|
14
|
+
ids.push(id);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const content = n.content;
|
|
18
|
+
if (Array.isArray(content)) {
|
|
19
|
+
for (const child of content) {
|
|
20
|
+
ids.push(...extractMentionsFromProseMirror(child));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return ids;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Extract mentioned user identifiers from a comment.
|
|
27
|
+
* Tries structured ProseMirror bodyData first (yields UUIDs), then
|
|
28
|
+
* falls back to regex on the markdown body (yields usernames/handles).
|
|
29
|
+
*/
|
|
30
|
+
function extractMentionedUserIds(body, bodyData) {
|
|
31
|
+
if (bodyData) {
|
|
32
|
+
const ids = extractMentionsFromProseMirror(bodyData);
|
|
33
|
+
if (ids.length > 0)
|
|
34
|
+
return [...new Set(ids)];
|
|
35
|
+
}
|
|
36
|
+
const matches = body.matchAll(/@([a-zA-Z0-9_.-]+)/g);
|
|
37
|
+
return [...new Set([...matches].map((m) => m[1]))];
|
|
38
|
+
}
|
|
39
|
+
function resolveIssueLabel(data) {
|
|
40
|
+
const identifier = data.identifier;
|
|
41
|
+
const title = data.title;
|
|
42
|
+
const id = String(data.id ?? "unknown");
|
|
43
|
+
const label = identifier ?? id;
|
|
44
|
+
return title ? `${label}: ${title}` : label;
|
|
45
|
+
}
|
|
46
|
+
function handleIssueUpdate(event, config) {
|
|
47
|
+
const changes = event.data.changes;
|
|
48
|
+
if (!changes?.assigneeId)
|
|
49
|
+
return [];
|
|
50
|
+
const actions = [];
|
|
51
|
+
const { from: oldAssignee, to: newAssignee } = changes.assigneeId;
|
|
52
|
+
const issueId = String(event.data.id ?? "unknown");
|
|
53
|
+
const issueLabel = resolveIssueLabel(event.data);
|
|
54
|
+
if (newAssignee) {
|
|
55
|
+
const agentId = config.agentMapping[newAssignee];
|
|
56
|
+
if (agentId) {
|
|
57
|
+
actions.push({
|
|
58
|
+
type: "wake",
|
|
59
|
+
agentId,
|
|
60
|
+
event: "issue.assigned",
|
|
61
|
+
detail: `Assigned to issue ${issueLabel}`,
|
|
62
|
+
issueId,
|
|
63
|
+
issueLabel,
|
|
64
|
+
linearUserId: newAssignee,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
config.logger.info(`Unmapped Linear user ${newAssignee} assigned to ${issueId}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (oldAssignee && !newAssignee) {
|
|
72
|
+
const agentId = config.agentMapping[oldAssignee];
|
|
73
|
+
if (agentId) {
|
|
74
|
+
actions.push({
|
|
75
|
+
type: "notify",
|
|
76
|
+
agentId,
|
|
77
|
+
event: "issue.unassigned",
|
|
78
|
+
detail: `Unassigned from issue ${issueLabel}`,
|
|
79
|
+
issueId,
|
|
80
|
+
issueLabel,
|
|
81
|
+
linearUserId: oldAssignee,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
config.logger.info(`Unmapped Linear user ${oldAssignee} unassigned from ${issueId}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Reassignment: both old and new assignee present — notify old assignee
|
|
89
|
+
if (oldAssignee && newAssignee) {
|
|
90
|
+
const agentId = config.agentMapping[oldAssignee];
|
|
91
|
+
if (agentId) {
|
|
92
|
+
actions.push({
|
|
93
|
+
type: "notify",
|
|
94
|
+
agentId,
|
|
95
|
+
event: "issue.reassigned",
|
|
96
|
+
detail: `Reassigned away from issue ${issueLabel}`,
|
|
97
|
+
issueId,
|
|
98
|
+
issueLabel,
|
|
99
|
+
linearUserId: oldAssignee,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return actions;
|
|
104
|
+
}
|
|
105
|
+
function handleComment(event, config) {
|
|
106
|
+
const body = event.data.body;
|
|
107
|
+
if (!body)
|
|
108
|
+
return [];
|
|
109
|
+
const bodyData = event.data.bodyData;
|
|
110
|
+
const mentionedIds = extractMentionedUserIds(body, bodyData);
|
|
111
|
+
const actions = [];
|
|
112
|
+
const issueRef = event.data.issue;
|
|
113
|
+
const issueId = String(issueRef?.id ?? event.data.issueId ?? "unknown");
|
|
114
|
+
const issueLabel = issueRef
|
|
115
|
+
? resolveIssueLabel(issueRef)
|
|
116
|
+
: issueId;
|
|
117
|
+
for (const userId of mentionedIds) {
|
|
118
|
+
const agentId = config.agentMapping[userId];
|
|
119
|
+
if (agentId) {
|
|
120
|
+
actions.push({
|
|
121
|
+
type: "wake",
|
|
122
|
+
agentId,
|
|
123
|
+
event: "comment.mention",
|
|
124
|
+
detail: `Mentioned in comment on issue ${issueLabel}\n\n> ${body}`,
|
|
125
|
+
issueId,
|
|
126
|
+
issueLabel,
|
|
127
|
+
linearUserId: userId,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
config.logger.info(`Unmapped Linear user ${userId} mentioned in comment on ${issueId}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return actions;
|
|
135
|
+
}
|
|
136
|
+
export function createEventRouter(config) {
|
|
137
|
+
return function route(event) {
|
|
138
|
+
// Apply event type filter
|
|
139
|
+
if (config.eventFilter?.length &&
|
|
140
|
+
!config.eventFilter.includes(event.type)) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
// Apply team filter
|
|
144
|
+
const teamId = event.data.teamId;
|
|
145
|
+
const teamObj = event.data.team;
|
|
146
|
+
const teamKey = teamObj?.key;
|
|
147
|
+
if (config.teamIds?.length) {
|
|
148
|
+
const match = config.teamIds.some((t) => t === teamId || t === teamKey);
|
|
149
|
+
if (!match && (teamId || teamKey))
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
if (event.type === "Issue" && event.action === "update") {
|
|
153
|
+
return handleIssueUpdate(event, config);
|
|
154
|
+
}
|
|
155
|
+
if (event.type === "Comment" &&
|
|
156
|
+
(event.action === "create" || event.action === "update")) {
|
|
157
|
+
return handleComment(event, config);
|
|
158
|
+
}
|
|
159
|
+
return [];
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=event-router.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"event-router.js","sourceRoot":"","sources":["../src/event-router.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { type RouterAction } from "./event-router.js";
|
|
3
|
+
export declare function formatConsolidatedMessage(actions: RouterAction[]): string;
|
|
4
|
+
export declare function activate(api: OpenClawPluginApi): void;
|
|
5
|
+
export declare function deactivate(api: OpenClawPluginApi): Promise<void>;
|
|
6
|
+
declare const plugin: {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
activate: typeof activate;
|
|
11
|
+
deactivate: typeof deactivate;
|
|
12
|
+
};
|
|
13
|
+
export default plugin;
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAYzE,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,CAYzE;AA0ED,wBAAgB,QAAQ,CAAC,GAAG,EAAE,iBAAiB,GAAG,IAAI,CAyErD;AAED,wBAAsB,UAAU,CAAC,GAAG,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAStE;AAED,QAAA,MAAM,MAAM;;;;;;CAYX,CAAC;AAEF,eAAe,MAAM,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createWebhookHandler } from "./webhook-handler.js";
|
|
2
|
+
import { createEventRouter } from "./event-router.js";
|
|
3
|
+
const CHANNEL_ID = "linear";
|
|
4
|
+
const DEFAULT_DEBOUNCE_MS = 30_000;
|
|
5
|
+
const EVENT_LABELS = {
|
|
6
|
+
"issue.assigned": "Assigned",
|
|
7
|
+
"issue.unassigned": "Unassigned",
|
|
8
|
+
"issue.reassigned": "Reassigned",
|
|
9
|
+
"comment.mention": "Mentioned",
|
|
10
|
+
};
|
|
11
|
+
export function formatConsolidatedMessage(actions) {
|
|
12
|
+
if (actions.length === 1) {
|
|
13
|
+
return actions[0].detail;
|
|
14
|
+
}
|
|
15
|
+
const lines = actions.map((a, i) => {
|
|
16
|
+
const label = EVENT_LABELS[a.event] ?? a.event;
|
|
17
|
+
const summary = formatActionSummary(a);
|
|
18
|
+
return `${i + 1}. [${label}] ${summary}`;
|
|
19
|
+
});
|
|
20
|
+
return `You have ${actions.length} new Linear notifications:\n\n${lines.join("\n")}\n\nReview and prioritize before starting work.`;
|
|
21
|
+
}
|
|
22
|
+
function formatActionSummary(action) {
|
|
23
|
+
if (action.event === "comment.mention") {
|
|
24
|
+
const bodyStart = action.detail.indexOf("\n\n> ");
|
|
25
|
+
if (bodyStart !== -1) {
|
|
26
|
+
const quote = action.detail.slice(bodyStart + 4); // skip "\n\n> "
|
|
27
|
+
return `${action.issueLabel}: "${quote}"`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return action.issueLabel || action.detail;
|
|
31
|
+
}
|
|
32
|
+
async function dispatchConsolidatedActions(actions, api) {
|
|
33
|
+
if (actions.length === 0)
|
|
34
|
+
return;
|
|
35
|
+
const core = api.runtime;
|
|
36
|
+
const cfg = api.config;
|
|
37
|
+
const first = actions[0];
|
|
38
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
39
|
+
cfg,
|
|
40
|
+
channel: CHANNEL_ID,
|
|
41
|
+
accountId: "default",
|
|
42
|
+
peer: {
|
|
43
|
+
kind: "direct",
|
|
44
|
+
id: first.linearUserId,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const body = formatConsolidatedMessage(actions);
|
|
48
|
+
const ctx = core.channel.reply.finalizeInboundContext({
|
|
49
|
+
Body: body,
|
|
50
|
+
BodyForAgent: body,
|
|
51
|
+
RawBody: body,
|
|
52
|
+
CommandBody: body,
|
|
53
|
+
From: `${CHANNEL_ID}:${first.linearUserId}`,
|
|
54
|
+
To: `${CHANNEL_ID}:${route.agentId ?? first.agentId}`,
|
|
55
|
+
SessionKey: route.sessionKey,
|
|
56
|
+
AccountId: route.accountId ?? "default",
|
|
57
|
+
ChatType: "direct",
|
|
58
|
+
ConversationLabel: `Linear: batch (${actions.length} events)`,
|
|
59
|
+
SenderId: first.linearUserId,
|
|
60
|
+
Provider: CHANNEL_ID,
|
|
61
|
+
Surface: CHANNEL_ID,
|
|
62
|
+
OriginatingChannel: CHANNEL_ID,
|
|
63
|
+
OriginatingTo: `${CHANNEL_ID}:${first.linearUserId}`,
|
|
64
|
+
});
|
|
65
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
66
|
+
ctx,
|
|
67
|
+
cfg,
|
|
68
|
+
dispatcherOptions: {
|
|
69
|
+
deliver: async () => {
|
|
70
|
+
// No-op: agent uses Linear tools to respond to specific issues after triage
|
|
71
|
+
},
|
|
72
|
+
onError: (err) => {
|
|
73
|
+
api.logger.error(`[linear] Reply error: ${err instanceof Error ? err.message : String(err)}`);
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
let activeDebouncer;
|
|
79
|
+
const activeDebouncerKeys = new Set();
|
|
80
|
+
export function activate(api) {
|
|
81
|
+
api.logger.info("Linear plugin activated");
|
|
82
|
+
const webhookSecret = api.pluginConfig?.["webhookSecret"];
|
|
83
|
+
if (typeof webhookSecret !== "string" || !webhookSecret) {
|
|
84
|
+
api.logger.error("[linear] webhookSecret is not configured — plugin is inert");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const agentMapping = api.pluginConfig?.["agentMapping"] ?? {};
|
|
88
|
+
if (Object.keys(agentMapping).length === 0) {
|
|
89
|
+
api.logger.info("[linear] agentMapping is empty — all events will be dropped");
|
|
90
|
+
}
|
|
91
|
+
const eventFilter = api.pluginConfig?.["eventFilter"] ?? [];
|
|
92
|
+
const teamIds = api.pluginConfig?.["teamIds"] ?? [];
|
|
93
|
+
const rawDebounceMs = api.pluginConfig?.["debounceMs"];
|
|
94
|
+
const debounceMs = (typeof rawDebounceMs === "number" && rawDebounceMs > 0)
|
|
95
|
+
? rawDebounceMs
|
|
96
|
+
: DEFAULT_DEBOUNCE_MS;
|
|
97
|
+
const route = createEventRouter({
|
|
98
|
+
agentMapping,
|
|
99
|
+
logger: api.logger,
|
|
100
|
+
eventFilter: eventFilter.length ? eventFilter : undefined,
|
|
101
|
+
teamIds: teamIds.length ? teamIds : undefined,
|
|
102
|
+
});
|
|
103
|
+
const debouncer = api.runtime.channel.debounce.createInboundDebouncer({
|
|
104
|
+
debounceMs,
|
|
105
|
+
buildKey: (action) => action.agentId,
|
|
106
|
+
shouldDebounce: () => true,
|
|
107
|
+
onFlush: async (actions) => {
|
|
108
|
+
await dispatchConsolidatedActions(actions, api);
|
|
109
|
+
},
|
|
110
|
+
onError: (err) => {
|
|
111
|
+
api.logger.error(`[linear] Debounce flush failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
activeDebouncer = debouncer;
|
|
115
|
+
const handler = createWebhookHandler({
|
|
116
|
+
webhookSecret,
|
|
117
|
+
logger: api.logger,
|
|
118
|
+
onEvent: (event) => {
|
|
119
|
+
const actions = route(event);
|
|
120
|
+
for (const action of actions) {
|
|
121
|
+
api.logger.info(`[event-router] ${action.type} agent=${action.agentId} event=${action.event}: ${action.detail}`);
|
|
122
|
+
if (action.type === "wake") {
|
|
123
|
+
activeDebouncerKeys.add(action.agentId);
|
|
124
|
+
debouncer.enqueue(action);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
api.registerHttpRoute({
|
|
130
|
+
path: "/hooks/linear",
|
|
131
|
+
handler,
|
|
132
|
+
});
|
|
133
|
+
api.logger.info(`Linear webhook handler registered at /hooks/linear (debounce: ${debounceMs}ms)`);
|
|
134
|
+
}
|
|
135
|
+
export async function deactivate(api) {
|
|
136
|
+
if (activeDebouncer) {
|
|
137
|
+
for (const key of activeDebouncerKeys) {
|
|
138
|
+
await activeDebouncer.flushKey(key);
|
|
139
|
+
}
|
|
140
|
+
activeDebouncerKeys.clear();
|
|
141
|
+
activeDebouncer = undefined;
|
|
142
|
+
}
|
|
143
|
+
api.logger.info("Linear plugin deactivated");
|
|
144
|
+
}
|
|
145
|
+
const plugin = {
|
|
146
|
+
id: "linear",
|
|
147
|
+
name: "Linear",
|
|
148
|
+
description: "Linear project management integration for OpenClaw",
|
|
149
|
+
activate,
|
|
150
|
+
deactivate,
|
|
151
|
+
};
|
|
152
|
+
export default plugin;
|
|
153
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export type LinearWebhookPayload = {
|
|
3
|
+
action: string;
|
|
4
|
+
type: string;
|
|
5
|
+
data: Record<string, unknown>;
|
|
6
|
+
createdAt: string;
|
|
7
|
+
};
|
|
8
|
+
type WebhookHandlerDeps = {
|
|
9
|
+
webhookSecret: string;
|
|
10
|
+
logger: {
|
|
11
|
+
info: (message: string) => void;
|
|
12
|
+
error: (message: string) => void;
|
|
13
|
+
};
|
|
14
|
+
onEvent?: (event: LinearWebhookPayload) => void;
|
|
15
|
+
};
|
|
16
|
+
export declare function createWebhookHandler(deps: WebhookHandlerDeps): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=webhook-handler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-handler.d.ts","sourceRoot":"","sources":["../src/webhook-handler.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,KAAK,kBAAkB,GAAG;IACxB,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE;QACN,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;QAChC,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;KAClC,CAAC;IACF,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAC;CACjD,CAAC;AAgCF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,kBAAkB,IAqB7C,KAAK,eAAe,EAAE,KAAK,cAAc,KAAG,OAAO,CAAC,IAAI,CAAC,CAyExE"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
const MAX_BODY_BYTES = 1024 * 1024; // 1 MB
|
|
3
|
+
const DEDUP_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
4
|
+
const DEDUP_MAX_SIZE = 10_000;
|
|
5
|
+
function verifySignature(body, signature, secret) {
|
|
6
|
+
const expected = createHmac("sha256", secret).update(body).digest("hex");
|
|
7
|
+
if (expected.length !== signature.length) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
|
11
|
+
}
|
|
12
|
+
function readBody(req) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const chunks = [];
|
|
15
|
+
let size = 0;
|
|
16
|
+
req.on("data", (chunk) => {
|
|
17
|
+
size += chunk.length;
|
|
18
|
+
if (size > MAX_BODY_BYTES) {
|
|
19
|
+
reject(new Error("Request body too large"));
|
|
20
|
+
req.destroy();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
chunks.push(chunk);
|
|
24
|
+
});
|
|
25
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
26
|
+
req.on("error", reject);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export function createWebhookHandler(deps) {
|
|
30
|
+
/** Map of delivery ID → timestamp for duplicate detection with TTL. */
|
|
31
|
+
const processedDeliveries = new Map();
|
|
32
|
+
function pruneDeliveries() {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
for (const [id, ts] of processedDeliveries) {
|
|
35
|
+
if (now - ts > DEDUP_TTL_MS) {
|
|
36
|
+
processedDeliveries.delete(id);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (processedDeliveries.size > DEDUP_MAX_SIZE) {
|
|
40
|
+
const excess = processedDeliveries.size - DEDUP_MAX_SIZE;
|
|
41
|
+
const iter = processedDeliveries.keys();
|
|
42
|
+
for (let i = 0; i < excess; i++) {
|
|
43
|
+
const key = iter.next().value;
|
|
44
|
+
if (key !== undefined)
|
|
45
|
+
processedDeliveries.delete(key);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return async (req, res) => {
|
|
50
|
+
if (req.method !== "POST") {
|
|
51
|
+
res.writeHead(405, { Allow: "POST" });
|
|
52
|
+
res.end("Method Not Allowed");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
let rawBody;
|
|
56
|
+
try {
|
|
57
|
+
rawBody = await readBody(req);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
61
|
+
if (msg.includes("too large")) {
|
|
62
|
+
res.writeHead(413);
|
|
63
|
+
res.end("Payload Too Large");
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
res.writeHead(500);
|
|
67
|
+
res.end("Internal Server Error");
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const signature = req.headers["linear-signature"];
|
|
72
|
+
if (typeof signature !== "string" || !verifySignature(rawBody, signature, deps.webhookSecret)) {
|
|
73
|
+
res.writeHead(400);
|
|
74
|
+
res.end("Invalid signature");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
let event;
|
|
78
|
+
try {
|
|
79
|
+
const payload = JSON.parse(rawBody);
|
|
80
|
+
const deliveryId = req.headers["linear-delivery"];
|
|
81
|
+
// Prune expired entries periodically
|
|
82
|
+
pruneDeliveries();
|
|
83
|
+
if (deliveryId) {
|
|
84
|
+
if (processedDeliveries.has(deliveryId)) {
|
|
85
|
+
deps.logger.info(`Duplicate delivery skipped: ${deliveryId}`);
|
|
86
|
+
res.writeHead(200);
|
|
87
|
+
res.end("OK");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
processedDeliveries.set(deliveryId, Date.now());
|
|
91
|
+
}
|
|
92
|
+
event = {
|
|
93
|
+
action: String(payload.action ?? ""),
|
|
94
|
+
type: String(payload.type ?? ""),
|
|
95
|
+
data: payload.data ?? {},
|
|
96
|
+
createdAt: String(payload.createdAt ?? ""),
|
|
97
|
+
};
|
|
98
|
+
deps.logger.info(`Linear webhook: ${event.action} ${event.type} (${String(event.data.id ?? "unknown")})`);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
deps.logger.error(`Webhook parse error: ${err instanceof Error ? err.message : String(err)}`);
|
|
102
|
+
res.writeHead(500);
|
|
103
|
+
res.end("Internal Server Error");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Always return 200 after successful parse — onEvent errors must not
|
|
107
|
+
// cause Linear to retry (which could create a retry storm).
|
|
108
|
+
res.writeHead(200);
|
|
109
|
+
res.end("OK");
|
|
110
|
+
try {
|
|
111
|
+
deps.onEvent?.(event);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
deps.logger.error(`Event handler error: ${err instanceof Error ? err.message : String(err)}`);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=webhook-handler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-handler.js","sourceRoot":"","sources":["../src/webhook-handler.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "linear",
|
|
3
|
+
"name": "Linear",
|
|
4
|
+
"description": "Linear project management integration for OpenClaw",
|
|
5
|
+
"configSchema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"webhookSecret": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"sensitive": true,
|
|
12
|
+
"description": "Webhook signing secret for HMAC verification"
|
|
13
|
+
},
|
|
14
|
+
"teamIds": {
|
|
15
|
+
"type": "array",
|
|
16
|
+
"items": { "type": "string" },
|
|
17
|
+
"description": "Linear team keys to listen for (e.g. [\"ENG\", \"OPS\"]). Empty = all teams."
|
|
18
|
+
},
|
|
19
|
+
"eventFilter": {
|
|
20
|
+
"type": "array",
|
|
21
|
+
"items": { "type": "string" },
|
|
22
|
+
"description": "Event types to handle (e.g. [\"Issue\", \"Comment\"]). Empty = all types."
|
|
23
|
+
},
|
|
24
|
+
"agentMapping": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"additionalProperties": { "type": "string" },
|
|
27
|
+
"description": "Map Linear user IDs to OpenClaw agent IDs for routing (e.g. {\"linear-user-uuid\": \"agent-name\"})"
|
|
28
|
+
},
|
|
29
|
+
"debounceMs": {
|
|
30
|
+
"type": "integer",
|
|
31
|
+
"description": "Debounce window in milliseconds for batching webhook events before dispatch (default: 30000)"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"required": ["webhookSecret"]
|
|
35
|
+
}
|
|
36
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-linear",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Linear webhook integration for OpenClaw — receives events, routes them, and dispatches consolidated notifications to agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"openclaw.plugin.json",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"clean": "rm -rf dist",
|
|
23
|
+
"prepack": "npm run build",
|
|
24
|
+
"test": "vitest run"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"openclaw",
|
|
28
|
+
"linear",
|
|
29
|
+
"webhook",
|
|
30
|
+
"plugin"
|
|
31
|
+
],
|
|
32
|
+
"author": "Stepan Arsentjev",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/stepandel/openclaw-linear.git"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"openclaw": ">=2026.2.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"openclaw": "^2026.2.12",
|
|
46
|
+
"typescript": "^5.7.0",
|
|
47
|
+
"vitest": "^4.0.18"
|
|
48
|
+
},
|
|
49
|
+
"openclaw": {
|
|
50
|
+
"extensions": [
|
|
51
|
+
"./dist/index.js"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
}
|