@zhin.js/adapter-github 3.0.1 → 3.0.3

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.
Files changed (84) hide show
  1. package/CHANGELOG.md +624 -0
  2. package/README.md +38 -190
  3. package/adapters/github.ts +51 -0
  4. package/{skills/github/SKILL.md → agent/skills/github.md} +22 -5
  5. package/agent/tools/bind.ts +12 -0
  6. package/agent/tools/create_pr.ts +19 -0
  7. package/agent/tools/install.ts +12 -0
  8. package/agent/tools/patch_file.ts +18 -0
  9. package/agent/tools/prepare_workspace.ts +14 -0
  10. package/agent/tools/push_branch.ts +17 -0
  11. package/agent/tools/star.ts +15 -0
  12. package/agent/tools/subscribe.ts +16 -0
  13. package/agent/tools/subscriptions.ts +13 -0
  14. package/agent/tools/unbind.ts +12 -0
  15. package/agent/tools/unsubscribe.ts +15 -0
  16. package/agent/tools/whoami.ts +12 -0
  17. package/lib/agent-prompt.d.ts +0 -1
  18. package/lib/agent-prompt.js +10 -9
  19. package/lib/endpoint.d.ts +46 -24
  20. package/lib/endpoint.js +144 -144
  21. package/lib/gh-client.d.ts +73 -1
  22. package/lib/gh-client.js +99 -1
  23. package/lib/github-agent-deps.d.ts +47 -0
  24. package/lib/github-agent-deps.js +43 -0
  25. package/lib/github-bot-handlers.d.ts +26 -0
  26. package/lib/github-bot-handlers.js +77 -0
  27. package/lib/github-channel-context.d.ts +16 -0
  28. package/lib/github-channel-context.js +31 -0
  29. package/lib/github-tool-handlers.d.ts +17 -0
  30. package/lib/github-tool-handlers.js +221 -0
  31. package/lib/index.d.ts +7 -32
  32. package/lib/index.js +7 -386
  33. package/lib/oauth-users.d.ts +33 -0
  34. package/lib/oauth-users.js +38 -0
  35. package/lib/protocol.d.ts +87 -0
  36. package/lib/protocol.js +241 -0
  37. package/lib/types.d.ts +6 -1
  38. package/lib/types.js +0 -1
  39. package/lib/webhook.d.ts +13 -0
  40. package/lib/webhook.js +87 -0
  41. package/lib/workspace-manager.d.ts +21 -0
  42. package/lib/workspace-manager.js +145 -0
  43. package/package.json +60 -23
  44. package/plugin.ts +52 -0
  45. package/schema.json +65 -0
  46. package/src/agent-prompt.ts +11 -10
  47. package/src/endpoint.ts +169 -150
  48. package/src/gh-client.ts +131 -0
  49. package/src/github-agent-deps.ts +82 -0
  50. package/src/github-bot-handlers.ts +109 -0
  51. package/src/github-channel-context.ts +46 -0
  52. package/src/github-tool-handlers.ts +257 -0
  53. package/src/index.ts +55 -431
  54. package/src/oauth-users.ts +47 -0
  55. package/src/protocol.ts +367 -0
  56. package/src/types.ts +6 -0
  57. package/src/webhook.ts +125 -0
  58. package/src/workspace-manager.ts +158 -0
  59. package/lib/adapter.d.ts +0 -66
  60. package/lib/adapter.d.ts.map +0 -1
  61. package/lib/adapter.js +0 -418
  62. package/lib/adapter.js.map +0 -1
  63. package/lib/agent-prompt.d.ts.map +0 -1
  64. package/lib/agent-prompt.js.map +0 -1
  65. package/lib/endpoint.d.ts.map +0 -1
  66. package/lib/endpoint.js.map +0 -1
  67. package/lib/gh-client.d.ts.map +0 -1
  68. package/lib/gh-client.js.map +0 -1
  69. package/lib/index.d.ts.map +0 -1
  70. package/lib/index.js.map +0 -1
  71. package/lib/register-github-mcp.d.ts +0 -7
  72. package/lib/register-github-mcp.d.ts.map +0 -1
  73. package/lib/register-github-mcp.js +0 -36
  74. package/lib/register-github-mcp.js.map +0 -1
  75. package/lib/segment-mapper.d.ts +0 -2
  76. package/lib/segment-mapper.d.ts.map +0 -1
  77. package/lib/segment-mapper.js +0 -2
  78. package/lib/segment-mapper.js.map +0 -1
  79. package/lib/types.d.ts.map +0 -1
  80. package/lib/types.js.map +0 -1
  81. package/plugin.yml +0 -3
  82. package/src/adapter.ts +0 -450
  83. package/src/register-github-mcp.ts +0 -62
  84. package/src/segment-mapper.ts +0 -1
