@modesto_ma/dsh-channels 0.0.0-stage → 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/README.md +302 -2
- package/cordis.patch.yml +3 -0
- package/docs/access-control-design.md +550 -0
- package/docs/assets/web-settings-panel.png +0 -0
- package/docs/assets/web-settings-restart-pending.png +0 -0
- package/docs/feasibility-and-design.md +213 -0
- package/docs/feishu-onboarding.md +268 -0
- package/docs/persistence.md +199 -0
- package/docs/refactor-plan.md +56 -0
- package/docs/review-dsh-0.2.0.md +389 -0
- package/docs/slash-commands-implementation-plan.md +410 -0
- package/docs/web-display-only-surface-roadmap.md +363 -0
- package/lib/client.js +2226 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +10217 -0
- package/lib/index.js.map +1 -0
- package/package.json +98 -4
- package/src/access/cards.ts +188 -0
- package/src/access/controller.ts +201 -0
- package/src/access/index.ts +13 -0
- package/src/access/policy.ts +103 -0
- package/src/access/requests.ts +512 -0
- package/src/access/store.ts +220 -0
- package/src/approvals.ts +8 -0
- package/src/cards/approval.ts +114 -0
- package/src/cards/index.ts +11 -0
- package/src/cards/primitives.ts +82 -0
- package/src/cards/question.ts +141 -0
- package/src/cards/streaming.ts +563 -0
- package/src/cards.ts +8 -0
- package/src/cleanup.ts +398 -0
- package/src/client/api.ts +262 -0
- package/src/client/catalog.ts +92 -0
- package/src/client/index.ts +61 -0
- package/src/client/panel.tsx +1475 -0
- package/src/client/styles.ts +98 -0
- package/src/commands/registry.ts +155 -0
- package/src/commands.ts +335 -0
- package/src/config.ts +206 -0
- package/src/domain.ts +428 -0
- package/src/dsh-types.ts +11 -0
- package/src/feishu/agent-context.ts +62 -0
- package/src/feishu/delivery.ts +188 -0
- package/src/feishu/inbound-attachments.ts +436 -0
- package/src/feishu-channel.ts +339 -0
- package/src/health.ts +233 -0
- package/src/host/agent.ts +181 -0
- package/src/host/commands.ts +63 -0
- package/src/host/core.ts +345 -0
- package/src/host/index.ts +12 -0
- package/src/host/interaction.ts +101 -0
- package/src/host/services.ts +320 -0
- package/src/index.ts +122 -0
- package/src/interactions/approvals.ts +154 -0
- package/src/interactions/pending.ts +162 -0
- package/src/interactions/questions.ts +274 -0
- package/src/questions.ts +8 -0
- package/src/routing/inbound.ts +524 -0
- package/src/service.ts +2138 -0
- package/src/session/manager.ts +352 -0
- package/src/session/repair.ts +157 -0
- package/src/session/request-policy.ts +192 -0
- package/src/session/routes.ts +165 -0
- package/src/session-commands.ts +310 -0
- package/src/session-export.ts +112 -0
- package/src/settings/catalog.ts +309 -0
- package/src/settings/contract.ts +508 -0
- package/src/settings/overlay.ts +164 -0
- package/src/settings/routes.ts +409 -0
- package/src/settings/surface.ts +773 -0
- package/src/stats.ts +485 -0
- package/src/streamer.ts +1108 -0
- package/src/workspaces.ts +199 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration: schema, defaults, and credential-reference validation.
|
|
3
|
+
*
|
|
4
|
+
* `appId` / `appSecret` are NOT secrets themselves — they are credential
|
|
5
|
+
* *references* (POSIX shell identifiers such as `FEISHU_APP_ID`), resolved at
|
|
6
|
+
* startup through the host's `ctx.credentials` seam, which layers
|
|
7
|
+
* `$DSH_HOME/.credentials.yaml` (the managed, writable store) between the
|
|
8
|
+
* process environment and `.env` fallbacks — the same seam the DeepSeek
|
|
9
|
+
* adapter uses for `DEEPSEEK_API_KEY`. Secrets therefore never sit in the
|
|
10
|
+
* composition file.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-channels/config
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { z } from 'zod'
|
|
16
|
+
import { WORKSPACE_RETENTION_VALUES } from './cleanup.ts'
|
|
17
|
+
|
|
18
|
+
export const FEISHU_LOGGER_LEVELS = ['debug', 'info', 'warn', 'error'] as const
|
|
19
|
+
export type FeishuLoggerLevel = (typeof FEISHU_LOGGER_LEVELS)[number]
|
|
20
|
+
|
|
21
|
+
/** Same shape as the host credentials seam's `CredentialRef` (shell identifier). */
|
|
22
|
+
export const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
23
|
+
|
|
24
|
+
/** Default route-table GC threshold: 30 days without activity. */
|
|
25
|
+
export const DEFAULT_ROUTE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
26
|
+
|
|
27
|
+
/** Default HITL approval decision window: 5 minutes per question. */
|
|
28
|
+
export const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60 * 1000
|
|
29
|
+
|
|
30
|
+
/** Default structured-question window: 5 minutes per `ask_user_question` call. */
|
|
31
|
+
export const DEFAULT_QUESTION_TIMEOUT_MS = 5 * 60 * 1000
|
|
32
|
+
|
|
33
|
+
/** A credential reference: `FEISHU_APP_ID`-style name resolved through ctx.credentials. */
|
|
34
|
+
const credentialRefSchema = z.string().regex(
|
|
35
|
+
CREDENTIAL_REF_PATTERN,
|
|
36
|
+
'must be a credential reference name such as FEISHU_APP_ID (resolved through the dsh credentials seam, e.g. $DSH_HOME/.credentials.yaml) — plaintext secrets are not accepted',
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
export const configSchema = z.object({
|
|
40
|
+
/** Feishu app id credential reference (e.g. `FEISHU_APP_ID`). */
|
|
41
|
+
appId: credentialRefSchema,
|
|
42
|
+
/** Feishu app secret credential reference (e.g. `FEISHU_APP_SECRET`). */
|
|
43
|
+
appSecret: credentialRefSchema,
|
|
44
|
+
/**
|
|
45
|
+
* Root directory for per-(user, chat) workspace directories — the filesystem
|
|
46
|
+
* root where files produced by channel sessions land. Read per session
|
|
47
|
+
* establishment, so editing it only affects sessions created afterwards
|
|
48
|
+
* (existing workspaces are never migrated). The Web settings panel may
|
|
49
|
+
* override it, but only with an absolute path (`~` expanded) — the relative
|
|
50
|
+
* default below is kept for profile-level compatibility and resolves
|
|
51
|
+
* against the host process cwd.
|
|
52
|
+
*/
|
|
53
|
+
baseDir: z.string().min(1).default('./channels-data'),
|
|
54
|
+
/**
|
|
55
|
+
* Optional path of the Web settings overlay (default
|
|
56
|
+
* `$DSH_HOME/dsh-channels/settings.json`). The Web panel writes only
|
|
57
|
+
* editable fields there; this path is for embedded hosts, tests, or users
|
|
58
|
+
* who want multiple instances with different overlays.
|
|
59
|
+
*/
|
|
60
|
+
settingsFile: z.string().min(1).optional(),
|
|
61
|
+
/** LLM provider route for sessions this plugin creates (optional → deployment default). */
|
|
62
|
+
provider: z.string().optional(),
|
|
63
|
+
/** Model id for sessions this plugin creates (optional → deployment default). */
|
|
64
|
+
model: z.string().optional(),
|
|
65
|
+
/** Exact model-advertised reasoning-effort id (optional → adapter/provider default). */
|
|
66
|
+
reasoningEffort: z.string().min(1).optional(),
|
|
67
|
+
/** Output-token cap per model request (optional → adapter default). */
|
|
68
|
+
maxTokens: z.number().int().positive().optional(),
|
|
69
|
+
/**
|
|
70
|
+
* Agent preset id sessions this plugin creates are composed from
|
|
71
|
+
* (optional → the roster default, e.g. `standard`). In web-profile
|
|
72
|
+
* deployments the model-facing tools exist only inside agent presets, so
|
|
73
|
+
* leaving this unset mounts the default; name a leaner preset (e.g. one
|
|
74
|
+
* with only bash/fs) to restrict what the bot can do.
|
|
75
|
+
*/
|
|
76
|
+
agentPreset: z.string().optional(),
|
|
77
|
+
/** Streaming card refresh interval in milliseconds. */
|
|
78
|
+
refreshMs: z.number().int().min(200).max(10_000).default(500),
|
|
79
|
+
/**
|
|
80
|
+
* Keep-alive interval for the card's activity status line (tool calls,
|
|
81
|
+
* subagent / workflow progress). While a turn produces no text deltas, the
|
|
82
|
+
* line's elapsed counter is re-rendered at this cadence so the card never
|
|
83
|
+
* sits silently. Min 1 s keeps Feishu card-update traffic modest.
|
|
84
|
+
*/
|
|
85
|
+
statusRefreshMs: z.number().int().min(1_000).max(60_000).default(5_000),
|
|
86
|
+
/** Accept p2p (private) chats. */
|
|
87
|
+
dmMode: z.enum(['open', 'disabled']).default('open'),
|
|
88
|
+
/**
|
|
89
|
+
* In normal (non-topic) groups, require an @-mention of the bot to start a
|
|
90
|
+
* session. Messages inside a topic the bot already owns never require a
|
|
91
|
+
* mention. Disable to answer every group message.
|
|
92
|
+
*/
|
|
93
|
+
requireMention: z.boolean().default(true),
|
|
94
|
+
/** SDK logger level. */
|
|
95
|
+
loggerLevel: z.enum(FEISHU_LOGGER_LEVELS).default('info'),
|
|
96
|
+
/**
|
|
97
|
+
* Reply-in-thread fallbacks. `degrade` (default) falls back to a chat-scoped
|
|
98
|
+
* conversation when Feishu refuses a topic (p2p / topic-less groups);
|
|
99
|
+
* `strict` fails the reply instead.
|
|
100
|
+
*/
|
|
101
|
+
threadFallback: z.enum(['degrade', 'strict']).default('degrade'),
|
|
102
|
+
/**
|
|
103
|
+
* Route-table GC threshold. Route records (and stale in-flight repair
|
|
104
|
+
* snapshots) whose last activity is older than this many milliseconds are
|
|
105
|
+
* retired at startup and on every periodic sweep. Real thread routes leave a
|
|
106
|
+
* minimal persistent tombstone, so a later message in the visibly old topic
|
|
107
|
+
* is rejected instead of silently creating a context-free Session. Degraded
|
|
108
|
+
* chat-scoped routes are deleted normally. The DSH session and workspace
|
|
109
|
+
* stay untouched. `0` disables route retirement entirely.
|
|
110
|
+
*/
|
|
111
|
+
routeTtlMs: z.number().int().min(0).default(DEFAULT_ROUTE_TTL_MS),
|
|
112
|
+
/**
|
|
113
|
+
* HITL approval decision window: how long one approval decision card waits
|
|
114
|
+
* for an 允许/拒绝 click before failing closed (`rejected`; the card is
|
|
115
|
+
* patched to 已超时). The pending turn is blocked while the question is
|
|
116
|
+
* open, so the window trades user think-time against agent stall time.
|
|
117
|
+
*/
|
|
118
|
+
approvalTimeoutMs: z.number().int().min(10_000).max(3_600_000).default(DEFAULT_APPROVAL_TIMEOUT_MS),
|
|
119
|
+
/**
|
|
120
|
+
* Accept inbound Feishu images, files, and merged-forward chat records and
|
|
121
|
+
* save them below `<workspace>/attachments/`. Nothing is forwarded to the
|
|
122
|
+
* model automatically: the Agent reads whichever attachment the
|
|
123
|
+
* conversation needs with its ordinary filesystem tools. Disabled keeps
|
|
124
|
+
* attachment messages inert (no save, no turn).
|
|
125
|
+
*/
|
|
126
|
+
acceptAttachments: z.boolean().default(true),
|
|
127
|
+
/**
|
|
128
|
+
* Delete the workspace directories of stale conversations. `off` (default)
|
|
129
|
+
* never deletes; `7d`/`30d`/`90d`/`180d` remove plugin-created workspaces
|
|
130
|
+
* whose last activity is older than the threshold. The managed Web panel
|
|
131
|
+
* exposes this as a select, previews the candidates, and can run one
|
|
132
|
+
* cleanup manually. DSH Session logs are never deleted.
|
|
133
|
+
*/
|
|
134
|
+
workspaceRetention: z.enum(WORKSPACE_RETENTION_VALUES).default('off'),
|
|
135
|
+
/**
|
|
136
|
+
* Run the workspace-retention cleanup at startup and once per day. Off by
|
|
137
|
+
* default: deletion is irreversible, so it stays an explicit opt-in even
|
|
138
|
+
* when a retention threshold is configured.
|
|
139
|
+
*/
|
|
140
|
+
workspaceRetentionAuto: z.boolean().default(false),
|
|
141
|
+
/**
|
|
142
|
+
* Send a one-time onboarding card on the first private message of a chat
|
|
143
|
+
* (facts + command cheat sheet). Persisted per chat; never repeats.
|
|
144
|
+
*/
|
|
145
|
+
welcomeOnboarding: z.boolean().default(true),
|
|
146
|
+
/**
|
|
147
|
+
* Allow `/permissions <name>` to switch this Session's sandbox + approval
|
|
148
|
+
* preset from Feishu. Read-only `/permissions` always works; switching is
|
|
149
|
+
* security-sensitive (it can widen the sandbox), so it is off by default.
|
|
150
|
+
*/
|
|
151
|
+
allowPermissionSwitch: z.boolean().default(false),
|
|
152
|
+
/**
|
|
153
|
+
* Admission policy for inbound Feishu traffic.
|
|
154
|
+
*
|
|
155
|
+
* - `guarded` (default): only the configured administrator can talk in
|
|
156
|
+
* private chats, and a group conversation additionally requires the group
|
|
157
|
+
* to be authorized and the sender to be the administrator or an explicitly
|
|
158
|
+
* authorized user.
|
|
159
|
+
* - `open`: legacy behavior — any user inside the app's availability scope
|
|
160
|
+
* can start a conversation. Debug/compatibility only; unsafe.
|
|
161
|
+
*/
|
|
162
|
+
accessMode: z.enum(['open', 'guarded']).default('guarded'),
|
|
163
|
+
/**
|
|
164
|
+
* The unique administrator's Feishu open_id (`ou_...`). Set it in the Web
|
|
165
|
+
* settings panel (or the profile config). Get it by sending `/userid` to the
|
|
166
|
+
* bot in a private chat. While `accessMode` is `guarded` and this is unset,
|
|
167
|
+
* every conversation is refused (fail closed); `/userid` and a fixed notice
|
|
168
|
+
* stay available so the deployment can be bootstrapped.
|
|
169
|
+
*/
|
|
170
|
+
adminOpenId: z.string().regex(
|
|
171
|
+
/^ou_[A-Za-z0-9_-]+$/,
|
|
172
|
+
'must be a Feishu open_id such as ou_xxx (send /userid to the bot in a private chat to see it)',
|
|
173
|
+
).optional(),
|
|
174
|
+
/**
|
|
175
|
+
* Lifetime of one pending authorization request and of the DM card that asks
|
|
176
|
+
* the administrator to approve a group or a user. Expired requests stay in
|
|
177
|
+
* the store as an audit trail but can no longer be decided.
|
|
178
|
+
*/
|
|
179
|
+
authRequestTtlDays: z.number().int().min(1).max(30).default(7),
|
|
180
|
+
/**
|
|
181
|
+
* Structured-question decision window for the model's `ask_user_question`
|
|
182
|
+
* tool (`user-questions/request`). A blocking ask that gets no answer in
|
|
183
|
+
* this window fails closed with an explicit error; a host *timed* wait owns
|
|
184
|
+
* its own deadline and is left to the host signal instead.
|
|
185
|
+
*/
|
|
186
|
+
questionTimeoutMs: z.number().int().min(10_000).max(3_600_000).default(DEFAULT_QUESTION_TIMEOUT_MS),
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
export type FeishuConfig = z.infer<typeof configSchema>
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Validate the raw loader config. Values stay credential *references*; the
|
|
193
|
+
* secrets themselves resolve through `ctx.credentials` at startup.
|
|
194
|
+
* @param raw - the config object the cordis loader passed (may be undefined).
|
|
195
|
+
* @returns the validated config.
|
|
196
|
+
*/
|
|
197
|
+
export function resolveConfig(raw: unknown): FeishuConfig {
|
|
198
|
+
const parsed = configSchema.safeParse(raw ?? {})
|
|
199
|
+
if (!parsed.success) {
|
|
200
|
+
const details = parsed.error.issues
|
|
201
|
+
.map(issue => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
202
|
+
.join('\n')
|
|
203
|
+
throw new Error(`dsh-channels: invalid configuration:\n${details}`)
|
|
204
|
+
}
|
|
205
|
+
return parsed.data
|
|
206
|
+
}
|
package/src/domain.ts
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable routing domain: thread/chat keys → bound DSH session records.
|
|
3
|
+
*
|
|
4
|
+
* Opened through the host's `ctx.storageDomain` facility; the spec is plain
|
|
5
|
+
* data (no runtime import of DSH packages), so the plugin carries zero
|
|
6
|
+
* runtime dependency on host internals — everything flows through `ctx`.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-channels/domain
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z } from 'zod'
|
|
12
|
+
|
|
13
|
+
/** Route key kinds. `thread` = real Feishu topic; `chat` = degraded chat-scoped fallback. */
|
|
14
|
+
export const ROUTE_RECORD_VERSION = 1
|
|
15
|
+
|
|
16
|
+
export const sessionHistoryEntrySchema = z.object({
|
|
17
|
+
/** One Session that has been bound to this route. */
|
|
18
|
+
sessionId: z.string().min(1),
|
|
19
|
+
/** Epoch ms when the Session was created. */
|
|
20
|
+
createdAt: z.number().int().nonnegative(),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export type SessionHistoryEntry = z.infer<typeof sessionHistoryEntrySchema>
|
|
24
|
+
|
|
25
|
+
export const routeRecordSchema = z.object({
|
|
26
|
+
/** Route key the record is stored under (thread_id or chat:<chatId>). */
|
|
27
|
+
key: z.string().min(1),
|
|
28
|
+
/** Kind of the key. */
|
|
29
|
+
kind: z.enum(['thread', 'chat']),
|
|
30
|
+
/** Bound DSH session id. */
|
|
31
|
+
sessionId: z.string().min(1),
|
|
32
|
+
/** Feishu chat the session lives in. */
|
|
33
|
+
chatId: z.string().min(1),
|
|
34
|
+
/** `p2p` or `group`. */
|
|
35
|
+
chatType: z.enum(['p2p', 'group']),
|
|
36
|
+
/** Workspace owner (sender open_id). */
|
|
37
|
+
senderOpenId: z.string().min(1),
|
|
38
|
+
/** Workspace directory for this (chat, user) pair. */
|
|
39
|
+
workspacePath: z.string().min(1),
|
|
40
|
+
/** Message id of the latest streaming card (updated per turn). */
|
|
41
|
+
cardMessageId: z.string().min(1),
|
|
42
|
+
/** Epoch ms when the record was created. */
|
|
43
|
+
createdAt: z.number().int().nonnegative(),
|
|
44
|
+
/** Epoch ms of the last activity routed through this record. */
|
|
45
|
+
lastActivityAt: z.number().int().nonnegative(),
|
|
46
|
+
/** Sessions this topic has pointed at, oldest first. Optional for v1 compatibility. */
|
|
47
|
+
sessionHistory: z.array(sessionHistoryEntrySchema).optional(),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export type RouteRecord = z.infer<typeof routeRecordSchema>
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Minimal persistent tombstone for a thread route retired by route TTL.
|
|
54
|
+
*
|
|
55
|
+
* Deleting the active route alone would make a later message look like a new
|
|
56
|
+
* conversation and silently create another Session in the same Feishu topic.
|
|
57
|
+
* The tombstone preserves only enough identity to reject that message across
|
|
58
|
+
* process restarts; the old Session and workspace remain host-owned.
|
|
59
|
+
*/
|
|
60
|
+
export const expiredRouteRecordSchema = z.object({
|
|
61
|
+
/** Expired Feishu thread id (also the table key). */
|
|
62
|
+
key: z.string().min(1),
|
|
63
|
+
/** Feishu chat containing the expired topic. */
|
|
64
|
+
chatId: z.string().min(1),
|
|
65
|
+
/** Original chat type, retained for diagnostics and future policy. */
|
|
66
|
+
chatType: z.enum(['p2p', 'group']),
|
|
67
|
+
/** Original route owner; never used to grant access after expiry. */
|
|
68
|
+
senderOpenId: z.string().min(1),
|
|
69
|
+
/** Epoch ms when the route was retired. */
|
|
70
|
+
expiredAt: z.number().int().nonnegative(),
|
|
71
|
+
/** Workspace the retired route belonged to; absent in tombstones written by
|
|
72
|
+
* older plugin versions, so the field must stay optional. */
|
|
73
|
+
workspacePath: z.string().min(1).optional(),
|
|
74
|
+
/** Epoch ms of the last activity through the route before retirement.
|
|
75
|
+
* Optional for the same legacy-compatibility reason; consumers fall back to
|
|
76
|
+
* `expiredAt` when it is absent. */
|
|
77
|
+
lastActivityAt: z.number().int().nonnegative().optional(),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
export type ExpiredRouteRecord = z.infer<typeof expiredRouteRecordSchema>
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Normalize legacy and partially duplicated histories without mutating the
|
|
84
|
+
* durable record. The current Session is always represented, duplicate ids
|
|
85
|
+
* collapse to their earliest timestamp, and output order is deterministic.
|
|
86
|
+
*/
|
|
87
|
+
export function normalizeSessionHistory(record: RouteRecord): SessionHistoryEntry[] {
|
|
88
|
+
const bySession = new Map<string, SessionHistoryEntry>()
|
|
89
|
+
for (const entry of record.sessionHistory ?? []) {
|
|
90
|
+
const existing = bySession.get(entry.sessionId)
|
|
91
|
+
if (existing === undefined || entry.createdAt < existing.createdAt) {
|
|
92
|
+
bySession.set(entry.sessionId, { ...entry })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!bySession.has(record.sessionId)) {
|
|
96
|
+
bySession.set(record.sessionId, { sessionId: record.sessionId, createdAt: record.createdAt })
|
|
97
|
+
}
|
|
98
|
+
return [...bySession.values()].sort((left, right) => left.createdAt - right.createdAt)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Materialize the optional legacy history field for in-memory consumers. */
|
|
102
|
+
export function normalizeRouteRecord(record: RouteRecord): RouteRecord {
|
|
103
|
+
return { ...record, sessionHistory: normalizeSessionHistory(record) }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Append a newly created Session to one normalized route history. */
|
|
107
|
+
export function appendSessionHistory(
|
|
108
|
+
record: RouteRecord,
|
|
109
|
+
sessionId: string,
|
|
110
|
+
createdAt: number,
|
|
111
|
+
): SessionHistoryEntry[] {
|
|
112
|
+
return normalizeSessionHistory({
|
|
113
|
+
...record,
|
|
114
|
+
sessionId,
|
|
115
|
+
sessionHistory: [...normalizeSessionHistory(record), { sessionId, createdAt }],
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* In-flight turn snapshot: everything needed to repair a streaming card that a
|
|
121
|
+
* restart left mid-flight (stuck panel, leftover Typing reaction, never-patched
|
|
122
|
+
* segment cards). Keyed by the turn's MAIN card message id — unique per turn
|
|
123
|
+
* and known at attach time — so overlapping turns of one session never
|
|
124
|
+
* overwrite each other. Written before the route record, updated as segment
|
|
125
|
+
* cards resolve, deleted when the turn finalizes (or the creation fails).
|
|
126
|
+
*/
|
|
127
|
+
export const INFLIGHT_VERSION = 1
|
|
128
|
+
|
|
129
|
+
export const inflightCardSchema = z.object({
|
|
130
|
+
/** Feishu message id of one segment card. */
|
|
131
|
+
messageId: z.string().min(1),
|
|
132
|
+
/** Delivery mode; `text` messages are complete and never patched by repair. */
|
|
133
|
+
mode: z.enum(['card', 'text']),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
export type InflightCard = z.infer<typeof inflightCardSchema>
|
|
137
|
+
|
|
138
|
+
export const inflightSnapshotSchema = z.object({
|
|
139
|
+
/** Key the snapshot is stored under (= `cards[0].messageId`, the main card). */
|
|
140
|
+
key: z.string().min(1),
|
|
141
|
+
/** DSH session the turn belongs to. */
|
|
142
|
+
sessionId: z.string().min(1),
|
|
143
|
+
/** Feishu chat the cards live in (reaction targeting). */
|
|
144
|
+
chatId: z.string().min(1),
|
|
145
|
+
/** User message that triggered the turn (Typing/DONE reaction anchor). */
|
|
146
|
+
triggerMessageId: z.string().min(1),
|
|
147
|
+
/** Reaction id of the Typing reaction (absent when the add failed). */
|
|
148
|
+
reactionId: z.string().optional(),
|
|
149
|
+
/** Segment cards in order; `[0]` is the main card. */
|
|
150
|
+
cards: z.array(inflightCardSchema).min(1),
|
|
151
|
+
/** Epoch ms when the turn attached. */
|
|
152
|
+
startedAt: z.number().int().nonnegative(),
|
|
153
|
+
/** Repair attempts spent (cap: 2 — see `repairInflight`). */
|
|
154
|
+
attempts: z.number().int().nonnegative(),
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
export type InflightSnapshot = z.infer<typeof inflightSnapshotSchema>
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Access-control grants (v1 format, added without a domain version bump —
|
|
161
|
+
* see the notes on `feishuDomain` below).
|
|
162
|
+
*
|
|
163
|
+
* One record per authorized group: the group gate is the first of the two
|
|
164
|
+
* independent admission gates. Inside an authorized group only the
|
|
165
|
+
* administrator and explicitly authorized users may start conversations.
|
|
166
|
+
*/
|
|
167
|
+
export const accessGroupRecordSchema = z.object({
|
|
168
|
+
/** Feishu chat id (`oc_...`) — also the table key. */
|
|
169
|
+
chatId: z.string().min(1),
|
|
170
|
+
/** Human-readable group name captured at authorization time (optional). */
|
|
171
|
+
name: z.string().optional(),
|
|
172
|
+
/** Which authorization entry produced this grant. */
|
|
173
|
+
source: z.enum(['command', 'card', 'panel']),
|
|
174
|
+
/** Administrator open_id that authorized it. */
|
|
175
|
+
addedBy: z.string().min(1),
|
|
176
|
+
/** Epoch ms when it was authorized. */
|
|
177
|
+
addedAt: z.number().int().nonnegative(),
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
export type AccessGroupRecord = z.infer<typeof accessGroupRecordSchema>
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* One authorized user (`open_id`), global across every authorized group.
|
|
184
|
+
* Private chats are administrator-only, so this grant only affects group
|
|
185
|
+
* conversations.
|
|
186
|
+
*/
|
|
187
|
+
export const accessUserRecordSchema = z.object({
|
|
188
|
+
/** Feishu open id (`ou_...`) — also the table key. */
|
|
189
|
+
openId: z.string().min(1),
|
|
190
|
+
/** Display name captured when the user was authorized (optional). */
|
|
191
|
+
name: z.string().optional(),
|
|
192
|
+
/** Which authorization entry produced this grant. */
|
|
193
|
+
source: z.enum(['command', 'card', 'panel']),
|
|
194
|
+
/** Administrator open_id that authorized it. */
|
|
195
|
+
addedBy: z.string().min(1),
|
|
196
|
+
/** Epoch ms when it was authorized. */
|
|
197
|
+
addedAt: z.number().int().nonnegative(),
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
export type AccessUserRecord = z.infer<typeof accessUserRecordSchema>
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* One authorization request the administrator has been (or should be) asked
|
|
204
|
+
* about. Requests are persisted so the DM card stays decidable across plugin
|
|
205
|
+
* restarts; settled records are kept as an audit trail and for request
|
|
206
|
+
* de-duplication/cooldown.
|
|
207
|
+
*/
|
|
208
|
+
export const accessRequestRecordSchema = z.object({
|
|
209
|
+
/** Opaque token carried by the card buttons — also the table key. */
|
|
210
|
+
token: z.string().min(1),
|
|
211
|
+
/** What is being requested. */
|
|
212
|
+
kind: z.enum(['group', 'user']),
|
|
213
|
+
/** `chat_id` for a group request; `open_id` for a user request. */
|
|
214
|
+
targetId: z.string().min(1),
|
|
215
|
+
/** Chat the request originated from, when applicable. */
|
|
216
|
+
chatId: z.string().optional(),
|
|
217
|
+
/** Group name captured for the card (group requests, or the group the user was seen in). */
|
|
218
|
+
groupName: z.string().optional(),
|
|
219
|
+
/** User who triggered a user request. */
|
|
220
|
+
requesterOpenId: z.string().optional(),
|
|
221
|
+
/** Display name of that user, when the event carried one. */
|
|
222
|
+
requesterName: z.string().optional(),
|
|
223
|
+
/** Lifecycle state; only `pending` requests accept a card action. */
|
|
224
|
+
status: z.enum(['pending', 'approved', 'denied', 'expired']),
|
|
225
|
+
/** Epoch ms when the request was created. */
|
|
226
|
+
createdAt: z.number().int().nonnegative(),
|
|
227
|
+
/** Epoch ms after which a pending request can no longer be decided. */
|
|
228
|
+
expiresAt: z.number().int().nonnegative(),
|
|
229
|
+
/** DM message id of the administrator card, once delivery succeeded. */
|
|
230
|
+
messageId: z.string().optional(),
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
export type AccessRequestRecord = z.infer<typeof accessRequestRecordSchema>
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* One processed inbound message, keyed by its Feishu message id. Feishu
|
|
237
|
+
* redelivers un-acked events on WS reconnect, so every accepted message is
|
|
238
|
+
* recorded here BEFORE its side effects begin; redeliveries then hit the
|
|
239
|
+
* table (or the in-process guard) and are dropped. Records are pruned by the
|
|
240
|
+
* GC sweep after a fixed retention window (24 h — redelivery windows are
|
|
241
|
+
* minutes).
|
|
242
|
+
*/
|
|
243
|
+
export const PROCESSED_VERSION = 1
|
|
244
|
+
|
|
245
|
+
export const processedRecordSchema = z.object({
|
|
246
|
+
/** Key the record is stored under (the Feishu message id). */
|
|
247
|
+
key: z.string().min(1),
|
|
248
|
+
/** Epoch ms when the message was accepted for processing. */
|
|
249
|
+
processedAt: z.number().int().nonnegative(),
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
export type ProcessedRecord = z.infer<typeof processedRecordSchema>
|
|
253
|
+
|
|
254
|
+
export interface FeishuDomainSpecShape {
|
|
255
|
+
name: 'channels_routes'
|
|
256
|
+
version: 1
|
|
257
|
+
global: {
|
|
258
|
+
schema: z.ZodType<FeishuDomainGlobal>
|
|
259
|
+
initial: FeishuDomainGlobal
|
|
260
|
+
}
|
|
261
|
+
tables: {
|
|
262
|
+
routes: {
|
|
263
|
+
valueSchema: typeof routeRecordSchema
|
|
264
|
+
__key?: string
|
|
265
|
+
}
|
|
266
|
+
inflight: {
|
|
267
|
+
valueSchema: typeof inflightSnapshotSchema
|
|
268
|
+
__key?: string
|
|
269
|
+
}
|
|
270
|
+
processed: {
|
|
271
|
+
valueSchema: typeof processedRecordSchema
|
|
272
|
+
__key?: string
|
|
273
|
+
}
|
|
274
|
+
expired_routes: {
|
|
275
|
+
valueSchema: typeof expiredRouteRecordSchema
|
|
276
|
+
__key?: string
|
|
277
|
+
}
|
|
278
|
+
access_groups: {
|
|
279
|
+
valueSchema: typeof accessGroupRecordSchema
|
|
280
|
+
__key?: string
|
|
281
|
+
}
|
|
282
|
+
access_users: {
|
|
283
|
+
valueSchema: typeof accessUserRecordSchema
|
|
284
|
+
__key?: string
|
|
285
|
+
}
|
|
286
|
+
access_requests: {
|
|
287
|
+
valueSchema: typeof accessRequestRecordSchema
|
|
288
|
+
__key?: string
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export const feishuDomainGlobalSchema = z.object({
|
|
294
|
+
/** Legacy: img_key of a previously uploaded chevron icon. Kept optional so
|
|
295
|
+
* storage written by older plugin versions still validates; the card now
|
|
296
|
+
* uses the built-in `standard_icon` chevron and never writes this. */
|
|
297
|
+
chevronImgKey: z.string().optional(),
|
|
298
|
+
/** p2p chats already shown the one-time onboarding card (bounded list). */
|
|
299
|
+
welcomedChats: z.array(z.string()).optional(),
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
export type FeishuDomainGlobal = z.infer<typeof feishuDomainGlobalSchema>
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Domain declaration for the host's storage facility. Plain data plus zod
|
|
306
|
+
* schemas — deliberately not built with `defineDomain` so this module has no
|
|
307
|
+
* runtime import of `@deepseek-ai/dsh-storage-domain`.
|
|
308
|
+
*/
|
|
309
|
+
export const feishuDomain: FeishuDomainSpecShape = {
|
|
310
|
+
name: 'channels_routes',
|
|
311
|
+
version: 1,
|
|
312
|
+
global: {
|
|
313
|
+
schema: feishuDomainGlobalSchema,
|
|
314
|
+
initial: {} satisfies FeishuDomainGlobal,
|
|
315
|
+
},
|
|
316
|
+
tables: {
|
|
317
|
+
routes: { valueSchema: routeRecordSchema },
|
|
318
|
+
inflight: { valueSchema: inflightSnapshotSchema },
|
|
319
|
+
processed: { valueSchema: processedRecordSchema },
|
|
320
|
+
expired_routes: { valueSchema: expiredRouteRecordSchema },
|
|
321
|
+
access_groups: { valueSchema: accessGroupRecordSchema },
|
|
322
|
+
access_users: { valueSchema: accessUserRecordSchema },
|
|
323
|
+
access_requests: { valueSchema: accessRequestRecordSchema },
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export type FeishuDomainSpec = typeof feishuDomain
|
|
328
|
+
|
|
329
|
+
/** Normalized table handle for the routes table. */
|
|
330
|
+
export interface RoutesTable {
|
|
331
|
+
get(key: string): RouteRecord | undefined
|
|
332
|
+
put(key: string, value: RouteRecord): Promise<unknown> | void
|
|
333
|
+
delete(key: string): Promise<unknown> | void
|
|
334
|
+
entries(): IterableIterator<[string, RouteRecord]>
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Normalized table handle for the inflight table (repair snapshots). */
|
|
338
|
+
export interface InflightTable {
|
|
339
|
+
get(key: string): InflightSnapshot | undefined
|
|
340
|
+
put(key: string, value: InflightSnapshot): Promise<unknown> | void
|
|
341
|
+
/**
|
|
342
|
+
* Atomic read-modify-write on the host's write chain (mirrors `KvTable`).
|
|
343
|
+
* Rejects with `missing-key` when the key is absent (e.g. the turn finalize
|
|
344
|
+
* deleted the snapshot while a late segment delivery was still landing).
|
|
345
|
+
*/
|
|
346
|
+
update(key: string, fn: (current: InflightSnapshot) => InflightSnapshot): Promise<unknown>
|
|
347
|
+
delete(key: string): Promise<unknown> | void
|
|
348
|
+
entries(): IterableIterator<[string, InflightSnapshot]>
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Normalized table handle for the processed-message dedup table. */
|
|
352
|
+
export interface ProcessedTable {
|
|
353
|
+
get(key: string): ProcessedRecord | undefined
|
|
354
|
+
put(key: string, value: ProcessedRecord): Promise<unknown> | void
|
|
355
|
+
delete(key: string): Promise<unknown> | void
|
|
356
|
+
entries(): IterableIterator<[string, ProcessedRecord]>
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Persistent thread tombstones used to reject post-TTL messages. */
|
|
360
|
+
export interface ExpiredRoutesTable {
|
|
361
|
+
get(key: string): ExpiredRouteRecord | undefined
|
|
362
|
+
put(key: string, value: ExpiredRouteRecord): Promise<unknown> | void
|
|
363
|
+
entries(): IterableIterator<[string, ExpiredRouteRecord]>
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Normalized table handle for authorized groups (key = `chat_id`). */
|
|
367
|
+
export interface AccessGroupsTable {
|
|
368
|
+
get(key: string): AccessGroupRecord | undefined
|
|
369
|
+
put(key: string, value: AccessGroupRecord): Promise<unknown> | void
|
|
370
|
+
delete(key: string): Promise<unknown> | void
|
|
371
|
+
entries(): IterableIterator<[string, AccessGroupRecord]>
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Normalized table handle for authorized users (key = `open_id`). */
|
|
375
|
+
export interface AccessUsersTable {
|
|
376
|
+
get(key: string): AccessUserRecord | undefined
|
|
377
|
+
put(key: string, value: AccessUserRecord): Promise<unknown> | void
|
|
378
|
+
delete(key: string): Promise<unknown> | void
|
|
379
|
+
entries(): IterableIterator<[string, AccessUserRecord]>
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Normalized table handle for authorization requests (key = opaque token). */
|
|
383
|
+
export interface AccessRequestsTable {
|
|
384
|
+
get(key: string): AccessRequestRecord | undefined
|
|
385
|
+
put(key: string, value: AccessRequestRecord): Promise<unknown> | void
|
|
386
|
+
delete(key: string): Promise<unknown> | void
|
|
387
|
+
entries(): IterableIterator<[string, AccessRequestRecord]>
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Normalized domain handle. */
|
|
391
|
+
export interface FeishuDomainHandle {
|
|
392
|
+
routes: RoutesTable
|
|
393
|
+
inflight: InflightTable
|
|
394
|
+
processed: ProcessedTable
|
|
395
|
+
expiredRoutes: ExpiredRoutesTable
|
|
396
|
+
accessGroups: AccessGroupsTable
|
|
397
|
+
accessUsers: AccessUsersTable
|
|
398
|
+
accessRequests: AccessRequestsTable
|
|
399
|
+
getGlobal(): FeishuDomainGlobal
|
|
400
|
+
setGlobal(value: FeishuDomainGlobal): Promise<unknown> | void
|
|
401
|
+
close(): Promise<void>
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** The raw shape `ctx.storageDomain.open()` resolves to (cast at the seam).
|
|
405
|
+
* Mirrors `KvTable` from `@deepseek-ai/dsh-storage-domain`: reads are sync,
|
|
406
|
+
* writes go through `put`/`delete` (NOT `set`), and `update` provides the
|
|
407
|
+
* atomic read-modify-write used by the inflight merge. */
|
|
408
|
+
export interface RawDomainHandle {
|
|
409
|
+
table(name: string): {
|
|
410
|
+
get(key: string): unknown
|
|
411
|
+
put(key: string, value: unknown): Promise<unknown>
|
|
412
|
+
update(key: string, fn: (current: unknown) => unknown): Promise<unknown>
|
|
413
|
+
delete(key: string): Promise<unknown>
|
|
414
|
+
entries(): IterableIterator<[string, unknown]>
|
|
415
|
+
}
|
|
416
|
+
global: {
|
|
417
|
+
get(): unknown
|
|
418
|
+
set(value: unknown): Promise<unknown>
|
|
419
|
+
}
|
|
420
|
+
close(): Promise<void>
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Build a chat-scoped fallback key for degraded (topic-less) routing. */
|
|
424
|
+
export function chatRouteKey(chatType: 'p2p' | 'group', chatId: string, senderOpenId: string): string {
|
|
425
|
+
return chatType === 'p2p'
|
|
426
|
+
? `chat:p2p:${chatId}:${senderOpenId}`
|
|
427
|
+
: `chat:group:${chatId}:${senderOpenId}`
|
|
428
|
+
}
|
package/src/dsh-types.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public type barrel for the DeepSeek Harness host surface.
|
|
3
|
+
*
|
|
4
|
+
* The bridge used to live in this one file; it is now split by domain under
|
|
5
|
+
* `src/host/` (core / agent / commands / interaction / services) and this
|
|
6
|
+
* module keeps the stable import specifier for consumers and tests.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-channels/dsh-types
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export * from './host/index.ts'
|