@2en/clawly-plugins 1.2.0 → 1.3.1
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/channel.ts +94 -0
- package/cron-hook.ts +66 -0
- package/index.ts +6 -0
- package/package.json +4 -1
- package/tools/clawly-is-user-online.ts +35 -0
- package/tools/clawly-send-app-push.ts +80 -0
package/channel.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clawly cron delivery channel — registers a minimal channel plugin so that
|
|
3
|
+
* cron jobs using `delivery: { channel: "clawly-cron" }` have a valid target.
|
|
4
|
+
*
|
|
5
|
+
* When a cron job delivers text, the channel injects it into the main session
|
|
6
|
+
* transcript via chat.inject (no LLM round-trip) and sends a push notification
|
|
7
|
+
* if the mobile client is offline.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {$} from 'zx'
|
|
11
|
+
import type {PluginApi} from './index'
|
|
12
|
+
import {sendPushNotification} from './notification'
|
|
13
|
+
import {isClientOnline} from './presence'
|
|
14
|
+
|
|
15
|
+
$.verbose = false
|
|
16
|
+
|
|
17
|
+
async function injectAndNotify(text: string, api: PluginApi): Promise<void> {
|
|
18
|
+
try {
|
|
19
|
+
// Resolve main session key from config
|
|
20
|
+
const config = api.pluginConfig as Record<string, unknown> | undefined
|
|
21
|
+
const agentId = typeof config?.agentId === 'string' ? config.agentId : 'clawly'
|
|
22
|
+
const mainKey = typeof config?.mainKey === 'string' ? config.mainKey : 'main'
|
|
23
|
+
const sessionKey = `agent:${agentId}:${mainKey}`
|
|
24
|
+
|
|
25
|
+
const params = JSON.stringify({sessionKey, message: text})
|
|
26
|
+
await $`openclaw gateway call chat.inject --json --params ${params}`
|
|
27
|
+
api.logger.info(`clawly-cron: injected message (${text.length} chars) into ${sessionKey}`)
|
|
28
|
+
|
|
29
|
+
// Push notification if client is offline
|
|
30
|
+
const online = await isClientOnline()
|
|
31
|
+
if (!online) {
|
|
32
|
+
const pushSent = await sendPushNotification({body: text}, api)
|
|
33
|
+
api.logger.info(`clawly-cron: push notification sent=${pushSent}`)
|
|
34
|
+
}
|
|
35
|
+
} catch (err) {
|
|
36
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
37
|
+
api.logger.error(`clawly-cron: failed to inject message — ${msg}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function registerClawlyCronChannel(api: PluginApi) {
|
|
42
|
+
const channelRegistration = {
|
|
43
|
+
plugin: {
|
|
44
|
+
id: 'clawly-cron',
|
|
45
|
+
meta: {
|
|
46
|
+
id: 'clawly-cron',
|
|
47
|
+
label: 'Clawly Cron',
|
|
48
|
+
selectionLabel: 'Clawly Cron (webchat)',
|
|
49
|
+
docsPath: '',
|
|
50
|
+
blurb: 'Webchat-only cron delivery channel',
|
|
51
|
+
},
|
|
52
|
+
capabilities: {chatTypes: ['dm'] as const},
|
|
53
|
+
config: {
|
|
54
|
+
listAccountIds: () => ['default'],
|
|
55
|
+
resolveAccount: () => ({id: 'default'}),
|
|
56
|
+
defaultAccountId: () => 'default',
|
|
57
|
+
isEnabled: () => true,
|
|
58
|
+
isConfigured: () => true,
|
|
59
|
+
},
|
|
60
|
+
status: {
|
|
61
|
+
buildAccountSnapshot: async () => ({
|
|
62
|
+
accountId: 'default',
|
|
63
|
+
enabled: true,
|
|
64
|
+
configured: true,
|
|
65
|
+
running: true,
|
|
66
|
+
}),
|
|
67
|
+
buildChannelSummary: async () => ({
|
|
68
|
+
configured: true,
|
|
69
|
+
running: true,
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
outbound: {
|
|
73
|
+
deliveryMode: 'direct' as const,
|
|
74
|
+
sendText: async (...args: unknown[]) => {
|
|
75
|
+
const firstArg = args[0] as Record<string, unknown> | undefined
|
|
76
|
+
const text = typeof firstArg?.text === 'string' ? firstArg.text : ''
|
|
77
|
+
api.logger.info(`clawly-cron sendText: text=${text.length} chars`)
|
|
78
|
+
if (text) {
|
|
79
|
+
// Fire-and-forget — delivery system may not await sendText
|
|
80
|
+
injectAndNotify(text, api).catch(() => {})
|
|
81
|
+
}
|
|
82
|
+
return {channel: 'clawly-cron', messageId: crypto.randomUUID()}
|
|
83
|
+
},
|
|
84
|
+
sendMedia: async (...args: unknown[]) => {
|
|
85
|
+
api.logger.info(`clawly-cron sendMedia: ${JSON.stringify(args)}`)
|
|
86
|
+
return {channel: 'clawly-cron', messageId: crypto.randomUUID()}
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
api.registerChannel(channelRegistration)
|
|
93
|
+
api.logger.info('channel: registered clawly-cron delivery channel')
|
|
94
|
+
}
|
package/cron-hook.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* before_tool_call hook for cron (action=add) — ensures delivery fields are
|
|
3
|
+
* always set correctly, even when the LLM omits them.
|
|
4
|
+
*
|
|
5
|
+
* Forces: delivery.channel = "clawly-cron", delivery.to = "self", delivery.mode = "announce"
|
|
6
|
+
* Patches: payload.kind "systemEvent" → "agentTurn"
|
|
7
|
+
*
|
|
8
|
+
* The cron tool name is "cron" (not "cron.create"). The LLM passes
|
|
9
|
+
* { action: "add", job: { delivery, payload, ... } } — delivery and payload
|
|
10
|
+
* live inside params.job (or at the top level for flat-params recovery).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {PluginApi} from './index'
|
|
14
|
+
|
|
15
|
+
type UnknownRecord = Record<string, unknown>
|
|
16
|
+
|
|
17
|
+
function isRecord(v: unknown): v is UnknownRecord {
|
|
18
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function patchJob(job: UnknownRecord): UnknownRecord {
|
|
22
|
+
const patched: UnknownRecord = {...job}
|
|
23
|
+
|
|
24
|
+
// Force delivery fields
|
|
25
|
+
const delivery = isRecord(job.delivery) ? job.delivery : {}
|
|
26
|
+
patched.delivery = {
|
|
27
|
+
...delivery,
|
|
28
|
+
mode: 'announce',
|
|
29
|
+
channel: 'clawly-cron',
|
|
30
|
+
to: 'self',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Patch payload.kind: systemEvent → agentTurn
|
|
34
|
+
if (isRecord(job.payload) && job.payload.kind === 'systemEvent') {
|
|
35
|
+
patched.payload = {...job.payload, kind: 'agentTurn'}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return patched
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function registerCronHook(api: PluginApi) {
|
|
42
|
+
api.on('before_tool_call', (event: {toolName: string; params: UnknownRecord}) => {
|
|
43
|
+
if (event.toolName !== 'cron') return
|
|
44
|
+
if (event.params.action !== 'add') return
|
|
45
|
+
|
|
46
|
+
const params: UnknownRecord = {}
|
|
47
|
+
|
|
48
|
+
// Patch job object if present (normal path)
|
|
49
|
+
if (isRecord(event.params.job)) {
|
|
50
|
+
params.job = patchJob(event.params.job)
|
|
51
|
+
} else {
|
|
52
|
+
// Flat-params recovery: delivery/payload may be at the top level
|
|
53
|
+
if ('delivery' in event.params || 'payload' in event.params) {
|
|
54
|
+
const synthetic = {...event.params}
|
|
55
|
+
delete synthetic.action
|
|
56
|
+
Object.assign(params, patchJob(synthetic))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (Object.keys(params).length === 0) return
|
|
61
|
+
|
|
62
|
+
return {params}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
api.logger.info('hook: registered before_tool_call for cron add delivery enforcement')
|
|
66
|
+
}
|
package/index.ts
CHANGED
|
@@ -18,10 +18,13 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Hooks:
|
|
20
20
|
* - tool_result_persist — copies TTS audio to persistent outbound directory
|
|
21
|
+
* - before_tool_call — enforces delivery fields on cron.create
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
24
|
import {registerAgentSend} from './agent-send'
|
|
24
25
|
import {registerIsUserOnlineTool, registerSendAppPushTool} from './tools'
|
|
26
|
+
import {registerClawlyCronChannel} from './channel'
|
|
27
|
+
import {registerCronHook} from './cron-hook'
|
|
25
28
|
import {registerEchoCommand} from './echo'
|
|
26
29
|
import {registerNotification} from './notification'
|
|
27
30
|
import {registerOutboundHook, registerOutboundMethods} from './outbound'
|
|
@@ -70,6 +73,7 @@ export type PluginApi = {
|
|
|
70
73
|
},
|
|
71
74
|
opts?: {optional?: boolean},
|
|
72
75
|
) => void
|
|
76
|
+
registerChannel: (registration: {plugin: any}) => void
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
export default {
|
|
@@ -85,6 +89,8 @@ export default {
|
|
|
85
89
|
registerAgentSend(api)
|
|
86
90
|
registerIsUserOnlineTool(api)
|
|
87
91
|
registerSendAppPushTool(api)
|
|
92
|
+
registerClawlyCronChannel(api)
|
|
93
|
+
registerCronHook(api)
|
|
88
94
|
api.logger.info(`Loaded ${api.id} plugin.`)
|
|
89
95
|
},
|
|
90
96
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2en/clawly-plugins",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
"zx": "npm:zx@8.8.5-lite"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
|
+
"tools",
|
|
15
16
|
"index.ts",
|
|
17
|
+
"channel.ts",
|
|
18
|
+
"cron-hook.ts",
|
|
16
19
|
"outbound.ts",
|
|
17
20
|
"echo.ts",
|
|
18
21
|
"presence.ts",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tool: clawly_is_user_online — check if the user's mobile
|
|
3
|
+
* device is currently connected to the OpenClaw gateway.
|
|
4
|
+
*
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {PluginApi} from '../index'
|
|
8
|
+
import {isClientOnline} from '../presence'
|
|
9
|
+
|
|
10
|
+
const TOOL_NAME = 'clawly_is_user_online'
|
|
11
|
+
|
|
12
|
+
const parameters: Record<string, unknown> = {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
host: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: 'Presence host identifier (default: "openclaw-ios")',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function registerIsUserOnlineTool(api: PluginApi) {
|
|
23
|
+
api.registerTool({
|
|
24
|
+
name: TOOL_NAME,
|
|
25
|
+
description: "Check if the user's mobile device is currently online.",
|
|
26
|
+
parameters,
|
|
27
|
+
async execute(_toolCallId, params) {
|
|
28
|
+
const host = typeof params.host === 'string' ? params.host : undefined
|
|
29
|
+
const isOnline = await isClientOnline(host)
|
|
30
|
+
return {content: [{type: 'text', text: JSON.stringify({isOnline})}]}
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
api.logger.info(`tool: registered ${TOOL_NAME} agent tool`)
|
|
35
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tool: clawly_send_app_push — send a push notification to
|
|
3
|
+
* the user's phone from the LLM agent during a conversation.
|
|
4
|
+
*
|
|
5
|
+
* Expo push API reference:
|
|
6
|
+
* https://docs.expo.dev/push-notifications/sending-notifications/
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {PluginApi} from '../index'
|
|
10
|
+
import {sendPushNotification} from '../notification'
|
|
11
|
+
|
|
12
|
+
const TOOL_NAME = 'clawly_send_app_push'
|
|
13
|
+
|
|
14
|
+
const parameters: Record<string, unknown> = {
|
|
15
|
+
type: 'object',
|
|
16
|
+
required: ['body'],
|
|
17
|
+
properties: {
|
|
18
|
+
body: {type: 'string', description: 'Notification message'},
|
|
19
|
+
title: {type: 'string', description: 'Title (default: "Clawly")'},
|
|
20
|
+
subtitle: {type: 'string', description: 'iOS subtitle'},
|
|
21
|
+
sound: {
|
|
22
|
+
type: ['string', 'null'],
|
|
23
|
+
description: 'Sound name (default: "default")',
|
|
24
|
+
},
|
|
25
|
+
badge: {type: 'number', description: 'iOS badge count'},
|
|
26
|
+
data: {type: 'object', description: 'Custom JSON payload'},
|
|
27
|
+
ttl: {type: 'number', description: 'Seconds to keep for redelivery'},
|
|
28
|
+
expiration: {type: 'number', description: 'Unix timestamp expiry'},
|
|
29
|
+
priority: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
enum: ['default', 'normal', 'high'],
|
|
32
|
+
description: 'Delivery priority',
|
|
33
|
+
},
|
|
34
|
+
channelId: {type: 'string', description: 'Android notification channel'},
|
|
35
|
+
categoryId: {type: 'string', description: 'Notification category'},
|
|
36
|
+
interruptionLevel: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
enum: ['active', 'critical', 'passive', 'time-sensitive'],
|
|
39
|
+
description: 'iOS interruption level',
|
|
40
|
+
},
|
|
41
|
+
mutableContent: {type: 'boolean', description: 'iOS mutable content'},
|
|
42
|
+
richContent: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
description: 'Rich content (e.g. {image: url})',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function registerSendAppPushTool(api: PluginApi) {
|
|
50
|
+
api.registerTool({
|
|
51
|
+
name: TOOL_NAME,
|
|
52
|
+
description: "Send a push notification to the user's phone.",
|
|
53
|
+
parameters,
|
|
54
|
+
async execute(_toolCallId, params) {
|
|
55
|
+
const body = typeof params.body === 'string' ? params.body.trim() : ''
|
|
56
|
+
if (!body) {
|
|
57
|
+
return {content: [{type: 'text', text: JSON.stringify({error: 'body is required'})}]}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const title = typeof params.title === 'string' ? params.title : undefined
|
|
61
|
+
const data =
|
|
62
|
+
typeof params.data === 'object' && params.data !== null
|
|
63
|
+
? (params.data as Record<string, unknown>)
|
|
64
|
+
: undefined
|
|
65
|
+
|
|
66
|
+
// Collect Expo-specific extras (everything except body/title/data)
|
|
67
|
+
const {body: _body, title: _title, data: _data, ...extras} = params
|
|
68
|
+
const hasExtras = Object.keys(extras).length > 0
|
|
69
|
+
|
|
70
|
+
const sent = await sendPushNotification(
|
|
71
|
+
{body, title, data},
|
|
72
|
+
api,
|
|
73
|
+
hasExtras ? extras : undefined,
|
|
74
|
+
)
|
|
75
|
+
return {content: [{type: 'text', text: JSON.stringify({sent})}]}
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
api.logger.info(`tool: registered ${TOOL_NAME} agent tool`)
|
|
80
|
+
}
|