@cydm/happy-elves 0.1.0-beta.83 → 0.1.0-beta.84

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.
@@ -52,6 +52,8 @@ const valueFlags = new Set([
52
52
  "runtime-session",
53
53
  "shell",
54
54
  "actions",
55
+ "allow-chat",
56
+ "allow-sender",
55
57
  "expires-in",
56
58
  "format",
57
59
  "loops",
@@ -108,7 +108,7 @@ session stars, recent sessions, pinned workspaces, and current project defaults.
108
108
  `,
109
109
  gateway: `Usage:
110
110
  happy-elves gateway enable [<channelId>] --channel lark --machine <machineId> (--cwd <cwd> | --session <sessionId>) [--agent codex] [--model <model>] --json
111
- happy-elves gateway enable lark-main --channel lark --machine <machineId> (--cwd <cwd> | --session <sessionId>) --app-id <appId> --app-secret-stdin [--domain feishu|lark] [--inbound mention|all] --json
111
+ happy-elves gateway enable lark-main --channel lark --machine <machineId> (--cwd <cwd> | --session <sessionId>) --app-id <appId> --app-secret-stdin [--domain feishu|lark] [--inbound mention|all] (--allow-any-sender | --allow-chat <id[,id]> | --allow-sender <id[,id]>) --json
112
112
  happy-elves gateway disable [<channelId>] [--machine <machineId>] --json
113
113
  happy-elves gateway status [--machine <machineId>] [--include-debug] [--json]
114
114
  happy-elves gateway test [<channelId>] [--machine <machineId>] --conversation <externalConversationId> --message-id <externalMessageId> --text <prompt> --json
@@ -116,6 +116,7 @@ session stars, recent sessions, pinned workspaces, and current project defaults.
116
116
 
117
117
  Gateway v1 official Feishu/Lark path is daemon-backed SDK long connection.
118
118
  App secrets are read from stdin and stored only in the target daemon config.
119
+ Lark/Feishu product gateways require an explicit inbound sender/chat policy.
119
120
  gateway serve is kept for fake/local debugging only; lark-cli is not a product dependency.
120
121
  `,
121
122
  memory: `Usage:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-cli",
3
- "version": "0.1.0-beta.83",
3
+ "version": "0.1.0-beta.84",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -1,14 +1,17 @@
1
+ import { createHash } from "node:crypto";
1
2
  import fs from "node:fs/promises";
3
+ import path from "node:path";
2
4
  import { configDir, gatewayStorePath } from "../paths.js";
3
5
  function emptyGatewayStore() {
4
6
  return { version: 1, channels: [], mappings: [], queue: [], pendingReplies: [], replyHistory: [] };
5
7
  }
6
8
  export async function readGatewayStore() {
9
+ let store;
7
10
  try {
8
11
  const parsed = JSON.parse(await fs.readFile(gatewayStorePath, "utf8"));
9
12
  if (parsed.version !== 1 || !Array.isArray(parsed.channels) || !Array.isArray(parsed.mappings))
10
- return emptyGatewayStore();
11
- return {
13
+ return await mergeLegacyGatewayStore(emptyGatewayStore());
14
+ store = {
12
15
  version: 1,
13
16
  channels: parsed.channels.map(normalizeGatewayChannel),
14
17
  mappings: parsed.mappings,
@@ -19,9 +22,10 @@ export async function readGatewayStore() {
19
22
  }
20
23
  catch (error) {
21
24
  if (error.code === "ENOENT")
22
- return emptyGatewayStore();
25
+ return await mergeLegacyGatewayStore(emptyGatewayStore());
23
26
  throw error;
24
27
  }
28
+ return await mergeLegacyGatewayStore(store);
25
29
  }
26
30
  async function writeGatewayStore(store) {
27
31
  await fs.mkdir(configDir, { recursive: true });
@@ -122,4 +126,143 @@ function normalizeGatewayChannel(channel) {
122
126
  allowedSenderIds: Array.isArray(channel.allowedSenderIds) ? channel.allowedSenderIds.filter(Boolean) : undefined,
123
127
  };
124
128
  }
129
+ async function mergeLegacyGatewayStore(store) {
130
+ const legacy = await readLegacyGatewayStore();
131
+ if (!legacy)
132
+ return store;
133
+ let changed = false;
134
+ let channels = store.channels;
135
+ for (const legacyChannel of legacy.channels) {
136
+ if (channels.some((channel) => channel.id === legacyChannel.id))
137
+ continue;
138
+ if (channels.some((channel) => channel.type === "lark" && channel.enabled && channel.appId === legacyChannel.appId))
139
+ continue;
140
+ channels = [legacyChannel, ...channels];
141
+ changed = true;
142
+ }
143
+ const channelIds = new Set(channels.map((channel) => channel.id));
144
+ let mappings = store.mappings;
145
+ for (const legacyMapping of legacy.mappings) {
146
+ if (!channelIds.has(legacyMapping.channelId))
147
+ continue;
148
+ if (mappings.some((mapping) => mapping.channelId === legacyMapping.channelId
149
+ && mapping.externalConversationId === legacyMapping.externalConversationId))
150
+ continue;
151
+ mappings = [legacyMapping, ...mappings];
152
+ changed = true;
153
+ }
154
+ if (!changed)
155
+ return store;
156
+ const next = { ...store, channels, mappings };
157
+ await writeGatewayStore(next);
158
+ return next;
159
+ }
160
+ async function readLegacyGatewayStore() {
161
+ const legacyConfig = await readJsonFile(path.join(configDir, "gateways.json"));
162
+ const legacyGateways = Array.isArray(legacyConfig?.gateways) ? legacyConfig.gateways : [];
163
+ if (legacyGateways.length === 0)
164
+ return undefined;
165
+ const legacyState = await readJsonFile(path.join(configDir, "gateway-state.json"));
166
+ const legacyBindings = Array.isArray(legacyState?.bindings) ? legacyState.bindings : [];
167
+ const channels = legacyGateways
168
+ .map(legacyChannelFromRecord)
169
+ .filter((channel) => Boolean(channel));
170
+ const channelIds = new Set(channels.map((channel) => channel.id));
171
+ const mappings = legacyBindings
172
+ .map(legacyMappingFromRecord)
173
+ .filter((mapping) => mapping !== undefined && channelIds.has(mapping.channelId));
174
+ return channels.length > 0 || mappings.length > 0 ? { channels, mappings } : undefined;
175
+ }
176
+ async function readJsonFile(filePath) {
177
+ try {
178
+ const parsed = JSON.parse(await fs.readFile(filePath, "utf8"));
179
+ return isRecord(parsed) ? parsed : undefined;
180
+ }
181
+ catch (error) {
182
+ if (error.code === "ENOENT")
183
+ return undefined;
184
+ throw error;
185
+ }
186
+ }
187
+ function legacyChannelFromRecord(value) {
188
+ const record = isRecord(value) ? value : undefined;
189
+ if (!record)
190
+ return undefined;
191
+ const platform = stringField(record, "platform");
192
+ if (platform !== "feishu" && platform !== "lark")
193
+ return undefined;
194
+ const id = stringField(record, "id");
195
+ if (!id)
196
+ return undefined;
197
+ const identities = Array.isArray(record.identities) ? record.identities.filter(isRecord) : [];
198
+ const allowedSenderIds = uniqueStrings(identities.map((identity) => stringField(identity, "platformUserId")));
199
+ const allowAnySender = booleanField(record, "allowAllUsers") ?? allowedSenderIds.length === 0;
200
+ const createdAt = legacyIsoDate(record.createdAt);
201
+ const updatedAt = legacyIsoDate(record.updatedAt, createdAt);
202
+ return normalizeGatewayChannel({
203
+ id,
204
+ type: "lark",
205
+ enabled: booleanField(record, "enabled") ?? true,
206
+ domain: gatewayDomain(stringField(record, "domain") ?? platform),
207
+ appId: stringField(record, "appId"),
208
+ appSecret: stringField(record, "appSecret"),
209
+ inboundMode: booleanField(record, "requireMention") === false ? "all" : "mention",
210
+ processingReaction: { enabled: true, emojiType: "Typing" },
211
+ targetCwd: stringField(record, "defaultCwd"),
212
+ defaultAgent: stringField(record, "defaultAgent") ?? "codex",
213
+ allowAnySender,
214
+ allowedSenderIds: allowAnySender ? undefined : allowedSenderIds,
215
+ createdAt,
216
+ updatedAt,
217
+ });
218
+ }
219
+ function legacyMappingFromRecord(value) {
220
+ const record = isRecord(value) ? value : undefined;
221
+ if (!record)
222
+ return undefined;
223
+ const channelId = stringField(record, "gatewayId");
224
+ const platformChatId = stringField(record, "platformChatId");
225
+ const sessionId = stringField(record, "sessionId");
226
+ if (!channelId || !platformChatId || !sessionId)
227
+ return undefined;
228
+ const contextKind = stringField(record, "contextKind");
229
+ const contextKey = stringField(record, "contextKey");
230
+ const externalConversationId = contextKind === "thread" && contextKey
231
+ ? `${platformChatId}:thread:${contextKey}`
232
+ : `${platformChatId}:main`;
233
+ const createdAt = legacyIsoDate(record.createdAt);
234
+ return {
235
+ channelId,
236
+ externalConversationId,
237
+ sessionId,
238
+ createRequestId: stableLegacyGatewayId("gw_create", channelId, externalConversationId),
239
+ createdAt,
240
+ updatedAt: legacyIsoDate(record.updatedAt, createdAt),
241
+ };
242
+ }
243
+ function stableLegacyGatewayId(prefix, ...parts) {
244
+ return `${prefix}_${createHash("sha256").update(parts.join("\0")).digest("base64url").slice(0, 32)}`;
245
+ }
246
+ function gatewayDomain(value) {
247
+ return value === "lark" ? "lark" : "feishu";
248
+ }
249
+ function legacyIsoDate(value, fallback = new Date().toISOString()) {
250
+ const numeric = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
251
+ const date = Number.isFinite(numeric) ? new Date(numeric) : new Date(fallback);
252
+ return Number.isFinite(date.getTime()) ? date.toISOString() : new Date().toISOString();
253
+ }
254
+ function uniqueStrings(values) {
255
+ return Array.from(new Set(values.map((value) => value?.trim()).filter((value) => Boolean(value))));
256
+ }
257
+ function stringField(record, key) {
258
+ const value = record[key];
259
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
260
+ }
261
+ function booleanField(record, key) {
262
+ const value = record[key];
263
+ return typeof value === "boolean" ? value : undefined;
264
+ }
265
+ function isRecord(value) {
266
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
267
+ }
125
268
  //# sourceMappingURL=store.js.map
@@ -25,7 +25,7 @@ export type HistoricalSessionHead = {
25
25
  lastTurnId: string;
26
26
  basis: SessionHeadAdvanceBasis;
27
27
  };
28
- export declare function latestCompletedHistoricalHead(sessionId: string, events: RuntimeHistoricalEvent[], input?: {
28
+ export declare function latestCompletedHistoricalHead(sessionId: string, events: RuntimeHistoricalEvent[], _input?: {
29
29
  preferTailBasisForExistingEvents?: boolean;
30
30
  previousTotalEvents?: number;
31
31
  tailStartIndex?: number;
@@ -100,7 +100,7 @@ function transcriptHead(events) {
100
100
  }
101
101
  return events.at(-1)?.turnId ? { currentHead: events.at(-1)?.turnId, lastTurnId: events.at(-1)?.turnId } : {};
102
102
  }
103
- export function latestCompletedHistoricalHead(sessionId, events, input = {}) {
103
+ export function latestCompletedHistoricalHead(sessionId, events, _input = {}) {
104
104
  for (let index = events.length - 1; index >= 0; index -= 1) {
105
105
  const event = events[index];
106
106
  if (!event || event.payload.type !== "done" || event.payload.status !== "completed")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.83",
3
+ "version": "0.1.0-beta.84",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -291,6 +291,9 @@ export function initDb(db) {
291
291
  ensureColumn(db, "relay_admissions", "finished_at", "INTEGER");
292
292
  ensureColumn(db, "relay_admissions", "terminal_status", "TEXT");
293
293
  ensureColumn(db, "relay_admissions", "last_error_code", "TEXT");
294
+ ensureColumn(db, "relay_dispatch_outbox", "message", "TEXT");
295
+ ensureColumn(db, "relay_dispatch_outbox", "command_type", "TEXT");
296
+ ensureColumn(db, "relay_dispatch_outbox", "delivered_at", "INTEGER");
294
297
  ensureColumn(db, "tokens", "device_name", "TEXT");
295
298
  ensureColumn(db, "tokens", "revoked_at", "INTEGER");
296
299
  ensureColumn(db, "tokens", "expires_at", "INTEGER");
@@ -312,6 +315,7 @@ export function initDb(db) {
312
315
  WHERE message_id IS NOT NULL;
313
316
  `);
314
317
  repairHistoricalEventTimestamps(db);
318
+ backfillRelayDispatchOutboxCommandType(db);
315
319
  restoreDurableSessionClaims(db);
316
320
  }
317
321
  export function pruneRelayRetention(db, now, config) {
@@ -323,11 +327,13 @@ export function pruneRelayRetention(db, now, config) {
323
327
  }
324
328
  }
325
329
  function ensureColumn(db, table, column, definition) {
326
- const rows = db.prepare(`PRAGMA table_info(${table})`).all();
327
- if (!rows.some((row) => row.name === column)) {
330
+ if (!tableColumnNames(db, table).has(column)) {
328
331
  db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
329
332
  }
330
333
  }
334
+ function tableColumnNames(db, table) {
335
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
336
+ }
331
337
  function restoreDurableSessionClaims(db) {
332
338
  db.prepare(`INSERT OR IGNORE INTO relay_session_claims
333
339
  (account_id, session_id, request_id, machine_id, turn_id, command_type, state, created_at, updated_at)
@@ -393,6 +399,38 @@ function repairHistoricalEventTimestamps(db) {
393
399
  throw error;
394
400
  }
395
401
  }
402
+ function backfillRelayDispatchOutboxCommandType(db) {
403
+ const columns = tableColumnNames(db, "relay_dispatch_outbox");
404
+ if (columns.has("command_json")) {
405
+ db.prepare("UPDATE relay_dispatch_outbox SET message = command_json WHERE (message IS NULL OR message = '') AND command_json IS NOT NULL AND command_json != ''").run();
406
+ }
407
+ const rows = db
408
+ .prepare("SELECT rowid, message FROM relay_dispatch_outbox WHERE command_type IS NULL OR command_type = ''")
409
+ .all();
410
+ if (rows.length === 0)
411
+ return;
412
+ const update = db.prepare("UPDATE relay_dispatch_outbox SET command_type = ? WHERE rowid = ?");
413
+ db.exec("BEGIN");
414
+ try {
415
+ for (const row of rows) {
416
+ update.run(commandTypeFromOutboxMessage(row.message ?? ""), row.rowid);
417
+ }
418
+ db.exec("COMMIT");
419
+ }
420
+ catch (error) {
421
+ db.exec("ROLLBACK");
422
+ throw error;
423
+ }
424
+ }
425
+ function commandTypeFromOutboxMessage(message) {
426
+ try {
427
+ const parsed = JSON.parse(message);
428
+ return typeof parsed.type === "string" && parsed.type.trim() ? parsed.type : "unknown";
429
+ }
430
+ catch {
431
+ return "unknown";
432
+ }
433
+ }
396
434
  function uuidV7TimestampMs(value) {
397
435
  if (!value)
398
436
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.83",
3
+ "version": "0.1.0-beta.84",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,6 +28,7 @@
28
28
  "dependencies": {
29
29
  "@fastify/cors": "^11.1.0",
30
30
  "@fastify/websocket": "^11.0.2",
31
+ "@larksuiteoapi/node-sdk": "^1.72.0",
31
32
  "@noble/ciphers": "^2.2.0",
32
33
  "@noble/hashes": "^2.2.0",
33
34
  "acpx": "^0.13.0",