xyne-plugin-spaces 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/bun.lock +21 -0
- package/package.json +15 -0
- package/src/auth.ts +105 -0
- package/src/index.ts +371 -0
- package/src/query.ts +338 -0
- package/tsconfig.json +17 -0
package/bun.lock
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"workspaces": {
|
|
4
|
+
"": {
|
|
5
|
+
"name": "xyne-spaces",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"xyne-plugin": "^1.2.0",
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"typescript": "^5.9.3",
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
"packages": {
|
|
15
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
16
|
+
|
|
17
|
+
"xyne-plugin": ["xyne-plugin@1.2.0", "", { "dependencies": { "zod": "^3.25.0" } }, "sha512-ecJJFMka/KvOlhF28CcRVfgmfHv6kE8JIn3cUlUCQ39b2j+9asM+/z5TOGkGHOXqSbtd5hl36zgtgvq8EA6h7A=="],
|
|
18
|
+
|
|
19
|
+
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
|
20
|
+
}
|
|
21
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "xyne-plugin-spaces",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"typecheck": "tsc --noEmit"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"xyne-plugin": "^1.2.0"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"typescript": "^5.9.3"
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spaces auth — custom auth via localhost Electron server.
|
|
3
|
+
*
|
|
4
|
+
* Flow: POST /auth/request → Electron shows consent dialog → token returned.
|
|
5
|
+
* Token is stored in memory only — user re-approves each Xyne session.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ToolAuthConfig, ServiceCredentials } from "xyne-plugin"
|
|
9
|
+
|
|
10
|
+
const BASE = "http://127.0.0.1:49231"
|
|
11
|
+
|
|
12
|
+
// In-memory only — no disk persistence
|
|
13
|
+
let _cached: ServiceCredentials | undefined
|
|
14
|
+
|
|
15
|
+
export const spacesAuth: ToolAuthConfig = {
|
|
16
|
+
serviceID: "spaces",
|
|
17
|
+
label: "Spaces",
|
|
18
|
+
storage: {
|
|
19
|
+
async get() { return _cached },
|
|
20
|
+
async set(entry) { _cached = entry },
|
|
21
|
+
async remove() { _cached = undefined },
|
|
22
|
+
},
|
|
23
|
+
methods: [
|
|
24
|
+
{
|
|
25
|
+
type: "custom" as const,
|
|
26
|
+
label: "Connect to Spaces",
|
|
27
|
+
pendingMessage: "Approve the connection in the Spaces desktop app",
|
|
28
|
+
pendingHint: "Waiting for approval... · esc cancel",
|
|
29
|
+
async authorize() {
|
|
30
|
+
const response = await fetch(`${BASE}/auth/request`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "Content-Type": "application/json" },
|
|
33
|
+
body: JSON.stringify({
|
|
34
|
+
agentName: "Xyne",
|
|
35
|
+
agentType: "cli",
|
|
36
|
+
description: "Xyne CLI agent requesting access to search and memory",
|
|
37
|
+
}),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
return { type: "failed" as const }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const data = (await response.json()) as {
|
|
45
|
+
status: string
|
|
46
|
+
accessToken?: string
|
|
47
|
+
expiresAt?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (data.status !== "approved" || !data.accessToken) {
|
|
51
|
+
return { type: "failed" as const }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
type: "success" as const,
|
|
56
|
+
data: {
|
|
57
|
+
accessToken: data.accessToken,
|
|
58
|
+
expiresAt: data.expiresAt ?? Date.now() + 5 * 60 * 1000,
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
|
|
65
|
+
async refresh(entry: ServiceCredentials) {
|
|
66
|
+
if (entry.type !== "custom") throw new Error("Expected custom credentials")
|
|
67
|
+
const expiresAt = entry.data["expiresAt"] as number | undefined
|
|
68
|
+
if (expiresAt && Date.now() < expiresAt) return entry
|
|
69
|
+
throw new Error("Token expired")
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const log = (msg: string) => {
|
|
74
|
+
try { require("fs").appendFileSync("/tmp/tui-spaces-debug.log", `[${new Date().toISOString()}] ${msg}\n`) } catch {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Make an authenticated request to the Spaces server. */
|
|
78
|
+
export async function spacesFetch(
|
|
79
|
+
path: string,
|
|
80
|
+
accessToken: string,
|
|
81
|
+
init?: RequestInit,
|
|
82
|
+
): Promise<unknown> {
|
|
83
|
+
const url = `${BASE}${path}`
|
|
84
|
+
const method = init?.method ?? "GET"
|
|
85
|
+
log(`${method} ${path}${init?.body ? ` body=${String(init.body).slice(0, 500)}` : ""}`)
|
|
86
|
+
|
|
87
|
+
const response = await fetch(url, {
|
|
88
|
+
...init,
|
|
89
|
+
headers: {
|
|
90
|
+
Authorization: `Bearer ${accessToken}`,
|
|
91
|
+
"Content-Type": "application/json",
|
|
92
|
+
...(init?.headers as Record<string, string> | undefined),
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
const text = await response.text()
|
|
98
|
+
log(`ERROR ${response.status}: ${text.slice(0, 500)}`)
|
|
99
|
+
throw new Error(`Spaces API ${response.status}: ${text}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const data = await response.json()
|
|
103
|
+
log(`OK ${response.status} (${JSON.stringify(data).length} bytes)`)
|
|
104
|
+
return data
|
|
105
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { type Plugin, tool, z } from "xyne-plugin"
|
|
2
|
+
import { spacesAuth, spacesFetch } from "./auth"
|
|
3
|
+
import { queryTickets, queryMessages, queryMessageDetail, queryChannels, queryUsers, queryUserActivity } from "./query"
|
|
4
|
+
|
|
5
|
+
function getToken(ctx: { credentials?: { type: string; data?: Record<string, unknown> } }): string {
|
|
6
|
+
const creds = ctx.credentials
|
|
7
|
+
if (!creds || creds.type !== "custom") throw new Error("Expected Spaces credentials")
|
|
8
|
+
return creds.data!["accessToken"] as string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ── Vespa Search ─────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const spacesSearch = tool({
|
|
14
|
+
description:
|
|
15
|
+
"Search across all connected apps in Spaces — messages, tickets, files, channels, users. " +
|
|
16
|
+
"Supports filters like app, type, date range, priority, status, tags, and more.",
|
|
17
|
+
auth: spacesAuth,
|
|
18
|
+
args: {
|
|
19
|
+
query: z.string().describe("Search query text. Can be empty if filterOnly is true."),
|
|
20
|
+
apps: z.string().optional().describe("Comma-separated apps to search: chat, ticket, user, file (default: all)"),
|
|
21
|
+
type: z.string().optional().describe("Filter by type: messages, attachments, channels, tickets, files"),
|
|
22
|
+
from: z.string().optional().describe("Filter by sender user ID(s), comma-separated"),
|
|
23
|
+
in: z.string().optional().describe("Filter by channel ID(s), comma-separated"),
|
|
24
|
+
status: z.string().optional().describe("Filter by ticket status(es), comma-separated"),
|
|
25
|
+
priority: z.enum(["HIGH", "MEDIUM", "LOW", "CRITICAL"]).optional().describe("Filter by ticket priority"),
|
|
26
|
+
board: z.string().optional().describe("Filter by board name"),
|
|
27
|
+
tags: z.string().optional().describe("Filter by tags, comma-separated"),
|
|
28
|
+
stage: z.string().optional().describe("Filter by ticket stage"),
|
|
29
|
+
assignee: z.string().optional().describe("Filter by assigned user ID"),
|
|
30
|
+
before: z.string().optional().describe("Created before date (e.g. '15 Mar 26' or ISO format)"),
|
|
31
|
+
after: z.string().optional().describe("Created after date"),
|
|
32
|
+
range: z.string().optional().describe("Time range: today, yesterday, this week, last 7 days, last 30 days"),
|
|
33
|
+
filterOnly: z.boolean().optional().describe("Set true to search with filters only, no query text required"),
|
|
34
|
+
limit: z.number().min(1).max(50).default(10).describe("Max results per group (default 10)"),
|
|
35
|
+
offset: z.number().min(0).default(0).describe("Pagination offset (default 0)"),
|
|
36
|
+
},
|
|
37
|
+
async execute(args, ctx) {
|
|
38
|
+
const token = getToken(ctx)
|
|
39
|
+
const params = new URLSearchParams()
|
|
40
|
+
params.set("q", args.query)
|
|
41
|
+
params.set("limit", String(args.limit))
|
|
42
|
+
if (args.offset > 0) params.set("offset", String(args.offset))
|
|
43
|
+
if (args.apps) params.set("apps", args.apps)
|
|
44
|
+
if (args.type) params.set("type", args.type)
|
|
45
|
+
if (args.from) params.set("from", args.from)
|
|
46
|
+
if (args.in) params.set("in", args.in)
|
|
47
|
+
if (args.status) params.set("status", args.status)
|
|
48
|
+
if (args.priority) params.set("priority", args.priority)
|
|
49
|
+
if (args.board) params.set("board", args.board)
|
|
50
|
+
if (args.tags) params.set("tags", args.tags)
|
|
51
|
+
if (args.stage) params.set("stage", args.stage)
|
|
52
|
+
if (args.assignee) params.set("assignee", args.assignee)
|
|
53
|
+
if (args.before) params.set("before", args.before)
|
|
54
|
+
if (args.after) params.set("after", args.after)
|
|
55
|
+
if (args.range) params.set("range", args.range)
|
|
56
|
+
if (args.filterOnly) params.set("filterOnly", "true")
|
|
57
|
+
|
|
58
|
+
const data = (await spacesFetch(`/search?${params}`, token)) as {
|
|
59
|
+
success: boolean
|
|
60
|
+
data?: {
|
|
61
|
+
grouped: boolean
|
|
62
|
+
groups?: Array<{ groupValue: string; count: number; results: Array<SearchResult> }>
|
|
63
|
+
results?: SearchResult[]
|
|
64
|
+
totalCount?: number
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!data.success || !data.data) return "Search failed."
|
|
69
|
+
|
|
70
|
+
if (data.data.grouped && data.data.groups) {
|
|
71
|
+
const groups = data.data.groups
|
|
72
|
+
if (groups.length === 0) return `No results found for "${args.query}".`
|
|
73
|
+
const parts: string[] = []
|
|
74
|
+
for (const group of groups) {
|
|
75
|
+
parts.push(`--- ${group.groupValue} (${group.count}) ---`)
|
|
76
|
+
for (const r of group.results) parts.push(formatSearchResult(r))
|
|
77
|
+
parts.push("")
|
|
78
|
+
}
|
|
79
|
+
return parts.join("\n")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const results = data.data.results ?? []
|
|
83
|
+
if (results.length === 0) return `No results found for "${args.query}".`
|
|
84
|
+
return `Found ${data.data.totalCount ?? results.length} result(s):\n\n${results.map(formatSearchResult).join("\n\n")}`
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
interface SearchResult {
|
|
89
|
+
id: string; type: string; title: string; subtitle?: string; context?: string
|
|
90
|
+
metadata?: Record<string, unknown>; searchContext?: Record<string, unknown>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatSearchResult(r: SearchResult): string {
|
|
94
|
+
const lines = [`[${r.type}] ${r.title}${r.subtitle ? ` — ${r.subtitle}` : ""}`]
|
|
95
|
+
if (r.context) lines.push(` ${r.context.replace(/<\/?[^>]+>/g, "").slice(0, 300)}`)
|
|
96
|
+
const meta = r.metadata
|
|
97
|
+
if (meta) {
|
|
98
|
+
const p: string[] = []
|
|
99
|
+
if (meta["timestamp"]) p.push(`${meta["timestamp"]}`)
|
|
100
|
+
if (meta["channelName"]) p.push(`#${meta["channelName"]}`)
|
|
101
|
+
if (meta["status"]) p.push(`status: ${meta["status"]}`)
|
|
102
|
+
if (p.length > 0) lines.push(` ${p.join(" · ")}`)
|
|
103
|
+
}
|
|
104
|
+
const sc = r.searchContext
|
|
105
|
+
if (sc) {
|
|
106
|
+
if (sc["senderName"]) lines.push(` From: ${sc["senderName"]}`)
|
|
107
|
+
if (sc["xyneId"]) lines.push(` ID: ${sc["xyneId"]}`)
|
|
108
|
+
}
|
|
109
|
+
return lines.join("\n")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Memory Search ────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const memorySearch = tool({
|
|
115
|
+
description: "Search Spaces memory — facts, SOPs, and knowledge base entries from past sessions.",
|
|
116
|
+
auth: spacesAuth,
|
|
117
|
+
args: {
|
|
118
|
+
query: z.string().optional().describe("Search query (leave empty to list recent)"),
|
|
119
|
+
scope: z.enum(["my", "all"]).default("my").describe("'my' for your items, 'all' for team-wide"),
|
|
120
|
+
limit: z.number().min(1).max(50).default(10).describe("Max results"),
|
|
121
|
+
offset: z.number().min(0).default(0).describe("Pagination offset"),
|
|
122
|
+
docType: z.enum(["fact", "sop"]).optional().describe("Filter by type"),
|
|
123
|
+
tags: z.array(z.string()).optional().describe("Filter by tags"),
|
|
124
|
+
reviewStatus: z.enum(["pending", "verified", "rejected"]).optional().describe("Filter by review status"),
|
|
125
|
+
},
|
|
126
|
+
async execute(args, ctx) {
|
|
127
|
+
const token = getToken(ctx)
|
|
128
|
+
const body: Record<string, unknown> = { scope: args.scope, limit: args.limit, offset: args.offset, includeSummary: true, includeQuery: true }
|
|
129
|
+
if (args.query) body["query"] = args.query
|
|
130
|
+
if (args.docType) body["docType"] = args.docType
|
|
131
|
+
if (args.tags) body["tags"] = args.tags
|
|
132
|
+
if (args.reviewStatus) body["reviewStatus"] = args.reviewStatus
|
|
133
|
+
|
|
134
|
+
const data = (await spacesFetch("/memory/search", token, { method: "POST", body: JSON.stringify(body) })) as {
|
|
135
|
+
success?: boolean; data?: { documents?: Array<{ docId: string; docType: string; userQuery?: string; chatSummary?: string[]; rawContent?: string; tags?: string[]; reviewStatus?: string; createdAt?: number }>; pagination?: { total: number } }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const docs = data.data?.documents ?? []
|
|
139
|
+
if (docs.length === 0) return args.query ? `No memory results for "${args.query}".` : "No memory entries found."
|
|
140
|
+
const parts = docs.map((d) => {
|
|
141
|
+
const lines = [`[${d.docType}] ${d.docId}`]
|
|
142
|
+
if (d.userQuery) lines.push(` Query: ${d.userQuery}`)
|
|
143
|
+
if (d.chatSummary?.length) lines.push(` Summary: ${d.chatSummary.join(" ")}`)
|
|
144
|
+
else if (d.rawContent) lines.push(` ${d.rawContent.slice(0, 300)}${d.rawContent.length > 300 ? "..." : ""}`)
|
|
145
|
+
if (d.tags?.length) lines.push(` Tags: ${d.tags.join(", ")}`)
|
|
146
|
+
if (d.reviewStatus) lines.push(` Review: ${d.reviewStatus}`)
|
|
147
|
+
return lines.join("\n")
|
|
148
|
+
})
|
|
149
|
+
return `Found ${data.data?.pagination?.total ?? docs.length} result(s):\n\n${parts.join("\n\n")}`
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// ── Tickets ──────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
const spacesTickets = tool({
|
|
156
|
+
description:
|
|
157
|
+
"List and filter tickets in Spaces. Filter by status, priority, assignee, board, project, or stage. " +
|
|
158
|
+
"Returns ticket details including assignee, tags, stage, and conversation ID.",
|
|
159
|
+
auth: spacesAuth,
|
|
160
|
+
args: {
|
|
161
|
+
status: z.enum(["TODO", "STARTED", "PAUSED", "CANCELLED", "COMPLETED"]).optional().describe("Filter by status"),
|
|
162
|
+
priority: z.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]).optional().describe("Filter by priority"),
|
|
163
|
+
assignedTo: z.string().optional().describe("Filter by assigned user's ID (use spaces-users to find user IDs)"),
|
|
164
|
+
createdBy: z.string().optional().describe("Filter by ticket creator's user ID"),
|
|
165
|
+
boardId: z.string().optional().describe("Filter by board ID (use spaces-search to find board IDs)"),
|
|
166
|
+
projectId: z.string().optional().describe("Filter by project ID (use spaces-search to find project IDs)"),
|
|
167
|
+
stageName: z.string().optional().describe("Filter by stage name"),
|
|
168
|
+
limit: z.number().min(1).max(50).default(20).describe("Max tickets (default 20)"),
|
|
169
|
+
offset: z.number().min(0).default(0).describe("Pagination offset"),
|
|
170
|
+
},
|
|
171
|
+
async execute(args, ctx) {
|
|
172
|
+
const token = getToken(ctx)
|
|
173
|
+
return queryTickets(token, args)
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// ── Messages ─────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
const spacesMessages = tool({
|
|
180
|
+
description:
|
|
181
|
+
"Read messages in a conversation thread. IMPORTANT: Use the conversationId field from spaces-tickets results (NOT the channel ID or ticket ID). " +
|
|
182
|
+
"Messages are returned in chronological order.",
|
|
183
|
+
auth: spacesAuth,
|
|
184
|
+
args: {
|
|
185
|
+
conversationId: z.string().describe("The conversationId field from spaces-tickets or spaces-activity results. This is NOT a channelId, ticketId, or messageId."),
|
|
186
|
+
limit: z.number().min(1).max(100).default(30).describe("Max messages (default 30)"),
|
|
187
|
+
offset: z.number().min(0).default(0).describe("Pagination offset"),
|
|
188
|
+
},
|
|
189
|
+
async execute(args, ctx) {
|
|
190
|
+
const token = getToken(ctx)
|
|
191
|
+
return queryMessages(token, args.conversationId, args.limit, args.offset)
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// ── Message Detail ───────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
const spacesMessageDetail = tool({
|
|
198
|
+
description:
|
|
199
|
+
"Get detailed information about a specific message including full content, sender details, " +
|
|
200
|
+
"reactions (with counts), and attachments. Use messageId from spaces-messages or spaces-activity results.",
|
|
201
|
+
auth: spacesAuth,
|
|
202
|
+
args: {
|
|
203
|
+
messageId: z.string().describe("The messageId field from spaces-messages or spaces-activity results. This is NOT a conversationId, channelId, or ticketId."),
|
|
204
|
+
},
|
|
205
|
+
async execute(args, ctx) {
|
|
206
|
+
const token = getToken(ctx)
|
|
207
|
+
return queryMessageDetail(token, args.messageId)
|
|
208
|
+
},
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// ── Channels ─────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
const spacesChannels = tool({
|
|
214
|
+
description:
|
|
215
|
+
"List channels in Spaces. Can filter by visibility (PUBLIC/PRIVATE) and scope type (DEFAULT/DM/TICKET/GROUP_DM). " +
|
|
216
|
+
"Returns channel name, participant count, project, and last activity.",
|
|
217
|
+
auth: spacesAuth,
|
|
218
|
+
args: {
|
|
219
|
+
visibility: z.enum(["PUBLIC", "PRIVATE"]).optional().describe("Filter by visibility"),
|
|
220
|
+
scopeType: z.enum(["DEFAULT", "DM", "TICKET", "DOCUMENT", "GROUP_DM"]).optional().describe("Filter by scope type"),
|
|
221
|
+
limit: z.number().min(1).max(50).default(20).describe("Max channels (default 20)"),
|
|
222
|
+
},
|
|
223
|
+
async execute(args, ctx) {
|
|
224
|
+
const token = getToken(ctx)
|
|
225
|
+
return queryChannels(token, args.limit, args.visibility, args.scopeType)
|
|
226
|
+
},
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// ── Users ────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
const spacesUsers = tool({
|
|
232
|
+
description: "Look up users by name or email. Returns user ID, name, email, and type.",
|
|
233
|
+
auth: spacesAuth,
|
|
234
|
+
args: {
|
|
235
|
+
nameOrEmail: z.string().describe("Person's name to search by name, or email address (with @ or .) to search by email"),
|
|
236
|
+
limit: z.number().min(1).max(20).default(10).describe("Max results (default 10)"),
|
|
237
|
+
},
|
|
238
|
+
async execute(args, ctx) {
|
|
239
|
+
const token = getToken(ctx)
|
|
240
|
+
return queryUsers(token, args.nameOrEmail, args.limit)
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// ── User Activity ────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
const spacesUserActivity = tool({
|
|
247
|
+
description:
|
|
248
|
+
"Get your activity feed — mentions, replies, assignments, and notifications. " +
|
|
249
|
+
"Returns messageId, conversationId, ticketId for each activity. " +
|
|
250
|
+
"Use conversationId with spaces-messages to read the full thread, or messageId with spaces-message-detail.",
|
|
251
|
+
auth: spacesAuth,
|
|
252
|
+
args: {
|
|
253
|
+
classification: z.string().optional().describe("Filter by classification (e.g. 'PENDING')"),
|
|
254
|
+
unreadOnly: z.boolean().optional().describe("Show only unread activity"),
|
|
255
|
+
limit: z.number().min(1).max(50).default(20).describe("Max entries (default 20)"),
|
|
256
|
+
},
|
|
257
|
+
async execute(args, ctx) {
|
|
258
|
+
const token = getToken(ctx)
|
|
259
|
+
return queryUserActivity(token, args.classification, args.unreadOnly === true ? false : undefined, args.limit)
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// ── Web Fetch ────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
const spacesWebFetch = tool({
|
|
266
|
+
description:
|
|
267
|
+
"Fetch an external URL and return its content as markdown or text. " +
|
|
268
|
+
"Only use for URLs outside Xyne Spaces (e.g. GitHub PRs, docs, external links from messages). " +
|
|
269
|
+
"Do NOT use for Xyne Spaces internal URLs — use the other spaces tools instead.",
|
|
270
|
+
auth: spacesAuth,
|
|
271
|
+
args: {
|
|
272
|
+
url: z.string().describe("The URL to fetch"),
|
|
273
|
+
format: z.enum(["text", "markdown", "html"]).default("markdown").describe("Output format (default: markdown)"),
|
|
274
|
+
},
|
|
275
|
+
async execute(args) {
|
|
276
|
+
if (!args.url.startsWith("http://") && !args.url.startsWith("https://")) {
|
|
277
|
+
throw new Error("URL must start with http:// or https://")
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const controller = new AbortController()
|
|
281
|
+
const timer = setTimeout(() => controller.abort(), 30_000)
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const response = await fetch(args.url, {
|
|
285
|
+
signal: controller.signal,
|
|
286
|
+
headers: {
|
|
287
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
|
|
288
|
+
"Accept": "text/html;q=1.0, text/plain;q=0.8, */*;q=0.1",
|
|
289
|
+
},
|
|
290
|
+
redirect: "follow",
|
|
291
|
+
})
|
|
292
|
+
clearTimeout(timer)
|
|
293
|
+
|
|
294
|
+
if (!response.ok) throw new Error(`Fetch failed: ${response.status} ${response.statusText}`)
|
|
295
|
+
|
|
296
|
+
const contentType = response.headers.get("content-type") ?? ""
|
|
297
|
+
let text = await response.text()
|
|
298
|
+
|
|
299
|
+
if (contentType.includes("html") && args.format !== "html") {
|
|
300
|
+
// Strip scripts, styles, and tags
|
|
301
|
+
text = text
|
|
302
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
303
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
304
|
+
.replace(/<noscript[\s\S]*?<\/noscript>/gi, "")
|
|
305
|
+
.replace(/<[^>]+>/g, " ")
|
|
306
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
307
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, " ")
|
|
308
|
+
.replace(/\s+/g, " ")
|
|
309
|
+
.trim()
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (text.length > 15000) text = text.slice(0, 15000) + "\n\n... (truncated)"
|
|
313
|
+
return text
|
|
314
|
+
} finally {
|
|
315
|
+
clearTimeout(timer)
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// ── Plugin ───────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
const spacesTools = {
|
|
323
|
+
"spaces-search": spacesSearch,
|
|
324
|
+
"spaces-memory-search": memorySearch,
|
|
325
|
+
"spaces-tickets": spacesTickets,
|
|
326
|
+
"spaces-messages": spacesMessages,
|
|
327
|
+
"spaces-message-detail": spacesMessageDetail,
|
|
328
|
+
"spaces-channels": spacesChannels,
|
|
329
|
+
"spaces-users": spacesUsers,
|
|
330
|
+
"spaces-activity": spacesUserActivity,
|
|
331
|
+
"webfetch": spacesWebFetch,
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const plugin: Plugin = async (_input) => ({
|
|
335
|
+
agent: {
|
|
336
|
+
spaces: {
|
|
337
|
+
description:
|
|
338
|
+
"Use this agent for any query about Xyne Spaces — tickets, messages, conversations, users, channels, or activity. " +
|
|
339
|
+
"Collects information and context from the Xyne Spaces app.",
|
|
340
|
+
mode: "all",
|
|
341
|
+
tools: spacesTools,
|
|
342
|
+
isolatedTools: true,
|
|
343
|
+
system: [
|
|
344
|
+
"You are a Spaces assistant with access to the user's Spaces workspace.",
|
|
345
|
+
"",
|
|
346
|
+
"## Capabilities",
|
|
347
|
+
"- **Search**: Full-text search across messages, tickets, files, channels, users",
|
|
348
|
+
"- **Memory**: Search facts, SOPs, and knowledge from past sessions",
|
|
349
|
+
"- **Tickets**: List/filter tickets by status, priority, assignee, board, stage",
|
|
350
|
+
"- **Messages**: Read conversation threads linked to tickets or channels",
|
|
351
|
+
"- **Message Detail**: Get full message with reactions and attachments",
|
|
352
|
+
"- **Channels**: Browse channels by visibility and scope",
|
|
353
|
+
"- **Users**: Look up users by name or email",
|
|
354
|
+
"- **Activity**: View user activity feed (mentions, replies, assignments)",
|
|
355
|
+
"",
|
|
356
|
+
"## Guidelines",
|
|
357
|
+
"- Use spaces-search for broad queries across all connected apps",
|
|
358
|
+
"- Use spaces-tickets for structured ticket queries with specific filters",
|
|
359
|
+
"- To read a ticket's discussion: first use spaces-tickets to get the conversationId, then use spaces-messages with that conversationId (NOT the channelId or ticket ID)",
|
|
360
|
+
"- Use spaces-users to resolve user names to IDs before filtering tickets",
|
|
361
|
+
"- Priority values: LOW, MEDIUM, HIGH, CRITICAL",
|
|
362
|
+
"- Status values: TODO, STARTED, PAUSED, CANCELLED, COMPLETED",
|
|
363
|
+
"- Date range shortcuts for search: today, yesterday, this week, last 7 days, last 30 days",
|
|
364
|
+
"- Be concise — summarize results, don't dump raw data",
|
|
365
|
+
].join("\n"),
|
|
366
|
+
color: "#ef4444",
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
export default plugin
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spaces query helpers — build Prisma ASTs and call /interact endpoint.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { spacesFetch } from "./auth"
|
|
6
|
+
|
|
7
|
+
interface QueryAST {
|
|
8
|
+
model: string
|
|
9
|
+
operation: "findMany" | "count"
|
|
10
|
+
where?: Record<string, unknown>
|
|
11
|
+
orderBy?: Record<string, string> | Array<Record<string, string>>
|
|
12
|
+
take?: number
|
|
13
|
+
skip?: number
|
|
14
|
+
include?: Record<string, unknown>
|
|
15
|
+
select?: Record<string, unknown>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function query(token: string, ast: QueryAST): Promise<unknown> {
|
|
19
|
+
const result = (await spacesFetch("/interact", token, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
body: JSON.stringify(ast),
|
|
22
|
+
})) as { data: unknown }
|
|
23
|
+
return result.data
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Tickets ──────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
interface TicketFilters {
|
|
29
|
+
status?: string | undefined
|
|
30
|
+
priority?: string | undefined
|
|
31
|
+
assignedTo?: string | undefined
|
|
32
|
+
createdBy?: string | undefined
|
|
33
|
+
boardId?: string | undefined
|
|
34
|
+
projectId?: string | undefined
|
|
35
|
+
stageName?: string | undefined
|
|
36
|
+
limit?: number | undefined
|
|
37
|
+
offset?: number | undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface TicketRow {
|
|
41
|
+
id: string
|
|
42
|
+
title: string
|
|
43
|
+
xyneId: string
|
|
44
|
+
statusV2: string
|
|
45
|
+
priority: string
|
|
46
|
+
stageName?: string
|
|
47
|
+
eta?: string
|
|
48
|
+
createdAt: string
|
|
49
|
+
updatedAt: string
|
|
50
|
+
conversationId?: string
|
|
51
|
+
assignedToUser?: { name: string } | null
|
|
52
|
+
createdByUser?: { name: string } | null
|
|
53
|
+
board?: { name: string } | null
|
|
54
|
+
project?: { name: string } | null
|
|
55
|
+
tags?: Array<{ name: string }>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function queryTickets(token: string, filters: TicketFilters): Promise<string> {
|
|
59
|
+
const where: Record<string, unknown> = {}
|
|
60
|
+
if (filters.status) where["statusV2"] = { equals: filters.status }
|
|
61
|
+
if (filters.priority) where["priority"] = { equals: filters.priority }
|
|
62
|
+
if (filters.assignedTo) where["assignedTo"] = { equals: filters.assignedTo }
|
|
63
|
+
if (filters.createdBy) where["createdBy"] = { equals: filters.createdBy }
|
|
64
|
+
if (filters.boardId) where["boardId"] = { equals: filters.boardId }
|
|
65
|
+
if (filters.projectId) where["projectId"] = { equals: filters.projectId }
|
|
66
|
+
if (filters.stageName) where["stageName"] = { equals: filters.stageName }
|
|
67
|
+
|
|
68
|
+
const rows = (await query(token, {
|
|
69
|
+
model: "ticket",
|
|
70
|
+
operation: "findMany",
|
|
71
|
+
where,
|
|
72
|
+
orderBy: [{ updatedAt: "desc" }],
|
|
73
|
+
take: filters.limit ?? 20,
|
|
74
|
+
skip: filters.offset ?? 0,
|
|
75
|
+
include: {
|
|
76
|
+
assignedToUser: { select: { name: true } },
|
|
77
|
+
createdByUser: { select: { name: true } },
|
|
78
|
+
board: { select: { name: true } },
|
|
79
|
+
project: { select: { name: true } },
|
|
80
|
+
tags: { select: { name: true } },
|
|
81
|
+
},
|
|
82
|
+
})) as TicketRow[]
|
|
83
|
+
|
|
84
|
+
if (rows.length === 0) return "No tickets found."
|
|
85
|
+
|
|
86
|
+
const lines = rows.map((t) => {
|
|
87
|
+
const parts = [`[${t.xyneId}] ${t.title}`]
|
|
88
|
+
parts.push(` Status: ${t.statusV2} · Priority: ${t.priority}${t.stageName ? ` · Stage: ${t.stageName}` : ""}`)
|
|
89
|
+
if (t.assignedToUser) parts.push(` Assigned: ${t.assignedToUser.name}`)
|
|
90
|
+
if (t.createdByUser) parts.push(` Created by: ${t.createdByUser.name}`)
|
|
91
|
+
if (t.board) parts.push(` Board: ${t.board.name}${t.project ? ` · Project: ${t.project.name}` : ""}`)
|
|
92
|
+
if (t.tags && t.tags.length > 0) parts.push(` Tags: ${t.tags.map((tg) => tg.name).join(", ")}`)
|
|
93
|
+
if (t.eta) parts.push(` ETA: ${new Date(t.eta).toLocaleDateString()}`)
|
|
94
|
+
if (t.conversationId) parts.push(` ConversationID: ${t.conversationId}`)
|
|
95
|
+
parts.push(` Updated: ${new Date(t.updatedAt).toLocaleString()}`)
|
|
96
|
+
return parts.join("\n")
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
return `${rows.length} ticket(s):\n\n${lines.join("\n\n")}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Messages ─────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
interface MessageRow {
|
|
105
|
+
messageId: string
|
|
106
|
+
content: string
|
|
107
|
+
msgType: string
|
|
108
|
+
createdAt: string
|
|
109
|
+
hasAttachment: boolean
|
|
110
|
+
sender?: { name: string } | null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function queryMessages(token: string, conversationId: string, limit: number, offset: number): Promise<string> {
|
|
114
|
+
const rows = (await query(token, {
|
|
115
|
+
model: "message",
|
|
116
|
+
operation: "findMany",
|
|
117
|
+
where: {
|
|
118
|
+
conversationId: { equals: conversationId },
|
|
119
|
+
isDeleted: { equals: false },
|
|
120
|
+
},
|
|
121
|
+
orderBy: [{ createdAt: "asc" }],
|
|
122
|
+
take: limit,
|
|
123
|
+
skip: offset,
|
|
124
|
+
include: {
|
|
125
|
+
sender: { select: { name: true } },
|
|
126
|
+
},
|
|
127
|
+
})) as MessageRow[]
|
|
128
|
+
|
|
129
|
+
if (rows.length === 0) return `No messages found in conversation ${conversationId}.`
|
|
130
|
+
|
|
131
|
+
const lines = rows.map((m) => {
|
|
132
|
+
const sender = m.sender?.name ?? "unknown"
|
|
133
|
+
const time = new Date(m.createdAt).toLocaleString()
|
|
134
|
+
const attach = m.hasAttachment ? " 📎" : ""
|
|
135
|
+
return `[${time}] ${sender}${attach}: ${m.content}`
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
return `${rows.length} message(s):\n\n${lines.join("\n")}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Message Detail ───────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
interface MessageDetailRow {
|
|
144
|
+
messageId: string
|
|
145
|
+
content: string
|
|
146
|
+
msgType: string
|
|
147
|
+
createdAt: string
|
|
148
|
+
edited: boolean
|
|
149
|
+
hasAttachment: boolean
|
|
150
|
+
sender?: { name: string; email: string } | null
|
|
151
|
+
reactions?: Array<{ emojiName: string; userId: string }>
|
|
152
|
+
reactionCounts?: Array<{ emojiName: string; count: number }>
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface AttachmentRow {
|
|
156
|
+
id: string
|
|
157
|
+
originalFilename: string
|
|
158
|
+
mimetype: string
|
|
159
|
+
size: number
|
|
160
|
+
url: string
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function queryMessageDetail(token: string, messageId: string): Promise<string> {
|
|
164
|
+
const rows = (await query(token, {
|
|
165
|
+
model: "message",
|
|
166
|
+
operation: "findMany",
|
|
167
|
+
where: { messageId: { equals: messageId } },
|
|
168
|
+
take: 1,
|
|
169
|
+
include: {
|
|
170
|
+
sender: { select: { name: true, email: true } },
|
|
171
|
+
reactions: { select: { emojiName: true, userId: true } },
|
|
172
|
+
reactionCounts: { select: { emojiName: true, count: true } },
|
|
173
|
+
},
|
|
174
|
+
})) as MessageDetailRow[]
|
|
175
|
+
|
|
176
|
+
if (rows.length === 0) return `Message ${messageId} not found.`
|
|
177
|
+
const m = rows[0]!
|
|
178
|
+
|
|
179
|
+
const parts = [
|
|
180
|
+
`Message: ${m.messageId}`,
|
|
181
|
+
`From: ${m.sender?.name ?? "unknown"} (${m.sender?.email ?? ""})`,
|
|
182
|
+
`Type: ${m.msgType}${m.edited ? " (edited)" : ""}`,
|
|
183
|
+
`Date: ${new Date(m.createdAt).toLocaleString()}`,
|
|
184
|
+
`\n${m.content}`,
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
// Reactions
|
|
188
|
+
if (m.reactionCounts && m.reactionCounts.length > 0) {
|
|
189
|
+
const rxns = m.reactionCounts.map((r) => `${r.emojiName} ×${r.count}`).join(" ")
|
|
190
|
+
parts.push(`\nReactions: ${rxns}`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Attachments
|
|
194
|
+
if (m.hasAttachment) {
|
|
195
|
+
const attachments = (await query(token, {
|
|
196
|
+
model: "messageAttachment",
|
|
197
|
+
operation: "findMany",
|
|
198
|
+
where: { entityId: { equals: messageId } },
|
|
199
|
+
take: 20,
|
|
200
|
+
})) as AttachmentRow[]
|
|
201
|
+
|
|
202
|
+
if (attachments.length > 0) {
|
|
203
|
+
parts.push(`\nAttachments (${attachments.length}):`)
|
|
204
|
+
for (const a of attachments) {
|
|
205
|
+
const size = a.size < 1024 ? `${a.size} B` : a.size < 1024 * 1024 ? `${(a.size / 1024).toFixed(1)} KB` : `${(a.size / (1024 * 1024)).toFixed(1)} MB`
|
|
206
|
+
parts.push(` - ${a.originalFilename} (${a.mimetype}, ${size})`)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return parts.join("\n")
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Channels ─────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
interface ChannelRow {
|
|
217
|
+
id: string
|
|
218
|
+
name: string
|
|
219
|
+
description?: string
|
|
220
|
+
type: string
|
|
221
|
+
scopeType: string
|
|
222
|
+
visibility: string
|
|
223
|
+
participantCount: number
|
|
224
|
+
lastActivityAt?: string
|
|
225
|
+
project?: { name: string } | null
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function queryChannels(token: string, limit: number, visibility?: string, scopeType?: string): Promise<string> {
|
|
229
|
+
const where: Record<string, unknown> = {}
|
|
230
|
+
if (visibility) where["visibility"] = { equals: visibility }
|
|
231
|
+
if (scopeType) where["scopeType"] = { equals: scopeType }
|
|
232
|
+
|
|
233
|
+
const rows = (await query(token, {
|
|
234
|
+
model: "channel",
|
|
235
|
+
operation: "findMany",
|
|
236
|
+
where,
|
|
237
|
+
orderBy: [{ lastActivityAt: "desc" }],
|
|
238
|
+
take: limit,
|
|
239
|
+
include: {
|
|
240
|
+
project: { select: { name: true } },
|
|
241
|
+
},
|
|
242
|
+
})) as ChannelRow[]
|
|
243
|
+
|
|
244
|
+
if (rows.length === 0) return "No channels found."
|
|
245
|
+
|
|
246
|
+
const lines = rows.map((c) => {
|
|
247
|
+
const parts = [`#${c.name} (${c.scopeType}, ${c.visibility})`]
|
|
248
|
+
if (c.description) parts.push(` ${c.description}`)
|
|
249
|
+
parts.push(` Participants: ${c.participantCount}${c.project ? ` · Project: ${c.project.name}` : ""}`)
|
|
250
|
+
if (c.lastActivityAt) parts.push(` Last active: ${new Date(c.lastActivityAt).toLocaleString()}`)
|
|
251
|
+
parts.push(` ID: ${c.id}`)
|
|
252
|
+
return parts.join("\n")
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
return `${rows.length} channel(s):\n\n${lines.join("\n\n")}`
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Users ────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
interface UserRow {
|
|
261
|
+
id: string
|
|
262
|
+
name: string
|
|
263
|
+
email: string
|
|
264
|
+
status: string
|
|
265
|
+
userType: string
|
|
266
|
+
picture?: string
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function queryUsers(token: string, nameOrEmail: string, limit: number): Promise<string> {
|
|
270
|
+
// OR not supported by query validator — search by name or email based on input
|
|
271
|
+
const isEmail = nameOrEmail.includes("@") || nameOrEmail.includes(".")
|
|
272
|
+
const where = isEmail
|
|
273
|
+
? { email: { contains: nameOrEmail }, status: { equals: "ACTIVE" } }
|
|
274
|
+
: { name: { contains: nameOrEmail }, status: { equals: "ACTIVE" } }
|
|
275
|
+
|
|
276
|
+
const rows = (await query(token, {
|
|
277
|
+
model: "user",
|
|
278
|
+
operation: "findMany",
|
|
279
|
+
where,
|
|
280
|
+
take: limit,
|
|
281
|
+
})) as UserRow[]
|
|
282
|
+
|
|
283
|
+
if (rows.length === 0) return `No users found matching "${nameOrEmail}".`
|
|
284
|
+
|
|
285
|
+
const lines = rows.map((u) => `${u.name} (${u.email}) — ${u.userType}\n ID: ${u.id}`)
|
|
286
|
+
|
|
287
|
+
return `${rows.length} user(s):\n\n${lines.join("\n\n")}`
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── User Activity ────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
interface UserActivityRow {
|
|
293
|
+
id: string
|
|
294
|
+
actorAction: string
|
|
295
|
+
classification?: string
|
|
296
|
+
isRead: boolean
|
|
297
|
+
createdAt: string
|
|
298
|
+
channelId?: string
|
|
299
|
+
ticketId?: string
|
|
300
|
+
conversationId?: string
|
|
301
|
+
messageId?: string
|
|
302
|
+
actorId: string
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function queryUserActivity(
|
|
306
|
+
token: string,
|
|
307
|
+
classification?: string,
|
|
308
|
+
isRead?: boolean,
|
|
309
|
+
limit?: number,
|
|
310
|
+
): Promise<string> {
|
|
311
|
+
const where: Record<string, unknown> = {}
|
|
312
|
+
if (classification) where["classification"] = { equals: classification }
|
|
313
|
+
if (isRead !== undefined) where["isRead"] = { equals: isRead }
|
|
314
|
+
|
|
315
|
+
const rows = (await query(token, {
|
|
316
|
+
model: "activity",
|
|
317
|
+
operation: "findMany",
|
|
318
|
+
where,
|
|
319
|
+
orderBy: [{ createdAt: "desc" }],
|
|
320
|
+
take: limit ?? 20,
|
|
321
|
+
})) as UserActivityRow[]
|
|
322
|
+
|
|
323
|
+
if (rows.length === 0) return "No activity found."
|
|
324
|
+
|
|
325
|
+
const lines = rows.map((a) => {
|
|
326
|
+
const when = new Date(a.createdAt).toLocaleString()
|
|
327
|
+
const read = a.isRead ? "" : " (unread)"
|
|
328
|
+
const refs: string[] = []
|
|
329
|
+
if (a.messageId) refs.push(`messageId: ${a.messageId}`)
|
|
330
|
+
if (a.conversationId) refs.push(`conversationId: ${a.conversationId}`)
|
|
331
|
+
if (a.ticketId) refs.push(`ticketId: ${a.ticketId}`)
|
|
332
|
+
if (a.channelId) refs.push(`channelId: ${a.channelId}`)
|
|
333
|
+
const refStr = refs.length > 0 ? `\n ${refs.join(" · ")}` : ""
|
|
334
|
+
return `[${when}] ${a.actorAction}${read}${a.classification ? ` · ${a.classification}` : ""}${refStr}`
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
return `${rows.length} activity entries:\n\n${lines.join("\n")}`
|
|
338
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"extends": "@tsconfig/bun/tsconfig.json",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
6
|
+
"noUnusedLocals": true,
|
|
7
|
+
"noUnusedParameters": true,
|
|
8
|
+
"exactOptionalPropertyTypes": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"noPropertyAccessFromIndexSignature": true,
|
|
11
|
+
"noImplicitReturns": true,
|
|
12
|
+
"noFallthroughCasesInSwitch": true,
|
|
13
|
+
"noImplicitOverride": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*"],
|
|
16
|
+
"exclude": ["node_modules"]
|
|
17
|
+
}
|