@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.
Files changed (73) hide show
  1. package/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. 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
+ }
@@ -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'