@yahaha-studio/focus-forwarder 0.0.1-alpha.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/index.ts +429 -0
- package/openclaw.plugin.json +31 -0
- package/package.json +23 -0
- package/skills/focus-forwarder/SKILL.md +158 -0
- package/src/config.ts +10 -0
- package/src/service.ts +144 -0
- package/src/types.ts +40 -0
package/index.ts
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { parse } from "./src/config.js";
|
|
3
|
+
import { FocusForwarderService } from "./src/service.js";
|
|
4
|
+
import type { FocusForwarderConfig } from "./src/types.js";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
+
|
|
9
|
+
// Default actions (fallback when no config file)
|
|
10
|
+
const DEFAULT_ACTIONS = {
|
|
11
|
+
stand: ["High Five", "Listen Music", "Arms Crossed", "Epiphany", "Yay", "Tired", "Wait"],
|
|
12
|
+
sit: ["Typing with Keyboard", "Thinking", "Study Look At", "Writing", "Hand Cramp", "Laze"],
|
|
13
|
+
lay: ["Rest Chin", "Lie Flat", "Lie Face Down"],
|
|
14
|
+
floor: ["Seiza", "Cross Legged", "Knee Hug"],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const DEFAULT_FALLBACKS = {
|
|
18
|
+
done: { poseType: "stand" as const, action: "Yay", bubble: "Done!" },
|
|
19
|
+
thinking: { poseType: "stand" as const, action: "Wait", bubble: "Thinking..." },
|
|
20
|
+
working: { poseType: "stand" as const, action: "Arms Crossed", bubble: "Working" },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type ActionResult = { poseType: "stand" | "sit" | "lay" | "floor"; action: string; bubble: string };
|
|
24
|
+
|
|
25
|
+
interface SkillsConfig {
|
|
26
|
+
actions: typeof DEFAULT_ACTIONS;
|
|
27
|
+
fallbacks: typeof DEFAULT_FALLBACKS;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SKILLS_CONFIG_PATH = path.join(process.env.HOME || "~", ".openclaw/focus-world/skills-config.json");
|
|
31
|
+
|
|
32
|
+
let cachedConfig: SkillsConfig | null = null;
|
|
33
|
+
let cachedConfigMtime = 0;
|
|
34
|
+
|
|
35
|
+
function loadSkillsConfig(): SkillsConfig {
|
|
36
|
+
try {
|
|
37
|
+
if (fs.existsSync(SKILLS_CONFIG_PATH)) {
|
|
38
|
+
const stat = fs.statSync(SKILLS_CONFIG_PATH);
|
|
39
|
+
if (stat.mtimeMs !== cachedConfigMtime || !cachedConfig) {
|
|
40
|
+
const raw = fs.readFileSync(SKILLS_CONFIG_PATH, "utf-8");
|
|
41
|
+
cachedConfig = JSON.parse(raw) as SkillsConfig;
|
|
42
|
+
cachedConfigMtime = stat.mtimeMs;
|
|
43
|
+
pluginApi?.logger.info(`[focus] Loaded skills config`);
|
|
44
|
+
}
|
|
45
|
+
return cachedConfig!;
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
pluginApi?.logger.warn(`[focus] Failed to load skills config: ${e}`);
|
|
49
|
+
}
|
|
50
|
+
return { actions: DEFAULT_ACTIONS, fallbacks: DEFAULT_FALLBACKS };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let service: FocusForwarderService | null = null;
|
|
54
|
+
let pluginApi: OpenClawPluginApi | null = null;
|
|
55
|
+
let coreApiPromise: Promise<any> | null = null;
|
|
56
|
+
|
|
57
|
+
// Find OpenClaw package root
|
|
58
|
+
function findPackageRoot(startDir: string, name: string): string | null {
|
|
59
|
+
let dir = startDir;
|
|
60
|
+
for (;;) {
|
|
61
|
+
const pkgPath = path.join(dir, "package.json");
|
|
62
|
+
try {
|
|
63
|
+
if (fs.existsSync(pkgPath)) {
|
|
64
|
+
const raw = fs.readFileSync(pkgPath, "utf8");
|
|
65
|
+
const pkg = JSON.parse(raw) as { name?: string };
|
|
66
|
+
if (pkg.name === name) return dir;
|
|
67
|
+
}
|
|
68
|
+
} catch {}
|
|
69
|
+
const parent = path.dirname(dir);
|
|
70
|
+
if (parent === dir) return null;
|
|
71
|
+
dir = parent;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resolveOpenClawRoot(): string {
|
|
76
|
+
const override = process.env.OPENCLAW_ROOT?.trim();
|
|
77
|
+
if (override) return override;
|
|
78
|
+
|
|
79
|
+
const candidates = new Set<string>();
|
|
80
|
+
if (process.argv[1]) candidates.add(path.dirname(process.argv[1]));
|
|
81
|
+
candidates.add(process.cwd());
|
|
82
|
+
try {
|
|
83
|
+
const urlPath = fileURLToPath(import.meta.url);
|
|
84
|
+
candidates.add(path.dirname(urlPath));
|
|
85
|
+
} catch {}
|
|
86
|
+
|
|
87
|
+
for (const start of candidates) {
|
|
88
|
+
const found = findPackageRoot(start, "openclaw");
|
|
89
|
+
if (found) return found;
|
|
90
|
+
}
|
|
91
|
+
throw new Error("Unable to resolve OpenClaw root");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Load core API (same approach as voice-call plugin)
|
|
95
|
+
async function loadCoreApi() {
|
|
96
|
+
if (coreApiPromise) return coreApiPromise;
|
|
97
|
+
coreApiPromise = (async () => {
|
|
98
|
+
const distPath = path.join(resolveOpenClawRoot(), "dist", "extensionAPI.js");
|
|
99
|
+
if (!fs.existsSync(distPath)) {
|
|
100
|
+
throw new Error(`Missing extensionAPI.js at ${distPath}`);
|
|
101
|
+
}
|
|
102
|
+
return await import(pathToFileURL(distPath).href);
|
|
103
|
+
})();
|
|
104
|
+
return coreApiPromise;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Get done action for a specific poseType
|
|
108
|
+
function getDoneActionForPose(poseType: string): string {
|
|
109
|
+
const config = loadSkillsConfig();
|
|
110
|
+
const actions = config.actions[poseType as keyof typeof config.actions];
|
|
111
|
+
// Pick a "done" style action based on poseType
|
|
112
|
+
if (poseType === "stand") return actions.includes("Yay") ? "Yay" : actions[0];
|
|
113
|
+
if (poseType === "sit") return actions.includes("Laze") ? "Laze" : actions[0];
|
|
114
|
+
if (poseType === "lay") return actions.includes("Rest Chin") ? "Rest Chin" : actions[0];
|
|
115
|
+
if (poseType === "floor") return actions.includes("Cross Legged") ? "Cross Legged" : actions[0];
|
|
116
|
+
return actions[0];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Fallback action picker (no LLM)
|
|
120
|
+
function pickActionFallback(context: string): ActionResult {
|
|
121
|
+
const config = loadSkillsConfig();
|
|
122
|
+
const fallbacks = config.fallbacks;
|
|
123
|
+
const ctx = context.toLowerCase();
|
|
124
|
+
if (ctx.includes("done") || ctx.includes("finish") || ctx.includes("complete")) {
|
|
125
|
+
return fallbacks.done;
|
|
126
|
+
}
|
|
127
|
+
if (ctx.includes("think") || ctx.includes("start")) {
|
|
128
|
+
return fallbacks.thinking;
|
|
129
|
+
}
|
|
130
|
+
if (ctx.includes("tool") || ctx.includes("exec")) {
|
|
131
|
+
return fallbacks.working;
|
|
132
|
+
}
|
|
133
|
+
return fallbacks.working;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// LLM-based action picker using extensionAPI
|
|
137
|
+
async function pickActionWithLLM(context: string): Promise<ActionResult> {
|
|
138
|
+
try {
|
|
139
|
+
const coreApi = await loadCoreApi();
|
|
140
|
+
const { runEmbeddedPiAgent } = coreApi;
|
|
141
|
+
|
|
142
|
+
// Get provider/model from config
|
|
143
|
+
const primary = pluginApi?.config?.agents?.defaults?.model?.primary;
|
|
144
|
+
const provider = typeof primary === "string" ? primary.split("/")[0] : undefined;
|
|
145
|
+
const model = typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined;
|
|
146
|
+
|
|
147
|
+
pluginApi?.logger.info(`[focus] LLM params: provider=${provider} model=${model} primary=${primary}`);
|
|
148
|
+
|
|
149
|
+
// Get auth profile (e.g., "synthetic:default")
|
|
150
|
+
const authProfiles = pluginApi?.config?.auth?.profiles || {};
|
|
151
|
+
const authProfileId = Object.keys(authProfiles).find(k => k.startsWith(provider + ":")) || undefined;
|
|
152
|
+
|
|
153
|
+
const config = loadSkillsConfig();
|
|
154
|
+
const actions = config.actions;
|
|
155
|
+
const prompt = `Pick avatar pose for: "${context}"
|
|
156
|
+
Available poseTypes and actions:
|
|
157
|
+
- stand: ${actions.stand.join(", ")}
|
|
158
|
+
- sit: ${actions.sit.join(", ")}
|
|
159
|
+
- lay: ${actions.lay.join(", ")}
|
|
160
|
+
- floor: ${actions.floor.join(", ")}
|
|
161
|
+
Return ONLY JSON: {"poseType":"stand|sit|lay|floor","action":"<action name>","bubble":"<5 words>"}`;
|
|
162
|
+
|
|
163
|
+
let result;
|
|
164
|
+
try {
|
|
165
|
+
result = await runEmbeddedPiAgent({
|
|
166
|
+
sessionId: `focus-action-${Date.now()}`,
|
|
167
|
+
sessionFile: path.join(process.env.HOME || "~", ".openclaw/focus-world/llm-session.json"),
|
|
168
|
+
workspaceDir: pluginApi?.config?.agents?.defaults?.workspace || process.cwd(),
|
|
169
|
+
config: pluginApi?.config,
|
|
170
|
+
prompt,
|
|
171
|
+
provider,
|
|
172
|
+
model,
|
|
173
|
+
authProfileId,
|
|
174
|
+
timeoutMs: 10000,
|
|
175
|
+
runId: `focus-${Date.now()}`,
|
|
176
|
+
lane: "focus-llm",
|
|
177
|
+
});
|
|
178
|
+
} catch (llmError) {
|
|
179
|
+
pluginApi?.logger.error(`[focus] runEmbeddedPiAgent error: ${llmError}`);
|
|
180
|
+
return pickActionFallback(context);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const text = (result?.payloads || [])
|
|
184
|
+
.filter((p: any) => !p.isError && typeof p.text === "string")
|
|
185
|
+
.map((p: any) => p.text)
|
|
186
|
+
.join("\n")
|
|
187
|
+
.trim();
|
|
188
|
+
|
|
189
|
+
if (!text) return pickActionFallback(context);
|
|
190
|
+
const cleaned = text.replace(/```json?\n?|\n?```/g, "").trim();
|
|
191
|
+
const parsed = JSON.parse(cleaned);
|
|
192
|
+
// Validate new format
|
|
193
|
+
if (parsed.poseType && parsed.action) {
|
|
194
|
+
return parsed as ActionResult;
|
|
195
|
+
}
|
|
196
|
+
// Fallback if LLM returns old format or invalid
|
|
197
|
+
pluginApi?.logger.warn(`[focus] LLM returned invalid format: ${cleaned}`);
|
|
198
|
+
return pickActionFallback(context);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
pluginApi?.logger.warn(`LLM pick failed: ${e}`);
|
|
201
|
+
return pickActionFallback(context);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Per-agent state tracking
|
|
206
|
+
interface AgentState {
|
|
207
|
+
pendingLLM: boolean;
|
|
208
|
+
llmCancelled: boolean;
|
|
209
|
+
cooldownStartTime: number;
|
|
210
|
+
lastLLMResult?: { poseType: string; action: string };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const agentStates = new Map<string, AgentState>();
|
|
214
|
+
const SYNC_COOLDOWN_MS = 15000;
|
|
215
|
+
const AGENT_STATE_TTL_MS = 60 * 60 * 1000; // 1 hour TTL for cleanup
|
|
216
|
+
|
|
217
|
+
function getAgentState(agentId: string): AgentState {
|
|
218
|
+
let state = agentStates.get(agentId);
|
|
219
|
+
if (!state) {
|
|
220
|
+
state = { pendingLLM: false, llmCancelled: false, cooldownStartTime: 0 };
|
|
221
|
+
agentStates.set(agentId, state);
|
|
222
|
+
}
|
|
223
|
+
return state;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Cleanup stale agent states to prevent memory leaks
|
|
227
|
+
function cleanupStaleAgents() {
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
for (const [agentId, state] of agentStates) {
|
|
230
|
+
if (!state.pendingLLM && now - state.cooldownStartTime > AGENT_STATE_TTL_MS) {
|
|
231
|
+
agentStates.delete(agentId);
|
|
232
|
+
pluginApi?.logger.debug(`[focus] cleaned up stale agent state: ${agentId}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function truncateLog(text: string, maxLen = 150): string {
|
|
238
|
+
return text.length > maxLen ? text.slice(0, maxLen) + "..." : text;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 内部 fallback 发送(给 syncStatus 用,不检查 identity)
|
|
242
|
+
function sendFallbackInternal(context: string, agentId: string) {
|
|
243
|
+
if (!service?.isConnected()) return;
|
|
244
|
+
const state = getAgentState(agentId);
|
|
245
|
+
const fallback = pickActionFallback(context);
|
|
246
|
+
const poseType = state.lastLLMResult?.poseType || fallback.poseType;
|
|
247
|
+
const action = state.lastLLMResult?.action || fallback.action;
|
|
248
|
+
service.sendStatus(poseType, action, fallback.bubble || "Working", truncateLog(context));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function syncStatus(context: string, agentId: string) {
|
|
252
|
+
if (!service?.isConnected()) {
|
|
253
|
+
pluginApi?.logger.info(`[focus] skipped: not connected`);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const state = getAgentState(agentId);
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
const elapsed = now - state.cooldownStartTime;
|
|
260
|
+
const inCooldown = state.cooldownStartTime > 0 && elapsed < SYNC_COOLDOWN_MS;
|
|
261
|
+
|
|
262
|
+
pluginApi?.logger.info(`[focus] syncStatus: agent=${agentId} elapsed=${elapsed}ms inCooldown=${inCooldown} pendingLLM=${state.pendingLLM}`);
|
|
263
|
+
|
|
264
|
+
// In cooldown OR LLM pending: send fallback
|
|
265
|
+
if (inCooldown || state.pendingLLM) {
|
|
266
|
+
pluginApi?.logger.info(`[focus] sending fallback (inCooldown=${inCooldown} pendingLLM=${state.pendingLLM})`);
|
|
267
|
+
sendFallbackInternal(context, agentId);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Start new LLM request
|
|
272
|
+
state.cooldownStartTime = now;
|
|
273
|
+
state.pendingLLM = true;
|
|
274
|
+
state.llmCancelled = false;
|
|
275
|
+
pluginApi?.logger.info(`[focus] calling LLM for agent ${agentId}`);
|
|
276
|
+
|
|
277
|
+
pickActionWithLLM(context)
|
|
278
|
+
.then((action) => {
|
|
279
|
+
state.pendingLLM = false;
|
|
280
|
+
if (state.llmCancelled) {
|
|
281
|
+
pluginApi?.logger.debug(`[focus] LLM result discarded (cancelled) for agent ${agentId}`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
state.lastLLMResult = { poseType: action.poseType, action: action.action };
|
|
285
|
+
pluginApi?.logger.debug(`[focus] LLM result: ${JSON.stringify(action)}`);
|
|
286
|
+
service?.sendStatus(action.poseType, action.action, action.bubble || "Working", truncateLog(context));
|
|
287
|
+
})
|
|
288
|
+
.catch((e) => {
|
|
289
|
+
state.pendingLLM = false;
|
|
290
|
+
pluginApi?.logger.warn(`[focus] LLM failed for agent ${agentId}: ${e}`);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const plugin = {
|
|
295
|
+
id: "focus-forwarder",
|
|
296
|
+
name: "Focus Forwarder",
|
|
297
|
+
configSchema: { parse },
|
|
298
|
+
|
|
299
|
+
register(api: OpenClawPluginApi) {
|
|
300
|
+
pluginApi = api;
|
|
301
|
+
|
|
302
|
+
api.registerService({
|
|
303
|
+
id: "focus-forwarder",
|
|
304
|
+
start: (ctx) => {
|
|
305
|
+
const cfg = parse(ctx.config.plugins?.entries?.["focus-forwarder"]?.config) as FocusForwarderConfig;
|
|
306
|
+
service = new FocusForwarderService(cfg, api.logger);
|
|
307
|
+
return service.start();
|
|
308
|
+
},
|
|
309
|
+
stop: () => service?.stop(),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
api.registerTool({
|
|
313
|
+
name: "focus_join",
|
|
314
|
+
description: "Join Focus world with userId",
|
|
315
|
+
parameters: {
|
|
316
|
+
type: "object",
|
|
317
|
+
properties: { userId: { type: "string", description: "User ID to join Focus world" } },
|
|
318
|
+
required: ["userId"],
|
|
319
|
+
},
|
|
320
|
+
execute: async (_toolCallId, params) => {
|
|
321
|
+
const identityPath = path.join(process.env.HOME || "~", ".openclaw/focus-world/identity.json");
|
|
322
|
+
let userId = (params as any)?.userId;
|
|
323
|
+
if (!userId) {
|
|
324
|
+
try { userId = JSON.parse(fs.readFileSync(identityPath, "utf-8")).userId; } catch {}
|
|
325
|
+
}
|
|
326
|
+
if (!userId) return { success: false, error: "No userId" };
|
|
327
|
+
const result = await service?.join(userId);
|
|
328
|
+
return result ? { success: true, authKey: result } : { success: false, error: "Failed" };
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
api.registerTool({
|
|
333
|
+
name: "focus_leave",
|
|
334
|
+
description: "Leave Focus world",
|
|
335
|
+
parameters: { type: "object", properties: {} },
|
|
336
|
+
execute: async (_toolCallId) => {
|
|
337
|
+
const result = await service?.leave();
|
|
338
|
+
return result ? { success: true } : { success: false, error: "Failed or not connected" };
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
api.registerTool({
|
|
343
|
+
name: "focus_action",
|
|
344
|
+
description: "Send an action/pose to Focus world (e.g., dance, wave, sit, stand)",
|
|
345
|
+
parameters: {
|
|
346
|
+
type: "object",
|
|
347
|
+
properties: {
|
|
348
|
+
poseType: { type: "string", description: "Pose type: stand, sit, lay, or floor" },
|
|
349
|
+
action: { type: "string", description: "Action name (e.g., High Five, Typing with Keyboard)" },
|
|
350
|
+
bubble: { type: "string", description: "Optional bubble text to display (max 5 words)" },
|
|
351
|
+
},
|
|
352
|
+
required: ["poseType", "action"],
|
|
353
|
+
},
|
|
354
|
+
execute: async (_toolCallId, params) => {
|
|
355
|
+
const { poseType, action, bubble } = (params || {}) as { poseType?: string; action?: string; bubble?: string };
|
|
356
|
+
if (!poseType || !action) {
|
|
357
|
+
return { success: false, error: "poseType and action parameters are required" };
|
|
358
|
+
}
|
|
359
|
+
if (!["stand", "sit", "lay", "floor"].includes(poseType)) {
|
|
360
|
+
return { success: false, error: `Invalid poseType: ${poseType}. Must be stand, sit, lay, or floor` };
|
|
361
|
+
}
|
|
362
|
+
if (!service?.hasValidIdentity() || !service?.isConnected()) {
|
|
363
|
+
return { success: false, error: "Not connected to Focus world" };
|
|
364
|
+
}
|
|
365
|
+
const config = loadSkillsConfig();
|
|
366
|
+
if (!config?.actions?.stand || !config?.actions?.sit || !config?.actions?.lay || !config?.actions?.floor) {
|
|
367
|
+
return { success: false, error: "Invalid skills config" };
|
|
368
|
+
}
|
|
369
|
+
// Validate action exists in the specified poseType
|
|
370
|
+
const poseActions = config.actions[poseType as keyof typeof config.actions];
|
|
371
|
+
const matched = poseActions.find((a: string) => a.toLowerCase() === action.toLowerCase());
|
|
372
|
+
if (!matched) {
|
|
373
|
+
return { success: false, error: `Unknown action "${action}" for poseType "${poseType}"`, available: poseActions };
|
|
374
|
+
}
|
|
375
|
+
// Update lastLLMResult for main agent
|
|
376
|
+
const state = getAgentState("main");
|
|
377
|
+
state.lastLLMResult = { poseType, action: matched };
|
|
378
|
+
service.sendStatus(poseType, matched, bubble || matched, `User requested: ${action}`);
|
|
379
|
+
return { success: true, poseType, action: matched, bubble: bubble || matched };
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// sendWithLLM: use LLM with cooldown
|
|
384
|
+
const sendWithLLM = (context: string, agentId: string) => {
|
|
385
|
+
pluginApi?.logger.info(`[focus] hook fired: agent=${agentId} context="${context.slice(0, 50)}" hasIdentity=${service?.hasValidIdentity()}`);
|
|
386
|
+
if (service?.hasValidIdentity()) syncStatus(context, agentId);
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// sendFallback: always use fallback, no LLM (for after_tool_call)
|
|
390
|
+
const sendFallback = (context: string, agentId: string) => {
|
|
391
|
+
pluginApi?.logger.info(`[focus] fallback: agent=${agentId} context="${context.slice(0, 50)}"`);
|
|
392
|
+
if (service?.hasValidIdentity()) sendFallbackInternal(context, agentId);
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
api.on("message_received", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
|
|
396
|
+
const agentId = ctx?.agentId || ctx?.sessionKey || "main";
|
|
397
|
+
const preview = event.content?.slice(0, 30) || "new message";
|
|
398
|
+
sendWithLLM(`[${agentId}] Received: ${preview}`, agentId);
|
|
399
|
+
});
|
|
400
|
+
api.on("before_agent_start", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
|
|
401
|
+
const agentId = ctx?.agentId || ctx?.sessionKey || "main";
|
|
402
|
+
const prompt = event?.prompt?.slice(0, 100) || "thinking";
|
|
403
|
+
sendWithLLM(`[${agentId}] Processing: ${prompt}`, agentId);
|
|
404
|
+
});
|
|
405
|
+
api.on("before_tool_call", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
|
|
406
|
+
const agentId = ctx?.agentId || ctx?.sessionKey || "main";
|
|
407
|
+
pluginApi?.logger.info(`[focus] before_tool_call ctx: ${JSON.stringify(ctx)}`);
|
|
408
|
+
const params = event.params ? JSON.stringify(event.params) : "";
|
|
409
|
+
sendWithLLM(`[${agentId}] Tool: ${event.toolName}${params ? ` ${params}` : ""}`, agentId);
|
|
410
|
+
});
|
|
411
|
+
api.on("after_tool_call", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
|
|
412
|
+
const agentId = ctx?.agentId || ctx?.sessionKey || "main";
|
|
413
|
+
sendFallback(`[${agentId}] Done: ${event.toolName}`, agentId);
|
|
414
|
+
});
|
|
415
|
+
api.on("agent_end", (event: any, ctx?: { agentId?: string; sessionKey?: string }) => {
|
|
416
|
+
const agentId = ctx?.agentId || ctx?.sessionKey || "main";
|
|
417
|
+
// 不再 cancel pending LLM,让结果正常发送
|
|
418
|
+
// Use last LLM poseType but pick done action for that pose
|
|
419
|
+
if (!service?.hasValidIdentity() || !service?.isConnected()) return;
|
|
420
|
+
const state = getAgentState(agentId);
|
|
421
|
+
const poseType = state.lastLLMResult?.poseType || "stand";
|
|
422
|
+
const action = getDoneActionForPose(poseType);
|
|
423
|
+
service.sendStatus(poseType, action, "Done!", truncateLog(`[${agentId}] Task complete`));
|
|
424
|
+
cleanupStaleAgents();
|
|
425
|
+
});
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
export default plugin;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "focus-forwarder",
|
|
3
|
+
"name": "Focus Forwarder",
|
|
4
|
+
"description": "Forward agent status to Focus world",
|
|
5
|
+
"version": "0.2.1",
|
|
6
|
+
"author": "OpenClaw",
|
|
7
|
+
"skills": ["./skills/focus-forwarder"],
|
|
8
|
+
"configSchema": {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"additionalProperties": false,
|
|
11
|
+
"properties": {
|
|
12
|
+
"wsUrl": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"default": "ws://127.0.0.1:48870"
|
|
15
|
+
},
|
|
16
|
+
"enabled": {
|
|
17
|
+
"type": "boolean",
|
|
18
|
+
"default": true
|
|
19
|
+
},
|
|
20
|
+
"cooldownMs": {
|
|
21
|
+
"type": "number",
|
|
22
|
+
"default": 15000
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"uiHints": {
|
|
27
|
+
"wsUrl": { "label": "WebSocket URL", "placeholder": "ws://127.0.0.1:48870/ws/openclaw" },
|
|
28
|
+
"enabled": { "label": "Enable Plugin" },
|
|
29
|
+
"cooldownMs": { "label": "Status Cooldown (ms)", "placeholder": "15000" }
|
|
30
|
+
}
|
|
31
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yahaha-studio/focus-forwarder",
|
|
3
|
+
"version": "0.0.1-alpha.0",
|
|
4
|
+
"description": "Forward OpenClaw agent events to external WebSocket server for visualization",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"openclaw": {
|
|
8
|
+
"extensions": [
|
|
9
|
+
"./index.ts"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"tag": "alpha"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"ws": "^8.19.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/ws": "^8.18.1",
|
|
21
|
+
"openclaw": "*"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: focus-forwarder
|
|
3
|
+
description: Sync OpenClaw agent status to Focus virtual world
|
|
4
|
+
metadata: {"openclaw":{"requires":{"config":["plugins.entries.focus-forwarder.enabled"]},"emoji":"target"}}
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Focus Forwarder
|
|
8
|
+
|
|
9
|
+
Sync your working status to Focus virtual world, and perform actions on command.
|
|
10
|
+
|
|
11
|
+
## Plugin Setup
|
|
12
|
+
|
|
13
|
+
Install and enable the plugin before using this skill:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
openclaw plugins install @yahaha-studio/focus-forwarder@0.0.2-alpha.0
|
|
17
|
+
openclaw plugins enable focus-forwarder
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Manual step required: restart OpenClaw after enabling the plugin.
|
|
21
|
+
|
|
22
|
+
## Tools
|
|
23
|
+
|
|
24
|
+
### focus_join
|
|
25
|
+
|
|
26
|
+
Join Focus World with a userId.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
focus_join(userId: "your-user-id")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
AuthKey is automatically saved to `~/.openclaw/focus-world/identity.json`.
|
|
33
|
+
|
|
34
|
+
### focus_leave
|
|
35
|
+
|
|
36
|
+
Leave Focus World and clear authKey.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
focus_leave()
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### focus_action
|
|
43
|
+
|
|
44
|
+
Send an action/pose to Focus World. Use this when a user asks you to do something in Focus (for example: "dance", "wave", "sit and type").
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
focus_action(poseType: "stand", action: "Yay", bubble: "Dancing!")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Parameters:
|
|
51
|
+
- `poseType` (required): Pose type - `stand`, `sit`, `lay`, or `floor`
|
|
52
|
+
- `action` (required): Action name to perform (must match poseType)
|
|
53
|
+
- `bubble` (optional): Text to display in bubble (max 5 words)
|
|
54
|
+
|
|
55
|
+
## Available Actions
|
|
56
|
+
|
|
57
|
+
Use action names exactly as listed below.
|
|
58
|
+
|
|
59
|
+
### Standing Actions
|
|
60
|
+
- HIgh Five
|
|
61
|
+
- Listen Music
|
|
62
|
+
- Arm Stretch
|
|
63
|
+
- BackBend Stretch
|
|
64
|
+
- Making Selfie
|
|
65
|
+
- Arms Crossed
|
|
66
|
+
- Epiphany
|
|
67
|
+
- Angry
|
|
68
|
+
- Yay
|
|
69
|
+
- Dance
|
|
70
|
+
- Sing
|
|
71
|
+
- Tired
|
|
72
|
+
- Wait
|
|
73
|
+
- Stand Phone Talk
|
|
74
|
+
- Stand Phone Play
|
|
75
|
+
- Curtsy
|
|
76
|
+
|
|
77
|
+
### Sitting Actions
|
|
78
|
+
- Typing with Keyboard
|
|
79
|
+
- Thinking
|
|
80
|
+
- Study Look At
|
|
81
|
+
- Writing
|
|
82
|
+
- Crazy
|
|
83
|
+
- Homework
|
|
84
|
+
- Take Notes
|
|
85
|
+
- Hand Cramp
|
|
86
|
+
- Dozing
|
|
87
|
+
- Phone Talk
|
|
88
|
+
- Situp with Arms Crossed
|
|
89
|
+
- Situp with Cross Legs
|
|
90
|
+
- Relax with Arms Crossed
|
|
91
|
+
- Eating
|
|
92
|
+
- Laze
|
|
93
|
+
- Laze with Cross Legs
|
|
94
|
+
- Typing with Phone
|
|
95
|
+
- Sit with Arm Stretch
|
|
96
|
+
- Drink
|
|
97
|
+
- Sit with Making Selfie
|
|
98
|
+
- Play Game
|
|
99
|
+
- Situp Sleep
|
|
100
|
+
- Sit Phone Play
|
|
101
|
+
|
|
102
|
+
### Laying Actions
|
|
103
|
+
- Bend One Knee
|
|
104
|
+
- Sleep Curl Up Side way
|
|
105
|
+
- Rest Chin
|
|
106
|
+
- Lie Flat
|
|
107
|
+
- Lie Face Down
|
|
108
|
+
- Lie Side
|
|
109
|
+
|
|
110
|
+
### Floor Actions
|
|
111
|
+
- Seiza
|
|
112
|
+
- Cross Legged
|
|
113
|
+
- Knee Hug
|
|
114
|
+
|
|
115
|
+
## Example Commands
|
|
116
|
+
|
|
117
|
+
User says: "Can you dance in Focus?"
|
|
118
|
+
-> `focus_action(poseType: "stand", action: "Yay", bubble: "Dancing!")`
|
|
119
|
+
|
|
120
|
+
User says: "Wave your hand"
|
|
121
|
+
-> `focus_action(poseType: "stand", action: "HIgh Five", bubble: "Hi!")`
|
|
122
|
+
|
|
123
|
+
User says: "Sit down and type"
|
|
124
|
+
-> `focus_action(poseType: "sit", action: "Typing with Keyboard", bubble: "Working...")`
|
|
125
|
+
|
|
126
|
+
User says: "Lie flat"
|
|
127
|
+
-> `focus_action(poseType: "lay", action: "Lie Flat", bubble: "Relaxing...")`
|
|
128
|
+
|
|
129
|
+
## Files
|
|
130
|
+
|
|
131
|
+
- `~/.openclaw/focus-world/identity.json` - userId and authKey (managed by plugin)
|
|
132
|
+
- `~/.openclaw/focus-world/skills-config.json` - actions and fallbacks config
|
|
133
|
+
|
|
134
|
+
## Skills Config
|
|
135
|
+
|
|
136
|
+
Custom actions can be configured in `~/.openclaw/focus-world/skills-config.json`:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"actions": {
|
|
141
|
+
"stand": ["HIgh Five", "Listen Music", "Arm Stretch", "BackBend Stretch", "Making Selfie", "Arms Crossed", "Epiphany", "Angry", "Yay", "Dance", "Sing", "Tired", "Wait", "Stand Phone Talk", "Stand Phone Play", "Curtsy"],
|
|
142
|
+
"sit": ["Typing with Keyboard", "Thinking", "Study Look At", "Writing", "Crazy", "Homework", "Take Notes", "Hand Cramp", "Dozing", "Phone Talk", "Situp with Arms Crossed", "Situp with Cross Legs", "Relax with Arms Crossed", "Eating", "Laze", "Laze with Cross Legs", "Typing with Phone", "Sit with Arm Stretch", "Drink", "Sit with Making Selfie", "Play Game", "Situp Sleep", "Sit Phone Play"],
|
|
143
|
+
"lay": ["Bend One Knee", "Sleep Curl Up Side way", "Rest Chin", "Lie Flat", "Lie Face Down", "Lie Side"],
|
|
144
|
+
"floor": ["Seiza", "Cross Legged", "Knee Hug"]
|
|
145
|
+
},
|
|
146
|
+
"fallbacks": {
|
|
147
|
+
"done": { "poseType": "stand", "action": "Yay", "bubble": "Done!" },
|
|
148
|
+
"thinking": { "poseType": "stand", "action": "Wait", "bubble": "Thinking..." },
|
|
149
|
+
"working": { "poseType": "stand", "action": "Arms Crossed", "bubble": "Working" }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## How It Works
|
|
155
|
+
|
|
156
|
+
- Plugin automatically syncs status when you are working (tool calls trigger updates)
|
|
157
|
+
- Use `focus_action` to manually perform specific actions on user request
|
|
158
|
+
- Bubble text shows short status (<=5 words)
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FocusForwarderConfig } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function parse(value: unknown): FocusForwarderConfig {
|
|
4
|
+
const config = (value ?? {}) as Partial<FocusForwarderConfig>;
|
|
5
|
+
return {
|
|
6
|
+
wsUrl: config.wsUrl ?? "ws://127.0.0.1:48870/ws/openclaw",
|
|
7
|
+
enabled: config.enabled ?? true,
|
|
8
|
+
cooldownMs: config.cooldownMs ?? 15000,
|
|
9
|
+
};
|
|
10
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import type { Logger } from "openclaw/plugin-sdk";
|
|
5
|
+
import type { FocusForwarderConfig, FocusIdentity, StatusPayload } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const IDENTITY_DIR = path.join(process.env.HOME || "~", ".openclaw/focus-world");
|
|
8
|
+
const IDENTITY_PATH = path.join(IDENTITY_DIR, "identity.json");
|
|
9
|
+
|
|
10
|
+
export class FocusForwarderService {
|
|
11
|
+
private ws: WebSocket | null = null;
|
|
12
|
+
private stopped = false;
|
|
13
|
+
private reconnectTimeout: NodeJS.Timeout | null = null;
|
|
14
|
+
private lastStatusTime = 0;
|
|
15
|
+
private identity: FocusIdentity | null = null;
|
|
16
|
+
private joinResolve: ((authKey: string) => void) | null = null;
|
|
17
|
+
|
|
18
|
+
constructor(private config: FocusForwarderConfig, private logger: Logger) {}
|
|
19
|
+
|
|
20
|
+
async start(): Promise<void> {
|
|
21
|
+
if (!this.config.enabled) return;
|
|
22
|
+
this.identity = this.loadIdentity();
|
|
23
|
+
this.stopped = false;
|
|
24
|
+
this.connect();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async stop(): Promise<void> {
|
|
28
|
+
this.stopped = true;
|
|
29
|
+
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
|
|
30
|
+
this.ws?.close();
|
|
31
|
+
this.ws = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async join(userId: string): Promise<string | null> {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
this.identity = { userId };
|
|
37
|
+
this.joinResolve = resolve;
|
|
38
|
+
const sendJoin = () => this.ws?.send(JSON.stringify({ type: "join", userId }));
|
|
39
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
40
|
+
sendJoin();
|
|
41
|
+
} else {
|
|
42
|
+
this.ws?.once("open", sendJoin);
|
|
43
|
+
}
|
|
44
|
+
setTimeout(() => { if (this.joinResolve) { this.joinResolve = null; resolve(null); } }, 10000);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private connect(): void {
|
|
49
|
+
if (this.stopped) return;
|
|
50
|
+
this.ws = new WebSocket(this.config.wsUrl);
|
|
51
|
+
this.ws.on("open", () => {
|
|
52
|
+
this.logger.info(`Connected to ${this.config.wsUrl}`);
|
|
53
|
+
// 如果有有效 identity,自动发送 rejoin
|
|
54
|
+
if (this.identity?.userId && this.identity?.authKey) {
|
|
55
|
+
this.ws?.send(JSON.stringify({ type: "rejoin", userId: this.identity.userId, authKey: this.identity.authKey }));
|
|
56
|
+
this.logger.info(`Sent rejoin for ${this.identity.userId}`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
this.ws.on("message", (data) => this.handleMessage(data.toString()));
|
|
60
|
+
this.ws.on("close", () => { this.ws = null; if (!this.stopped) setTimeout(() => this.connect(), 2000); });
|
|
61
|
+
this.ws.on("error", () => {});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private handleMessage(data: string): void {
|
|
65
|
+
try {
|
|
66
|
+
const msg = JSON.parse(data);
|
|
67
|
+
if (msg.type === "join_ack" && msg.authKey && this.identity) {
|
|
68
|
+
this.identity.authKey = msg.authKey;
|
|
69
|
+
this.saveIdentity();
|
|
70
|
+
this.logger.info(`Joined as ${this.identity.userId}`);
|
|
71
|
+
this.joinResolve?.(msg.authKey);
|
|
72
|
+
this.joinResolve = null;
|
|
73
|
+
} else if (msg.type === "rejoin_failed" || msg.type === "auth_error") {
|
|
74
|
+
// AuthKey invalid/expired, clear it
|
|
75
|
+
this.logger.warn(`Auth failed: ${msg.reason || "unknown"}`);
|
|
76
|
+
this.clearAuthKey();
|
|
77
|
+
} else if (msg.type === "leave_ack") {
|
|
78
|
+
this.logger.info("Left Focus world");
|
|
79
|
+
}
|
|
80
|
+
} catch (e) {
|
|
81
|
+
this.logger.warn(`Failed to parse message: ${e}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private loadIdentity(): FocusIdentity | null {
|
|
86
|
+
try {
|
|
87
|
+
if (!fs.existsSync(IDENTITY_PATH)) return null;
|
|
88
|
+
const data = JSON.parse(fs.readFileSync(IDENTITY_PATH, "utf-8"));
|
|
89
|
+
// Validate required fields
|
|
90
|
+
if (!data.userId || typeof data.userId !== "string") return null;
|
|
91
|
+
return data;
|
|
92
|
+
} catch (e) {
|
|
93
|
+
this.logger.warn(`Failed to load identity: ${e}`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private saveIdentity(): void {
|
|
99
|
+
if (!this.identity?.userId) return;
|
|
100
|
+
try {
|
|
101
|
+
if (!fs.existsSync(IDENTITY_DIR)) fs.mkdirSync(IDENTITY_DIR, { recursive: true, mode: 0o700 });
|
|
102
|
+
fs.writeFileSync(IDENTITY_PATH, JSON.stringify(this.identity, null, 2), { mode: 0o600 });
|
|
103
|
+
} catch (e) {
|
|
104
|
+
this.logger.error(`Failed to save identity: ${e}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private clearAuthKey(): void {
|
|
109
|
+
if (!this.identity) return;
|
|
110
|
+
this.identity.authKey = undefined;
|
|
111
|
+
this.saveIdentity();
|
|
112
|
+
this.logger.info("AuthKey cleared");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
sendStatus(actions: StatusPayload["actions"], bubble: string, log: string): void {
|
|
116
|
+
if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return;
|
|
117
|
+
this.ws.send(JSON.stringify({ type: "status", userId: this.identity.userId, authKey: this.identity.authKey, actions, bubble, log }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
isConnected(): boolean { return this.ws?.readyState === WebSocket.OPEN && !!this.identity?.authKey; }
|
|
121
|
+
|
|
122
|
+
hasValidIdentity(): boolean { return !!this.identity?.userId && !!this.identity?.authKey; }
|
|
123
|
+
|
|
124
|
+
async leave(): Promise<boolean> {
|
|
125
|
+
if (!this.identity?.userId || !this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return false;
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
const handler = (data: WebSocket.Data) => {
|
|
128
|
+
try {
|
|
129
|
+
const msg = JSON.parse(data.toString());
|
|
130
|
+
if (msg.type === "leave_ack") {
|
|
131
|
+
this.ws?.off("message", handler);
|
|
132
|
+
this.clearAuthKey();
|
|
133
|
+
resolve(true);
|
|
134
|
+
}
|
|
135
|
+
} catch (e) {
|
|
136
|
+
this.logger.warn(`Failed to parse leave response: ${e}`);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
this.ws!.on("message", handler);
|
|
140
|
+
this.ws!.send(JSON.stringify({ type: "leave", userId: this.identity!.userId, authKey: this.identity!.authKey }));
|
|
141
|
+
setTimeout(() => { this.ws?.off("message", handler); resolve(false); }, 10000);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type FocusForwarderConfig = {
|
|
2
|
+
wsUrl: string;
|
|
3
|
+
enabled: boolean;
|
|
4
|
+
cooldownMs: number;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type FocusIdentity = {
|
|
8
|
+
userId: string;
|
|
9
|
+
authKey?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type JoinPayload = {
|
|
13
|
+
type: "join";
|
|
14
|
+
userId: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type JoinAckPayload = {
|
|
18
|
+
type: "join_ack";
|
|
19
|
+
authKey: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type LeavePayload = {
|
|
23
|
+
type: "leave";
|
|
24
|
+
userId: string;
|
|
25
|
+
authKey: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type StatusPayload = {
|
|
29
|
+
type: "status";
|
|
30
|
+
userId: string;
|
|
31
|
+
authKey: string;
|
|
32
|
+
actions: {
|
|
33
|
+
stand: string;
|
|
34
|
+
sit: string;
|
|
35
|
+
lay: string;
|
|
36
|
+
floor: string;
|
|
37
|
+
};
|
|
38
|
+
bubble: string;
|
|
39
|
+
log: string;
|
|
40
|
+
};
|