@2en/clawly-plugins 1.26.0-beta.0 → 1.26.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/auto-pair.ts +5 -0
- package/config-setup.ts +113 -17
- package/gateway/analytics.ts +83 -0
- package/gateway/clawhub2gateway.ts +15 -0
- package/gateway/cron-delivery.ts +13 -2
- package/gateway/cron-telemetry.test.ts +407 -0
- package/gateway/cron-telemetry.ts +253 -0
- package/gateway/index.ts +33 -0
- package/gateway/offline-push.test.ts +209 -0
- package/gateway/offline-push.ts +107 -12
- package/gateway/otel.test.ts +88 -0
- package/gateway/otel.ts +57 -0
- package/gateway/plugins.ts +3 -0
- package/gateway/posthog.test.ts +73 -0
- package/gateway/posthog.ts +61 -0
- package/gateway/telemetry-config.test.ts +58 -0
- package/gateway/telemetry-config.ts +27 -0
- package/index.ts +7 -1
- package/model-gateway-setup.ts +5 -7
- package/openclaw.plugin.json +6 -1
- package/outbound.ts +67 -32
- package/package.json +7 -1
- package/tools/clawly-send-image.ts +228 -0
- package/tools/index.ts +2 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test'
|
|
2
|
+
|
|
3
|
+
let constructorArgs: {apiKey: string; host: string} | null = null
|
|
4
|
+
let captures: Array<{distinctId: string; event: string; properties?: Record<string, unknown>}> = []
|
|
5
|
+
let shutdownCount = 0
|
|
6
|
+
|
|
7
|
+
mock.module('posthog-node', () => ({
|
|
8
|
+
PostHog: class PostHog {
|
|
9
|
+
constructor(apiKey: string, opts: {host: string}) {
|
|
10
|
+
constructorArgs = {apiKey, host: opts.host}
|
|
11
|
+
}
|
|
12
|
+
capture(payload: {distinctId: string; event: string; properties?: Record<string, unknown>}) {
|
|
13
|
+
captures.push(payload)
|
|
14
|
+
}
|
|
15
|
+
async shutdown() {
|
|
16
|
+
shutdownCount++
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
const {captureEvent, initPostHog, shutdownPostHog} = await import('./posthog')
|
|
22
|
+
|
|
23
|
+
describe('initPostHog', () => {
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
await shutdownPostHog()
|
|
26
|
+
constructorArgs = null
|
|
27
|
+
captures = []
|
|
28
|
+
shutdownCount = 0
|
|
29
|
+
delete process.env.PLUGIN_POSTHOG_API_KEY
|
|
30
|
+
delete process.env.PLUGIN_POSTHOG_HOST
|
|
31
|
+
delete process.env.INSTANCE_ID
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
await shutdownPostHog()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('prefers pluginConfig over legacy env vars', () => {
|
|
39
|
+
process.env.PLUGIN_POSTHOG_API_KEY = 'legacy-key'
|
|
40
|
+
process.env.PLUGIN_POSTHOG_HOST = 'https://legacy.posthog.com'
|
|
41
|
+
process.env.INSTANCE_ID = 'legacy-inst'
|
|
42
|
+
|
|
43
|
+
expect(
|
|
44
|
+
initPostHog({
|
|
45
|
+
posthogApiKey: 'cfg-key',
|
|
46
|
+
posthogHost: 'https://cfg.posthog.com',
|
|
47
|
+
instanceId: 'inst-1',
|
|
48
|
+
}),
|
|
49
|
+
).toBe(true)
|
|
50
|
+
|
|
51
|
+
expect(constructorArgs).toEqual({
|
|
52
|
+
apiKey: 'cfg-key',
|
|
53
|
+
host: 'https://cfg.posthog.com',
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('falls back to legacy env vars', () => {
|
|
58
|
+
process.env.PLUGIN_POSTHOG_API_KEY = 'legacy-key'
|
|
59
|
+
process.env.INSTANCE_ID = 'legacy-inst'
|
|
60
|
+
|
|
61
|
+
expect(initPostHog()).toBe(true)
|
|
62
|
+
expect(constructorArgs).toEqual({
|
|
63
|
+
apiKey: 'legacy-key',
|
|
64
|
+
host: 'https://us.i.posthog.com',
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('returns false when api key is missing', () => {
|
|
69
|
+
expect(initPostHog({instanceId: 'inst-1'})).toBe(false)
|
|
70
|
+
captureEvent('cron.deleted')
|
|
71
|
+
expect(captures).toHaveLength(0)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostHog analytics provider for plugin telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Reads telemetry config provisioned by Fleet via pluginConfig:
|
|
5
|
+
*
|
|
6
|
+
* posthogApiKey=<PostHog project API key>
|
|
7
|
+
* posthogHost=<PostHog ingest URL> (optional, defaults to https://us.i.posthog.com)
|
|
8
|
+
* instanceId=<distinct_id>
|
|
9
|
+
*
|
|
10
|
+
* Falls back to legacy PLUGIN_POSTHOG_* / INSTANCE_ID env vars for backward
|
|
11
|
+
* compatibility and manual debug sessions.
|
|
12
|
+
*
|
|
13
|
+
* The distinct_id for all events is the provisioned instanceId (one sprite = one user).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {PostHog} from 'posthog-node'
|
|
17
|
+
import pkg from '../package.json'
|
|
18
|
+
import {readTelemetryPluginConfig} from './telemetry-config'
|
|
19
|
+
|
|
20
|
+
let client: PostHog | null = null
|
|
21
|
+
let distinctId: string | null = null
|
|
22
|
+
let superProperties: Record<string, unknown> = {}
|
|
23
|
+
|
|
24
|
+
export function initPostHog(pluginConfig?: Record<string, unknown>): boolean {
|
|
25
|
+
const cfg = readTelemetryPluginConfig(pluginConfig)
|
|
26
|
+
const apiKey = cfg.posthogApiKey ?? process.env.PLUGIN_POSTHOG_API_KEY
|
|
27
|
+
if (!apiKey) return false
|
|
28
|
+
if (client) return true
|
|
29
|
+
|
|
30
|
+
const host = cfg.posthogHost ?? process.env.PLUGIN_POSTHOG_HOST ?? 'https://us.i.posthog.com'
|
|
31
|
+
distinctId = cfg.instanceId ?? process.env.INSTANCE_ID ?? null
|
|
32
|
+
if (!distinctId) return false
|
|
33
|
+
|
|
34
|
+
client = new PostHog(apiKey, {host})
|
|
35
|
+
superProperties = {plugin_version: pkg.version}
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Set the OpenClaw version (read from api.config after gateway init). */
|
|
40
|
+
export function setOpenClawVersion(version: string): void {
|
|
41
|
+
superProperties.openclaw_version = version
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const EVENT_PREFIX = 'plugin.'
|
|
45
|
+
|
|
46
|
+
export function captureEvent(event: string, properties?: Record<string, unknown>): void {
|
|
47
|
+
if (!client || !distinctId) return
|
|
48
|
+
client.capture({
|
|
49
|
+
distinctId,
|
|
50
|
+
event: `${EVENT_PREFIX}${event}`,
|
|
51
|
+
properties: {...superProperties, ...properties},
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function shutdownPostHog(): Promise<void> {
|
|
56
|
+
if (client) {
|
|
57
|
+
await client.shutdown()
|
|
58
|
+
client = null
|
|
59
|
+
distinctId = null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {describe, expect, test} from 'bun:test'
|
|
2
|
+
import {readTelemetryPluginConfig} from './telemetry-config'
|
|
3
|
+
|
|
4
|
+
describe('readTelemetryPluginConfig', () => {
|
|
5
|
+
test('reads telemetry fields from pluginConfig', () => {
|
|
6
|
+
expect(
|
|
7
|
+
readTelemetryPluginConfig({
|
|
8
|
+
instanceId: 'inst-1',
|
|
9
|
+
otelToken: 'otel-token',
|
|
10
|
+
otelDataset: 'clawly-otel-logs-dev',
|
|
11
|
+
posthogApiKey: 'ph-key',
|
|
12
|
+
posthogHost: 'https://us.i.posthog.com',
|
|
13
|
+
}),
|
|
14
|
+
).toEqual({
|
|
15
|
+
instanceId: 'inst-1',
|
|
16
|
+
otelToken: 'otel-token',
|
|
17
|
+
otelDataset: 'clawly-otel-logs-dev',
|
|
18
|
+
posthogApiKey: 'ph-key',
|
|
19
|
+
posthogHost: 'https://us.i.posthog.com',
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('trims surrounding whitespace from string values', () => {
|
|
24
|
+
expect(
|
|
25
|
+
readTelemetryPluginConfig({
|
|
26
|
+
instanceId: ' inst-1 ',
|
|
27
|
+
otelToken: ' otel-token ',
|
|
28
|
+
otelDataset: ' clawly-otel-logs-dev ',
|
|
29
|
+
posthogApiKey: ' ph-key ',
|
|
30
|
+
posthogHost: ' https://us.i.posthog.com ',
|
|
31
|
+
}),
|
|
32
|
+
).toEqual({
|
|
33
|
+
instanceId: 'inst-1',
|
|
34
|
+
otelToken: 'otel-token',
|
|
35
|
+
otelDataset: 'clawly-otel-logs-dev',
|
|
36
|
+
posthogApiKey: 'ph-key',
|
|
37
|
+
posthogHost: 'https://us.i.posthog.com',
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('drops empty and non-string values', () => {
|
|
42
|
+
expect(
|
|
43
|
+
readTelemetryPluginConfig({
|
|
44
|
+
instanceId: ' ',
|
|
45
|
+
otelToken: null,
|
|
46
|
+
otelDataset: 1,
|
|
47
|
+
posthogApiKey: '',
|
|
48
|
+
posthogHost: undefined,
|
|
49
|
+
}),
|
|
50
|
+
).toEqual({
|
|
51
|
+
instanceId: undefined,
|
|
52
|
+
otelToken: undefined,
|
|
53
|
+
otelDataset: undefined,
|
|
54
|
+
posthogApiKey: undefined,
|
|
55
|
+
posthogHost: undefined,
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface TelemetryPluginConfig {
|
|
2
|
+
instanceId?: string
|
|
3
|
+
otelToken?: string
|
|
4
|
+
otelDataset?: string
|
|
5
|
+
posthogApiKey?: string
|
|
6
|
+
posthogHost?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function readString(value: unknown): string | undefined {
|
|
10
|
+
if (typeof value !== 'string') return undefined
|
|
11
|
+
const trimmed = value.trim()
|
|
12
|
+
return trimmed.length > 0 ? trimmed : undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readTelemetryPluginConfig(
|
|
16
|
+
pluginConfig?: Record<string, unknown> | null,
|
|
17
|
+
): TelemetryPluginConfig {
|
|
18
|
+
const cfg = pluginConfig ?? {}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
instanceId: readString(cfg.instanceId),
|
|
22
|
+
otelToken: readString(cfg.otelToken),
|
|
23
|
+
otelDataset: readString(cfg.otelDataset),
|
|
24
|
+
posthogApiKey: readString(cfg.posthogApiKey),
|
|
25
|
+
posthogHost: readString(cfg.posthogHost),
|
|
26
|
+
}
|
|
27
|
+
}
|
package/index.ts
CHANGED
|
@@ -16,16 +16,22 @@
|
|
|
16
16
|
* Agent tools:
|
|
17
17
|
* - clawly_is_user_online — check if user's device is connected
|
|
18
18
|
* - clawly_send_app_push — send a push notification to user's device
|
|
19
|
+
* - clawly_send_image — send an image to the user (URL download or local file)
|
|
19
20
|
* - clawly_send_message — send a message to user via main session agent (supports role: user|assistant)
|
|
20
21
|
*
|
|
21
22
|
* Commands:
|
|
22
23
|
* - /clawly_echo — echo text back without LLM
|
|
23
24
|
*
|
|
25
|
+
* HTTP routes:
|
|
26
|
+
* - GET /clawly/file/outbound — serve files (hash lookup first, then direct path with allowlist)
|
|
27
|
+
*
|
|
24
28
|
* Hooks:
|
|
25
29
|
* - before_message_write — restores original /skill command in user messages (undoes gateway rewrite)
|
|
26
30
|
* - tool_result_persist — copies TTS audio to persistent outbound directory
|
|
27
31
|
* - before_tool_call — enforces delivery fields on cron.create
|
|
28
32
|
* - agent_end — sends push notification when client is offline; injects cron results into main session
|
|
33
|
+
* - after_tool_call — cron telemetry: captures cron job creation/deletion
|
|
34
|
+
* - agent_end (pri 100) — cron telemetry: captures cron execution outcomes with delivery/push flags
|
|
29
35
|
* - gateway_start — auto-approves device pairing for Clawly mobile clients (clientId: openclaw-ios)
|
|
30
36
|
* - gateway_start — registers auto-update cron job (0 3 * * *) for clawly-plugins
|
|
31
37
|
*/
|
|
@@ -76,6 +82,6 @@ export default {
|
|
|
76
82
|
registerCalendar(api, gw)
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
api.logger.info(`Loaded ${api.id} plugin
|
|
85
|
+
api.logger.info(`Loaded ${api.id} plugin. (debug-instrumented build)`)
|
|
80
86
|
},
|
|
81
87
|
}
|
package/model-gateway-setup.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* is derived from `agents.defaults.model`
|
|
5
|
-
* already present in the config.
|
|
2
|
+
* Reconciles the `clawly-model-gateway` provider in openclaw.json from
|
|
3
|
+
* pluginConfig inputs and the current runtime-facing agent defaults.
|
|
6
4
|
*
|
|
7
|
-
* This
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* This is a runtime reconcile path, not a provision/bootstrap contract.
|
|
6
|
+
* The first-boot correctness of gateway startup should not rely on this file
|
|
7
|
+
* write winning a race against OpenClaw's internal startup snapshot timing.
|
|
10
8
|
*/
|
|
11
9
|
|
|
12
10
|
import fs from 'node:fs'
|
package/openclaw.plugin.json
CHANGED
|
@@ -50,13 +50,18 @@
|
|
|
50
50
|
"skillGatewayToken": { "type": "string" },
|
|
51
51
|
"modelGatewayBaseUrl": { "type": "string" },
|
|
52
52
|
"modelGatewayToken": { "type": "string" },
|
|
53
|
+
"instanceId": { "type": "string" },
|
|
53
54
|
"agentId": { "type": "string" },
|
|
54
55
|
"agentName": { "type": "string" },
|
|
55
56
|
"workspaceDir": { "type": "string" },
|
|
56
57
|
"defaultModel": { "type": "string" },
|
|
57
58
|
"defaultImageModel": { "type": "string" },
|
|
58
59
|
"elevenlabsApiKey": { "type": "string" },
|
|
59
|
-
"elevenlabsVoiceId": { "type": "string" }
|
|
60
|
+
"elevenlabsVoiceId": { "type": "string" },
|
|
61
|
+
"otelToken": { "type": "string" },
|
|
62
|
+
"otelDataset": { "type": "string" },
|
|
63
|
+
"posthogApiKey": { "type": "string" },
|
|
64
|
+
"posthogHost": { "type": "string" }
|
|
60
65
|
},
|
|
61
66
|
"required": []
|
|
62
67
|
}
|
package/outbound.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Outbound file serving — serves files to the mobile client.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order (HTTP route + gateway method):
|
|
5
|
+
* 1. Hash-mapped file: ~/.openclaw/clawly/outbound/<sha256(path)>.<ext>
|
|
6
|
+
* 2. Direct path: if the raw path is under an allowlisted directory
|
|
4
7
|
*
|
|
5
8
|
* Hook: tool_result_persist → copies TTS audioPath to ~/.openclaw/clawly/outbound/<hash>.<ext>
|
|
6
|
-
* Method: clawly.file.getOutbound → reads outbound file by original-path hash
|
|
9
|
+
* Method: clawly.file.getOutbound → reads outbound file by original-path hash or direct path
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
import crypto from 'node:crypto'
|
|
@@ -74,6 +77,8 @@ export function registerOutboundHook(api: PluginApi) {
|
|
|
74
77
|
// ── Gateway method: clawly.file.getOutbound ─────────────────────────
|
|
75
78
|
|
|
76
79
|
export function registerOutboundMethods(api: PluginApi) {
|
|
80
|
+
const stateDir = api.runtime.state.resolveStateDir()
|
|
81
|
+
|
|
77
82
|
api.registerGatewayMethod('clawly.file.getOutbound', async ({params, respond}) => {
|
|
78
83
|
const rawPath = typeof params.path === 'string' ? params.path.trim() : ''
|
|
79
84
|
|
|
@@ -82,30 +87,18 @@ export function registerOutboundMethods(api: PluginApi) {
|
|
|
82
87
|
return
|
|
83
88
|
}
|
|
84
89
|
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// Fallback: TTS writes directly to /tmp/openclaw/ and the tool_result_persist hook
|
|
89
|
-
// only fires for agent tool calls, not tts.convert RPC calls. Read the source file
|
|
90
|
-
// directly if it lives in the known-safe TTS temp directory.
|
|
91
|
-
if (rawPath.startsWith('/tmp/openclaw/') && (await fileExists(rawPath))) {
|
|
92
|
-
const buffer = await fsp.readFile(rawPath)
|
|
93
|
-
api.logger.info(
|
|
94
|
-
`clawly.file.getOutbound: served from source ${rawPath} (${buffer.length} bytes)`,
|
|
95
|
-
)
|
|
96
|
-
respond(true, {base64: buffer.toString('base64')})
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
api.logger.warn(`outbound: file not found: ${rawPath} ${dest}`)
|
|
90
|
+
const resolved = await resolveOutboundFile(rawPath, stateDir)
|
|
91
|
+
if (!resolved) {
|
|
92
|
+
api.logger.warn(`outbound: file not found: ${rawPath}`)
|
|
100
93
|
respond(false, undefined, {code: 'not_found', message: 'outbound file not found'})
|
|
101
94
|
return
|
|
102
95
|
}
|
|
103
96
|
|
|
104
|
-
const buffer = await fsp.readFile(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
respond(true, {base64})
|
|
97
|
+
const buffer = await fsp.readFile(resolved)
|
|
98
|
+
api.logger.info(
|
|
99
|
+
`clawly.file.getOutbound: served ${rawPath} -> ${resolved} (${buffer.length} bytes)`,
|
|
100
|
+
)
|
|
101
|
+
respond(true, {base64: buffer.toString('base64')})
|
|
109
102
|
})
|
|
110
103
|
}
|
|
111
104
|
|
|
@@ -119,6 +112,47 @@ const MIME: Record<string, string> = {
|
|
|
119
112
|
'.aac': 'audio/aac',
|
|
120
113
|
'.flac': 'audio/flac',
|
|
121
114
|
'.webm': 'audio/webm',
|
|
115
|
+
'.jpg': 'image/jpeg',
|
|
116
|
+
'.jpeg': 'image/jpeg',
|
|
117
|
+
'.png': 'image/png',
|
|
118
|
+
'.gif': 'image/gif',
|
|
119
|
+
'.webp': 'image/webp',
|
|
120
|
+
'.bmp': 'image/bmp',
|
|
121
|
+
'.heic': 'image/heic',
|
|
122
|
+
'.avif': 'image/avif',
|
|
123
|
+
'.ico': 'image/x-icon',
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Directories from which direct-path serving is allowed (no hash required). */
|
|
127
|
+
let allowedRoots: string[] | null = null
|
|
128
|
+
|
|
129
|
+
function getAllowedRoots(stateDir?: string): string[] {
|
|
130
|
+
if (!allowedRoots) {
|
|
131
|
+
const roots = [OUTBOUND_DIR + path.sep, '/tmp/']
|
|
132
|
+
if (stateDir) roots.push(stateDir + path.sep)
|
|
133
|
+
allowedRoots = roots
|
|
134
|
+
}
|
|
135
|
+
return allowedRoots
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isDirectPathAllowed(resolved: string, stateDir?: string): boolean {
|
|
139
|
+
return getAllowedRoots(stateDir).some((root) => resolved.startsWith(root))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resolve which file to serve: hash-mapped first, then direct path if allowlisted.
|
|
144
|
+
* Returns the resolved file path or null.
|
|
145
|
+
*/
|
|
146
|
+
async function resolveOutboundFile(rawPath: string, stateDir?: string): Promise<string | null> {
|
|
147
|
+
// 1. Hash-mapped file
|
|
148
|
+
const hashed = outboundFilePath(rawPath)
|
|
149
|
+
if (await fileExists(hashed)) return hashed
|
|
150
|
+
|
|
151
|
+
// 2. Direct path (allowlisted directories only)
|
|
152
|
+
const resolved = path.resolve(rawPath)
|
|
153
|
+
if (isDirectPathAllowed(resolved, stateDir) && (await fileExists(resolved))) return resolved
|
|
154
|
+
|
|
155
|
+
return null
|
|
122
156
|
}
|
|
123
157
|
|
|
124
158
|
function sendJson(res: ServerResponse, status: number, body: Record<string, unknown>) {
|
|
@@ -127,6 +161,8 @@ function sendJson(res: ServerResponse, status: number, body: Record<string, unkn
|
|
|
127
161
|
}
|
|
128
162
|
|
|
129
163
|
export function registerOutboundHttpRoute(api: PluginApi) {
|
|
164
|
+
const stateDir = api.runtime.state.resolveStateDir()
|
|
165
|
+
|
|
130
166
|
api.registerHttpRoute({
|
|
131
167
|
path: '/clawly/file/outbound',
|
|
132
168
|
handler: async (_req: IncomingMessage, res: ServerResponse) => {
|
|
@@ -138,17 +174,16 @@ export function registerOutboundHttpRoute(api: PluginApi) {
|
|
|
138
174
|
return
|
|
139
175
|
}
|
|
140
176
|
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
if (!(await fileExists(dest))) {
|
|
177
|
+
const resolved = await resolveOutboundFile(rawPath, stateDir)
|
|
178
|
+
if (!resolved) {
|
|
144
179
|
api.logger.warn(`outbound http: file not found: ${rawPath}`)
|
|
145
180
|
sendJson(res, 404, {error: 'outbound file not found'})
|
|
146
181
|
return
|
|
147
182
|
}
|
|
148
183
|
|
|
149
|
-
const ext = path.extname(
|
|
184
|
+
const ext = path.extname(resolved).toLowerCase()
|
|
150
185
|
const contentType = MIME[ext] ?? 'application/octet-stream'
|
|
151
|
-
const stat = await fsp.stat(
|
|
186
|
+
const stat = await fsp.stat(resolved)
|
|
152
187
|
const total = stat.size
|
|
153
188
|
const rangeHeader = _req.headers.range
|
|
154
189
|
|
|
@@ -158,7 +193,7 @@ export function registerOutboundHttpRoute(api: PluginApi) {
|
|
|
158
193
|
const start = Number(match[1])
|
|
159
194
|
const end = match[2] ? Number(match[2]) : total - 1
|
|
160
195
|
const chunkSize = end - start + 1
|
|
161
|
-
const stream = fs.createReadStream(
|
|
196
|
+
const stream = fs.createReadStream(resolved, {start, end})
|
|
162
197
|
|
|
163
198
|
res.writeHead(206, {
|
|
164
199
|
'Content-Type': contentType,
|
|
@@ -168,13 +203,13 @@ export function registerOutboundHttpRoute(api: PluginApi) {
|
|
|
168
203
|
})
|
|
169
204
|
stream.pipe(res)
|
|
170
205
|
api.logger.info(
|
|
171
|
-
`outbound http: served ${rawPath} -> ${
|
|
206
|
+
`outbound http: served ${rawPath} -> ${resolved} range ${start}-${end}/${total}`,
|
|
172
207
|
)
|
|
173
208
|
return
|
|
174
209
|
}
|
|
175
210
|
}
|
|
176
211
|
|
|
177
|
-
const buffer = await fsp.readFile(
|
|
212
|
+
const buffer = await fsp.readFile(resolved)
|
|
178
213
|
res.writeHead(200, {
|
|
179
214
|
'Content-Type': contentType,
|
|
180
215
|
'Content-Length': total,
|
|
@@ -182,7 +217,7 @@ export function registerOutboundHttpRoute(api: PluginApi) {
|
|
|
182
217
|
})
|
|
183
218
|
res.end(buffer)
|
|
184
219
|
|
|
185
|
-
api.logger.info(`outbound http: served ${rawPath} -> ${
|
|
220
|
+
api.logger.info(`outbound http: served ${rawPath} -> ${resolved} (${total} bytes)`)
|
|
186
221
|
},
|
|
187
222
|
})
|
|
188
223
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2en/clawly-plugins",
|
|
3
|
-
"version": "1.26.0
|
|
3
|
+
"version": "1.26.0",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
"directory": "plugins/clawly-plugins"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
+
"@opentelemetry/api-logs": "^0.57.0",
|
|
13
|
+
"@opentelemetry/exporter-logs-otlp-http": "^0.57.0",
|
|
14
|
+
"@opentelemetry/resources": "^1.30.0",
|
|
15
|
+
"@opentelemetry/sdk-logs": "^0.57.0",
|
|
16
|
+
"posthog-node": "^5.28.0",
|
|
17
|
+
"file-type": "^21.3.0",
|
|
12
18
|
"zx": "npm:zx@8.8.5-lite"
|
|
13
19
|
},
|
|
14
20
|
"files": [
|