package/plugin.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { definePlugin, databaseHostToken } from '@zhin.js/plugin-runtime';
2
+ import { defineGithubOauthUsersTable } from './src/oauth-users.js';
3
+
4
+ /**
5
+ * github_subscriptions — repo event subscriptions per chat channel
6
+ * (used by github_subscriptions agent tool; schema matches legacy defineModel).
7
+ */
8
+ const GITHUB_SUBSCRIPTIONS_SCHEMA = {
9
+ id: { type: 'integer', primary: true },
10
+ repo: { type: 'text', nullable: false },
11
+ events: { type: 'json', default: [] },
12
+ target_id: { type: 'text', nullable: false },
13
+ target_type: { type: 'text', nullable: false },
14
+ adapter: { type: 'text', nullable: false },
15
+ endpoint: { type: 'text', nullable: false },
16
+ } as const;
17
+
18
+ /**
19
+ * Plugin Runtime GitHub adapter.
20
+ * - Endpoint: `adapters/github.ts`
21
+ * - OAuth user tokens: define `github_oauth_users` when DatabaseHost is present
22
+ */
23
+ export default definePlugin({
24
+ name: 'github',
25
+ metadata: {
26
+ displayName: 'GitHub Adapter',
27
+ },
28
+ setup(context) {
29
+ if (context.resources.has(databaseHostToken)) {
30
+ const host = context.resources.use(databaseHostToken);
31
+ defineGithubOauthUsersTable(host);
32
+ host.define('github_subscriptions', { ...GITHUB_SUBSCRIPTIONS_SCHEMA });
33
+ }
34
+
35
+ // Agent prompt contributor (orchestrator/deferred-worker GitHub guidance).
36
+ // `zhin.js/agent` is an optional peer — skip silently on IM-only installs.
37
+ let cancelled = false;
38
+ let unregister: (() => void) | undefined;
39
+ void Promise.all([
40
+ import('zhin.js/agent'),
41
+ import('./src/agent-prompt.js'),
42
+ ]).then(([agent, prompt]) => {
43
+ if (cancelled) return;
44
+ agent.registerAgentPromptContributor(prompt.createGithubAgentPromptContributor());
45
+ unregister = () => agent.unregisterAgentPromptContributor('github');
46
+ }).catch(() => { /* optional peer not installed */ });
47
+ return () => {
48
+ cancelled = true;
49
+ unregister?.();
50
+ };
51
+ },
52
+ });
package/schema.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "properties": {
6
+ "name": {
7
+ "type": "string",
8
+ "default": "github-bot"
9
+ },
10
+ "host": {
11
+ "type": "string",
12
+ "description": "GitHub Enterprise hostname (default github.com)"
13
+ },
14
+ "app_id": {
15
+ "type": ["string", "number"],
16
+ "description": "GitHub App ID"
17
+ },
18
+ "appId": {
19
+ "type": ["string", "number"],
20
+ "description": "GitHub App ID (camelCase alias)"
21
+ },
22
+ "private_key": {
23
+ "type": "string",
24
+ "description": "GitHub App private key (PEM content or file path)"
25
+ },
26
+ "privateKey": {
27
+ "type": "string",
28
+ "description": "GitHub App private key (camelCase alias)"
29
+ },
30
+ "webhook_secret": {
31
+ "type": "string",
32
+ "description": "Webhook HMAC secret; enables httpHostToken POST route"
33
+ },
34
+ "webhookSecret": {
35
+ "type": "string",
36
+ "description": "Webhook HMAC secret (camelCase alias)"
37
+ },
38
+ "webhook_path": {
39
+ "type": "string",
40
+ "default": "/github/webhook"
41
+ },
42
+ "webhookPath": {
43
+ "type": "string",
44
+ "default": "/github/webhook"
45
+ },
46
+ "poll_interval": {
47
+ "type": "number",
48
+ "default": 60,
49
+ "description": "Deferred: polling fallback was removed in the Plugin Runtime migration; currently parsed but unused"
50
+ },
51
+ "auto_reply_repos": {
52
+ "type": "array",
53
+ "items": { "type": "string" },
54
+ "description": "Repos whose Issue/PR comments auto-trigger AI without @bot"
55
+ },
56
+ "bot_login": {
57
+ "type": "string",
58
+ "description": "Override App bot login (default {slug}[bot])"
59
+ },
60
+ "workspace_root": {
61
+ "type": "string",
62
+ "description": "Managed git workspace root"
63
+ }
64
+ }
65
+ }
@@ -4,8 +4,7 @@ import type {
4
4
  AgentPromptSection,
5
5
  DeferredToolCatalogItem,
6
6
  } from 'zhin.js';
