@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 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.2.0",
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
+ }