imessage-mcp-server 1.0.0 → 2.0.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/README.md +159 -79
- package/README.zh.md +310 -0
- package/bin/imessage-mcp-server +4 -0
- package/package.json +18 -8
- package/scripts/install-launchagent.sh +98 -0
- package/src/bridge/daemon.js +243 -0
- package/src/bridge/index.js +31 -0
- package/src/bridge/llm-loop.js +232 -0
- package/src/bridge/mcp-client.js +100 -0
- package/src/cli.js +125 -0
- package/src/config.js +236 -0
- package/src/providers/anthropic.js +80 -0
- package/src/providers/base.js +37 -0
- package/src/providers/index.js +19 -0
- package/src/providers/openai.js +104 -0
- package/src/safety.js +58 -0
- package/src/server/index.js +45 -0
- package/src/server/tools.js +163 -0
- package/src/shared/constants.js +30 -0
- package/src/shared/imessage.js +277 -0
- package/src/shared/utils.js +54 -0
- package/index.js +0 -480
package/src/safety.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { LOG } from "./shared/utils.js";
|
|
2
|
+
|
|
3
|
+
export function isToolAllowed(toolName, safety) {
|
|
4
|
+
if (safety.readOnly && isWriteTool(toolName)) {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
if (safety.allowedTools && !safety.allowedTools.includes(toolName)) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
if (safety.blockedTools && safety.blockedTools.includes(toolName)) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isWriteTool(toolName) {
|
|
17
|
+
const writeTools = new Set([
|
|
18
|
+
"run_shell_command",
|
|
19
|
+
"shell",
|
|
20
|
+
"execute_command",
|
|
21
|
+
"write_file",
|
|
22
|
+
"edit_file",
|
|
23
|
+
"delete_file",
|
|
24
|
+
]);
|
|
25
|
+
return writeTools.has(toolName);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isCommandBlocked(command, blockedPatterns) {
|
|
29
|
+
if (!command || !blockedPatterns || blockedPatterns.length === 0) return false;
|
|
30
|
+
for (const pattern of blockedPatterns) {
|
|
31
|
+
const regex = new RegExp(pattern, "i");
|
|
32
|
+
if (regex.test(command)) {
|
|
33
|
+
LOG.warn("Blocked command matched", { command, pattern });
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function requestConfirmation(text, replyFn) {
|
|
41
|
+
try {
|
|
42
|
+
await replyFn(`⚠️ 请求确认:\n${text}\n\n回复 "确认" 以继续执行。`);
|
|
43
|
+
// In a real implementation, this would wait for the user's reply.
|
|
44
|
+
// For now, we return false to be safe; the daemon can implement polling.
|
|
45
|
+
return false;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
LOG.error("Failed to send confirmation request", { error: err.message });
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function sanitizeForLogging(input) {
|
|
53
|
+
const clone = { ...input };
|
|
54
|
+
if (clone.apiKey) clone.apiKey = "***";
|
|
55
|
+
if (clone.password) clone.password = "***";
|
|
56
|
+
if (clone.token) clone.token = "***";
|
|
57
|
+
return clone;
|
|
58
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import {
|
|
4
|
+
CallToolRequestSchema,
|
|
5
|
+
ListToolsRequestSchema,
|
|
6
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { DEFAULTS } from "../shared/constants.js";
|
|
8
|
+
import { TOOLS } from "./tools.js";
|
|
9
|
+
|
|
10
|
+
export async function startServer() {
|
|
11
|
+
const server = new Server(
|
|
12
|
+
{
|
|
13
|
+
name: DEFAULTS.SERVER.name,
|
|
14
|
+
version: DEFAULTS.SERVER.version,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
capabilities: {
|
|
18
|
+
tools: {},
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
24
|
+
return {
|
|
25
|
+
tools: Object.entries(TOOLS).map(([name, tool]) => ({
|
|
26
|
+
name,
|
|
27
|
+
description: tool.description,
|
|
28
|
+
inputSchema: tool.inputSchema,
|
|
29
|
+
})),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
34
|
+
const { name, arguments: args } = request.params;
|
|
35
|
+
const tool = TOOLS[name];
|
|
36
|
+
if (!tool) {
|
|
37
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
38
|
+
}
|
|
39
|
+
return tool.handler(args ?? {});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const transport = new StdioServerTransport();
|
|
43
|
+
await server.connect(transport);
|
|
44
|
+
console.error("iMessage MCP Server running on stdio");
|
|
45
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sendMessage,
|
|
3
|
+
listConversations,
|
|
4
|
+
readConversation,
|
|
5
|
+
getNewMessages,
|
|
6
|
+
} from "../shared/imessage.js";
|
|
7
|
+
|
|
8
|
+
export const TOOLS = {
|
|
9
|
+
send_imessage: {
|
|
10
|
+
description: "Send an iMessage to a recipient's email or phone number",
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
recipient: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "Recipient iMessage account (email or phone number)",
|
|
17
|
+
},
|
|
18
|
+
text: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description: "Message text to send",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
required: ["recipient", "text"],
|
|
24
|
+
},
|
|
25
|
+
handler: (args) => {
|
|
26
|
+
const result = sendMessage(args.recipient, args.text);
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
list_conversations: {
|
|
34
|
+
description:
|
|
35
|
+
"List recent iMessage conversations with their latest message preview and unread count",
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
limit: {
|
|
40
|
+
type: "number",
|
|
41
|
+
description: "Max conversations to return (default 20)",
|
|
42
|
+
default: 20,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
handler: (args) => {
|
|
47
|
+
const conversations = listConversations(args.limit ?? 20);
|
|
48
|
+
return {
|
|
49
|
+
content: [
|
|
50
|
+
{
|
|
51
|
+
type: "text",
|
|
52
|
+
text: JSON.stringify(conversations, null, 2),
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
read_conversation: {
|
|
60
|
+
description:
|
|
61
|
+
"Read messages from a specific conversation by chat_id or handle (email/phone). Returns paginated messages.",
|
|
62
|
+
inputSchema: {
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: {
|
|
65
|
+
handle: {
|
|
66
|
+
type: "string",
|
|
67
|
+
description:
|
|
68
|
+
"Filter by handle (email or phone number of the conversation partner)",
|
|
69
|
+
},
|
|
70
|
+
chat_id: {
|
|
71
|
+
type: "number",
|
|
72
|
+
description: "Filter by chat ROWID",
|
|
73
|
+
},
|
|
74
|
+
limit: {
|
|
75
|
+
type: "number",
|
|
76
|
+
description: "Max messages to return (default 30)",
|
|
77
|
+
default: 30,
|
|
78
|
+
},
|
|
79
|
+
before_id: {
|
|
80
|
+
type: "number",
|
|
81
|
+
description:
|
|
82
|
+
"Return messages before this ROWID (for pagination/older messages)",
|
|
83
|
+
},
|
|
84
|
+
include_read: {
|
|
85
|
+
type: "boolean",
|
|
86
|
+
description: "Include already-read messages (default true)",
|
|
87
|
+
default: true,
|
|
88
|
+
},
|
|
89
|
+
unread_only: {
|
|
90
|
+
type: "boolean",
|
|
91
|
+
description: "Only return unread messages (default false)",
|
|
92
|
+
default: false,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
handler: (args) => {
|
|
97
|
+
const result = readConversation({
|
|
98
|
+
handle: args.handle,
|
|
99
|
+
chat_id: args.chat_id,
|
|
100
|
+
limit: args.limit ?? 30,
|
|
101
|
+
before_id: args.before_id,
|
|
102
|
+
include_read: args.include_read !== false,
|
|
103
|
+
unread_only: args.unread_only === true,
|
|
104
|
+
});
|
|
105
|
+
return {
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
108
|
+
type: "text",
|
|
109
|
+
text: JSON.stringify(result, null, 2),
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
get_new_messages: {
|
|
117
|
+
description:
|
|
118
|
+
"Get recently received messages since a given timestamp. Use this to poll for new incoming iMessages.",
|
|
119
|
+
inputSchema: {
|
|
120
|
+
type: "object",
|
|
121
|
+
properties: {
|
|
122
|
+
since: {
|
|
123
|
+
type: "string",
|
|
124
|
+
description:
|
|
125
|
+
"ISO timestamp to fetch messages from (e.g., '2026-06-28T10:00:00.000Z'). If omitted, returns last 10 messages.",
|
|
126
|
+
},
|
|
127
|
+
max_results: {
|
|
128
|
+
type: "number",
|
|
129
|
+
description: "Max messages to return (default 10)",
|
|
130
|
+
default: 10,
|
|
131
|
+
},
|
|
132
|
+
unread_only: {
|
|
133
|
+
type: "boolean",
|
|
134
|
+
description: "Only return unread messages (default false)",
|
|
135
|
+
default: false,
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
handler: (args) => {
|
|
140
|
+
const messages = getNewMessages({
|
|
141
|
+
since: args.since,
|
|
142
|
+
max_results: args.max_results ?? 10,
|
|
143
|
+
unread_only: args.unread_only === true,
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
content: [
|
|
147
|
+
{
|
|
148
|
+
type: "text",
|
|
149
|
+
text: JSON.stringify(
|
|
150
|
+
{
|
|
151
|
+
total: messages.length,
|
|
152
|
+
has_unread: messages.some((m) => !m.is_read),
|
|
153
|
+
messages,
|
|
154
|
+
},
|
|
155
|
+
null,
|
|
156
|
+
2
|
|
157
|
+
),
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
|
|
4
|
+
// Apple Cocoa epoch: 2001-01-01 00:00:00 UTC
|
|
5
|
+
export const COCOA_EPOCH_OFFSET_S = 978_307_200;
|
|
6
|
+
|
|
7
|
+
// Default iMessage SQLite database path
|
|
8
|
+
export const DEFAULT_DB_PATH = resolve(
|
|
9
|
+
homedir(),
|
|
10
|
+
"Library/Messages/chat.db"
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
// Runtime path override via environment
|
|
14
|
+
export const DB_PATH = resolve(
|
|
15
|
+
process.env.IMESSAGE_DB_PATH || DEFAULT_DB_PATH
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
export const DEFAULTS = {
|
|
19
|
+
SERVER: {
|
|
20
|
+
name: "imessage-mcp",
|
|
21
|
+
version: "2.0.0",
|
|
22
|
+
},
|
|
23
|
+
BRIDGE: {
|
|
24
|
+
pollIntervalMs: 3000,
|
|
25
|
+
maxHistoryPerConversation: 20,
|
|
26
|
+
maxToolIterations: 10,
|
|
27
|
+
maxTokens: 4096,
|
|
28
|
+
sendProcessingIndicator: true,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { COCOA_EPOCH_OFFSET_S, DB_PATH } from "./constants.js";
|
|
5
|
+
import { cocoaDateToISO } from "./utils.js";
|
|
6
|
+
|
|
7
|
+
export function validateDbPath() {
|
|
8
|
+
if (!existsSync(DB_PATH)) {
|
|
9
|
+
console.error("❌ iMessage database not found at: " + DB_PATH);
|
|
10
|
+
console.error(" Make sure you are signed into iMessage on this Mac.");
|
|
11
|
+
console.error(
|
|
12
|
+
" If the database is elsewhere, set IMESSAGE_DB_PATH to its location."
|
|
13
|
+
);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function openDb(options = {}) {
|
|
19
|
+
validateDbPath();
|
|
20
|
+
const db = new Database(DB_PATH, {
|
|
21
|
+
readonly: options.readonly !== false,
|
|
22
|
+
fileMustExist: true,
|
|
23
|
+
});
|
|
24
|
+
db.pragma("journal_mode = WAL");
|
|
25
|
+
if (options.queryOnly) {
|
|
26
|
+
db.pragma("query_only = true");
|
|
27
|
+
}
|
|
28
|
+
return db;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function sendMessage(recipient, text) {
|
|
32
|
+
const escaped = text.replace(/"/g, '\\"').replace(/\n/g, "\\n");
|
|
33
|
+
const script = `tell application "Messages" to send "${escaped}" to buddy "${recipient}"`;
|
|
34
|
+
execSync(`osascript -e '${script}'`, {
|
|
35
|
+
encoding: "utf-8",
|
|
36
|
+
timeout: 15_000,
|
|
37
|
+
});
|
|
38
|
+
return { success: true, recipient, text };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function listConversations(limit = 20) {
|
|
42
|
+
const db = openDb();
|
|
43
|
+
const rows = db
|
|
44
|
+
.prepare(
|
|
45
|
+
`
|
|
46
|
+
SELECT
|
|
47
|
+
c.ROWID AS chat_id,
|
|
48
|
+
c.display_name,
|
|
49
|
+
c.chat_identifier,
|
|
50
|
+
c.service_name,
|
|
51
|
+
h.id AS handle_id_str,
|
|
52
|
+
m.ROWID AS last_msg_id,
|
|
53
|
+
m.text AS last_msg_text,
|
|
54
|
+
m.date AS last_msg_date,
|
|
55
|
+
m.is_from_me AS last_msg_from_me,
|
|
56
|
+
(SELECT COUNT(*) FROM message
|
|
57
|
+
WHERE handle_id = h.ROWID
|
|
58
|
+
AND is_read = 0 AND is_from_me = 0
|
|
59
|
+
AND is_finished = 1
|
|
60
|
+
) AS unread_count
|
|
61
|
+
FROM chat c
|
|
62
|
+
JOIN chat_handle_join chj ON chj.chat_id = c.ROWID
|
|
63
|
+
JOIN handle h ON h.ROWID = chj.handle_id
|
|
64
|
+
LEFT JOIN (
|
|
65
|
+
SELECT cmj.chat_id, MAX(m.ROWID) AS max_msg_id
|
|
66
|
+
FROM chat_message_join cmj
|
|
67
|
+
JOIN message m ON m.ROWID = cmj.message_id
|
|
68
|
+
GROUP BY cmj.chat_id
|
|
69
|
+
) latest ON latest.chat_id = c.ROWID
|
|
70
|
+
LEFT JOIN message m ON m.ROWID = latest.max_msg_id
|
|
71
|
+
ORDER BY COALESCE(m.date, 0) DESC
|
|
72
|
+
LIMIT ?
|
|
73
|
+
`
|
|
74
|
+
)
|
|
75
|
+
.all(Math.min(limit, 100));
|
|
76
|
+
db.close();
|
|
77
|
+
|
|
78
|
+
return rows.map((r) => ({
|
|
79
|
+
chat_id: r.chat_id,
|
|
80
|
+
display_name: r.display_name || r.chat_identifier || r.handle_id_str,
|
|
81
|
+
handle: r.handle_id_str,
|
|
82
|
+
service: r.service_name,
|
|
83
|
+
unread_count: r.unread_count || 0,
|
|
84
|
+
last_message: r.last_msg_text
|
|
85
|
+
? {
|
|
86
|
+
text: r.last_msg_text.substring(0, 200),
|
|
87
|
+
date: cocoaDateToISO(r.last_msg_date),
|
|
88
|
+
is_from_me: !!r.last_msg_from_me,
|
|
89
|
+
}
|
|
90
|
+
: null,
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function readConversation({
|
|
95
|
+
handle,
|
|
96
|
+
chat_id,
|
|
97
|
+
limit = 30,
|
|
98
|
+
before_id,
|
|
99
|
+
include_read = true,
|
|
100
|
+
unread_only = false,
|
|
101
|
+
}) {
|
|
102
|
+
const db = openDb();
|
|
103
|
+
let where = "WHERE 1=1";
|
|
104
|
+
const params = [];
|
|
105
|
+
|
|
106
|
+
if (handle) {
|
|
107
|
+
where += " AND h.id = ?";
|
|
108
|
+
params.push(handle);
|
|
109
|
+
}
|
|
110
|
+
if (chat_id) {
|
|
111
|
+
where += " AND cmj.chat_id = ?";
|
|
112
|
+
params.push(chat_id);
|
|
113
|
+
}
|
|
114
|
+
if (before_id) {
|
|
115
|
+
where += " AND m.ROWID < ?";
|
|
116
|
+
params.push(before_id);
|
|
117
|
+
}
|
|
118
|
+
if (unread_only) {
|
|
119
|
+
where += " AND m.is_read = 0 AND m.is_from_me = 0 AND m.is_finished = 1";
|
|
120
|
+
} else if (!include_read) {
|
|
121
|
+
where += " AND m.is_read = 0 AND m.is_from_me = 0";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const rows = db
|
|
125
|
+
.prepare(
|
|
126
|
+
`
|
|
127
|
+
SELECT
|
|
128
|
+
m.ROWID,
|
|
129
|
+
m.text,
|
|
130
|
+
m.is_from_me,
|
|
131
|
+
m.is_read,
|
|
132
|
+
m.is_delivered,
|
|
133
|
+
m.date,
|
|
134
|
+
m.service,
|
|
135
|
+
m.date_read,
|
|
136
|
+
m.date_delivered,
|
|
137
|
+
h.id AS handle_id_str,
|
|
138
|
+
c.ROWID AS chat_id,
|
|
139
|
+
COALESCE(c.display_name, c.chat_identifier) AS chat_name
|
|
140
|
+
FROM message m
|
|
141
|
+
JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
|
|
142
|
+
JOIN chat c ON c.ROWID = cmj.chat_id
|
|
143
|
+
LEFT JOIN handle h ON h.ROWID = m.handle_id
|
|
144
|
+
${where}
|
|
145
|
+
ORDER BY m.date DESC
|
|
146
|
+
LIMIT ?
|
|
147
|
+
`
|
|
148
|
+
)
|
|
149
|
+
.all(...params, Math.min(limit, 200));
|
|
150
|
+
db.close();
|
|
151
|
+
|
|
152
|
+
const messages = rows
|
|
153
|
+
.map((r) => ({
|
|
154
|
+
id: r.ROWID,
|
|
155
|
+
chat_id: r.chat_id,
|
|
156
|
+
chat_name: r.chat_name,
|
|
157
|
+
text: r.text,
|
|
158
|
+
from_me: !!r.is_from_me,
|
|
159
|
+
from: r.is_from_me ? "me" : r.handle_id_str,
|
|
160
|
+
is_read: !!r.is_read,
|
|
161
|
+
is_delivered: !!r.is_delivered,
|
|
162
|
+
service: r.service,
|
|
163
|
+
date: cocoaDateToISO(r.date),
|
|
164
|
+
date_read: cocoaDateToISO(r.date_read),
|
|
165
|
+
date_delivered: cocoaDateToISO(r.date_delivered),
|
|
166
|
+
}))
|
|
167
|
+
.reverse();
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
chat_id: rows[0]?.chat_id ?? null,
|
|
171
|
+
chat_name: rows[0]?.chat_name ?? null,
|
|
172
|
+
total: messages.length,
|
|
173
|
+
messages,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function getNewMessages({ since, max_results = 10, unread_only = false }) {
|
|
178
|
+
const db = openDb();
|
|
179
|
+
let where = "WHERE m.is_from_me = 0 AND m.is_finished = 1";
|
|
180
|
+
|
|
181
|
+
if (since) {
|
|
182
|
+
const d = new Date(since);
|
|
183
|
+
if (!isNaN(d.getTime())) {
|
|
184
|
+
const cocoaNs = (d.getTime() / 1000 - COCOA_EPOCH_OFFSET_S) * 1_000_000_000;
|
|
185
|
+
where += ` AND m.date > ${Math.floor(cocoaNs)}`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (unread_only) {
|
|
190
|
+
where += " AND m.is_read = 0";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const rows = db
|
|
194
|
+
.prepare(
|
|
195
|
+
`
|
|
196
|
+
SELECT
|
|
197
|
+
m.ROWID,
|
|
198
|
+
m.text,
|
|
199
|
+
m.is_from_me,
|
|
200
|
+
m.is_read,
|
|
201
|
+
m.is_delivered,
|
|
202
|
+
m.date,
|
|
203
|
+
m.service,
|
|
204
|
+
h.id AS handle_id_str,
|
|
205
|
+
c.ROWID AS chat_id,
|
|
206
|
+
COALESCE(c.display_name, c.chat_identifier) AS chat_name
|
|
207
|
+
FROM message m
|
|
208
|
+
JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
|
|
209
|
+
JOIN chat c ON c.ROWID = cmj.chat_id
|
|
210
|
+
LEFT JOIN handle h ON h.ROWID = m.handle_id
|
|
211
|
+
${where}
|
|
212
|
+
ORDER BY m.date DESC
|
|
213
|
+
LIMIT ?
|
|
214
|
+
`
|
|
215
|
+
)
|
|
216
|
+
.all(Math.min(max_results, 100));
|
|
217
|
+
db.close();
|
|
218
|
+
|
|
219
|
+
return rows.map((r) => ({
|
|
220
|
+
id: r.ROWID,
|
|
221
|
+
chat_id: r.chat_id,
|
|
222
|
+
chat_name: r.chat_name,
|
|
223
|
+
text: r.text,
|
|
224
|
+
from: r.handle_id_str,
|
|
225
|
+
is_read: !!r.is_read,
|
|
226
|
+
is_delivered: !!r.is_delivered,
|
|
227
|
+
service: r.service,
|
|
228
|
+
date: cocoaDateToISO(r.date),
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Bridge-specific helpers ────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
export function getMasterHandleId(db, masterHandle) {
|
|
235
|
+
const row = db.prepare("SELECT ROWID FROM handle WHERE id = ?").get(masterHandle);
|
|
236
|
+
return row?.ROWID;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function getLatestMessageId(db) {
|
|
240
|
+
const row = db.prepare("SELECT MAX(ROWID) as max_id FROM message").get();
|
|
241
|
+
return row?.max_id || 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function getChatName(db, chatId) {
|
|
245
|
+
const row = db
|
|
246
|
+
.prepare(
|
|
247
|
+
`
|
|
248
|
+
SELECT COALESCE(c.display_name, c.chat_identifier, h.id) AS name
|
|
249
|
+
FROM chat c
|
|
250
|
+
LEFT JOIN chat_handle_join chj ON chj.chat_id = c.ROWID
|
|
251
|
+
LEFT JOIN handle h ON h.ROWID = chj.handle_id
|
|
252
|
+
WHERE c.ROWID = ?
|
|
253
|
+
LIMIT 1
|
|
254
|
+
`
|
|
255
|
+
)
|
|
256
|
+
.get(chatId);
|
|
257
|
+
return row?.name || `chat_${chatId}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function getNewMessagesForBridge(db, handleId, lastId) {
|
|
261
|
+
return db
|
|
262
|
+
.prepare(
|
|
263
|
+
`
|
|
264
|
+
SELECT m.ROWID, m.text, m.date, cmj.chat_id
|
|
265
|
+
FROM message m
|
|
266
|
+
JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
|
|
267
|
+
WHERE m.is_from_me = 0
|
|
268
|
+
AND m.handle_id = ?
|
|
269
|
+
AND m.ROWID > ?
|
|
270
|
+
AND m.is_finished = 1
|
|
271
|
+
AND m.text IS NOT NULL
|
|
272
|
+
AND m.text != ''
|
|
273
|
+
ORDER BY m.ROWID ASC
|
|
274
|
+
`
|
|
275
|
+
)
|
|
276
|
+
.all(handleId, lastId || 0);
|
|
277
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { COCOA_EPOCH_OFFSET_S } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
export function cocoaDateToISO(cocoaNs) {
|
|
4
|
+
if (!cocoaNs || cocoaNs <= 0) return null;
|
|
5
|
+
const unixMs = (cocoaNs / 1_000_000_000 + COCOA_EPOCH_OFFSET_S) * 1000;
|
|
6
|
+
return new Date(unixMs).toISOString();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isoToCocoaNs(isoString) {
|
|
10
|
+
const d = new Date(isoString);
|
|
11
|
+
if (isNaN(d.getTime())) return null;
|
|
12
|
+
return Math.floor((d.getTime() / 1000 - COCOA_EPOCH_OFFSET_S) * 1_000_000_000);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function sleep(ms) {
|
|
16
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function log(level, msg, data = null) {
|
|
20
|
+
const ts = new Date().toISOString();
|
|
21
|
+
const line = data
|
|
22
|
+
? `[${ts}] [${level}] ${msg} ${JSON.stringify(data)}`
|
|
23
|
+
: `[${ts}] [${level}] ${msg}`;
|
|
24
|
+
console.error(line);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const LOG = {
|
|
28
|
+
info: (m, d) => log("INFO", m, d),
|
|
29
|
+
warn: (m, d) => log("WARN", m, d),
|
|
30
|
+
error: (m, d) => log("ERROR", m, d),
|
|
31
|
+
debug: (m, d) => log("DEBUG", m, d),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function truncate(str, maxLen = 5000, suffix = "\n\n... (truncated)") {
|
|
35
|
+
if (!str || str.length <= maxLen) return str;
|
|
36
|
+
return str.substring(0, maxLen) + suffix;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function splitLongReply(text, maxPartLen = 300) {
|
|
40
|
+
if (text.length <= maxPartLen) return [text];
|
|
41
|
+
const sentences = text.split(/(?<=[。!?\n])/);
|
|
42
|
+
const parts = [];
|
|
43
|
+
let current = "";
|
|
44
|
+
for (const s of sentences) {
|
|
45
|
+
if (current.length + s.length > maxPartLen && current.length > 0) {
|
|
46
|
+
parts.push(current.trim());
|
|
47
|
+
current = s;
|
|
48
|
+
} else {
|
|
49
|
+
current += s;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (current.trim()) parts.push(current.trim());
|
|
53
|
+
return parts.length ? parts : [text.substring(0, maxPartLen)];
|
|
54
|
+
}
|