7
- import type { AgentTool } from 'zhin.js/ai';
8
- import { filterTools } from 'zhin.js/ai';
7
+ import { filterTools, type AgentTool } from 'zhin.js/ai';
9
8
 
10
9
  function selectGithubDeferredTools(
11
10
  query: string,
@@ -21,8 +20,8 @@ function selectGithubDeferredTools(
21
20
  if (bash) pinned.push(bash);
22
21
 
23
22
  const preferNames = [
24
- ...pool.filter(t => t.name.startsWith('mcp_github_')).map(t => t.name),
25
23
  ...pool.filter(t => t.name.startsWith('github_')).map(t => t.name),
24
+ ...pool.filter(t => t.name.startsWith('mcp_github_')).map(t => t.name),
26
25
  ];
27
26
  for (const name of preferNames) {
28
27
  if (pinned.length >= maxTools) break;
@@ -47,16 +46,18 @@ function isGithubDelegatedTask(query: string, goal: string): boolean {
47
46
  }
48
47
 
49
48
  const ORCHESTRATOR_GITHUB = [
50
- 'On GitHub: use run_deferred_task with tool_query "github_" or "mcp_github_" or "gh issue"/"gh pr".',
51
- 'Discuss issues/PRs in chat context; do not call github_* or mcp_github_* tools on this orchestrator.',
52
- 'Skip tool_search when the user clearly names a repo, issue number, or PR.',
49
+ 'On GitHub: use run_deferred_task with tool_query "github_".',
50
+ 'Discuss issues/PRs in chat context; do not call github_* tools on this orchestrator.',
51
+ 'Bot write operations use github_* tools (Installation Token), not mcp_github_*.',
53
52
  ].join('\n');
54
53
 
55
54
  const WORKER_GITHUB = [
56
- 'Prefer `gh` via bash for repo operations when bash is available.',
57
- 'Use mcp_github_* or github_* plugin tools for structured API actions.',
58
- 'Do not use mcp_filesystem_* or unrelated MCP servers to "discover" GitHub.',
59
- 'Summarize outcomes (issue link, PR state) for the orchestrator.',
55
+ 'Use github_prepare_workspace before multi-file edits in a repo.',
56
+ 'Small single-file change: github_patch_file (Contents API).',
57
+ 'Multi-file / tests: workspace + bash, then github_push_branch (requires approval) and github_create_pr for Issues.',
58
+ 'Issue thread: new branch + new PR. PR thread: push to existing PR head branch.',
59
+ 'Do NOT use mcp_github_* for writes — PAT acts as human, not Bot.',
60
+ 'Summarize outcomes (PR link, branch) for the orchestrator.',
60
61
  ].map(line => `- ${line}`).join('\n');
61
62
 
62
63
  export function createGithubAgentPromptContributor(): AgentPromptContributor {
package/src/endpoint.ts CHANGED
@@ -1,182 +1,201 @@
1
1
  /**
2
- * GitHub Endpoint 实现(基于 gh CLI)
2
+ * GithubEndpoint lifecycle, outbound send, inbound admit.
3
3
  */
4
- import { formatCompact, Endpoint, Message, segment, SendContent, SendOptions, type MessageSegment,
5
- coerceQrcodeSegmentsToText,
6
- expandInteractiveSegmentsInContent,
7
- } from 'zhin.js';
8
- import type {
9
- GitHubEndpointConfig,
10
- IssueCommentPayload,
11
- PRReviewCommentPayload,
12
- PRReviewPayload,
13
- } from './types.js';
14
- import { buildChannelId, parseChannelId } from './types.js';
15
- import type { GitHubAdapter } from './adapter.js';
4
+ import path from 'node:path';
5
+ import type { EndpointInstance } from '@zhin.js/adapter';
6
+ import type { MessageGateway } from '@zhin.js/core/runtime';
7
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
8
+ import { formatCompact, getLogger } from '@zhin.js/logger';
9
+ import type { CapabilityId, DatabaseHost } from '@zhin.js/plugin-runtime';
16
10
  import { GhClient } from './gh-client.js';
17
- import { fromCanonicalSegments, toCanonicalSegments } from './segment-mapper.js';
18
-
19
- export function parseMarkdown(md: string): MessageSegment[] {
20
- const segments: MessageSegment[] = [];
21
- const mentionRe = /@(\w[-\w]*)/g;
22
- let lastIdx = 0;
23
- let match: RegExpExecArray | null;
24
- while ((match = mentionRe.exec(md)) !== null) {
25
- if (match.index > lastIdx) segments.push({ type: 'text', data: { text: md.slice(lastIdx, match.index) } });
26
- segments.push({ type: 'at', data: { id: match[1], name: match[1], text: match[0] } });
27
- lastIdx = match.index + match[0].length;
28
- }
29
- if (lastIdx < md.length) segments.push({ type: 'text', data: { text: md.slice(lastIdx) } });
30
- return segments.length ? segments : [{ type: 'text', data: { text: md } }];
11
+ import { registerGithubAgentEndpoint } from './github-agent-deps.js';
12
+ import { lookupGithubOauthAccessToken } from './oauth-users.js';
13
+ import {
14
+ enrichInboundContent,
15
+ formatInboundContent,
16
+ formatOutboundBody,
17
+ parseChannelId,
18
+ type GithubInboundComment,
19
+ type ResolvedGithubConfig,
20
+ } from './protocol.js';
21
+ import { registerGithubWebhookRoutes } from './webhook.js';
22
+ import { WorkspaceManager } from './workspace-manager.js';
23
+
24
+ const logger = getLogger('github');
25
+
26
+ export interface GithubEndpointOptions {
27
+ readonly id: CapabilityId;
28
+ readonly gateway: MessageGateway;
29
+ readonly http?: HttpHost;
30
+ readonly database?: DatabaseHost;
31
+ readonly config: ResolvedGithubConfig;
32
+ readonly createClient?: (config: ResolvedGithubConfig) => GhClient;
31
33
  }
32
34
 
33
- export function toMarkdown(content: SendContent): string {
34
- if (!Array.isArray(content)) content = [content];
35
- return content.map(seg => {
36
- if (typeof seg === 'string') return seg;
37
- switch (seg.type) {
38
- case 'text': return seg.data.text || '';
39
- case 'mention': return `@${seg.data.name || seg.data.target}`;
40
- case 'at': return `@${seg.data.name || seg.data.id}`;
41
- case 'image': return seg.data.url ? `![image](${seg.data.url})` : '[image]';
42
- case 'link': return `[${seg.data.text || seg.data.url}](${seg.data.url})`;
43
- default: return seg.data?.text || `[${seg.type}]`;
35
+ export class GithubEndpoint implements EndpointInstance {
36
+ readonly #options: GithubEndpointOptions;
37
+ readonly gh: GhClient;
38
+ readonly config: ResolvedGithubConfig;
39
+ readonly name: string;
40
+ #workspaceManager: WorkspaceManager | null = null;
41
+ #routeReleases: HttpRouteRegistration[] = [];
42
+ #open = false;
43
+ #started = false;
44
+ #unregisterAgent?: () => void;
45
+
46
+ constructor(options: GithubEndpointOptions) {
47
+ this.#options = options;
48
+ this.config = options.config;
49
+ this.name = options.config.name;
50
+ this.gh = options.createClient?.(options.config) ?? defaultCreateClient(options.config);
51
+ }
52
+
53
+ getAPI(): GhClient {
54
+ return this.gh;
55
+ }
56
+
57
+ async getUserOrDefaultAPI(platform?: string, platformUid?: string): Promise<GhClient | null> {
58
+ if (platform && platformUid) {
59
+ const token = await lookupGithubOauthAccessToken(
60
+ this.#options.database,
61
+ platform,
62
+ platformUid,
63
+ );
64
+ if (token) return this.gh.withToken(token);
44
65
  }
45
- }).join('');
46
- }
66
+ return this.gh;
67
+ }
47
68
 
48
- export class GitHubEndpoint implements Endpoint<GitHubEndpointConfig, IssueCommentPayload> {
49
- $connected = false;
50
- gh: GhClient;
69
+ getClientId(): string | null {
70
+ return this.gh.clientId || null;
71
+ }
51
72
 
52
- get $id() { return this.$config.name; }
73
+ getHost(): string | undefined {
74
+ return this.config.host;
75
+ }
53
76
 
54
- get logger() {
55
- return this.adapter.plugin.logger;
77
+ getAppSlug(): string | null {
78
+ return this.gh.appSlug || null;
56
79
  }
57
80
 
58
- constructor(public adapter: GitHubAdapter, public $config: GitHubEndpointConfig) {
59
- const { host, app_id, private_key } = $config;
60
- const appAuth = app_id && private_key
61
- ? { appId: app_id, privateKey: private_key }
62
- : undefined;
63
- this.gh = new GhClient({ host, appAuth });
81
+ getInstallations() {
82
+ return this.gh.installations || [];
64
83
  }
65
84
 
66
- async $connect(): Promise<void> {
67
- const result = await this.gh.verifyAuth();
68
- if (!result.ok) throw new Error(`GitHub 认证失败: ${result.message}`);
69
- this.$connected = true;
70
- this.logger.info(formatCompact({ endpoint: this.$id }));
85
+ getWorkspaceManager(): WorkspaceManager {
86
+ if (this.#workspaceManager) return this.#workspaceManager;
87
+ const workspaceRoot = this.config.workspaceRoot
88
+ ?? path.join(process.cwd(), 'data', 'github-workspaces');
89
+ this.#workspaceManager = new WorkspaceManager(this.gh, workspaceRoot);
90
+ return this.#workspaceManager;
71
91
  }
72
92
 
73
- async $disconnect(): Promise<void> {
74
- this.$connected = false;
75
- this.logger.debug(formatCompact({ endpoint: this.$id, disconnect: true }));
93
+ getDatabase(): DatabaseHost | undefined {
94
+ return this.#options.database;
76
95
  }
77
96
 
78
- $formatMessage(payload: IssueCommentPayload): Message<IssueCommentPayload> {
79
- const repo = payload.repository.full_name;
80
- const number = payload.issue.number;
81
- const isPR = 'pull_request' in (payload.issue as any);
82
- const channelId = buildChannelId(repo, isPR ? 'pr' : 'issue', number);
83
- const gh = this.gh;
84
-
85
- const result = Message.from(payload, {
86
- $id: payload.comment.id.toString(),
87
- $adapter: 'github',
88
- $endpoint: this.$config.name,
89
- $sender: { id: payload.sender.login, name: payload.sender.login },
90
- $channel: { id: channelId, type: 'group' },
91
- $content: toCanonicalSegments(parseMarkdown(payload.comment.body)),
92
- $raw: payload.comment.body,
93
- $timestamp: new Date(payload.comment.created_at).getTime(),
94
- $recall: async () => { await gh.deleteIssueComment(repo, payload.comment.id); },
95
- $reply: async (content: SendContent, quote?: boolean | string): Promise<string> => {
96
- const text = toMarkdown(content);
97
- const finalBody = quote ? `> ${payload.comment.body.split('\n')[0]}\n\n${text}` : text;
98
- const r = await gh.createIssueComment(repo, number, finalBody);
99
- return r.ok ? r.data.id.toString() : '';
100
- },
101
- });
102
- return result;
97
+ async start(): Promise<void> {
98
+ if (this.#started) return;
99
+ this.#started = true;
100
+ try {
101
+ const result = await this.gh.verifyAuth();
102
+ if (!result.ok) throw new Error(`GitHub 认证失败: ${result.message}`);
103
+ this.#unregisterAgent = registerGithubAgentEndpoint(this.name, this);
104
+ if (this.config.webhookSecret) {
105
+ if (!this.#options.http) {
106
+ throw new TypeError('GitHub webhook_secret requires httpHostToken');
107
+ }
108
+ this.#routeReleases.push(...registerGithubWebhookRoutes(this.#options.http, this));
109
+ logger.debug(formatCompact({
110
+ endpoint: this.name,
111
+ op: 'webhook',
112
+ path: this.config.webhookPath,
113
+ }));
114
+ } else {
115
+ logger.debug(formatCompact({
116
+ endpoint: this.name,
117
+ op: 'connect',
118
+ mode: 'api-only',
119
+ bot: this.gh.authenticatedUser,
120
+ }));
121
+ }
122
+ } catch (error) {
123
+ await this.stop();
124
+ logger.error('Failed to connect GitHub endpoint:', error);
125
+ throw error;
126
+ }
103
127
  }
104
128
 
105
- formatPRReviewComment(payload: PRReviewCommentPayload): Message<PRReviewCommentPayload> {
106
- const repo = payload.repository.full_name;
107
- const number = payload.pull_request.number;
108
- const channelId = buildChannelId(repo, 'pr', number);
109
- const gh = this.gh;
110
-
111
- const body = payload.comment.path
112
- ? `**${payload.comment.path}**\n${payload.comment.diff_hunk ? '```diff\n' + payload.comment.diff_hunk + '\n```\n' : ''}${payload.comment.body}`
113
- : payload.comment.body;
114
-
115
- return Message.from(payload, {
116
- $id: payload.comment.id.toString(),
117
- $adapter: 'github',
118
- $endpoint: this.$config.name,
119
- $sender: { id: payload.sender.login, name: payload.sender.login },
120
- $channel: { id: channelId, type: 'group' },
121
- $content: toCanonicalSegments(parseMarkdown(body)),
122
- $raw: body,
123
- $timestamp: new Date(payload.comment.created_at).getTime(),
124
- $recall: async () => { await gh.deletePRReviewComment(repo, payload.comment.id); },
125
- $reply: async (content: SendContent): Promise<string> => {
126
- const r = await gh.createPRComment(repo, number, toMarkdown(content));
127
- return r.ok ? r.data.id.toString() : '';
128
- },
129
- });
129
+ open(): void {
130
+ this.#open = true;
130
131
  }
131
132
 
132
- formatPRReview(payload: PRReviewPayload): Message<PRReviewPayload> | null {
133
- if (!payload.review.body) return null;
134
- const repo = payload.repository.full_name;
135
- const number = payload.pull_request.number;
136
- const channelId = buildChannelId(repo, 'pr', number);
137
- const gh = this.gh;
138
-
139
- const stateLabel: Record<string, string> = {
140
- approved: '✅ APPROVED', changes_requested: '🔄 CHANGES REQUESTED',
141
- commented: '💬 COMMENTED', dismissed: '❌ DISMISSED',
142
- };
143
- const body = `**[${stateLabel[payload.review.state] || payload.review.state}]**\n${payload.review.body}`;
144
-
145
- return Message.from(payload, {
146
- $id: payload.review.id.toString(),
147
- $adapter: 'github',
148
- $endpoint: this.$config.name,
149
- $sender: { id: payload.sender.login, name: payload.sender.login },
150
- $channel: { id: channelId, type: 'group' },
151
- $content: toCanonicalSegments(parseMarkdown(body)),
152
- $raw: body,
153
- $timestamp: new Date(payload.review.submitted_at).getTime(),
154
- $recall: async () => {},
155
- $reply: async (content: SendContent): Promise<string> => {
156
- const r = await gh.createPRComment(repo, number, toMarkdown(content));
157
- return r.ok ? r.data.id.toString() : '';
158
- },
159
- });
133
+ close(): void {
134
+ this.#open = false;
160
135
  }
161
136
 
162
- async $sendMessage(options: SendOptions): Promise<string> {
163
- const parsed = parseChannelId(options.id);
164
- if (!parsed) throw new Error(`无效的 GitHub channel ID: ${options.id}`);
137
+ async stop(): Promise<void> {
138
+ this.#open = false;
139
+ for (const release of this.#routeReleases.splice(0)) release();
140
+ this.#unregisterAgent?.();
141
+ this.#unregisterAgent = undefined;
142
+ this.#started = false;
143
+ logger.debug(formatCompact({ op: 'disconnect', endpoint: this.name }));
144
+ }
165
145
 
166
- const expanded = expandInteractiveSegmentsInContent(coerceQrcodeSegmentsToText(options.content ?? ''));
167
- const arr = Array.isArray(expanded) ? expanded : [expanded];
168
- const canonical = arr.map((s) => (typeof s === 'string' ? { type: 'text' as const, data: { text: s } } : s));
169
- const text = toMarkdown(fromCanonicalSegments(canonical));
146
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
147
+ const parsed = parseChannelId(target);
148
+ if (!parsed) throw new Error(`无效的 GitHub channel ID: ${target}`);
149
+ const text = formatOutboundBody(payload);
170
150
  const r = parsed.type === 'issue'
171
151
  ? await this.gh.createIssueComment(parsed.repo, parsed.number, text)
172
152
  : await this.gh.createPRComment(parsed.repo, parsed.number, text);
173
153
  if (!r.ok) throw new Error(`发送失败: ${JSON.stringify(r.data)}`);
174
-
175
- this.logger.debug(`${this.$id} send → ${options.id}: ${text.slice(0, 80)}...`);
176
- return r.data.id.toString();
154
+ logger.debug(formatCompact({
155
+ op: 'github_send',
156
+ endpoint: this.name,
157
+ target,
158
+ messageId: r.data.id,
159
+ }));
160
+ return String(r.data.id);
177
161
  }
178
162
 
179
- async $recallMessage(id: string): Promise<void> {
180
- this.logger.warn(formatCompact( { op: 'recall', ok: false, error: 'use message.$recall()' }));
163
+ /** Test / internal: admit a parsed comment when open. */
164
+ admit(comment: GithubInboundComment): void {
165
+ if (!this.#open) return;
166
+ const botUser = this.config.botLogin || this.gh.getBotLogin() || this.gh.authenticatedUser;
167
+ if (botUser && comment.sender === botUser) return;
168
+ const content = enrichInboundContent(
169
+ formatInboundContent(comment.content),
170
+ this.config,
171
+ botUser ?? undefined,
172
+ comment.repo,
173
+ );
174
+ void this.#options.gateway.receive({
175
+ adapter: this.#options.id,
176
+ target: comment.channelId,
177
+ content,
178
+ sender: comment.sender,
179
+ id: comment.id,
180
+ metadata: Object.freeze({
181
+ endpoint: this.name,
182
+ repo: comment.repo,
183
+ kind: comment.kind,
184
+ createdAt: comment.createdAt,
185
+ }),
186
+ }).catch((err) => {
187
+ logger.warn(formatCompact({
188
+ op: 'github_gateway_receive_failed',
189
+ target: comment.channelId,
190
+ error: err instanceof Error ? err.message : String(err),
191
+ }));
192
+ });
181
193
  }
182
194
  }
195
+
196
+ export function defaultCreateClient(config: ResolvedGithubConfig): GhClient {
197
+ const appAuth = config.appId && config.privateKey
198
+ ? { appId: config.appId, privateKey: config.privateKey }
199
+ : undefined;
200
+ return new GhClient({ host: config.host, appAuth });
201
+ }