@2en/clawly-plugins 1.32.0-beta.2 → 1.32.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/config-setup.ts +102 -0
- package/gateway/notification.test.ts +221 -0
- package/gateway/notification.ts +91 -19
- package/gateway/weixin-login.test.ts +110 -0
- package/gateway/weixin-login.ts +64 -0
- package/package.json +1 -1
package/config-setup.ts
CHANGED
|
@@ -236,6 +236,66 @@ export function patchBrowser(config: OpenClawConfig): boolean {
|
|
|
236
236
|
// patchMemorySearch — migrated to clawly-config-defaults.json5
|
|
237
237
|
// patchHeartbeat — migrated to clawly-config-defaults.json5
|
|
238
238
|
|
|
239
|
+
/** Fields enforced by patchModelDefinitions on every gateway restart. */
|
|
240
|
+
const MODEL_ENFORCED_KEYS = ['contextWindow', 'maxTokens', 'input'] as const
|
|
241
|
+
|
|
242
|
+
/** Shallow equality — only supports primitives and arrays of primitives. */
|
|
243
|
+
function valuesEqual(a: unknown, b: unknown): boolean {
|
|
244
|
+
if (a === b) return true
|
|
245
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
246
|
+
return a.length === b.length && a.every((v, i) => v === b[i])
|
|
247
|
+
}
|
|
248
|
+
return false
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Enforce model definitions from defaults onto existing provider model arrays.
|
|
253
|
+
*
|
|
254
|
+
* deepMergeDefaults skips arrays ("config wins"), so stale model entries
|
|
255
|
+
* (e.g. wrong contextWindow from a previous auto target) persist forever.
|
|
256
|
+
* This patches each existing entry's enforced fields to match the canonical
|
|
257
|
+
* defaults from clawly-config-defaults.json5.
|
|
258
|
+
*/
|
|
259
|
+
export function patchModelDefinitions(
|
|
260
|
+
config: OpenClawConfig,
|
|
261
|
+
defaults: Record<string, unknown>,
|
|
262
|
+
): boolean {
|
|
263
|
+
const defaultProviders = defaults.models as Record<string, unknown> | undefined
|
|
264
|
+
const defaultGw = (defaultProviders?.providers as Record<string, unknown> | undefined)?.[
|
|
265
|
+
PROVIDER_NAME
|
|
266
|
+
] as Record<string, unknown> | undefined
|
|
267
|
+
const defaultModels = defaultGw?.models as Array<Record<string, unknown>> | undefined
|
|
268
|
+
if (!Array.isArray(defaultModels) || defaultModels.length === 0) return false
|
|
269
|
+
|
|
270
|
+
const configGw = config.models?.providers?.[PROVIDER_NAME] as Record<string, unknown> | undefined
|
|
271
|
+
const configModels = configGw?.models as Array<Record<string, unknown>> | undefined
|
|
272
|
+
if (!Array.isArray(configModels) || configModels.length === 0) return false
|
|
273
|
+
|
|
274
|
+
const defaultsById = new Map<string, Record<string, unknown>>()
|
|
275
|
+
for (const m of defaultModels) {
|
|
276
|
+
if (typeof m.id === 'string') defaultsById.set(m.id, m)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let dirty = false
|
|
280
|
+
for (const model of configModels) {
|
|
281
|
+
const id = model.id
|
|
282
|
+
if (typeof id !== 'string') continue
|
|
283
|
+
const canonical = defaultsById.get(id)
|
|
284
|
+
if (!canonical) continue
|
|
285
|
+
|
|
286
|
+
for (const key of MODEL_ENFORCED_KEYS) {
|
|
287
|
+
const canonicalVal = canonical[key]
|
|
288
|
+
if (canonicalVal === undefined) continue
|
|
289
|
+
if (!valuesEqual(model[key], canonicalVal)) {
|
|
290
|
+
model[key] = Array.isArray(canonicalVal) ? [...canonicalVal] : canonicalVal
|
|
291
|
+
dirty = true
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return dirty
|
|
297
|
+
}
|
|
298
|
+
|
|
239
299
|
// TODO: Re-enable patchToolPolicy once rollback-safe (deny is sticky — rolling back
|
|
240
300
|
// the plugin leaves web_search permanently denied). Tracked in ENG-1493.
|
|
241
301
|
// export function patchToolPolicy(config: Record<string, unknown>, pc: ConfigPluginConfig): boolean {
|
|
@@ -497,6 +557,42 @@ function loadDefaults(stateDir: string): Record<string, unknown> | null {
|
|
|
497
557
|
}
|
|
498
558
|
}
|
|
499
559
|
|
|
560
|
+
/**
|
|
561
|
+
* Ensure plugins.allow lists every installed non-bundled plugin.
|
|
562
|
+
*
|
|
563
|
+
* OpenClaw v2026.3+ enforces an explicit allowlist when non-bundled plugins
|
|
564
|
+
* are discovered in the extensions directory. Without this, sessions.resolve
|
|
565
|
+
* fails — which breaks cron delivery (the delivery step resolves the main
|
|
566
|
+
* session to inject results).
|
|
567
|
+
*
|
|
568
|
+
* We derive the set from plugins.installs keys (written by `openclaw plugins
|
|
569
|
+
* install`) plus 'clawly-plugins' as a baseline. User-installed plugins
|
|
570
|
+
* (e.g. openclaw-weixin) are picked up automatically because their install
|
|
571
|
+
* records land in plugins.installs.
|
|
572
|
+
*/
|
|
573
|
+
export function patchPluginsAllow(config: OpenClawConfig): boolean {
|
|
574
|
+
const plugins = (config.plugins ?? {}) as Record<string, unknown>
|
|
575
|
+
const installs = asObj(plugins.installs)
|
|
576
|
+
|
|
577
|
+
// Collect all installed plugin ids; always include clawly-plugins
|
|
578
|
+
const required = new Set<string>(['clawly-plugins'])
|
|
579
|
+
for (const id of Object.keys(installs)) {
|
|
580
|
+
required.add(id)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const existing = Array.isArray(plugins.allow) ? (plugins.allow as string[]) : []
|
|
584
|
+
const existingSet = new Set(existing)
|
|
585
|
+
|
|
586
|
+
// Check if the allowlist already contains all required ids
|
|
587
|
+
const missing = [...required].filter((id) => !existingSet.has(id))
|
|
588
|
+
if (missing.length === 0) return false
|
|
589
|
+
|
|
590
|
+
// Append missing ids (preserve any manually added entries)
|
|
591
|
+
plugins.allow = [...existing, ...missing]
|
|
592
|
+
config.plugins = plugins as OpenClawConfig['plugins']
|
|
593
|
+
return true
|
|
594
|
+
}
|
|
595
|
+
|
|
500
596
|
function reconcileRuntimeConfig(
|
|
501
597
|
api: PluginApi,
|
|
502
598
|
config: OpenClawConfig,
|
|
@@ -516,6 +612,7 @@ function reconcileRuntimeConfig(
|
|
|
516
612
|
}
|
|
517
613
|
|
|
518
614
|
let dirty = false
|
|
615
|
+
dirty = patchPluginsAllow(config) || dirty
|
|
519
616
|
dirty = patchEnvVars(config, pc, api) || dirty
|
|
520
617
|
dirty = patchAgent(config, pc) || dirty
|
|
521
618
|
dirty = patchGateway(config) || dirty
|
|
@@ -525,6 +622,11 @@ function reconcileRuntimeConfig(
|
|
|
525
622
|
const defaults = loadDefaults(stateDir)
|
|
526
623
|
if (defaults) {
|
|
527
624
|
dirty = deepMergeDefaults(config, defaults) || dirty
|
|
625
|
+
const modelsDirty = patchModelDefinitions(config, defaults)
|
|
626
|
+
if (modelsDirty) {
|
|
627
|
+
api.logger.info('Config setup: patched stale model definitions from defaults.')
|
|
628
|
+
}
|
|
629
|
+
dirty = modelsDirty || dirty
|
|
528
630
|
} else {
|
|
529
631
|
api.logger.warn('Config setup: failed to load defaults json5, skipping merge.')
|
|
530
632
|
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {beforeEach, describe, expect, mock, test} from 'bun:test'
|
|
2
|
+
import type {PluginApi} from '../types'
|
|
3
|
+
|
|
4
|
+
// ── fs mock ──────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const fileSystem = new Map<string, string>()
|
|
7
|
+
|
|
8
|
+
mock.module('node:fs', () => ({
|
|
9
|
+
default: {
|
|
10
|
+
readFileSync: (filePath: string, _encoding: string) => {
|
|
11
|
+
const content = fileSystem.get(filePath)
|
|
12
|
+
if (content === undefined) {
|
|
13
|
+
const err = new Error(`ENOENT: no such file '${filePath}'`) as NodeJS.ErrnoException
|
|
14
|
+
err.code = 'ENOENT'
|
|
15
|
+
throw err
|
|
16
|
+
}
|
|
17
|
+
if (content === '<<EPERM>>') {
|
|
18
|
+
const err = new Error(`EPERM: permission denied '${filePath}'`) as NodeJS.ErrnoException
|
|
19
|
+
err.code = 'EPERM'
|
|
20
|
+
throw err
|
|
21
|
+
}
|
|
22
|
+
return content
|
|
23
|
+
},
|
|
24
|
+
existsSync: (filePath: string) => fileSystem.has(filePath),
|
|
25
|
+
mkdirSync: () => {},
|
|
26
|
+
writeFileSync: () => {},
|
|
27
|
+
unlinkSync: () => {},
|
|
28
|
+
},
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
import {getAgentIdentity, parseIdentityFields, resolveAgentTitle} from './notification'
|
|
32
|
+
|
|
33
|
+
// ── helpers ──────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const logs: {level: string; msg: string}[] = []
|
|
36
|
+
|
|
37
|
+
function createMockApi(stateDir = '/data'): PluginApi {
|
|
38
|
+
return {
|
|
39
|
+
runtime: {state: {resolveStateDir: () => stateDir}},
|
|
40
|
+
logger: {
|
|
41
|
+
info: (msg: string) => logs.push({level: 'info', msg}),
|
|
42
|
+
warn: (msg: string) => logs.push({level: 'warn', msg}),
|
|
43
|
+
error: (msg: string) => logs.push({level: 'error', msg}),
|
|
44
|
+
},
|
|
45
|
+
} as unknown as PluginApi
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
fileSystem.clear()
|
|
50
|
+
logs.length = 0
|
|
51
|
+
delete process.env.OPENCLAW_WORKSPACE
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// ── parseIdentityFields ──────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
describe('parseIdentityFields', () => {
|
|
57
|
+
test('parses standard IDENTITY.md format', () => {
|
|
58
|
+
const content = [
|
|
59
|
+
'# IDENTITY.md - Who Am I?',
|
|
60
|
+
'',
|
|
61
|
+
'- **Name:** Tigermountain',
|
|
62
|
+
'- **Creature:** AI agent',
|
|
63
|
+
'- **Emoji:** 🐅',
|
|
64
|
+
'- **Avatar:** *(none yet)*',
|
|
65
|
+
].join('\n')
|
|
66
|
+
expect(parseIdentityFields(content)).toEqual({name: 'Tigermountain', emoji: '🐅'})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('parses without list markers', () => {
|
|
70
|
+
const content = 'Name: Alice\nEmoji: 🦊'
|
|
71
|
+
expect(parseIdentityFields(content)).toEqual({name: 'Alice', emoji: '🦊'})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('strips markdown heading syntax from labels', () => {
|
|
75
|
+
const content = '## Name: HeadingAgent\n## Emoji: 🎯'
|
|
76
|
+
expect(parseIdentityFields(content)).toEqual({name: 'HeadingAgent', emoji: '🎯'})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('returns empty when no name or emoji', () => {
|
|
80
|
+
const content = '- **Creature:** robot\n- **Vibe:** chill'
|
|
81
|
+
expect(parseIdentityFields(content)).toEqual({})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('returns empty for blank content', () => {
|
|
85
|
+
expect(parseIdentityFields('')).toEqual({})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('handles Windows line endings', () => {
|
|
89
|
+
const content = '- **Name:** WinBot\r\n- **Emoji:** 💻\r\n'
|
|
90
|
+
expect(parseIdentityFields(content)).toEqual({name: 'WinBot', emoji: '💻'})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('handles value without markdown wrapping', () => {
|
|
94
|
+
const content = '- **Name:** PlainName'
|
|
95
|
+
expect(parseIdentityFields(content)).toEqual({name: 'PlainName'})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('ignores lines without colon', () => {
|
|
99
|
+
const content = 'No colon here\nName: Valid'
|
|
100
|
+
expect(parseIdentityFields(content)).toEqual({name: 'Valid'})
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// ── getAgentIdentity ─────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
describe('getAgentIdentity', () => {
|
|
107
|
+
test('returns null for undefined agentId', () => {
|
|
108
|
+
expect(getAgentIdentity(undefined, createMockApi())).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('reads identity from convention workspace path', () => {
|
|
112
|
+
fileSystem.set('/data/openclaw.json', '{}')
|
|
113
|
+
fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- **Name:** Tiger\n- **Emoji:** 🐅')
|
|
114
|
+
expect(getAgentIdentity('clawly', createMockApi())).toEqual({name: 'Tiger', emoji: '🐅'})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('uses explicit workspace from agent config', () => {
|
|
118
|
+
fileSystem.set(
|
|
119
|
+
'/data/openclaw.json',
|
|
120
|
+
JSON.stringify({agents: {list: [{id: 'clawly', workspace: '/custom/ws'}]}}),
|
|
121
|
+
)
|
|
122
|
+
fileSystem.set('/custom/ws/IDENTITY.md', '- Name: CustomAgent\n- Emoji: 🎭')
|
|
123
|
+
expect(getAgentIdentity('clawly', createMockApi())).toEqual({
|
|
124
|
+
name: 'CustomAgent',
|
|
125
|
+
emoji: '🎭',
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('does not fall back to default agent workspace', () => {
|
|
130
|
+
fileSystem.set(
|
|
131
|
+
'/data/openclaw.json',
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
agents: {list: [{id: 'other', default: true, workspace: '/other/ws'}]},
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
fileSystem.set('/other/ws/IDENTITY.md', '- Name: WrongAgent')
|
|
137
|
+
fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- Name: CorrectAgent')
|
|
138
|
+
expect(getAgentIdentity('clawly', createMockApi())).toEqual({name: 'CorrectAgent'})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('returns null silently when IDENTITY.md missing (ENOENT)', () => {
|
|
142
|
+
fileSystem.set('/data/openclaw.json', '{}')
|
|
143
|
+
expect(getAgentIdentity('clawly', createMockApi())).toBeNull()
|
|
144
|
+
expect(logs.filter((l) => l.level === 'warn')).toHaveLength(0)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('warns when IDENTITY.md exists but has no name/emoji', () => {
|
|
148
|
+
fileSystem.set('/data/openclaw.json', '{}')
|
|
149
|
+
fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- Creature: robot')
|
|
150
|
+
expect(getAgentIdentity('clawly', createMockApi())).toBeNull()
|
|
151
|
+
expect(logs).toContainEqual({
|
|
152
|
+
level: 'warn',
|
|
153
|
+
msg: expect.stringContaining('no name/emoji found'),
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('warns on IDENTITY.md read error (non-ENOENT)', () => {
|
|
158
|
+
fileSystem.set('/data/openclaw.json', '{}')
|
|
159
|
+
fileSystem.set('/data/workspace-clawly/IDENTITY.md', '<<EPERM>>')
|
|
160
|
+
expect(getAgentIdentity('clawly', createMockApi())).toBeNull()
|
|
161
|
+
expect(logs).toContainEqual({
|
|
162
|
+
level: 'warn',
|
|
163
|
+
msg: expect.stringContaining('failed to read IDENTITY.md'),
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('warns on corrupted openclaw.json (non-ENOENT)', () => {
|
|
168
|
+
fileSystem.set('/data/openclaw.json', '{invalid json')
|
|
169
|
+
fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- Name: Tiger')
|
|
170
|
+
// Still falls back to convention path and reads identity
|
|
171
|
+
expect(getAgentIdentity('clawly', createMockApi())).toEqual({name: 'Tiger'})
|
|
172
|
+
expect(logs).toContainEqual({
|
|
173
|
+
level: 'warn',
|
|
174
|
+
msg: expect.stringContaining('failed to parse openclaw.json'),
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('ignores OPENCLAW_WORKSPACE when openclaw.json is readable', () => {
|
|
179
|
+
process.env.OPENCLAW_WORKSPACE = '/env/workspace'
|
|
180
|
+
fileSystem.set('/data/openclaw.json', '{}')
|
|
181
|
+
fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- Name: CorrectAgent')
|
|
182
|
+
fileSystem.set('/env/workspace/IDENTITY.md', '- Name: WrongAgent')
|
|
183
|
+
expect(getAgentIdentity('clawly', createMockApi())).toEqual({name: 'CorrectAgent'})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('ignores OPENCLAW_WORKSPACE even when openclaw.json is missing', () => {
|
|
187
|
+
process.env.OPENCLAW_WORKSPACE = '/env/workspace'
|
|
188
|
+
fileSystem.set('/env/workspace/IDENTITY.md', '- Name: WrongAgent')
|
|
189
|
+
fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- Name: CorrectAgent')
|
|
190
|
+
expect(getAgentIdentity('clawly', createMockApi())).toEqual({name: 'CorrectAgent'})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('returns null when stateDir is empty', () => {
|
|
194
|
+
expect(getAgentIdentity('clawly', createMockApi(''))).toBeNull()
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// ── resolveAgentTitle ────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
describe('resolveAgentTitle', () => {
|
|
201
|
+
test('returns agent name with emoji', () => {
|
|
202
|
+
fileSystem.set('/data/openclaw.json', '{}')
|
|
203
|
+
fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- Name: Tiger\n- Emoji: 🐅')
|
|
204
|
+
expect(resolveAgentTitle('clawly', createMockApi())).toBe('🐅 Tiger')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('falls back to lobster Clawly when no identity', () => {
|
|
208
|
+
fileSystem.set('/data/openclaw.json', '{}')
|
|
209
|
+
expect(resolveAgentTitle('clawly', createMockApi())).toBe('🦞 Clawly')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('uses lobster emoji when name exists but no emoji', () => {
|
|
213
|
+
fileSystem.set('/data/openclaw.json', '{}')
|
|
214
|
+
fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- Name: NoEmoji')
|
|
215
|
+
expect(resolveAgentTitle('clawly', createMockApi())).toBe('🦞 NoEmoji')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('falls back to lobster Clawly when agentId is undefined', () => {
|
|
219
|
+
expect(resolveAgentTitle(undefined, createMockApi())).toBe('🦞 Clawly')
|
|
220
|
+
})
|
|
221
|
+
})
|
package/gateway/notification.ts
CHANGED
|
@@ -14,13 +14,9 @@ import fs from 'node:fs'
|
|
|
14
14
|
import os from 'node:os'
|
|
15
15
|
import path from 'node:path'
|
|
16
16
|
|
|
17
|
-
import {$} from 'zx'
|
|
18
17
|
import type {PluginApi} from '../types'
|
|
19
|
-
import {stripCliLogs} from '../lib/stripCliLogs'
|
|
20
18
|
import {stripMarkdown} from '../lib/stripMarkdown'
|
|
21
19
|
|
|
22
|
-
$.verbose = false
|
|
23
|
-
|
|
24
20
|
/** Extract the first emoji from a string, or null if none found. */
|
|
25
21
|
const EMOJI_RE =
|
|
26
22
|
/(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(?:\u200d(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*/u
|
|
@@ -120,22 +116,80 @@ function resetBadgeCount(): void {
|
|
|
120
116
|
}
|
|
121
117
|
|
|
122
118
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
119
|
+
* Parse name and emoji from an IDENTITY.md file content.
|
|
120
|
+
* Mirrors the field extraction in openclaw's parseIdentityMarkdown.
|
|
125
121
|
*/
|
|
126
|
-
export
|
|
122
|
+
export function parseIdentityFields(content: string): {name?: string; emoji?: string} {
|
|
123
|
+
const result: {name?: string; emoji?: string} = {}
|
|
124
|
+
for (const line of content.split(/\r?\n/)) {
|
|
125
|
+
const cleaned = line.trim().replace(/^-\s*/, '')
|
|
126
|
+
const colonIdx = cleaned.indexOf(':')
|
|
127
|
+
if (colonIdx === -1) continue
|
|
128
|
+
const label = cleaned.slice(0, colonIdx).replace(/[*_#]/g, '').trim().toLowerCase()
|
|
129
|
+
const value = cleaned
|
|
130
|
+
.slice(colonIdx + 1)
|
|
131
|
+
.replace(/^[*_]+|[*_]+$/g, '')
|
|
132
|
+
.trim()
|
|
133
|
+
if (!value) continue
|
|
134
|
+
if (label === 'name') result.name = value
|
|
135
|
+
if (label === 'emoji') result.emoji = value
|
|
136
|
+
}
|
|
137
|
+
return result
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve the workspace directory for a given agent by reading openclaw.json.
|
|
142
|
+
* Falls back to `<stateDir>/workspace-<agentId>` when the agent entry has no explicit workspace.
|
|
143
|
+
*/
|
|
144
|
+
function resolveWorkspaceDir(api: PluginApi, agentId: string): string | null {
|
|
145
|
+
const stateDir = api.runtime.state.resolveStateDir()
|
|
146
|
+
if (!stateDir) return null
|
|
147
|
+
try {
|
|
148
|
+
const raw = fs.readFileSync(path.join(stateDir, 'openclaw.json'), 'utf-8')
|
|
149
|
+
const config = JSON.parse(raw)
|
|
150
|
+
const agents: Array<{id?: string; default?: boolean; workspace?: string}> = Array.isArray(
|
|
151
|
+
config?.agents?.list,
|
|
152
|
+
)
|
|
153
|
+
? config.agents.list
|
|
154
|
+
: []
|
|
155
|
+
const agent = agents.find((a) => a.id === agentId)
|
|
156
|
+
if (agent?.workspace) return agent.workspace
|
|
157
|
+
// Config parsed successfully — use convention path, not env var (which may belong to a different agent)
|
|
158
|
+
return path.join(stateDir, `workspace-${agentId}`)
|
|
159
|
+
} catch (err: unknown) {
|
|
160
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
161
|
+
api.logger.warn(
|
|
162
|
+
`notification: failed to parse openclaw.json for agentId=${agentId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// openclaw.json unreadable — use convention path (stateDir is always available)
|
|
167
|
+
return path.join(stateDir, `workspace-${agentId}`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Read agent display identity (name, emoji) directly from IDENTITY.md.
|
|
172
|
+
* Avoids the CLI→WebSocket roundtrip that fails under gateway load.
|
|
173
|
+
*/
|
|
174
|
+
export function getAgentIdentity(
|
|
127
175
|
agentId: string | undefined,
|
|
128
|
-
|
|
176
|
+
api: PluginApi,
|
|
177
|
+
): {name?: string; emoji?: string} | null {
|
|
129
178
|
if (!agentId) return null
|
|
179
|
+
const wsDir = resolveWorkspaceDir(api, agentId)
|
|
180
|
+
if (!wsDir) return null
|
|
130
181
|
try {
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
182
|
+
const content = fs.readFileSync(path.join(wsDir, 'IDENTITY.md'), 'utf-8')
|
|
183
|
+
const fields = parseIdentityFields(content)
|
|
184
|
+
if (fields.name || fields.emoji) return fields
|
|
185
|
+
api.logger.warn(`notification: IDENTITY.md parsed but no name/emoji found (agentId=${agentId})`)
|
|
186
|
+
return null
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
189
|
+
api.logger.warn(
|
|
190
|
+
`notification: failed to read IDENTITY.md for agentId=${agentId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
191
|
+
)
|
|
137
192
|
}
|
|
138
|
-
} catch {
|
|
139
193
|
return null
|
|
140
194
|
}
|
|
141
195
|
}
|
|
@@ -144,8 +198,8 @@ export async function getAgentIdentity(
|
|
|
144
198
|
* Build a push notification title from agent identity.
|
|
145
199
|
* Falls back to "🦞 Clawly" when identity is unavailable.
|
|
146
200
|
*/
|
|
147
|
-
export
|
|
148
|
-
const identity =
|
|
201
|
+
export function resolveAgentTitle(agentId: string | undefined, api: PluginApi): string {
|
|
202
|
+
const identity = getAgentIdentity(agentId, api)
|
|
149
203
|
const emoji = extractEmoji(identity?.emoji) ?? '🦞'
|
|
150
204
|
return `${emoji} ${identity?.name ?? 'Clawly'}`
|
|
151
205
|
}
|
|
@@ -161,7 +215,7 @@ export async function sendPushNotification(
|
|
|
161
215
|
return false
|
|
162
216
|
}
|
|
163
217
|
|
|
164
|
-
const title = stripMarkdown(opts.title ??
|
|
218
|
+
const title = stripMarkdown(opts.title ?? resolveAgentTitle(opts.agentId, api))
|
|
165
219
|
const body = stripMarkdown(opts.body)
|
|
166
220
|
if (!body) {
|
|
167
221
|
api.logger.warn('notification: body stripped to empty, skipping push')
|
|
@@ -255,7 +309,25 @@ export function registerNotification(api: PluginApi) {
|
|
|
255
309
|
respond(true, {cleared: true})
|
|
256
310
|
})
|
|
257
311
|
|
|
312
|
+
api.registerGatewayMethod('clawly.notification.clearToken', async ({respond}) => {
|
|
313
|
+
let fileCleared = true
|
|
314
|
+
try {
|
|
315
|
+
await fs.promises.unlink(TOKEN_FILE)
|
|
316
|
+
} catch (err) {
|
|
317
|
+
// ENOENT is fine — file already gone.
|
|
318
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
319
|
+
fileCleared = false
|
|
320
|
+
api.logger.warn(
|
|
321
|
+
`notification: failed to delete token file: ${err instanceof Error ? err.message : String(err)}`,
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (fileCleared) resetBadgeCount()
|
|
326
|
+
api.logger.info(`notification: push token cleared (fileCleared=${fileCleared})`)
|
|
327
|
+
respond(fileCleared, {cleared: fileCleared})
|
|
328
|
+
})
|
|
329
|
+
|
|
258
330
|
api.logger.info(
|
|
259
|
-
'notification: registered clawly.notification.setToken + clawly.notification.send + clawly.notification.clearBadge',
|
|
331
|
+
'notification: registered clawly.notification.setToken + clawly.notification.send + clawly.notification.clearBadge + clawly.notification.clearToken',
|
|
260
332
|
)
|
|
261
333
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {afterEach, describe, expect, mock, test} from 'bun:test'
|
|
2
|
+
import type {PluginApi} from '../types'
|
|
3
|
+
import {
|
|
4
|
+
ensureWeixinBinding,
|
|
5
|
+
resetBindingEnsureInFlight,
|
|
6
|
+
resolveDefaultAgentId,
|
|
7
|
+
} from './weixin-login'
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// resolveDefaultAgentId
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
describe('resolveDefaultAgentId', () => {
|
|
14
|
+
test('returns "clawly" when agents.list is empty', () => {
|
|
15
|
+
expect(resolveDefaultAgentId({agents: {list: []}} as any)).toBe('clawly')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('returns "clawly" when agents is undefined', () => {
|
|
19
|
+
expect(resolveDefaultAgentId({} as any)).toBe('clawly')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('returns the default agent id', () => {
|
|
23
|
+
const config = {
|
|
24
|
+
agents: {list: [{id: 'other'}, {id: 'mybot', default: true}]},
|
|
25
|
+
}
|
|
26
|
+
expect(resolveDefaultAgentId(config as any)).toBe('mybot')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('returns first agent when none marked default', () => {
|
|
30
|
+
const config = {agents: {list: [{id: 'alpha'}, {id: 'beta'}]}}
|
|
31
|
+
expect(resolveDefaultAgentId(config as any)).toBe('alpha')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// ensureWeixinBinding
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
function createMockApi(config: Record<string, unknown> = {}) {
|
|
40
|
+
return {
|
|
41
|
+
runtime: {
|
|
42
|
+
config: {
|
|
43
|
+
loadConfig: mock(() => config),
|
|
44
|
+
writeConfigFile: mock(() => Promise.resolve()),
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
logger: {
|
|
48
|
+
info: mock(() => {}),
|
|
49
|
+
warn: mock(() => {}),
|
|
50
|
+
},
|
|
51
|
+
} as unknown as PluginApi
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('ensureWeixinBinding', () => {
|
|
55
|
+
afterEach(() => resetBindingEnsureInFlight())
|
|
56
|
+
|
|
57
|
+
test('skips write when binding already exists', async () => {
|
|
58
|
+
const api = createMockApi({
|
|
59
|
+
bindings: [{agentId: 'clawly', match: {channel: 'openclaw-weixin'}}],
|
|
60
|
+
})
|
|
61
|
+
await ensureWeixinBinding(api)
|
|
62
|
+
expect(api.runtime.config.writeConfigFile).not.toHaveBeenCalled()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('writes binding when none exists', async () => {
|
|
66
|
+
const api = createMockApi({agents: {list: [{id: 'clawly', default: true}]}})
|
|
67
|
+
await ensureWeixinBinding(api)
|
|
68
|
+
expect(api.runtime.config.writeConfigFile).toHaveBeenCalledTimes(1)
|
|
69
|
+
const written = (api.runtime.config.writeConfigFile as ReturnType<typeof mock>).mock.calls[0][0]
|
|
70
|
+
expect(written.bindings).toEqual([{agentId: 'clawly', match: {channel: 'openclaw-weixin'}}])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('returns silently when loadConfig throws', async () => {
|
|
74
|
+
const api = createMockApi()
|
|
75
|
+
;(api.runtime.config.loadConfig as ReturnType<typeof mock>).mockImplementation(() => {
|
|
76
|
+
throw new Error('corrupted')
|
|
77
|
+
})
|
|
78
|
+
await ensureWeixinBinding(api)
|
|
79
|
+
expect(api.runtime.config.writeConfigFile).not.toHaveBeenCalled()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('resets in-flight flag after loadConfig throws', async () => {
|
|
83
|
+
const api = createMockApi()
|
|
84
|
+
;(api.runtime.config.loadConfig as ReturnType<typeof mock>).mockImplementation(() => {
|
|
85
|
+
throw new Error('corrupted')
|
|
86
|
+
})
|
|
87
|
+
await ensureWeixinBinding(api)
|
|
88
|
+
// Second call should not be blocked
|
|
89
|
+
;(api.runtime.config.loadConfig as ReturnType<typeof mock>).mockImplementation(() => ({}))
|
|
90
|
+
await ensureWeixinBinding(api)
|
|
91
|
+
expect(api.runtime.config.writeConfigFile).toHaveBeenCalledTimes(1)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('concurrent call is a no-op while in-flight', async () => {
|
|
95
|
+
let resolveWrite!: () => void
|
|
96
|
+
const api = createMockApi({})
|
|
97
|
+
;(api.runtime.config.writeConfigFile as ReturnType<typeof mock>).mockImplementation(
|
|
98
|
+
() =>
|
|
99
|
+
new Promise<void>((r) => {
|
|
100
|
+
resolveWrite = r
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
const first = ensureWeixinBinding(api)
|
|
104
|
+
// Second call while first is awaiting writeConfigFile
|
|
105
|
+
await ensureWeixinBinding(api)
|
|
106
|
+
expect(api.runtime.config.writeConfigFile).toHaveBeenCalledTimes(1)
|
|
107
|
+
resolveWrite()
|
|
108
|
+
await first
|
|
109
|
+
})
|
|
110
|
+
})
|
package/gateway/weixin-login.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* clawly.weixin.login.wait → { connected, message }
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import type {OpenClawConfig} from '../types/openclaw'
|
|
13
14
|
import type {PluginApi} from '../types'
|
|
14
15
|
|
|
15
16
|
const WEIXIN_CHANNEL_ID = 'openclaw-weixin'
|
|
@@ -73,6 +74,66 @@ function resolveWeixinPlugin(logger?: {warn: (msg: string) => void}): ChannelPlu
|
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the default agent ID from config (first default agent, or first agent, or 'clawly').
|
|
79
|
+
*/
|
|
80
|
+
/** @internal exported for testing */
|
|
81
|
+
export function resolveDefaultAgentId(config: OpenClawConfig): string {
|
|
82
|
+
const list = config.agents?.list
|
|
83
|
+
if (!Array.isArray(list) || list.length === 0) return 'clawly'
|
|
84
|
+
const defaultAgent = list.find((a) => a.default) ?? list[0]
|
|
85
|
+
return defaultAgent?.id ?? 'clawly'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Ensure a binding exists for openclaw-weixin → default agent.
|
|
90
|
+
* Bindings changes are classified as "none" in OpenClaw's config-reload,
|
|
91
|
+
* so this does NOT trigger a gateway restart.
|
|
92
|
+
*/
|
|
93
|
+
/** @internal exported for testing */
|
|
94
|
+
export let bindingEnsureInFlight = false
|
|
95
|
+
|
|
96
|
+
/** @internal reset for testing */
|
|
97
|
+
export function resetBindingEnsureInFlight() {
|
|
98
|
+
bindingEnsureInFlight = false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @internal exported for testing */
|
|
102
|
+
export async function ensureWeixinBinding(api: PluginApi): Promise<void> {
|
|
103
|
+
if (bindingEnsureInFlight) return
|
|
104
|
+
bindingEnsureInFlight = true
|
|
105
|
+
try {
|
|
106
|
+
let config: OpenClawConfig
|
|
107
|
+
try {
|
|
108
|
+
config = {...(api.runtime.config.loadConfig() as OpenClawConfig)}
|
|
109
|
+
} catch {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const bindings = Array.isArray(config.bindings) ? config.bindings : []
|
|
114
|
+
const agentId = resolveDefaultAgentId(config)
|
|
115
|
+
const hasValidBinding = bindings.some(
|
|
116
|
+
(b) => b.match?.channel === WEIXIN_CHANNEL_ID && b.agentId === agentId,
|
|
117
|
+
)
|
|
118
|
+
if (hasValidBinding) return
|
|
119
|
+
|
|
120
|
+
// Remove any stale weixin bindings (pointing to a different agent), then add the current one.
|
|
121
|
+
const filtered = bindings.filter((b) => b.match?.channel !== WEIXIN_CHANNEL_ID)
|
|
122
|
+
config.bindings = [...filtered, {agentId, match: {channel: WEIXIN_CHANNEL_ID}}]
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await api.runtime.config.writeConfigFile(config)
|
|
126
|
+
api.logger.info(`weixin-login: created binding ${WEIXIN_CHANNEL_ID} → ${agentId}`)
|
|
127
|
+
} catch (err) {
|
|
128
|
+
api.logger.warn(
|
|
129
|
+
`weixin-login: failed to write binding: ${err instanceof Error ? err.message : String(err)}`,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
} finally {
|
|
133
|
+
bindingEnsureInFlight = false
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
76
137
|
export function registerWeixinLogin(api: PluginApi) {
|
|
77
138
|
api.registerGatewayMethod('clawly.weixin.login.start', async ({params, respond}) => {
|
|
78
139
|
const plugin = resolveWeixinPlugin(api.logger)
|
|
@@ -98,6 +159,9 @@ export function registerWeixinLogin(api: PluginApi) {
|
|
|
98
159
|
GATEWAY_TIMEOUT_MS,
|
|
99
160
|
'loginWithQrStart',
|
|
100
161
|
)
|
|
162
|
+
// Ensure routing binding exists before responding — if the user scans
|
|
163
|
+
// the QR code and WeChat connects, messages need to be routed immediately.
|
|
164
|
+
await ensureWeixinBinding(api)
|
|
101
165
|
respond(true, result)
|
|
102
166
|
} catch (err) {
|
|
103
167
|
respond(false, undefined, {
|